blockworx_paint/edit.rs
1//! The in-place editor as the kernel asks for it and the front end answers.
2//!
3//! The editor is the front end's: it runs a visible text field of its own
4//! over the diagram, keeps the draft, the caret and the selection, and says
5//! only how the edit ended. The kernel hears no keystroke and leaves out of
6//! the diagram the text the field covers, so what the editor lays out may
7//! differ from what is committed — a rotated label edited upright, a text box
8//! wrapped where the editor wraps — until the commit lays it out again.
9
10use std::borrow::Cow;
11
12use blockworx_geom::{Align2, Angle, Rect, Vec2, WorldPx, vec2};
13
14use crate::{DrawOp, EditColors, EditId, EditText, Font, Renderer, text::COLUMN_GLYPH};
15
16/// The air between a field's ring and its text.
17pub const EDITOR_PAD: Vec2 = Vec2::new(4.0, 2.0);
18pub const EDITOR_ROUNDING: WorldPx = WorldPx::new(2.0);
19/// The width of the ring round a field.
20pub const EDITOR_BORDER: WorldPx = WorldPx::new(1.5);
21/// The narrowest a field is, so a short or empty one is still a place to type.
22pub const EDITOR_MIN_WIDTH: WorldPx = WorldPx::new(60.0);
23/// The smallest a multi-line field opens at, in columns and lines of its own
24/// font: a text box is written in a text area, not in a one-line slot.
25pub const EDITOR_AREA_COLUMNS: u16 = 40;
26pub const EDITOR_AREA_LINES: u16 = 4;
27
28/// How the front end's editor ended the edit on field `id`.
29#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
30pub enum TextEvent {
31 /// Enter on a single line, or the field lost focus: the text stands.
32 Committed { id: EditId, text: String },
33 /// Escape: the text is dropped.
34 Cancelled { id: EditId },
35 /// Tab in a cycle editor: the text stands, and the cycle steps on.
36 TabPressed { id: EditId, text: String },
37}
38
39impl TextEvent {
40 pub fn id(&self) -> EditId {
41 match self {
42 TextEvent::Committed { id, .. }
43 | TextEvent::Cancelled { id }
44 | TextEvent::TabPressed { id, .. } => *id,
45 }
46 }
47
48 /// The event for the tool that opened the field, which knows the field
49 /// and needs only the ending.
50 pub fn outcome(self) -> TextOutcome {
51 match self {
52 TextEvent::Committed { text, .. } => TextOutcome::Committed(text),
53 TextEvent::Cancelled { .. } => TextOutcome::Cancelled,
54 TextEvent::TabPressed { text, .. } => TextOutcome::Tab(text),
55 }
56 }
57}
58
59/// How the editor a tool opened ended, as the tool reads it off the frame's
60/// [`Interaction`](crate::Interaction): the one way the edited text reaches
61/// a tool, so a tool cannot read a stale draft.
62#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub enum TextOutcome {
64 Committed(String),
65 Cancelled,
66 /// Commit the text, then advance the cycle.
67 Tab(String),
68}
69
70/// The editor as the front end shows it: where its field sits on screen,
71/// what the field opens on, and how it is drawn. `font` and `wrap_width` are
72/// in world units, scaled by the vantage the view was painted under, like
73/// the request they came from.
74#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
75pub struct EditField {
76 pub id: EditId,
77 pub rect: Rect,
78 /// The field's rotation about the centre of `rect`. A front end that
79 /// cannot rotate a field edits upright, an accepted disparity.
80 pub angle: Angle,
81 pub font: Font,
82 pub align: Align2,
83 pub wrap_width: Option<WorldPx>,
84 /// The text as it stands: what the field opens on.
85 pub text: String,
86 pub multiline: bool,
87 pub char_limit: Option<usize>,
88 pub tab_cycle: bool,
89 pub select_all_on_focus: bool,
90 pub hint: Option<Cow<'static, str>>,
91 pub colors: EditColors,
92}
93
94impl EditText {
95 /// The request with its field sized once for every tool: room for the
96 /// wider of the text and the hint as laid out — no narrower than the
97 /// tool's field or [`EDITOR_MIN_WIDTH`], and a multi-line field no smaller
98 /// than [`EDITOR_AREA_COLUMNS`] by [`EDITOR_AREA_LINES`] within its wrap —
99 /// placed about the point `align` anchors, then the pad and the ring laid
100 /// round it. The draft sits where the label was drawn, the field covers
101 /// the label and no more of its neighbours than the pad, and no hint is
102 /// cut short.
103 #[must_use]
104 pub fn fitted(mut self, measure: &impl Renderer) -> Self {
105 let wrap = self.wrap_width.unwrap_or(WorldPx::UNBOUNDED);
106 let laid = |text: &str| measure.text_size_wrapped(text, &self.font, wrap);
107 let area = if self.multiline {
108 let cell = laid(COLUMN_GLYPH);
109 vec2(
110 (cell.x * f32::from(EDITOR_AREA_COLUMNS)).min(wrap.get()),
111 cell.y * f32::from(EDITOR_AREA_LINES),
112 )
113 } else {
114 Vec2::ZERO
115 };
116 let content = self
117 .hint
118 .map_or(Vec2::ZERO, laid)
119 .max(laid(&self.text))
120 .max(vec2(EDITOR_MIN_WIDTH.get().max(self.position.width()), 0.0))
121 .max(area);
122 let room = self
123 .align
124 .anchor_size(self.align.pos_in_rect(self.position), content);
125 self.position = room.expand2(EDITOR_PAD + Vec2::splat(EDITOR_BORDER.get()));
126 self
127 }
128}
129
130impl EditField {
131 /// Whether `op` is a run of text lying wholly under the field, which
132 /// is opaque and drawn over the diagram: the run could never be seen.
133 pub fn covers(&self, op: &DrawOp) -> bool {
134 match op {
135 DrawOp::Text { rect, .. } | DrawOp::TextWrapped { rect, .. } => {
136 self.rect.contains_rect(*rect)
137 }
138 _ => false,
139 }
140 }
141
142 pub fn of(asked: &EditText, rect: Rect) -> Self {
143 Self {
144 id: asked.id,
145 rect,
146 angle: asked.angle,
147 font: asked.font.clone(),
148 align: asked.align,
149 wrap_width: asked.wrap_width,
150 text: asked.text.clone(),
151 multiline: asked.multiline,
152 char_limit: asked.char_limit,
153 tab_cycle: asked.tab_cycle,
154 select_all_on_focus: asked.select_all_on_focus,
155 hint: asked.hint.map(Cow::Borrowed),
156 colors: asked.colors,
157 }
158 }
159}