Skip to main content

blockworx/tools/
rename_pin.rs

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