Skip to repository content599 lines · 23.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:35:28.935Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
input_field.rs
1use std::rc::Rc;
2
3use editor::{Editor, MultiBufferOffset};
4use gpui::{
5 A11ySubtreeBuilder, AccessibleAction, AnyElement, ElementId, Entity, Focusable, Role,
6 TextStyleRefinement,
7 accesskit::{self, ActionData},
8};
9use settings::Settings as _;
10use theme_settings::ThemeSettings;
11use ui::{Tooltip, prelude::*, rems};
12
13#[derive(IntoElement)]
14pub struct SettingsInputField {
15 id: ElementId,
16 initial_text: Option<String>,
17 placeholder: Option<&'static str>,
18 confirm: Option<Rc<dyn Fn(Option<String>, &mut Window, &mut App)>>,
19 tab_index: Option<isize>,
20 use_buffer_font: bool,
21 display_confirm_button: bool,
22 display_clear_button: bool,
23 clear_on_confirm: bool,
24 confirm_on_focus_out: bool,
25 action_slot: Option<AnyElement>,
26 color: Option<Color>,
27 aria_label: Option<SharedString>,
28 aria_description: Option<SharedString>,
29}
30
31impl SettingsInputField {
32 /// Creates a new input field.
33 ///
34 /// The `id` must be unique among sibling elements: it keys the underlying
35 /// editor's state across frames and identifies this field in the
36 /// accessibility tree. Derive it from data unique to the setting being
37 /// edited (e.g. its JSON path).
38 pub fn new(id: impl Into<ElementId>) -> Self {
39 Self {
40 id: id.into(),
41 initial_text: None,
42 placeholder: None,
43 confirm: None,
44 tab_index: None,
45 use_buffer_font: false,
46 display_confirm_button: false,
47 display_clear_button: false,
48 clear_on_confirm: false,
49 confirm_on_focus_out: false,
50 action_slot: None,
51 color: None,
52 aria_label: None,
53 aria_description: None,
54 }
55 }
56
57 pub fn with_initial_text(mut self, initial_text: String) -> Self {
58 self.initial_text = Some(initial_text);
59 self
60 }
61
62 pub fn with_placeholder(mut self, placeholder: &'static str) -> Self {
63 self.placeholder = Some(placeholder);
64 self
65 }
66
67 pub fn on_confirm(
68 mut self,
69 confirm: impl Fn(Option<String>, &mut Window, &mut App) + 'static,
70 ) -> Self {
71 self.confirm = Some(Rc::new(confirm));
72 self
73 }
74
75 pub fn display_confirm_button(mut self) -> Self {
76 self.display_confirm_button = true;
77 self
78 }
79
80 pub fn display_clear_button(mut self) -> Self {
81 self.display_clear_button = true;
82 self
83 }
84
85 pub fn clear_on_confirm(mut self) -> Self {
86 self.clear_on_confirm = true;
87 self
88 }
89
90 pub fn confirm_on_focus_out(mut self) -> Self {
91 self.confirm_on_focus_out = true;
92 self
93 }
94
95 pub fn action_slot(mut self, action: impl IntoElement) -> Self {
96 self.action_slot = Some(action.into_any_element());
97 self
98 }
99
100 pub(crate) fn tab_index(mut self, arg: isize) -> Self {
101 self.tab_index = Some(arg);
102 self
103 }
104
105 pub fn with_buffer_font(mut self) -> Self {
106 self.use_buffer_font = true;
107 self
108 }
109
110 pub fn color(mut self, color: Color) -> Self {
111 self.color = Some(color);
112 self
113 }
114
115 /// Sets the label announced by assistive technology.
116 /// Defaults to the placeholder text, if any.
117 pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {
118 self.aria_label = Some(label.into());
119 self
120 }
121
122 /// Sets the supplementary description announced by assistive technology
123 /// after the field's name, role, and value.
124 pub fn aria_description(mut self, description: impl Into<SharedString>) -> Self {
125 self.aria_description = Some(description.into());
126 self
127 }
128}
129
130impl RenderOnce for SettingsInputField {
131 fn render(self, window: &mut Window, cx: &mut App) -> impl ui::IntoElement {
132 let settings = ThemeSettings::get_global(cx);
133 let use_buffer_font = self.use_buffer_font;
134 let color = self.color.map(|c| c.color(cx));
135 let styles = TextStyleRefinement {
136 font_family: use_buffer_font.then(|| settings.buffer_font.family.clone()),
137 font_size: use_buffer_font.then(|| rems(0.75).into()),
138 color,
139 ..Default::default()
140 };
141
142 let first_render_initial_text = window.use_keyed_state(
143 (self.id.clone(), "first-render-initial-text"),
144 cx,
145 |_, _| self.initial_text.clone(),
146 );
147
148 let editor = window.use_keyed_state((self.id.clone(), "editor"), cx, {
149 let initial_text = self.initial_text.clone();
150 let placeholder = self.placeholder;
151 let mut confirm = self.confirm.clone();
152
153 move |window, cx| {
154 let mut editor = Editor::single_line(window, cx);
155 let editor_focus_handle = editor.focus_handle(cx);
156 if let Some(text) = initial_text {
157 editor.set_text(text, window, cx);
158 }
159
160 if let Some(confirm) = confirm.take()
161 && (self.confirm_on_focus_out
162 || (!self.display_confirm_button
163 && !self.display_clear_button
164 && !self.clear_on_confirm))
165 {
166 cx.on_focus_out(
167 &editor_focus_handle,
168 window,
169 move |editor, _, window, cx| {
170 let text = Some(editor.text(cx));
171 confirm(text, window, cx);
172 },
173 )
174 .detach();
175 }
176
177 if let Some(placeholder) = placeholder {
178 editor.set_placeholder_text(placeholder, window, cx);
179 }
180 editor.set_text_style_refinement(styles);
181 editor
182 }
183 });
184
185 let is_editor_focused = editor.read(cx).is_focused(window);
186 let editor_text = editor.read(cx).text(cx);
187
188 // The cached editor keeps stale text when the setting changes underneath it, so
189 // reconcile it here, skipping focused editors with unsaved edits to avoid clobbering.
190 let synced_text = first_render_initial_text.read(cx);
191 if &self.initial_text != synced_text {
192 let has_unsaved_edits = editor_text != synced_text.as_deref().unwrap_or_default();
193 if !is_editor_focused || !has_unsaved_edits {
194 *first_render_initial_text.as_mut(cx) = self.initial_text.clone();
195 let weak_editor = editor.downgrade();
196 let new_text = self.initial_text.clone().unwrap_or_default();
197
198 window.defer(cx, move |window, cx| {
199 weak_editor
200 .update(cx, |editor, cx| {
201 editor.set_text(new_text, window, cx);
202 })
203 .ok();
204 });
205 }
206 }
207
208 let weak_editor = editor.downgrade();
209 let weak_editor_for_button = editor.downgrade();
210 let weak_editor_for_clear = editor.downgrade();
211
212 let clear_on_confirm = self.clear_on_confirm;
213 let clear_on_confirm_for_button = self.clear_on_confirm;
214
215 let display_confirm_button = self.display_confirm_button;
216 let display_clear_button = self.display_clear_button;
217 let confirm_for_button = self.confirm.clone();
218 let is_editor_empty = editor_text.trim().is_empty();
219
220 let aria_label = self
221 .aria_label
222 .or_else(|| self.placeholder.map(SharedString::new_static));
223 let aria_description = self.aria_description;
224
225 let (a11y_value, a11y_text_runs) =
226 text_field_a11y_state(self.id.clone(), &editor, window, cx);
227
228 let theme_colors = cx.theme().colors();
229
230 h_flex()
231 .id(self.id.clone())
232 .role(Role::TextInput)
233 .when_some(aria_label, |this, label| this.aria_label(label))
234 .when_some(aria_description, |this, description| {
235 this.aria_description(description)
236 })
237 .aria_value(a11y_value)
238 .when_some(self.placeholder, |this, placeholder| {
239 this.aria_placeholder(placeholder)
240 })
241 .a11y_synthetic_children(a11y_text_runs)
242 .on_a11y_action(AccessibleAction::SetValue, {
243 let weak_editor = editor.downgrade();
244 let confirm = self.confirm.clone();
245 move |data, window, cx| {
246 let Some(ActionData::Value(text)) = data else {
247 return;
248 };
249 let Some(editor) = weak_editor.upgrade() else {
250 return;
251 };
252 let text = text.to_string();
253 editor.update(cx, |editor, cx| {
254 editor.set_text(text.clone(), window, cx);
255 });
256 if let Some(confirm) = confirm.as_ref() {
257 let new_value = (!text.is_empty()).then_some(text);
258 confirm(new_value, window, cx);
259 }
260 }
261 })
262 .group("settings-input-field-editor")
263 .relative()
264 .py_1()
265 .px_2()
266 .h_8()
267 .min_w_64()
268 .rounded_md()
269 .border_1()
270 .border_color(theme_colors.border)
271 .bg(theme_colors.editor_background)
272 .map(|this| {
273 let focus_handle = editor.focus_handle(cx);
274 let focus_handle = if let Some(tab_index) = self.tab_index {
275 focus_handle.tab_index(tab_index).tab_stop(true)
276 } else {
277 focus_handle
278 };
279 this.track_focus(&focus_handle)
280 .focus(|s| s.border_color(theme_colors.border_focused))
281 })
282 .child(editor)
283 .child(
284 h_flex()
285 .absolute()
286 .top_1()
287 .right_1()
288 .invisible()
289 .when(is_editor_focused, |this| this.visible())
290 .group_hover("settings-input-field-editor", |this| this.visible())
291 .when(
292 display_clear_button && !is_editor_empty && is_editor_focused,
293 |this| {
294 this.child(
295 IconButton::new("clear-button", IconName::Close)
296 .icon_size(IconSize::Small)
297 .icon_color(Color::Muted)
298 .aria_label("Clear")
299 .tooltip(Tooltip::text("Clear"))
300 .on_click(move |_, window, cx| {
301 let Some(editor) = weak_editor_for_clear.upgrade() else {
302 return;
303 };
304 editor.update(cx, |editor, cx| {
305 editor.set_text("", window, cx);
306 });
307 }),
308 )
309 },
310 )
311 .when(
312 display_confirm_button && !is_editor_empty && is_editor_focused,
313 |this| {
314 this.child(
315 IconButton::new("confirm-button", IconName::Check)
316 .icon_size(IconSize::Small)
317 .icon_color(Color::Success)
318 .aria_label("Confirm")
319 .tooltip(Tooltip::text("Enter to Confirm"))
320 .on_click(move |_, window, cx| {
321 let Some(confirm) = confirm_for_button.as_ref() else {
322 return;
323 };
324 let Some(editor) = weak_editor_for_button.upgrade() else {
325 return;
326 };
327 let new_value =
328 editor.read_with(cx, |editor, cx| editor.text(cx));
329 let new_value =
330 (!new_value.is_empty()).then_some(new_value);
331 confirm(new_value, window, cx);
332 if clear_on_confirm_for_button {
333 editor.update(cx, |editor, cx| {
334 editor.set_text("", window, cx);
335 });
336 }
337 }),
338 )
339 },
340 )
341 .when_some(self.action_slot, |this, action| this.child(action)),
342 )
343 .when_some(self.confirm, |this, confirm| {
344 this.on_action::<menu::Confirm>({
345 move |_, window, cx| {
346 let Some(editor) = weak_editor.upgrade() else {
347 return;
348 };
349 let new_value = editor.read_with(cx, |editor, cx| editor.text(cx));
350 let new_value = (!new_value.is_empty()).then_some(new_value);
351 confirm(new_value, window, cx);
352 if clear_on_confirm {
353 editor.update(cx, |editor, cx| {
354 editor.set_text("", window, cx);
355 });
356 }
357 }
358 })
359 })
360 }
361}
362
363/// Compute the shared accessibility state for a focusable wrapper around a
364/// single-line [`Editor`]:
365///
366/// - The value to report via `aria_value`. While the editor is focused this
367/// is frozen at its focus-time content: screen readers announce the full value
368/// on every change of a focused control, which would re-read the whole content
369/// on each keystroke. The snapshot re-syncs on blur.
370/// - A closure for `a11y_synthetic_children` exposing the editor's live text
371/// and selection as AccessKit text runs, enabling the platform text
372/// pattern (caret tracking, review commands, typed-character echo).
373///
374/// The caller must also give the element an id, a text input role (e.g.
375/// [`Role::TextInput`]), a label, and track the editor's focus handle.
376///
377/// All work is skipped when accessibility is inactive (no assistive
378/// technology connected), since the results are only observable through the
379/// accessibility tree.
380///
381/// Note: much of this may want
382pub(crate) fn text_field_a11y_state(
383 state_key: impl Into<ElementId>,
384 editor: &Entity<Editor>,
385 window: &mut Window,
386 cx: &mut App,
387) -> (String, impl FnOnce(&mut A11ySubtreeBuilder) + 'static) {
388 let state = window.is_a11y_active().then(|| {
389 let (text, selection_head, selection_tail) = editor.update(cx, |editor, cx| {
390 let display_snapshot = editor.display_snapshot(cx);
391 let selection = editor
392 .selections
393 .newest::<MultiBufferOffset>(&display_snapshot);
394 (editor.text(cx), selection.head().0, selection.tail().0)
395 });
396 let is_focused = editor.read(cx).is_focused(window);
397
398 let a11y_value = window.use_keyed_state((state_key.into(), "a11y-value"), cx, {
399 let text = text.clone();
400 move |_, _| text
401 });
402 if !is_focused && *a11y_value.read(cx) != text {
403 *a11y_value.as_mut(cx) = text.clone();
404 }
405 let frozen_value = a11y_value.read(cx).clone();
406
407 (frozen_value, text, selection_head, selection_tail)
408 });
409
410 let (frozen_value, run_data) = match state {
411 Some((frozen_value, text, selection_head, selection_tail)) => {
412 (frozen_value, Some((text, selection_head, selection_tail)))
413 }
414 None => (String::new(), None),
415 };
416
417 let text_runs = move |builder: &mut A11ySubtreeBuilder| {
418 if let Some((text, selection_head, selection_tail)) = run_data {
419 push_a11y_text_runs(builder, &text, selection_tail, selection_head);
420 }
421 };
422
423 (frozen_value, text_runs)
424}
425
426/// AccessKit's `word_starts` uses `u8` indices, so a single text run cannot
427/// exceed this many characters. Longer text is split into multiple runs.
428const MAX_CHARS_PER_TEXT_RUN: usize = 255;
429
430fn is_word_char(c: char) -> bool {
431 c.is_alphanumeric() || c == '_'
432}
433
434fn char_index_for_byte(text: &str, byte_offset: usize) -> usize {
435 text.char_indices()
436 .take_while(|(byte_ix, _)| *byte_ix < byte_offset)
437 .count()
438}
439
440/// Convert a character index into an AccessKit text position, accounting for
441/// text that is split into multiple runs.
442///
443/// `synthetic_node_id` maps a chunk index to the run's node id (in practice
444/// [`A11ySubtreeBuilder::synthetic_node_id`]); it is a parameter so this
445/// arithmetic can be property-tested without constructing a builder.
446fn a11y_text_position(
447 char_index: usize,
448 synthetic_node_id: impl Fn(u64) -> accesskit::NodeId,
449) -> accesskit::TextPosition {
450 // A position landing exactly on a chunk boundary refers to the end of the
451 // previous chunk rather than the start of the next one.
452 let chunk_index = if char_index > 0 && char_index.is_multiple_of(MAX_CHARS_PER_TEXT_RUN) {
453 char_index / MAX_CHARS_PER_TEXT_RUN - 1
454 } else {
455 char_index / MAX_CHARS_PER_TEXT_RUN
456 };
457 accesskit::TextPosition {
458 node: synthetic_node_id(chunk_index as u64),
459 character_index: char_index - chunk_index * MAX_CHARS_PER_TEXT_RUN,
460 }
461}
462
463/// Split `text` into AccessKit text runs (chunked small enough that per-run
464/// character indices fit AccessKit's `u8`-indexed `word_starts`), and compute
465/// the text selection for the given byte offsets.
466///
467/// `synthetic_node_id` maps a chunk index to that run's node id. Returns the
468/// runs in order plus the selection, leaving it to the caller to push them —
469/// this keeps the logic free of [`A11ySubtreeBuilder`] so it can be
470/// property-tested against arbitrary strings.
471///
472/// `selection_tail` and `selection_head` are byte offsets into `text`.
473fn build_a11y_text_runs(
474 text: &str,
475 selection_tail: usize,
476 selection_head: usize,
477 synthetic_node_id: impl Fn(u64) -> accesskit::NodeId,
478) -> (
479 Vec<(accesskit::NodeId, accesskit::Node)>,
480 accesskit::TextSelection,
481) {
482 let chars: Vec<char> = text.chars().collect();
483 let total_chars = chars.len();
484 // Build at least one (possibly empty) run so the text pattern remains
485 // supported when the field is empty.
486 let num_chunks = total_chars.div_ceil(MAX_CHARS_PER_TEXT_RUN).max(1);
487
488 let mut word_starts = Vec::new();
489 let mut was_word_char = false;
490 for (ix, c) in chars.iter().enumerate() {
491 let is_word = is_word_char(*c);
492 if is_word && !was_word_char {
493 word_starts.push(ix);
494 }
495 was_word_char = is_word;
496 }
497
498 let mut runs = Vec::with_capacity(num_chunks);
499 for chunk_index in 0..num_chunks {
500 let char_start = chunk_index * MAX_CHARS_PER_TEXT_RUN;
501 let char_end = (char_start + MAX_CHARS_PER_TEXT_RUN).min(total_chars);
502 let chunk_chars = &chars[char_start..char_end];
503
504 let mut node = accesskit::Node::new(accesskit::Role::TextRun);
505 node.set_text_direction(accesskit::TextDirection::LeftToRight);
506 node.set_value(chunk_chars.iter().collect::<String>());
507 node.set_character_lengths(
508 chunk_chars
509 .iter()
510 .map(|c| c.len_utf8() as u8)
511 .collect::<Vec<u8>>(),
512 );
513 node.set_word_starts(
514 word_starts
515 .iter()
516 .filter(|&&word_start| word_start >= char_start && word_start < char_end)
517 .map(|&word_start| (word_start - char_start) as u8)
518 .collect::<Vec<u8>>(),
519 );
520 if chunk_index > 0 {
521 node.set_previous_on_line(synthetic_node_id(chunk_index as u64 - 1));
522 }
523 if chunk_index + 1 < num_chunks {
524 node.set_next_on_line(synthetic_node_id(chunk_index as u64 + 1));
525 }
526
527 runs.push((synthetic_node_id(chunk_index as u64), node));
528 }
529
530 let anchor = a11y_text_position(
531 char_index_for_byte(text, selection_tail),
532 &synthetic_node_id,
533 );
534 let focus = a11y_text_position(
535 char_index_for_byte(text, selection_head),
536 &synthetic_node_id,
537 );
538 (runs, accesskit::TextSelection { anchor, focus })
539}
540
541/// Expose the field's text content as AccessKit text runs, plus the current
542/// selection/caret, enabling the platform's text pattern (e.g. UIA
543/// TextPattern on Windows) so screen readers can track the caret, review the
544/// text, and handle typed-character echo natively.
545///
546/// `selection_tail` and `selection_head` are byte offsets into `text`.
547fn push_a11y_text_runs(
548 builder: &mut A11ySubtreeBuilder,
549 text: &str,
550 selection_tail: usize,
551 selection_head: usize,
552) {
553 let (runs, selection) = build_a11y_text_runs(text, selection_tail, selection_head, |chunk| {
554 builder.synthetic_node_id(chunk)
555 });
556 for (id, node) in runs {
557 builder.push_child(id, node);
558 }
559 builder.parent_node().set_text_selection(selection);
560}
561
562#[cfg(test)]
563mod tests {
564 use super::build_a11y_text_runs;
565 use gpui::accesskit::NodeId;
566 use gpui::proptest::strategy::Strategy;
567
568 /// A strategy producing strings with a deliberate mix of character
569 /// categories — ASCII, Latin accents, Cyrillic, Arabic, CJK, emoji, and
570 /// arbitrary scalars — so run-splitting is exercised across scripts and
571 /// byte widths (1–4 UTF-8 bytes). Lengths reach past one chunk (255 chars).
572 fn arbitrary_text() -> impl Strategy<Value = String> {
573 let character = gpui::proptest::prop_oneof![
574 gpui::proptest::char::range(' ', '~'), // ASCII printable
575 gpui::proptest::char::range('\u{00A1}', '\u{00FF}'), // Latin-1 (accents)
576 gpui::proptest::char::range('\u{0100}', '\u{024F}'), // Latin Extended-A/B
577 gpui::proptest::char::range('\u{0400}', '\u{04FF}'), // Cyrillic
578 gpui::proptest::char::range('\u{0600}', '\u{06FF}'), // Arabic
579 gpui::proptest::char::range('\u{4E00}', '\u{9FFF}'), // CJK Unified Ideographs
580 gpui::proptest::char::range('\u{1F300}', '\u{1FAFF}'), // emoji & pictographs
581 gpui::proptest::char::any(), // anything else
582 ];
583 gpui::proptest::collection::vec(character, 0..600)
584 .prop_map(|chars| chars.into_iter().collect::<String>())
585 }
586
587 /// Splitting an arbitrary string into AccessKit text runs must never panic,
588 /// for any text and any byte selection offsets — including empty text, text
589 /// spanning multiple chunks, multi-byte characters, and offsets past the end.
590 #[gpui::property_test]
591 fn building_text_runs_never_panics(
592 #[strategy = arbitrary_text()] text: String,
593 selection_tail: usize,
594 selection_head: usize,
595 ) {
596 let _ = build_a11y_text_runs(&text, selection_tail, selection_head, NodeId);
597 }
598}
599