598 lines · 23.2 KB · Rust
RawDownload
use std::rc::Rc; use editor::{Editor, MultiBufferOffset};use gpui::{    A11ySubtreeBuilder, AccessibleAction, AnyElement, ElementId, Entity, Focusable, Role,    TextStyleRefinement,    accesskit::{self, ActionData},};use settings::Settings as _;use theme_settings::ThemeSettings;use ui::{Tooltip, prelude::*, rems}; #[derive(IntoElement)]pub struct SettingsInputField {    id: ElementId,    initial_text: Option<String>,    placeholder: Option<&'static str>,    confirm: Option<Rc<dyn Fn(Option<String>, &mut Window, &mut App)>>,    tab_index: Option<isize>,    use_buffer_font: bool,    display_confirm_button: bool,    display_clear_button: bool,    clear_on_confirm: bool,    confirm_on_focus_out: bool,    action_slot: Option<AnyElement>,    color: Option<Color>,    aria_label: Option<SharedString>,    aria_description: Option<SharedString>,} impl SettingsInputField {    /// Creates a new input field.    ///    /// The `id` must be unique among sibling elements: it keys the underlying    /// editor's state across frames and identifies this field in the    /// accessibility tree. Derive it from data unique to the setting being    /// edited (e.g. its JSON path).    pub fn new(id: impl Into<ElementId>) -> Self {        Self {            id: id.into(),            initial_text: None,            placeholder: None,            confirm: None,            tab_index: None,            use_buffer_font: false,            display_confirm_button: false,            display_clear_button: false,            clear_on_confirm: false,            confirm_on_focus_out: false,            action_slot: None,            color: None,            aria_label: None,            aria_description: None,        }    }     pub fn with_initial_text(mut self, initial_text: String) -> Self {        self.initial_text = Some(initial_text);        self    }     pub fn with_placeholder(mut self, placeholder: &'static str) -> Self {        self.placeholder = Some(placeholder);        self    }     pub fn on_confirm(        mut self,        confirm: impl Fn(Option<String>, &mut Window, &mut App) + 'static,    ) -> Self {        self.confirm = Some(Rc::new(confirm));        self    }     pub fn display_confirm_button(mut self) -> Self {        self.display_confirm_button = true;        self    }     pub fn display_clear_button(mut self) -> Self {        self.display_clear_button = true;        self    }     pub fn clear_on_confirm(mut self) -> Self {        self.clear_on_confirm = true;        self    }     pub fn confirm_on_focus_out(mut self) -> Self {        self.confirm_on_focus_out = true;        self    }     pub fn action_slot(mut self, action: impl IntoElement) -> Self {        self.action_slot = Some(action.into_any_element());        self    }     pub(crate) fn tab_index(mut self, arg: isize) -> Self {        self.tab_index = Some(arg);        self    }     pub fn with_buffer_font(mut self) -> Self {        self.use_buffer_font = true;        self    }     pub fn color(mut self, color: Color) -> Self {        self.color = Some(color);        self    }     /// Sets the label announced by assistive technology.    /// Defaults to the placeholder text, if any.    pub fn aria_label(mut self, label: impl Into<SharedString>) -> Self {        self.aria_label = Some(label.into());        self    }     /// Sets the supplementary description announced by assistive technology    /// after the field's name, role, and value.    pub fn aria_description(mut self, description: impl Into<SharedString>) -> Self {        self.aria_description = Some(description.into());        self    }} impl RenderOnce for SettingsInputField {    fn render(self, window: &mut Window, cx: &mut App) -> impl ui::IntoElement {        let settings = ThemeSettings::get_global(cx);        let use_buffer_font = self.use_buffer_font;        let color = self.color.map(|c| c.color(cx));        let styles = TextStyleRefinement {            font_family: use_buffer_font.then(|| settings.buffer_font.family.clone()),            font_size: use_buffer_font.then(|| rems(0.75).into()),            color,            ..Default::default()        };         let first_render_initial_text = window.use_keyed_state(            (self.id.clone(), "first-render-initial-text"),            cx,            |_, _| self.initial_text.clone(),        );         let editor = window.use_keyed_state((self.id.clone(), "editor"), cx, {            let initial_text = self.initial_text.clone();            let placeholder = self.placeholder;            let mut confirm = self.confirm.clone();             move |window, cx| {                let mut editor = Editor::single_line(window, cx);                let editor_focus_handle = editor.focus_handle(cx);                if let Some(text) = initial_text {                    editor.set_text(text, window, cx);                }                 if let Some(confirm) = confirm.take()                    && (self.confirm_on_focus_out                        || (!self.display_confirm_button                            && !self.display_clear_button                            && !self.clear_on_confirm))                {                    cx.on_focus_out(                        &editor_focus_handle,                        window,                        move |editor, _, window, cx| {                            let text = Some(editor.text(cx));                            confirm(text, window, cx);                        },                    )                    .detach();                }                 if let Some(placeholder) = placeholder {                    editor.set_placeholder_text(placeholder, window, cx);                }                editor.set_text_style_refinement(styles);                editor            }        });         let is_editor_focused = editor.read(cx).is_focused(window);        let editor_text = editor.read(cx).text(cx);         // The cached editor keeps stale text when the setting changes underneath it, so        // reconcile it here, skipping focused editors with unsaved edits to avoid clobbering.        let synced_text = first_render_initial_text.read(cx);        if &self.initial_text != synced_text {            let has_unsaved_edits = editor_text != synced_text.as_deref().unwrap_or_default();            if !is_editor_focused || !has_unsaved_edits {                *first_render_initial_text.as_mut(cx) = self.initial_text.clone();                let weak_editor = editor.downgrade();                let new_text = self.initial_text.clone().unwrap_or_default();                 window.defer(cx, move |window, cx| {                    weak_editor                        .update(cx, |editor, cx| {                            editor.set_text(new_text, window, cx);                        })                        .ok();                });            }        }         let weak_editor = editor.downgrade();        let weak_editor_for_button = editor.downgrade();        let weak_editor_for_clear = editor.downgrade();         let clear_on_confirm = self.clear_on_confirm;        let clear_on_confirm_for_button = self.clear_on_confirm;         let display_confirm_button = self.display_confirm_button;        let display_clear_button = self.display_clear_button;        let confirm_for_button = self.confirm.clone();        let is_editor_empty = editor_text.trim().is_empty();         let aria_label = self            .aria_label            .or_else(|| self.placeholder.map(SharedString::new_static));        let aria_description = self.aria_description;         let (a11y_value, a11y_text_runs) =            text_field_a11y_state(self.id.clone(), &editor, window, cx);         let theme_colors = cx.theme().colors();         h_flex()            .id(self.id.clone())            .role(Role::TextInput)            .when_some(aria_label, |this, label| this.aria_label(label))            .when_some(aria_description, |this, description| {                this.aria_description(description)            })            .aria_value(a11y_value)            .when_some(self.placeholder, |this, placeholder| {                this.aria_placeholder(placeholder)            })            .a11y_synthetic_children(a11y_text_runs)            .on_a11y_action(AccessibleAction::SetValue, {                let weak_editor = editor.downgrade();                let confirm = self.confirm.clone();                move |data, window, cx| {                    let Some(ActionData::Value(text)) = data else {                        return;                    };                    let Some(editor) = weak_editor.upgrade() else {                        return;                    };                    let text = text.to_string();                    editor.update(cx, |editor, cx| {                        editor.set_text(text.clone(), window, cx);                    });                    if let Some(confirm) = confirm.as_ref() {                        let new_value = (!text.is_empty()).then_some(text);                        confirm(new_value, window, cx);                    }                }            })            .group("settings-input-field-editor")            .relative()            .py_1()            .px_2()            .h_8()            .min_w_64()            .rounded_md()            .border_1()            .border_color(theme_colors.border)            .bg(theme_colors.editor_background)            .map(|this| {                let focus_handle = editor.focus_handle(cx);                let focus_handle = if let Some(tab_index) = self.tab_index {                    focus_handle.tab_index(tab_index).tab_stop(true)                } else {                    focus_handle                };                this.track_focus(&focus_handle)                    .focus(|s| s.border_color(theme_colors.border_focused))            })            .child(editor)            .child(                h_flex()                    .absolute()                    .top_1()                    .right_1()                    .invisible()                    .when(is_editor_focused, |this| this.visible())                    .group_hover("settings-input-field-editor", |this| this.visible())                    .when(                        display_clear_button && !is_editor_empty && is_editor_focused,                        |this| {                            this.child(                                IconButton::new("clear-button", IconName::Close)                                    .icon_size(IconSize::Small)                                    .icon_color(Color::Muted)                                    .aria_label("Clear")                                    .tooltip(Tooltip::text("Clear"))                                    .on_click(move |_, window, cx| {                                        let Some(editor) = weak_editor_for_clear.upgrade() else {                                            return;                                        };                                        editor.update(cx, |editor, cx| {                                            editor.set_text("", window, cx);                                        });                                    }),                            )                        },                    )                    .when(                        display_confirm_button && !is_editor_empty && is_editor_focused,                        |this| {                            this.child(                                IconButton::new("confirm-button", IconName::Check)                                    .icon_size(IconSize::Small)                                    .icon_color(Color::Success)                                    .aria_label("Confirm")                                    .tooltip(Tooltip::text("Enter to Confirm"))                                    .on_click(move |_, window, cx| {                                        let Some(confirm) = confirm_for_button.as_ref() else {                                            return;                                        };                                        let Some(editor) = weak_editor_for_button.upgrade() else {                                            return;                                        };                                        let new_value =                                            editor.read_with(cx, |editor, cx| editor.text(cx));                                        let new_value =                                            (!new_value.is_empty()).then_some(new_value);                                        confirm(new_value, window, cx);                                        if clear_on_confirm_for_button {                                            editor.update(cx, |editor, cx| {                                                editor.set_text("", window, cx);                                            });                                        }                                    }),                            )                        },                    )                    .when_some(self.action_slot, |this, action| this.child(action)),            )            .when_some(self.confirm, |this, confirm| {                this.on_action::<menu::Confirm>({                    move |_, window, cx| {                        let Some(editor) = weak_editor.upgrade() else {                            return;                        };                        let new_value = editor.read_with(cx, |editor, cx| editor.text(cx));                        let new_value = (!new_value.is_empty()).then_some(new_value);                        confirm(new_value, window, cx);                        if clear_on_confirm {                            editor.update(cx, |editor, cx| {                                editor.set_text("", window, cx);                            });                        }                    }                })            })    }} /// Compute the shared accessibility state for a focusable wrapper around a/// single-line [`Editor`]:////// - The value to report via `aria_value`. While the editor is focused this///   is frozen at its focus-time content: screen readers announce the full value///   on every change of a focused control, which would re-read the whole content///   on each keystroke. The snapshot re-syncs on blur./// - A closure for `a11y_synthetic_children` exposing the editor's live text///   and selection as AccessKit text runs, enabling the platform text///   pattern (caret tracking, review commands, typed-character echo).////// The caller must also give the element an id, a text input role (e.g./// [`Role::TextInput`]), a label, and track the editor's focus handle.////// All work is skipped when accessibility is inactive (no assistive/// technology connected), since the results are only observable through the/// accessibility tree.////// Note: much of this may wantpub(crate) fn text_field_a11y_state(    state_key: impl Into<ElementId>,    editor: &Entity<Editor>,    window: &mut Window,    cx: &mut App,) -> (String, impl FnOnce(&mut A11ySubtreeBuilder) + 'static) {    let state = window.is_a11y_active().then(|| {        let (text, selection_head, selection_tail) = editor.update(cx, |editor, cx| {            let display_snapshot = editor.display_snapshot(cx);            let selection = editor                .selections                .newest::<MultiBufferOffset>(&display_snapshot);            (editor.text(cx), selection.head().0, selection.tail().0)        });        let is_focused = editor.read(cx).is_focused(window);         let a11y_value = window.use_keyed_state((state_key.into(), "a11y-value"), cx, {            let text = text.clone();            move |_, _| text        });        if !is_focused && *a11y_value.read(cx) != text {            *a11y_value.as_mut(cx) = text.clone();        }        let frozen_value = a11y_value.read(cx).clone();         (frozen_value, text, selection_head, selection_tail)    });     let (frozen_value, run_data) = match state {        Some((frozen_value, text, selection_head, selection_tail)) => {            (frozen_value, Some((text, selection_head, selection_tail)))        }        None => (String::new(), None),    };     let text_runs = move |builder: &mut A11ySubtreeBuilder| {        if let Some((text, selection_head, selection_tail)) = run_data {            push_a11y_text_runs(builder, &text, selection_tail, selection_head);        }    };     (frozen_value, text_runs)} /// AccessKit's `word_starts` uses `u8` indices, so a single text run cannot/// exceed this many characters. Longer text is split into multiple runs.const MAX_CHARS_PER_TEXT_RUN: usize = 255; fn is_word_char(c: char) -> bool {    c.is_alphanumeric() || c == '_'} fn char_index_for_byte(text: &str, byte_offset: usize) -> usize {    text.char_indices()        .take_while(|(byte_ix, _)| *byte_ix < byte_offset)        .count()} /// Convert a character index into an AccessKit text position, accounting for/// text that is split into multiple runs.////// `synthetic_node_id` maps a chunk index to the run's node id (in practice/// [`A11ySubtreeBuilder::synthetic_node_id`]); it is a parameter so this/// arithmetic can be property-tested without constructing a builder.fn a11y_text_position(    char_index: usize,    synthetic_node_id: impl Fn(u64) -> accesskit::NodeId,) -> accesskit::TextPosition {    // A position landing exactly on a chunk boundary refers to the end of the    // previous chunk rather than the start of the next one.    let chunk_index = if char_index > 0 && char_index.is_multiple_of(MAX_CHARS_PER_TEXT_RUN) {        char_index / MAX_CHARS_PER_TEXT_RUN - 1    } else {        char_index / MAX_CHARS_PER_TEXT_RUN    };    accesskit::TextPosition {        node: synthetic_node_id(chunk_index as u64),        character_index: char_index - chunk_index * MAX_CHARS_PER_TEXT_RUN,    }} /// Split `text` into AccessKit text runs (chunked small enough that per-run/// character indices fit AccessKit's `u8`-indexed `word_starts`), and compute/// the text selection for the given byte offsets.////// `synthetic_node_id` maps a chunk index to that run's node id. Returns the/// runs in order plus the selection, leaving it to the caller to push them —/// this keeps the logic free of [`A11ySubtreeBuilder`] so it can be/// property-tested against arbitrary strings.////// `selection_tail` and `selection_head` are byte offsets into `text`.fn build_a11y_text_runs(    text: &str,    selection_tail: usize,    selection_head: usize,    synthetic_node_id: impl Fn(u64) -> accesskit::NodeId,) -> (    Vec<(accesskit::NodeId, accesskit::Node)>,    accesskit::TextSelection,) {    let chars: Vec<char> = text.chars().collect();    let total_chars = chars.len();    // Build at least one (possibly empty) run so the text pattern remains    // supported when the field is empty.    let num_chunks = total_chars.div_ceil(MAX_CHARS_PER_TEXT_RUN).max(1);     let mut word_starts = Vec::new();    let mut was_word_char = false;    for (ix, c) in chars.iter().enumerate() {        let is_word = is_word_char(*c);        if is_word && !was_word_char {            word_starts.push(ix);        }        was_word_char = is_word;    }     let mut runs = Vec::with_capacity(num_chunks);    for chunk_index in 0..num_chunks {        let char_start = chunk_index * MAX_CHARS_PER_TEXT_RUN;

Lines 1–500 of 598.