From 260be447ec869f2f4b4ccbcc26818e33366e167d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 25 Sep 2026 18:06:00 -0700 Subject: [PATCH 1/3] input: Fix accessibility focus and SetValue Bind semantic input focus to the editor and send accessibility value changes through the existing user-edit path. Preserve addon focus rings, read-only and disabled guards, masked value privacy, change events and undo. --- crates/component/src/input/input.rs | 122 ++++++++++++++++++++++------ crates/component/src/input/state.rs | 12 ++- 2 files changed, 105 insertions(+), 29 deletions(-) diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index 69af2aa802..d3c2650074 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -485,21 +485,32 @@ impl Input { let Some(gpui::accesskit::ActionData::Value(value)) = data else { return; }; - state.replace_all(value.to_string(), window, cx); + state.accessibility_set_value(value, window, cx); + } + + fn handle_accessibility_focus(state: &TextInputState, window: &mut Window, cx: &mut App) { + if !state.presentation(cx).is_disabled() { + state.focus(window, cx); + } } /// This method must after the refine_style. fn render_editor( input_state: TextInputState, search_panel: Option, + focus_scope: &gpui::FocusHandle, _: &Window, ) -> impl IntoElement { - v_flex().size_full().children(search_panel).child( - div() - .relative() - .flex_1() - .child(input_state.into_any_element()), - ) + v_flex() + .track_focus(focus_scope) + .size_full() + .children(search_panel) + .child( + div() + .relative() + .flex_1() + .child(input_state.into_any_element()), + ) } } @@ -661,14 +672,18 @@ impl RenderOnce for Input { if input_focused { sync_native_content_type(window, content_type, presentation.is_editable()); } - let frame_focus_handle = window - .use_keyed_state(("input-frame-focus", state.entity_id()), cx, |_, cx| { - cx.focus_handle() + // The semantic frame tracks the editor; addons retain their own focus scopes. + let [prefix_focus, suffix_focus, editor_scope_focus] = window + .use_keyed_state(("input-addon-focus", state.entity_id()), cx, |_, cx| { + [cx.focus_handle(), cx.focus_handle(), cx.focus_handle()] }) .read(cx) .clone(); let focused = input_focused - || (frame_focus_handle.contains_focused(window, cx) && !presentation.is_disabled()); + || (!presentation.is_disabled() + && [&prefix_focus, &suffix_focus, &editor_scope_focus] + .iter() + .any(|focus| focus.contains_focused(window, cx))); let gap_x = match self.size { Size::Small => px(4.), @@ -713,7 +728,7 @@ impl RenderOnce for Input { BaseInput::new(id) .focused(focused) .disabled(disabled) - .track_focus(&frame_focus_handle) + .track_focus(presentation.focus_handle()) .styles(|styles| { styles.focused(|style| { style.when( @@ -729,7 +744,11 @@ impl RenderOnce for Input { this.aria_placeholder(placeholder) }) .when_some(accessibility_value, |this, value| this.aria_value(value)) - .when(!disabled, |this| { + .on_a11y_action(AccessibleAction::Focus, { + let state = state.clone(); + move |_, window, cx| Self::handle_accessibility_focus(&state, window, cx) + }) + .when(presentation.is_editable(), |this| { this.on_a11y_action(AccessibleAction::SetValue, move |data, window, cx| { Self::handle_accessibility_set_value(&accessibility_state, data, window, cx); }) @@ -763,11 +782,17 @@ impl RenderOnce for Input { ) .children(prefix.map(|p| { div() + .track_focus(&prefix_focus) .when(presentation.is_disabled(), |this| this.opacity(0.5)) .child(p) })) .when(presentation.is_multi_line(), |this| { - this.child(Self::render_editor(state.clone(), overlays.search, window)) + this.child(Self::render_editor( + state.clone(), + overlays.search, + &editor_scope_focus, + window, + )) }) .when(!presentation.is_multi_line(), |this| { this.child(state.clone().into_any_element()) @@ -776,6 +801,7 @@ impl RenderOnce for Input { this.pr(self.size.input_px()).child( h_flex() .id("suffix") + .track_focus(&suffix_focus) .gap(gap_x) .items_center() .cursor_default() @@ -1001,15 +1027,17 @@ mod tests { ) -> impl IntoElement { let state = self.state.clone(); let emitted = self.emitted.clone(); - div().on_prepaint(move |_, window, cx| { - let input = Input::new(&state).render(window, cx).into_element(); - let mut node = gpui::accesskit::Node::new(Role::TextInput); - input.write_a11y_info(&mut node); - *emitted.lock().unwrap() = Some(( - node.value().map(ToOwned::to_owned), - node.supports_action(AccessibleAction::SetValue), - )); - }) + div() + .child(Input::new(&state)) + .on_prepaint(move |_, window, cx| { + let input = Input::new(&state).render(window, cx).into_element(); + let mut node = gpui::accesskit::Node::new(Role::TextInput); + input.write_a11y_info(&mut node); + *emitted.lock().unwrap() = Some(( + node.value().map(ToOwned::to_owned), + node.supports_action(AccessibleAction::SetValue), + )); + }) } } @@ -1032,14 +1060,58 @@ mod tests { let base: TextInputState = state.clone().into(); cx.update(|window, cx| { Input::handle_accessibility_set_value(&base, None, window, cx); + Input::handle_accessibility_focus(&base, window, cx); + assert!(base.presentation(cx).focus_handle().is_focused(window)); + window.draw(cx).clear(cx); }); assert_eq!(state.read_with(cx, |state, _| state.value()), "initial"); - let action = gpui::accesskit::ActionData::Value("updated".into()); + let changes = std::rc::Rc::new(std::cell::Cell::new(0)); + let observed = changes.clone(); + let _subscription = cx.update(|_, cx| { + cx.subscribe(&state, move |_, event: &super::super::InputEvent, _| { + if matches!(event, super::super::InputEvent::Change) { + observed.set(observed.get() + 1); + } + }) + }); + let action = gpui::accesskit::ActionData::Value("updated🦀".into()); + cx.update(|window, cx| { + Input::handle_accessibility_set_value(&base, Some(&action), window, cx); + }); + assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀"); + assert_eq!(changes.get(), 1); + for disabled in [false, true] { + cx.update(|window, cx| { + base.set_disabled(disabled, cx); + base.set_readonly(!disabled, cx); + window.blur(cx); + Input::handle_accessibility_focus(&base, window, cx); + assert_eq!( + base.presentation(cx).focus_handle().is_focused(window), + !disabled + ); + let action = gpui::accesskit::ActionData::Value("rejected".into()); + Input::handle_accessibility_set_value(&base, Some(&action), window, cx); + }); + assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀"); + assert_eq!(changes.get(), 1); + } + cx.update(|window, cx| { + base.set_disabled(false, cx); + base.set_readonly(false, cx); + Input::handle_accessibility_focus(&base, window, cx); + window.draw(cx).clear(cx); + window.dispatch_action(Box::new(super::super::Undo), cx); + }); + assert_eq!(state.read_with(cx, |state, _| state.value()), "initial"); cx.update(|window, cx| { + state.update(cx, |state, cx| state.set_masked(true, window, cx)); Input::handle_accessibility_set_value(&base, Some(&action), window, cx); + window.draw(cx).clear(cx); }); - assert_eq!(state.read_with(cx, |state, _| state.value()), "updated"); + assert_eq!(state.read_with(cx, |state, _| state.value()), "updated🦀"); + assert_eq!(*captured.lock().unwrap(), Some((None, true))); } #[gpui::test] diff --git a/crates/component/src/input/state.rs b/crates/component/src/input/state.rs index 02d32a3544..c765f1f6eb 100644 --- a/crates/component/src/input/state.rs +++ b/crates/component/src/input/state.rs @@ -1,5 +1,7 @@ use crate::root::WindowState; -use gpui::{App, Entity, FocusHandle, Focusable as _, SharedString, Window}; +use gpui::{ + App, Entity, EntityInputHandler as _, FocusHandle, Focusable as _, SharedString, Window, +}; use gpui_base::OtpState; use ropey::Rope; @@ -206,9 +208,11 @@ impl TextInputState { )) } - pub(crate) fn replace_all(&self, value: String, window: &mut Window, cx: &mut App) { - dispatch!(self, |state| state - .update(cx, |state, cx| state.replace_all(value, window, cx))) + pub(crate) fn accessibility_set_value(&self, value: &str, window: &mut Window, cx: &mut App) { + dispatch!(self, |state| state.update(cx, |state, cx| { + let range = 0..state.text().len_utf16(); + state.replace_text_in_range(Some(range), value, window, cx); + })) } /// The text element itself, as a child to place in the frame. From 84a4c8cbd9e402356cd0ee163cad34e19f6848fc Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 26 Sep 2026 21:55:32 +0800 Subject: [PATCH 2/3] input: Preserve exact accessibility SetValue in editors --- crates/base/src/input/base/state.rs | 18 ++++++++++++++++++ crates/component/src/input/input.rs | 23 +++++++++++++++++++++++ crates/component/src/input/state.rs | 10 +++------- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index 722a78c0cd..ec9f9125e7 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -962,6 +962,24 @@ impl InputBaseState { cx.notify(); } + /// Replace the whole value as an editable user action, such as an + /// accessibility SetValue request. Read-only and disabled inputs reject + /// it; validation, change events, and undo still apply. Typing helpers + /// such as automatic bracket closing do not run. + pub fn replace_editable_value( + &mut self, + value: &str, + window: &mut Window, + cx: &mut Context, + ) { + if !self.is_editable() { + return; + } + self.undo_manager.set_pending_intent(EditIntent::Atomic); + let range = 0..self.text.len_utf16(); + self.replace_text_in_range_silent(Some(range), value, window, cx); + } + /// Perform `f` with the user-facing edit restrictions lifted. /// /// The `disabled` and `readonly` modes only reject the changes made by the diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index d3c2650074..be5a9dcc57 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -1114,6 +1114,29 @@ mod tests { assert_eq!(*captured.lock().unwrap(), Some((None, true))); } + #[gpui::test] + fn accessibility_set_value_preserves_exact_editor_text(cx: &mut gpui::TestAppContext) { + use gpui::{AppContext as _, Render}; + + struct Probe(Entity); + + impl Render for Probe { + fn render(&mut self, _: &mut Window, _: &mut gpui::Context) -> impl IntoElement { + div().child(crate::input::Editor::new(&self.0)) + } + } + + cx.update(crate::init); + let (probe, cx) = cx.add_window_view(|window, cx| { + Probe(cx.new(|cx| crate::input::EditorState::new(window, cx).language("rust"))) + }); + let editor = probe.read_with(cx, |probe, _| probe.0.clone()); + let state: TextInputState = editor.clone().into(); + + cx.update(|window, cx| state.accessibility_set_value("(", window, cx)); + assert_eq!(editor.read_with(cx, |editor, _| editor.value()), "("); + } + #[gpui::test] fn input_emits_accessibility_id(cx: &mut gpui::TestAppContext) { use crate::ElementExt as _; diff --git a/crates/component/src/input/state.rs b/crates/component/src/input/state.rs index c765f1f6eb..a6842030dc 100644 --- a/crates/component/src/input/state.rs +++ b/crates/component/src/input/state.rs @@ -1,7 +1,5 @@ use crate::root::WindowState; -use gpui::{ - App, Entity, EntityInputHandler as _, FocusHandle, Focusable as _, SharedString, Window, -}; +use gpui::{App, Entity, FocusHandle, Focusable as _, SharedString, Window}; use gpui_base::OtpState; use ropey::Rope; @@ -209,10 +207,8 @@ impl TextInputState { } pub(crate) fn accessibility_set_value(&self, value: &str, window: &mut Window, cx: &mut App) { - dispatch!(self, |state| state.update(cx, |state, cx| { - let range = 0..state.text().len_utf16(); - state.replace_text_in_range(Some(range), value, window, cx); - })) + dispatch!(self, |state| state.update(cx, |state, cx| state + .replace_editable_value(value, window, cx))) } /// The text element itself, as a child to place in the frame. From d5d9c1fdc8c77d9359d3fb26f9de9b5d45ac99d2 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 26 Sep 2026 22:01:51 +0800 Subject: [PATCH 3/3] input: Keep accessibility SetValue on replace_all --- crates/base/src/input/base/state.rs | 18 ------------------ crates/component/src/input/input.rs | 10 ++++++++-- crates/component/src/input/state.rs | 6 +++--- 3 files changed, 11 insertions(+), 23 deletions(-) diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index ec9f9125e7..722a78c0cd 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -962,24 +962,6 @@ impl InputBaseState { cx.notify(); } - /// Replace the whole value as an editable user action, such as an - /// accessibility SetValue request. Read-only and disabled inputs reject - /// it; validation, change events, and undo still apply. Typing helpers - /// such as automatic bracket closing do not run. - pub fn replace_editable_value( - &mut self, - value: &str, - window: &mut Window, - cx: &mut Context, - ) { - if !self.is_editable() { - return; - } - self.undo_manager.set_pending_intent(EditIntent::Atomic); - let range = 0..self.text.len_utf16(); - self.replace_text_in_range_silent(Some(range), value, window, cx); - } - /// Perform `f` with the user-facing edit restrictions lifted. /// /// The `disabled` and `readonly` modes only reject the changes made by the diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index be5a9dcc57..6ef800a5bf 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -485,7 +485,10 @@ impl Input { let Some(gpui::accesskit::ActionData::Value(value)) = data else { return; }; - state.accessibility_set_value(value, window, cx); + if !state.presentation(cx).is_editable() { + return; + } + state.replace_all(value.to_string(), window, cx); } fn handle_accessibility_focus(state: &TextInputState, window: &mut Window, cx: &mut App) { @@ -1132,8 +1135,11 @@ mod tests { }); let editor = probe.read_with(cx, |probe, _| probe.0.clone()); let state: TextInputState = editor.clone().into(); + let action = gpui::accesskit::ActionData::Value("(".into()); - cx.update(|window, cx| state.accessibility_set_value("(", window, cx)); + cx.update(|window, cx| { + Input::handle_accessibility_set_value(&state, Some(&action), window, cx) + }); assert_eq!(editor.read_with(cx, |editor, _| editor.value()), "("); } diff --git a/crates/component/src/input/state.rs b/crates/component/src/input/state.rs index a6842030dc..02d32a3544 100644 --- a/crates/component/src/input/state.rs +++ b/crates/component/src/input/state.rs @@ -206,9 +206,9 @@ impl TextInputState { )) } - pub(crate) fn accessibility_set_value(&self, value: &str, window: &mut Window, cx: &mut App) { - dispatch!(self, |state| state.update(cx, |state, cx| state - .replace_editable_value(value, window, cx))) + pub(crate) fn replace_all(&self, value: String, window: &mut Window, cx: &mut App) { + dispatch!(self, |state| state + .update(cx, |state, cx| state.replace_all(value, window, cx))) } /// The text element itself, as a child to place in the frame.