Skip to main content

blockworx_tools/
rename_pin.rs

1use blockworx_doc::id::PinId;
2use blockworx_geom::{Align2, Angle, Rect, vec2};
3
4use crate::render::pin_text_location;
5use crate::theme::{Role, Style};
6use crate::{
7    block_edit::{self, EditTarget},
8    grid::PORT_TEXT_SIZE,
9    names::ToolName,
10    resize_block::ResizeBlock,
11    shape::{
12        ShapeId, ShapeRef,
13        pin::{self, PinSide},
14    },
15    tool::{ToolTrait, Transition},
16    widget::drawing::Drawing,
17};
18use blockworx_paint::{Canvas, EditId, EditText, Event, Interaction, Renderer, TextOutcome};
19/// Which of a pin's two labels the rename tool is editing.
20#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
21pub enum Field {
22    Name,
23    Tag,
24}
25
26pub enum RenamePin {
27    Idle,
28    /// Armed on a pin the arming gesture has only just authored: its ops are
29    /// still in the sink, so the label is read on the first `widget()` call
30    /// rather than here.
31    Renaming {
32        anchor: PinId,
33        field: Field,
34        label: String,
35    },
36}
37
38impl RenamePin {
39    /// Switch to editing `field` on `anchor`, or to selecting the pin when a
40    /// frozen interface refuses the edit.
41    pub fn action(data: &Drawing, anchor: PinId, field: Field) -> Transition {
42        Transition::SwitchTool(match RenamePin::new_with_anchor(data, anchor, field) {
43            Some(tool) => tool.into(),
44            None => crate::tool::select_tool_for_anchor(data, anchor),
45        })
46    }
47
48    pub fn new_with_anchor(data: &Drawing, anchor: PinId, field: Field) -> Option<Self> {
49        // A locked block keeps its pin interface frozen: neither the name nor the
50        // tag editor opens on its pins/ports.
51        if data.pin_owner_locked(anchor) {
52            return None;
53        }
54        let (_, pin) = data.pin_on_shape(anchor)?;
55        let initial = match field {
56            // Names are single-line; load only the first line so committing
57            // drops any `\n`-separated tail.
58            Field::Name => crate::render::first_line(&pin.name).to_string(),
59            Field::Tag => pin.tag.clone(),
60        };
61        Some(RenamePin::Renaming {
62            anchor,
63            field,
64            label: initial,
65        })
66    }
67}
68
69/// The editor's rect over the committed label, re-measured every frame so it
70/// tracks the pin's geometry; `None` once the pin is gone.
71fn editor_position(
72    data: &Drawing,
73    anchor: PinId,
74    field: Field,
75    painter: &Style<'_, impl Renderer>,
76) -> Option<(Rect, Align2)> {
77    let (shape, pin) = data.pin_on_shape(anchor)?;
78    let drawn_as_port = matches!(shape, ShapeRef::Port(_));
79    // A port draws its labels by its `orientation`; a child-block pin by its
80    // edge `side`. Anchor the editor to whichever the label uses.
81    let label_side = if drawn_as_port {
82        pin::orientation(pin)
83    } else {
84        pin::slot(pin).side
85    };
86    match field {
87        Field::Name => {
88            let name = crate::render::first_line(&pin.name);
89            let editor_width =
90                (painter.text_size(name, &painter.theme().pin_font).x + 10.0).max(20.0);
91            let size = vec2(editor_width * 2.0, PORT_TEXT_SIZE * 1.5);
92            if drawn_as_port {
93                // A port's name renders centered in the port box; the shape's
94                // own name rect (the one the double-click hit-tests against)
95                // is the editor's anchor, like the type and tag editors.
96                let name_box = shape.pin_text_rect(anchor, painter)?;
97                Some((
98                    Rect::from_center_size(name_box.center(), size),
99                    Align2::CENTER_CENTER,
100                ))
101            } else {
102                let (pos, align) = pin_text_location(shape.gui_rect(), pin, label_side, 0.0);
103                Some((align.anchor_size(pos, size), align))
104            }
105        }
106        Field::Tag => {
107            // The tag's on-canvas rect already accounts for block-vs-port
108            // placement; derive the editor anchor from it. An empty tag uses
109            // its "+tag" placeholder extent so the editor opens at the prompt.
110            let tag = pin.tag.clone();
111            let tag_text = if tag.is_empty() {
112                crate::render::ADD_TAG_PLACEHOLDER
113            } else {
114                &tag
115            };
116            let tag_box = shape.tag_text_rect_for(anchor, tag_text, painter)?;
117            let editor_width =
118                (painter.text_size(tag, &painter.theme().tag_font).x + 10.0).max(20.0);
119            let (pos, align) = match label_side {
120                PinSide::West => (tag_box.right_center(), Align2::RIGHT_CENTER),
121                PinSide::East => (tag_box.left_center(), Align2::LEFT_CENTER),
122            };
123            Some((
124                align.anchor_size(pos, vec2(editor_width * 2.0, PORT_TEXT_SIZE * 1.5)),
125                align,
126            ))
127        }
128    }
129}
130
131impl ToolTrait for RenamePin {
132    fn name(&self) -> ToolName {
133        ToolName::RenamePin
134    }
135
136    fn widget<C: Canvas>(
137        &mut self,
138        data: &mut Drawing,
139        interaction: &Interaction,
140        painter: &mut Style<'_, C>,
141    ) -> Option<Transition> {
142        match self {
143            RenamePin::Idle => {
144                crate::widget::display::widget(data, interaction, painter);
145                if let Some(Event::DoubleClicked { pos }) = interaction.event {
146                    // Whichever label the click landed on decides the field.
147                    let hit = data
148                        .pin_text_at_pos(pos, painter)
149                        .map(|(a, _)| (a, Field::Name))
150                        .or_else(|| {
151                            data.pin_tag_at_pos(pos, painter)
152                                .map(|(a, _)| (a, Field::Tag))
153                        });
154                    if let Some((anchor, field)) = hit
155                        && let Some(tool) = RenamePin::new_with_anchor(data, anchor, field)
156                    {
157                        *self = tool;
158                    }
159                }
160                None
161            }
162            RenamePin::Renaming {
163                anchor,
164                field,
165                label,
166            } => {
167                crate::widget::display::widget(data, interaction, painter);
168                let anchor = *anchor;
169                let field = *field;
170                let id = EditId::of(("pin_edit", anchor, field));
171                let label = label.clone();
172                match &interaction.text {
173                    Some(TextOutcome::Cancelled) => {
174                        // Cancel: drop the edit, reselect the owning shape.
175                        let Some(shape) = data.pin_shape(anchor) else {
176                            return Some(Transition::default());
177                        };
178                        return Some(Transition::SwitchTool(
179                            ResizeBlock::Selected { shape }.into(),
180                        ));
181                    }
182                    Some(TextOutcome::Tab(text))
183                        if let Some(ShapeId::Rect(block)) = data.pin_shape(anchor) =>
184                    {
185                        commit_pin(data, anchor, field, text);
186                        let target = match field {
187                            Field::Name => EditTarget::PinName(anchor),
188                            Field::Tag => EditTarget::PinTag(anchor),
189                        };
190                        return block_edit::advance(data, block, target);
191                    }
192                    Some(TextOutcome::Committed(text)) => {
193                        commit_pin(data, anchor, field, text);
194                        // Return to the owning shape's selection (the per-pin
195                        // SelectPin for a block pin, the port's ResizeBlock for a
196                        // port) rather than the generic Select tool.
197                        return Some(Transition::SwitchTool(crate::tool::select_tool_for_anchor(
198                            data, anchor,
199                        )));
200                    }
201                    // A Tab with no cycle to step keeps the editor open.
202                    Some(TextOutcome::Tab(_)) | None => {}
203                }
204                let Some((position, align)) = editor_position(data, anchor, field, painter) else {
205                    // The pin vanished under the editor (an undo); drop the edit.
206                    return Some(Transition::default());
207                };
208                let font = match field {
209                    Field::Name => painter.theme().pin_font.clone(),
210                    Field::Tag => painter.theme().tag_font.clone(),
211                };
212                // The name sits inside the block body, so match its label colors
213                // and the body fill. The tag hangs outboard over the stub, so it
214                // keeps the default editor look.
215                let colors = match field {
216                    Field::Name => painter
217                        .theme()
218                        .editor_colors(Role::PinText, Role::ShapeFill),
219                    Field::Tag => painter
220                        .theme()
221                        .editor_colors(Role::EditorText, Role::EditorFill),
222                };
223                painter.set_edit_text(EditText {
224                    align,
225                    position,
226                    angle: Angle::ZERO,
227                    text: label,
228                    font,
229                    id,
230                    // Both labels are single-line: Enter commits, no line breaks.
231                    multiline: false,
232                    char_limit: Some(match field {
233                        Field::Name => crate::grid::MAX_LABEL_CHARS,
234                        Field::Tag => crate::grid::MAX_LOCATION_CHARS,
235                    }),
236                    tab_cycle: true,
237                    select_all_on_focus: false,
238                    hint: Some(match field {
239                        Field::Name => crate::render::ADD_NAME_PLACEHOLDER,
240                        Field::Tag => crate::render::ADD_TAG_PLACEHOLDER,
241                    }),
242                    colors,
243                    wrap_width: None,
244                });
245                None
246            }
247        }
248    }
249}
250
251/// Write `text` to the pin's name or tag. Names widen the port body to fit; tags
252/// extend outward over the stub and don't.
253fn commit_pin(data: &mut Drawing, anchor: PinId, field: Field, text: &str) {
254    match field {
255        Field::Name => {
256            let fit = data.label_fit(anchor, Some(text), None);
257            data.rename_pin(anchor, text, fit);
258        }
259        Field::Tag => data.set_pin_tag(anchor, text),
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::path::Scope;
267    use crate::widget::test_fixtures::{self as fx, Scene};
268    use blockworx_doc::fixtures::pin_id;
269    use blockworx_geom::pos2;
270
271    /// A boundary port's name renders centered in the port box, not at the
272    /// block-pin label offset — the editor must open over the label the
273    /// double-click hit, matching the type and tag editors.
274    #[test]
275    fn a_ports_name_editor_opens_over_the_ports_own_label() {
276        use crate::theme::Theme;
277        use blockworx_paint::FontChoice;
278        let anchor = pin_id(3);
279        let mut scene = Scene::new(vec![fx::pin_at(
280            3,
281            Scope::Root,
282            "out",
283            fx::slot(PinSide::West, 0),
284            Rect::from_min_max(pos2(0.0, 0.0), pos2(75.0, 30.0)),
285        )]);
286        let data = scene.drawing();
287        let theme = Theme::default();
288        let measured =
289            blockworx_text::measure::Measured::new(FontChoice::Sketchy, theme.palette().clone());
290        measured.frame(|painter| {
291            let style = Style::new(&theme, painter);
292            let (shape, pin) = data
293                .pin_on_shape(anchor)
294                .expect("the port is in this scope");
295            let name_box = shape.pin_text_rect(anchor, &style).unwrap();
296            // Precondition: the block-pin label math lands somewhere else for
297            // this port, so centering on the name rect is a real distinction.
298            let (block_style_pos, _) =
299                pin_text_location(shape.gui_rect(), pin, pin::orientation(pin), 0.0);
300            assert!(
301                block_style_pos.distance(name_box.center()) > crate::grid::GRID_SIZE,
302                "the two placements should disagree for a port ({block_style_pos:?} vs {:?})",
303                name_box.center()
304            );
305            let (editor, _) = editor_position(&data, anchor, Field::Name, &style).unwrap();
306            // The editor's rect is built from this very centre and read back
307            // out of it, so the two agree to within the round trip's own
308            // rounding — below one ULP at this magnitude, not a placement
309            // the reader could see. Tight enough that a real drift fails.
310            let apart = editor.center().distance(name_box.center());
311            assert!(
312                apart < 1e-5,
313                "the editor opened at {:?}, {apart} away from the name box at {:?}",
314                editor.center(),
315                name_box.center(),
316            );
317        });
318    }
319
320    /// A locked block freezes its pin interface: the rename and the tag edit
321    /// both drop, so a gesture over them seals to nothing at all.
322    #[test]
323    fn commit_pin_is_a_no_op_on_a_locked_block() {
324        use blockworx_doc::values::PinSide as DocPinSide;
325        let (block, pin) = (1, pin_id(3));
326        let mut scene = Scene::new(vec![
327            fx::block(block, 0.0),
328            fx::pin(3, block, DocPinSide::East, 0),
329            fx::pin_tagged(3, "T0"),
330            fx::locked(block),
331        ]);
332        let before = scene.doc.stamp();
333
334        scene.commit(|data| {
335            commit_pin(data, pin, Field::Name, "renamed");
336            commit_pin(data, pin, Field::Tag, "T1");
337        });
338
339        assert_eq!(
340            scene.doc.stamp(),
341            before,
342            "a gesture a locked interface refuses authors nothing"
343        );
344        let data = scene.drawing();
345        let held = data.held_pin(pin).expect("the pin survives");
346        assert_eq!(held.name, "p", "locked block's pin name is unchanged");
347        assert_eq!(held.tag, "T0", "locked block's pin tag is unchanged");
348    }
349}