Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:43:37.960Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

input_field.rs

599 lines · 23.8 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.