From dcfe1b43e6b78f8fd761c58f81861e8dc3b1ddc8 Mon Sep 17 00:00:00 2001 From: Olivier FAURE Date: Tue, 14 Jan 2025 11:48:39 +0100 Subject: [PATCH 1/4] Clear the focus when user clicks outside of the focused widget Add "ghost focus" concept. --- masonry/src/contexts.rs | 1 + masonry/src/passes/event.rs | 29 ++++++++++++++++++++++++----- masonry/src/passes/update.rs | 2 ++ masonry/src/render_root.rs | 7 ++++++- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/masonry/src/contexts.rs b/masonry/src/contexts.rs index 383882960..957f310ff 100644 --- a/masonry/src/contexts.rs +++ b/masonry/src/contexts.rs @@ -387,6 +387,7 @@ impl EventCtx<'_> { // to deliver on the "last focus request wins" promise. let id = self.widget_id(); self.global_state.next_focused_widget = Some(id); + self.global_state.ghost_focus = Some(id); } /// Transfer [text focus] to the widget with the given `WidgetId`. diff --git a/masonry/src/passes/event.rs b/masonry/src/passes/event.rs index c3f9e7b19..6f220e956 100644 --- a/masonry/src/passes/event.rs +++ b/masonry/src/passes/event.rs @@ -105,6 +105,26 @@ pub(crate) fn run_on_pointer_event_pass(root: &mut RenderRoot, event: &PointerEv let target_widget_id = get_pointer_target(root, event.position()); + if matches!(event, PointerEvent::PointerDown(..)) { + if let Some(target_widget_id) = target_widget_id { + // The next tab event assign focus around this widget. + root.global_state.ghost_focus = Some(target_widget_id); + + // If we click outside of the focused widget, we clear the focus. + if let Some(focused_widget) = root.global_state.focused_widget { + // Focused_widget isn't ancestor of target_widget_id + if !root + .widget_arena + .states + .get_id_path(target_widget_id) + .contains(&focused_widget.to_raw()) + { + root.global_state.next_focused_widget = None; + } + } + } + } + let handled = run_event_pass( root, target_widget_id, @@ -174,11 +194,10 @@ pub(crate) fn run_on_text_event_pass(root: &mut RenderRoot, event: &TextEvent) - && key.state == ElementState::Pressed && handled == Handled::No { - if !mods.shift_key() { - root.global_state.next_focused_widget = root.widget_from_focus_chain(true); - } else { - root.global_state.next_focused_widget = root.widget_from_focus_chain(false); - } + let forward = !mods.shift_key(); + let next_focused_widget = root.widget_from_focus_chain(forward); + root.global_state.next_focused_widget = next_focused_widget; + root.global_state.ghost_focus = next_focused_widget; handled = Handled::Yes; } } diff --git a/masonry/src/passes/update.rs b/masonry/src/passes/update.rs index b0b9a6f49..f5c489ad8 100644 --- a/masonry/src/passes/update.rs +++ b/masonry/src/passes/update.rs @@ -530,6 +530,8 @@ pub(crate) fn run_update_focus_pass(root: &mut RenderRoot) { if widget_state.accepts_text_input { root.global_state.emit_signal(RenderRootSignal::StartIme); } + + root.global_state.ghost_focus = Some(next_focused); } else { root.global_state.is_ime_active = false; } diff --git a/masonry/src/render_root.rs b/masonry/src/render_root.rs index f5ce3690e..0ba7d9117 100644 --- a/masonry/src/render_root.rs +++ b/masonry/src/render_root.rs @@ -73,6 +73,10 @@ pub(crate) struct RenderRootState { pub(crate) signal_queue: VecDeque, pub(crate) focused_widget: Option, pub(crate) focused_path: Vec, + /// The most recently clicked widget. + /// + /// When tab-focusing, this will be used as the base. + pub(crate) ghost_focus: Option, pub(crate) next_focused_widget: Option, pub(crate) scroll_request_targets: Vec<(WidgetId, Rect)>, pub(crate) hovered_path: Vec, @@ -197,6 +201,7 @@ impl RenderRoot { signal_queue: VecDeque::new(), focused_widget: None, focused_path: Vec::new(), + ghost_focus: None, next_focused_widget: None, scroll_request_targets: Vec::new(), hovered_path: Vec::new(), @@ -616,7 +621,7 @@ impl RenderRoot { } pub(crate) fn widget_from_focus_chain(&mut self, forward: bool) -> Option { - let focused_widget = self.global_state.focused_widget; + let focused_widget = self.global_state.ghost_focus; let focused_idx = focused_widget.and_then(|focused_widget| { self.focus_chain() .iter() From 4153f6fa3c6993066a71e6ef7e6fee94aa2336d1 Mon Sep 17 00:00:00 2001 From: Olivier FAURE Date: Tue, 14 Jan 2025 11:55:12 +0100 Subject: [PATCH 2/4] Update focus documentation --- masonry/src/doc/06_masonry_concepts.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/masonry/src/doc/06_masonry_concepts.md b/masonry/src/doc/06_masonry_concepts.md index ca662e729..827647ad6 100644 --- a/masonry/src/doc/06_masonry_concepts.md +++ b/masonry/src/doc/06_masonry_concepts.md @@ -57,9 +57,13 @@ Focus marks whether a widget receives text events. To give a simple example, when you click a textbox, the textbox gets focus: anything you type on your keyboard will be sent to that textbox. -Focus can be changed with the tab key, or by clicking on a widget, both which Masonry automatically handles. -Masonry will only give focus to widgets that accept focus (see [`Widget::accepts_focus`]). -Widgets can also use context types to request focus. +Focus will be changed: + +- When users press the Tab key: Masonry will automatically pick the next widget in the tree that accepts focus [`Widget::accepts_focus`]. +- When users click outside the currently focused widget: Masonry will automatically remove focus. + +Widgets that want to gain focus when clicked should call [`EventCtx::request_focus`] inside [`Widget::on_pointer_event`]. +Other context types can also request focus. If a widget gains or loses focus it will get a [`FocusChanged`] event. @@ -108,3 +112,5 @@ They should not be relied upon to check code correctness, but are meant to help [`PointerLeave`]: crate::PointerEvent::PointerLeave [`FocusChanged`]: crate::Update::FocusChanged [`Widget::accepts_focus`]: crate::Widget::accepts_focus +[`EventCtx::request_focus`]: crate::EventCtx::request_focus +[`Widget::on_pointer_event`]: crate::Widget::on_pointer_event From 786638ea62ae8f4d9c88f2f79696777d89358cc4 Mon Sep 17 00:00:00 2001 From: Olivier FAURE Date: Tue, 14 Jan 2025 11:57:14 +0100 Subject: [PATCH 3/4] Document ghost focus --- masonry/src/doc/06_masonry_concepts.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/masonry/src/doc/06_masonry_concepts.md b/masonry/src/doc/06_masonry_concepts.md index 827647ad6..8a130cf1e 100644 --- a/masonry/src/doc/06_masonry_concepts.md +++ b/masonry/src/doc/06_masonry_concepts.md @@ -75,6 +75,12 @@ Active focus is the default one; inactive focus is when the window your app runs In that case, we still mark the widget as focused, but with a different color to signal that e.g. typing on the keyboard won't actually affect it. +### Ghost focus + +If a user clicks on a non-focusable widget, future tab events will still navigate the widget tree as if that widget was focused. + +This concept is referred to internally as "ghost focus". + ## Disabled From f8a1b54e07e05109df0581085bfbea66cdcd377c Mon Sep 17 00:00:00 2001 From: Olivier FAURE Date: Tue, 14 Jan 2025 17:30:13 +0100 Subject: [PATCH 4/4] Call the Ghostbusters I ain't afraid of no ghost. --- masonry/src/contexts.rs | 1 - masonry/src/doc/06_masonry_concepts.md | 8 +------- masonry/src/passes/event.rs | 3 +-- masonry/src/passes/update.rs | 2 -- masonry/src/render_root.rs | 12 ++++++------ 5 files changed, 8 insertions(+), 18 deletions(-) diff --git a/masonry/src/contexts.rs b/masonry/src/contexts.rs index 957f310ff..383882960 100644 --- a/masonry/src/contexts.rs +++ b/masonry/src/contexts.rs @@ -387,7 +387,6 @@ impl EventCtx<'_> { // to deliver on the "last focus request wins" promise. let id = self.widget_id(); self.global_state.next_focused_widget = Some(id); - self.global_state.ghost_focus = Some(id); } /// Transfer [text focus] to the widget with the given `WidgetId`. diff --git a/masonry/src/doc/06_masonry_concepts.md b/masonry/src/doc/06_masonry_concepts.md index 8a130cf1e..15acb15e3 100644 --- a/masonry/src/doc/06_masonry_concepts.md +++ b/masonry/src/doc/06_masonry_concepts.md @@ -59,7 +59,7 @@ To give a simple example, when you click a textbox, the textbox gets focus: anyt Focus will be changed: -- When users press the Tab key: Masonry will automatically pick the next widget in the tree that accepts focus [`Widget::accepts_focus`]. +- When users press the Tab key: Masonry will automatically pick the next widget in the tree that accepts focus [`Widget::accepts_focus`]. (If no widget is currently focused, its starting point will be the most recently clicked widget.) - When users click outside the currently focused widget: Masonry will automatically remove focus. Widgets that want to gain focus when clicked should call [`EventCtx::request_focus`] inside [`Widget::on_pointer_event`]. @@ -75,12 +75,6 @@ Active focus is the default one; inactive focus is when the window your app runs In that case, we still mark the widget as focused, but with a different color to signal that e.g. typing on the keyboard won't actually affect it. -### Ghost focus - -If a user clicks on a non-focusable widget, future tab events will still navigate the widget tree as if that widget was focused. - -This concept is referred to internally as "ghost focus". - ## Disabled diff --git a/masonry/src/passes/event.rs b/masonry/src/passes/event.rs index 6f220e956..983a6e7d6 100644 --- a/masonry/src/passes/event.rs +++ b/masonry/src/passes/event.rs @@ -108,7 +108,7 @@ pub(crate) fn run_on_pointer_event_pass(root: &mut RenderRoot, event: &PointerEv if matches!(event, PointerEvent::PointerDown(..)) { if let Some(target_widget_id) = target_widget_id { // The next tab event assign focus around this widget. - root.global_state.ghost_focus = Some(target_widget_id); + root.global_state.most_recently_clicked_widget = Some(target_widget_id); // If we click outside of the focused widget, we clear the focus. if let Some(focused_widget) = root.global_state.focused_widget { @@ -197,7 +197,6 @@ pub(crate) fn run_on_text_event_pass(root: &mut RenderRoot, event: &TextEvent) - let forward = !mods.shift_key(); let next_focused_widget = root.widget_from_focus_chain(forward); root.global_state.next_focused_widget = next_focused_widget; - root.global_state.ghost_focus = next_focused_widget; handled = Handled::Yes; } } diff --git a/masonry/src/passes/update.rs b/masonry/src/passes/update.rs index f5c489ad8..b0b9a6f49 100644 --- a/masonry/src/passes/update.rs +++ b/masonry/src/passes/update.rs @@ -530,8 +530,6 @@ pub(crate) fn run_update_focus_pass(root: &mut RenderRoot) { if widget_state.accepts_text_input { root.global_state.emit_signal(RenderRootSignal::StartIme); } - - root.global_state.ghost_focus = Some(next_focused); } else { root.global_state.is_ime_active = false; } diff --git a/masonry/src/render_root.rs b/masonry/src/render_root.rs index 0ba7d9117..82399590d 100644 --- a/masonry/src/render_root.rs +++ b/masonry/src/render_root.rs @@ -73,11 +73,8 @@ pub(crate) struct RenderRootState { pub(crate) signal_queue: VecDeque, pub(crate) focused_widget: Option, pub(crate) focused_path: Vec, - /// The most recently clicked widget. - /// - /// When tab-focusing, this will be used as the base. - pub(crate) ghost_focus: Option, pub(crate) next_focused_widget: Option, + pub(crate) most_recently_clicked_widget: Option, pub(crate) scroll_request_targets: Vec<(WidgetId, Rect)>, pub(crate) hovered_path: Vec, pub(crate) pointer_capture_target: Option, @@ -201,8 +198,8 @@ impl RenderRoot { signal_queue: VecDeque::new(), focused_widget: None, focused_path: Vec::new(), - ghost_focus: None, next_focused_widget: None, + most_recently_clicked_widget: None, scroll_request_targets: Vec::new(), hovered_path: Vec::new(), pointer_capture_target: None, @@ -621,7 +618,10 @@ impl RenderRoot { } pub(crate) fn widget_from_focus_chain(&mut self, forward: bool) -> Option { - let focused_widget = self.global_state.ghost_focus; + let focused_widget = self + .global_state + .focused_widget + .or(self.global_state.most_recently_clicked_widget); let focused_idx = focused_widget.and_then(|focused_widget| { self.focus_chain() .iter()