From 9170d47f8bd12a91ec2c9cabaf01583e71177651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 08:41:05 -0400 Subject: [PATCH] =?UTF-8?q?Canvas=20=C2=B7=20Graphs=20and=20boards:=20a=20?= =?UTF-8?q?field=20hands=20focus=20back=20on=20Enter=20and=20Escape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writing::begin records the owner's focus, the mind map's or the tool layer's, through forms::Editing::returning. The field keeps its words on Enter's release and drops them on Escape, and both hand focus back, so the map's arrows and the board's Backspace work at once after a write. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + TASKS.md | 1 + src/canvas/mindmap_view.rs | 18 +++++++++++++++--- src/canvas/press.rs | 6 ++++-- src/canvas/tests/boards.rs | 38 ++++++++++++++++++++++++++++++++++++++ src/canvas/tools.rs | 1 + src/canvas/writing.rs | 24 +++++++++++++++++++----- src/forms/inline.rs | 4 ++-- 8 files changed, 81 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ea3894d..074a148 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -170,6 +170,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A ScrubInput shows its number in about 140px beside a short label. In a narrower column a pair hides it, so a long label takes a row of its own (`canvas::TypographyPanel`). - Design panels edit a whole value and hand the owner a changed copy through `canvas::panel::editing`. A swatch that opens a picker is `canvas::panel::color_well`. - A press that stops propagation also stops the ancestors' focus on press. A layer that takes keys focuses itself in its own press handler (`canvas::ToolLayer`, `canvas::MindMap`), and a field over it stops its own presses. +- A field opened over a component hands focus back to that component on Enter and Escape, through `forms::Editing`'s `returning` and `give_back` (`canvas::writing`); a blur keeps the words and leaves focus where it went. - A focus ring on a box whose children measure themselves goes on an overlay child: a border insets the children and shifts what they measure (`canvas::ToolLayer`). - A menu of times goes through `mail::times`: each row names its day and hour, a moment comes once (tomorrow is next Monday on a Sunday), and "Pick a time…" opens one dialog that waits for a time ahead of now (`ScheduleSend`, `SnoozePicker`). - A list whose cursor a field drives marks the cursor only while the field has focus; at rest its first row would look hovered (`mail::LabelPicker`). Its rows are option rows, and `forms::marked_row` leads one with a mark such as a label's hue. diff --git a/TASKS.md b/TASKS.md index a05e3d1..5601535 100644 --- a/TASKS.md +++ b/TASKS.md @@ -379,3 +379,4 @@ Per-component lines live in `tasks/`. Tags there: | T32b | 2 | PASS | | | T32c | 1 | PASS | | | T32d | 1 | FAIL | Tab in a mind map's rename field added a topic; the replaced wire, the connector released on its own shape and a deleted shape's links went untested | +| T32d | 2 | FAIL | Enter or Escape in a canvas field handed focus nowhere, so the map's and the layer's keys went dead until the next press | diff --git a/src/canvas/mindmap_view.rs b/src/canvas/mindmap_view.rs index 9b204cb..0be9411 100644 --- a/src/canvas/mindmap_view.rs +++ b/src/canvas/mindmap_view.rs @@ -125,15 +125,27 @@ impl RenderOnce for MindMap { ); } let rename = { - let (editing, placed, on_rename) = - (editing.clone(), placed.clone(), self.on_rename.clone()); + let (editing, placed, on_rename, home) = ( + editing.clone(), + placed.clone(), + self.on_rename.clone(), + focus.clone(), + ); move |key: &SharedString, window: &mut Window, cx: &mut App| { let topic = placed .iter() .find(|topic| topic.key == *key) .expect("a placed topic"); let words = topic.text.to_string(); - writing::begin(&editing, key.clone(), words, on_rename.clone(), window, cx); + writing::begin( + &editing, + key.clone(), + words, + on_rename.clone(), + home.clone(), + window, + cx, + ); } }; let rename = Rc::new(rename); diff --git a/src/canvas/press.rs b/src/canvas/press.rs index 5f0d8c8..6333e62 100644 --- a/src/canvas/press.rs +++ b/src/canvas/press.rs @@ -1,4 +1,4 @@ -use gpui::{App, Entity, SharedString, Window}; +use gpui::{App, Entity, FocusHandle, SharedString, Window}; use super::{ edit::{Tool, hit}, @@ -11,7 +11,7 @@ use super::{ }; use crate::forms::Editing; -/// A press on a tool layer: the tool, where it landed in canvas units and in view pixels, how many presses it counts, whether Shift is held, and a handle's side. +/// A press on a tool layer: the tool, where it landed in canvas units and in view pixels, how many presses it counts, whether Shift is held, a handle's side, and the layer's focus, which a field hands back. pub(super) struct Press { pub tool: Tool, pub at: (f32, f32), @@ -19,6 +19,7 @@ pub(super) struct Press { pub count: usize, pub shift: bool, pub side: f32, + pub back: FocusHandle, } /// The gesture a press sets going, after what it asks the owner at once: a selection, a dropped text or note, an ended path, or words to rewrite. @@ -68,6 +69,7 @@ pub(super) fn pressed( shape.key.clone(), words, handlers.on_text.clone(), + press.back.clone(), window, cx, ); diff --git a/src/canvas/tests/boards.rs b/src/canvas/tests/boards.rs index 7eb21de..09b284c 100644 --- a/src/canvas/tests/boards.rs +++ b/src/canvas/tests/boards.rs @@ -207,3 +207,41 @@ fn a_link_released_on_its_own_shape_links_nothing(cx: &mut TestAppContext) { drag((50.0, 50.0), (60.0, 70.0), cx); assert!(said(&host, cx).is_empty()); } + +#[gpui::test] +fn enter_and_escape_in_a_topic_hand_the_keys_back_to_the_map(cx: &mut TestAppContext) { + let (host, cx) = stage(map, cx); + press(276.0, 126.0, 1, cx); + tap("f2", cx); + cx.simulate_input("Web"); + tap("enter", cx); + tap("down", cx); + tap("f2", cx); + cx.simulate_input("Gone"); + tap("escape", cx); + tap("down", cx); + assert_eq!( + said(&host, cx), + [ + "select Some(\"a\")", + "rename a Web", + "select Some(\"b\")", + "select Some(\"b\")", + ], + "the owner keeps a selected, so each Down steps from a" + ); +} + +#[gpui::test] +fn after_writing_on_a_shape_its_board_still_takes_backspace(cx: &mut TestAppContext) { + let (host, cx) = stage(board, cx); + press(250.0, 50.0, 1, cx); + press(250.0, 50.0, 2, cx); + cx.simulate_input("Idea"); + tap("enter", cx); + tap("backspace", cx); + assert_eq!( + said(&host, cx), + ["shapes [\"a\", \"b\"] links 1", "shapes [\"a\"] links 0"] + ); +} diff --git a/src/canvas/tools.rs b/src/canvas/tools.rs index 815430e..82ae9fb 100644 --- a/src/canvas/tools.rs +++ b/src/canvas/tools.rs @@ -355,6 +355,7 @@ impl RenderOnce for ToolLayer { count: event.click_count, shift: event.modifiers.shift, side, + back: held_focus.clone(), }; let gesture = pressed_with( &press, diff --git a/src/canvas/writing.rs b/src/canvas/writing.rs index ecf91ef..d0814ee 100644 --- a/src/canvas/writing.rs +++ b/src/canvas/writing.rs @@ -1,19 +1,20 @@ use std::rc::Rc; use gpui::{ - AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, - SharedString, Styled, Window, div, + AnyElement, App, Entity, FocusHandle, InteractiveElement, IntoElement, MouseButton, + ParentElement, Pixels, SharedString, Styled, Window, div, }; use super::{gesture::OnPair, view::Frame}; use crate::forms::{Editing, Input}; -/// Opens a field on the words of what `key` names. Enter or leaving hands them to the owner; Escape drops them. +/// Opens a field on the words of what `key` names. Enter or leaving hands them to the owner; Escape drops them. Enter and Escape hand focus back to `back`. pub(super) fn begin( editing: &Entity, key: SharedString, words: String, on_text: Option, + back: FocusHandle, window: &mut Window, cx: &mut App, ) { @@ -29,12 +30,13 @@ pub(super) fn begin( )); }); Editing::begin(editing, words, window, cx); + editing.update(cx, |editing, _| editing.returning(back)); } /// The open field over `frame`, in view pixels. pub(super) fn field(editing: &Entity, frame: Frame, cx: &App) -> Option { let field = editing.read(cx).field()?; - let closing = editing.clone(); + let (closing, keeping) = (editing.clone(), editing.clone()); Some( div() .absolute() @@ -45,7 +47,19 @@ pub(super) fn field(editing: &Entity, frame: Frame, cx: &App) -> Option .on_key_down(move |event, window, cx| { if event.keystroke.key == "escape" { cx.stop_propagation(); - closing.update(cx, |editing, cx| editing.finish(true, window, cx)); + closing.update(cx, |editing, cx| { + editing.finish(true, window, cx); + editing.give_back(window); + }); + } + }) + .on_key_up(move |event, window, cx| { + if event.keystroke.key == "enter" { + cx.stop_propagation(); + keeping.update(cx, |editing, cx| { + editing.finish(false, window, cx); + editing.give_back(window); + }); } }) .child(Input::new(&field)) diff --git a/src/forms/inline.rs b/src/forms/inline.rs index 4b77b63..2625044 100644 --- a/src/forms/inline.rs +++ b/src/forms/inline.rs @@ -54,11 +54,11 @@ impl Editing { } /// Focus goes back here after Enter or Escape. - fn returning(&mut self, back: FocusHandle) { + pub(crate) fn returning(&mut self, back: FocusHandle) { self.back = Some(back); } - fn give_back(&mut self, window: &mut Window) { + pub(crate) fn give_back(&mut self, window: &mut Window) { if let Some(back) = self.back.take() { log::info!("inline edit: focus handed back"); window.focus(&back);