From 457da888a20ce9e37a80defc8c06841fe74cc105 Mon Sep 17 00:00:00 2001 From: suxiaoshao <48886207+suxiaoshao@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:09:02 +0800 Subject: [PATCH 01/11] input: Add atomic inline tokens to Input and Textarea --- crates/base/src/input/base/change.rs | 3 + crates/base/src/input/base/element.rs | 486 +++++++++++++++++- crates/base/src/input/base/inline_tokens.rs | 435 ++++++++++++++++ crates/base/src/input/base/mod.rs | 9 + crates/base/src/input/base/state.rs | 401 +++++++++++++-- .../base/src/input/base/token_presentation.rs | 190 +++++++ crates/base/src/input/base/undo_manager.rs | 11 +- .../input/editor/display_map/display_map.rs | 9 + .../input/editor/display_map/inline_line.rs | 156 ++++++ .../base/src/input/editor/display_map/mod.rs | 2 + .../input/editor/display_map/text_wrapper.rs | 166 +++++- .../src/input/editor/display_map/wrap_map.rs | 9 + crates/base/src/input/input/mod.rs | 26 +- crates/base/src/input/mod.rs | 6 + crates/base/src/input/textarea/mod.rs | 26 +- .../src/shell/input_group/mod.rs | 20 +- .../component-shell/src/shell/input_tokens.rs | 168 ++++++ .../src/shell/layout/textarea.rs | 15 +- crates/component-shell/src/shell/mod.rs | 1 + .../src/shell/retained_forms/mod.rs | 15 +- .../tests/inline_tokens_host.rs | 121 +++++ crates/component-shell/tests/layout_host.rs | 4 + crates/component/locales/ui.yml | 4 + crates/component/src/input/input.rs | 40 ++ crates/component/src/input/mod.rs | 6 + crates/component/src/input/state.rs | 9 + crates/component/src/input/textarea.rs | 26 + crates/component/src/input/token.rs | 72 +++ crates/shell/src/component_registry.rs | 138 ++++- crates/shell/src/engine/quickjs/entity_api.rs | 46 ++ crates/shell/src/engine/quickjs/mod.rs | 262 +++++++++- crates/shell/src/input_tokens.rs | 330 ++++++++++++ crates/shell/src/lib.rs | 7 +- crates/shell/src/materialize.rs | 17 +- .../src/materialize/components/textarea.rs | 13 +- crates/shell/src/runtime.rs | 4 + crates/shell/src/typings.rs | 45 +- crates/story/src/stories/input_story.rs | 5 + crates/story/src/stories/input_tokens.rs | 314 +++++++++++ crates/story/src/stories/mod.rs | 1 + crates/story/src/stories/textarea_story.rs | 5 + examples/js_story/stories/registered.js | 53 ++ website/base/primitives/input.md | 27 + website/base/primitives/textarea.md | 27 + website/component/input-group.md | 20 + website/component/input.md | 162 ++++++ website/component/textarea.md | 18 + website/zh-CN/base/primitives/input.md | 20 + website/zh-CN/base/primitives/textarea.md | 20 + website/zh-CN/component/input-group.md | 17 + website/zh-CN/component/input.md | 117 +++++ website/zh-CN/component/textarea.md | 11 + 52 files changed, 4030 insertions(+), 85 deletions(-) create mode 100644 crates/base/src/input/base/inline_tokens.rs create mode 100644 crates/base/src/input/base/token_presentation.rs create mode 100644 crates/base/src/input/editor/display_map/inline_line.rs create mode 100644 crates/component-shell/src/shell/input_tokens.rs create mode 100644 crates/component-shell/tests/inline_tokens_host.rs create mode 100644 crates/component/src/input/token.rs create mode 100644 crates/shell/src/input_tokens.rs create mode 100644 crates/story/src/stories/input_tokens.rs diff --git a/crates/base/src/input/base/change.rs b/crates/base/src/input/base/change.rs index 6de376829e..e8c136587c 100644 --- a/crates/base/src/input/base/change.rs +++ b/crates/base/src/input/base/change.rs @@ -6,6 +6,7 @@ use crate::input::Selection; /// immediately before the replacement was applied. #[derive(Debug, PartialEq, Clone)] pub(super) struct Change { + pub(super) token_delta: Option>, pub(crate) old_range: Selection, pub(crate) old_text: String, pub(crate) new_range: Selection, @@ -20,6 +21,7 @@ impl Change { new_text: &str, ) -> Self { Self { + token_delta: None, old_range: old_range.into(), old_text: old_text.to_string(), new_range: new_range.into(), @@ -32,6 +34,7 @@ impl Change { pub(super) fn shifted(&self, delta: isize) -> Self { let shift = |offset: usize| (offset as isize + delta).max(0) as usize; Self { + token_delta: self.token_delta.clone(), old_range: (shift(self.old_range.start)..shift(self.old_range.end)).into(), old_text: self.old_text.clone(), new_range: (shift(self.new_range.start)..shift(self.new_range.end)).into(), diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index 6d2be4acc0..4e6cb9b24f 100644 --- a/crates/base/src/input/base/element.rs +++ b/crates/base/src/input/base/element.rs @@ -1374,6 +1374,463 @@ impl TextElement { } } + fn token_element( + &self, + token: &super::InlineTokenContext, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + use gpui::{StatefulInteractiveElement as _, prelude::FluentBuilder as _}; + let presentation = self.state.read(cx).token_presentation.clone(); + let child = presentation.render(token, window, cx); + let id = token.token().id().clone(); + let down_id = id.clone(); + let down_state = self.state.clone(); + let click_state = self.state.clone(); + let move_state = self.state.clone(); + let disabled = token.is_disabled(); + let accessible = presentation.has_listener() && !disabled; + let accessible_state = self.state.clone(); + let accessible_id = id.clone(); + gpui::div() + .id((ElementId::Name("inline-token".into()), id.clone())) + .flex() + .items_center() + .h(token.line_height()) + .max_w(token.available_width()) + .overflow_hidden() + .when(accessible, |this| { + this.role(gpui::Role::Button) + .aria_label(token.token().label().clone()) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, window, cx| { + let state = accessible_state.read(cx); + let activation = state + .token_spans() + .iter() + .find(|s| s.token().id() == &accessible_id) + .and_then(|span| { + state.range_to_bounds(&span.range()).and_then(|bounds| { + state.token_activation( + &accessible_id, + bounds, + gpui::ClickEvent::Keyboard(gpui::KeyboardClickEvent { + bounds, + ..Default::default() + }), + ) + }) + }); + if let Some((listener, event)) = activation { + listener(&event, window, cx); + } + }) + }) + .capture_any_mouse_down(move |_, window, cx| { + if disabled { + window.prevent_default(); + cx.stop_propagation(); + } + }) + .capture_any_mouse_up(move |_, window, cx| { + if disabled { + window.prevent_default(); + cx.stop_propagation(); + } + }) + .capture_key_down(move |_, _, cx| { + if disabled { + cx.stop_propagation(); + } + }) + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + if window.default_prevented() { + return; + } + down_state.update(cx, |state, cx| { + state.pressed_token = None; + if event.click_count == 2 { + if let Some(span) = state + .token_spans() + .iter() + .find(|s| s.token().id() == &down_id) + { + state.set_selected_range(span.range(), cx); + state.focus(window, cx); + window.prevent_default(); + } + } else if event.click_count == 1 + && !event.modifiers.shift + && !event.modifiers.alt + { + state.pressed_token = + Some((down_id.clone(), state.document_revision, event.position)); + } + }); + }) + .on_mouse_move(move |event, _, cx| { + move_state.update(cx, |state, _| { + if state.pressed_token.as_ref().is_some_and(|(_, _, p)| { + (event.position.x - p.x).abs() + (event.position.y - p.y).abs() > px(4.) + }) { + state.pressed_token = None; + } + }); + }) + .on_click(move |event, window, cx| { + let activation = click_state.update(cx, |state, _| { + let (pressed, revision, _) = state.pressed_token.take()?; + if pressed != id || revision != state.document_revision { + return None; + } + if event.modifiers().shift || event.modifiers().alt { + return None; + } + let span = state.token_spans().iter().find(|s| s.token().id() == &id)?; + let bounds = state.range_to_bounds(&span.range())?; + state.token_activation(&id, bounds, event.clone()) + }); + if let Some((listener, event)) = activation { + listener(&event, window, cx); + } + }) + .child(child) + .into_any_element() + } + + fn measure_tokens( + &self, + width: Pixels, + line_height: Pixels, + viewport: Pixels, + window: &mut Window, + cx: &mut App, + ) -> std::collections::HashMap { + let style = window.text_style(); + let state = self.state.read(cx); + let key = ( + style.font(), + style.font_size.to_pixels(window.rem_size()), + width, + line_height, + state.is_single_line() || !state.soft_wrap, + ); + if !state.tokens_visible() { + if state.token_layout_cache.is_none() { + return Default::default(); + } + self.state.update(cx, |state, cx| { + state.display_map.set_inline_metrics(Rc::from([]), cx) + }); + return Default::default(); + } + let revision = state.document_revision; + let cache = state.token_layout_cache.as_ref(); + let all = cache.is_none_or(|cache| { + cache.key.as_ref() != Some(&key) + || cache.revision != revision + || cache.widths.len() != state.token_spans().len() + }); + let (visible, _, _) = self.calculate_visible_range(state, line_height, viewport); + let start = state.text.line_start_offset(visible.start); + let end = state.text.line_end_offset(visible.end.saturating_sub(1)); + let spans = state.token_spans(); + let first = if all { + 0 + } else { + spans.partition_point(|s| s.range().end <= start) + }; + let contexts: Vec<_> = spans[first..] + .iter() + .take_while(|s| all || s.range().start <= end) + .filter(|span| { + let range = span.range(); + (range.start <= end && range.end >= start) + || cache.is_none_or(|cache| { + cache.key.as_ref() != Some(&key) + || cache + .widths + .get(span.token().id()) + .is_none_or(|(t, _)| t != span.token()) + }) + }) + .map(|span| state.token_context(span, line_height, width)) + .collect(); + let mut elements = std::collections::HashMap::new(); + let mut measured = Vec::new(); + for token in contexts { + let mut element = self.token_element(&token, window, cx); + let size = element.layout_as_root( + size( + gpui::AvailableSpace::MaxContent, + gpui::AvailableSpace::Definite(line_height), + ), + window, + cx, + ); + measured.push((token.token().clone(), size.width.min(width).max(px(1.)))); + elements.insert(token.token().id().clone(), element); + } + self.state.update(cx, |state, cx| { + let mut cache = state.token_layout_cache.take().unwrap_or_default(); + let mut changed = cache.key.as_ref() != Some(&key) || cache.revision != revision; + if cache.key.as_ref() != Some(&key) { + cache.widths.clear(); + } + for (token, width) in measured { + changed |= cache + .widths + .get(token.id()) + .is_none_or(|(old, w)| old != &token || *w != width); + cache.widths.insert(token.id().clone(), (token, width)); + } + cache.key = Some(key); + if changed { + let spans = state.token_spans(); + let ids: std::collections::HashSet<_> = + spans.iter().map(|s| s.token().id()).collect(); + cache.widths.retain(|id, _| ids.contains(id)); + cache.metrics = spans + .iter() + .filter_map(|span| { + cache + .widths + .get(span.token().id()) + .map(|(_, width)| (span.range(), *width)) + }) + .collect(); + cache.revision = revision; + if state.is_single_line() || !state.soft_wrap { + let mut rows: Vec<_> = spans + .iter() + .map(|s| state.text.offset_to_point(s.range().start).row) + .collect(); + rows.push(state.display_map.longest_row()); + rows.sort_unstable(); + rows.dedup(); + cache.unwrapped_width = rows + .into_iter() + .map(|row| { + let start = state.text.line_start_offset(row); + let text = state.text.slice_line(row).to_string(); + let mut offset = 0; + let mut width = px(0.); + let first = spans.partition_point(|s| s.range().end <= start); + for span in spans[first..] + .iter() + .take_while(|s| s.range().start < start + text.len()) + { + let local = span.range().start - start..span.range().end - start; + let part = &text[offset..local.start]; + width += window + .text_system() + .shape_line( + part.to_owned().into(), + style.font_size.to_pixels(window.rem_size()), + &[style.to_run(part.len())], + None, + ) + .width; + width += cache + .widths + .get(span.token().id()) + .map_or(px(0.), |(_, w)| *w); + offset = local.end; + } + let part = &text[offset..]; + width + + window + .text_system() + .shape_line( + part.to_owned().into(), + style.font_size.to_pixels(window.rem_size()), + &[style.to_run(part.len())], + None, + ) + .width + }) + .max() + .unwrap_or_default(); + } + } + let metrics = cache.metrics.clone(); + state.token_layout_cache = Some(cache); + state.display_map.set_inline_metrics(metrics, cx); + if state.mode.is_auto_grow() { + let rows = state.mode.rows(); + state.mode.update_auto_grow(&state.display_map); + if state.mode.rows() != rows { + cx.notify(); + } + } + }); + elements + } + + fn layout_token_lines( + state: &InputBaseState, + last_layout: &LastLayout, + font_size: Pixels, + runs: &[TextRun], + window: &mut Window, + ) -> Vec { + use crate::input::display_map::{InlineFragment, InputLine}; + let spans = state.token_spans(); + let cache = state + .token_layout_cache + .as_ref() + .expect("tokens measured before shaping"); + let mut run_offset = 0; + last_layout + .visible_buffer_lines + .iter() + .map(|&row| { + let line_start = state.text.line_start_offset(row); + let text: String = state.text.slice_line(row).into(); + let ranges = if state.is_single_line() { + smallvec::smallvec![0..text.len()] + } else { + state + .display_map + .line(row) + .expect("prepared line") + .wrapped_lines + .clone() + }; + let mut lines: SmallVec<[InputLine; 1]> = SmallVec::new(); + for range in ranges { + let mut fragments = Vec::new(); + let mut offset = range.start; + let mut x = px(0.); + let first = + spans.partition_point(|s| s.range().end <= line_start + range.start); + for span in spans[first..] + .iter() + .take_while(|s| s.range().start < line_start + range.end) + { + let local = span.range().start - line_start..span.range().end - line_start; + if offset < local.start { + let part = offset..local.start; + let shaped = window.text_system().shape_line( + text[part.clone()].to_owned().into(), + font_size, + &runs_for_range(runs, run_offset, &part), + None, + ); + let width = shaped.width; + fragments.push(InlineFragment { + range: part.start - range.start..part.end - range.start, + x, + width, + text: Some(shaped), + }); + x += width; + } + let width = cache + .widths + .get(span.token().id()) + .map_or(px(0.), |(_, width)| *width); + fragments.push(InlineFragment { + range: local.start - range.start..local.end - range.start, + x, + width, + text: None, + }); + x += width; + offset = local.end; + } + if offset < range.end { + let part = offset..range.end; + let shaped = window.text_system().shape_line( + text[part.clone()].to_owned().into(), + font_size, + &runs_for_range(runs, run_offset, &part), + None, + ); + let width = shaped.width; + fragments.push(InlineFragment { + range: part.start - range.start..part.end - range.start, + x, + width, + text: Some(shaped), + }); + } + lines.push(InputLine::inline(text[range].to_owned().into(), fragments)); + } + let indent = state.display_map.line(row).map_or(0, |line| line.indent); + let wrap_indent = if indent > 0 && lines.len() > 1 { + let bytes = text + .char_indices() + .nth(indent as usize) + .map_or(text.len(), |(ix, _)| ix); + lines[0].x_for_index(bytes) + } else { + px(0.) + }; + run_offset += text.len() + 1; + LineLayout::new() + .inline_lines(lines) + .wrap_indent(wrap_indent) + }) + .collect() + } + + fn prepaint_tokens( + &self, + layout: &LastLayout, + bounds: Bounds, + mut measured: std::collections::HashMap, + window: &mut Window, + cx: &mut App, + ) -> Vec { + let state = self.state.read(cx); + if !state.tokens_visible() { + return vec![]; + } + let width = state + .token_layout_cache + .as_ref() + .and_then(|c| c.key.as_ref()) + .map_or(bounds.size.width, |k| k.2); + let mut placements = Vec::new(); + let mut y = layout.visible_top; + for (ix, &row) in layout.visible_buffer_lines.iter().enumerate() { + let start = state.text.line_start_offset(row); + let end = state.text.line_end_offset(row); + let spans = state.token_spans(); + let first = spans.partition_point(|s| s.range().end <= start); + for span in spans[first..].iter().take_while(|s| s.range().start < end) { + if let Some(position) = + layout.lines[ix].position_for_index(span.range().start - start, layout, false) + { + placements.push(( + state.token_context(span, layout.line_height, width), + bounds.origin + position + point(layout.line_number_width, y), + )); + } + } + y += layout.lines[ix].size(layout.line_height).height; + } + placements + .into_iter() + .map(|(token, origin)| { + let mut element = measured.remove(token.token().id()).unwrap_or_else(|| { + let mut element = self.token_element(&token, window, cx); + element.layout_as_root( + size( + gpui::AvailableSpace::MaxContent, + gpui::AvailableSpace::Definite(layout.line_height), + ), + window, + cx, + ); + element + }); + element.prepaint_at(origin, window, cx); + element + }) + .collect() + } + #[allow(clippy::too_many_arguments)] fn layout_lines( state: &InputBaseState, @@ -1386,6 +1843,9 @@ impl TextElement { window: &mut Window, ) -> Vec { let is_single_line = state.is_single_line(); + if state.tokens_visible() { + return Self::layout_token_lines(state, last_layout, font_size, runs, window); + } if is_single_line { let text: SharedString = display_text.to_string().into(); @@ -1657,6 +2117,7 @@ struct CursorRenderInfo { pub(super) struct PrepaintState { /// The lines of entire lines. last_layout: LastLayout, + token_elements: Vec, /// The lines only contains the visible lines in the viewport, based on `visible_range`. /// /// The child is the soft lines. @@ -1864,8 +2325,15 @@ impl Element for TextElement { }); } - let state = self.state.read(cx); let line_height = window.line_height(); + let token_elements = self.measure_tokens( + (bounds.size.width - line_number_width - RIGHT_MARGIN).max(px(1.)), + line_height, + bounds.size.height, + window, + cx, + ); + let state = self.state.read(cx); let (visible_range, visible_buffer_lines, visible_top) = self.calculate_visible_range(&state, line_height, bounds.size.height); @@ -2018,6 +2486,16 @@ impl Element for TextElement { ) .width; } + if state.tokens_visible() { + longest_line_width = if let Some(width) = wrap_width { + width + } else { + state + .token_layout_cache + .as_ref() + .map_or(px(0.), |cache| cache.unwrapped_width) + }; + } last_layout.lines = Rc::new(lines); let (ghost_first_line, ghost_lines) = Self::layout_inline_completion( @@ -2176,7 +2654,9 @@ impl Element for TextElement { self.layout_fold_icons(original_x, &bounds, &last_layout, window, cx); let hitbox = window.insert_hitbox(input_bounds, HitboxBehavior::Normal); + let token_elements = self.prepaint_tokens(&last_layout, bounds, token_elements, window, cx); PrepaintState { + token_elements, hitbox, bounds, last_layout, @@ -2416,6 +2896,10 @@ impl Element for TextElement { } } + for element in &mut prepaint.token_elements { + element.paint(window, cx); + } + // Paint blinking cursors (shared blink state for all carets) if focused && show_cursor { for cursor_info in prepaint.cursor_infos_with_scroll() { diff --git a/crates/base/src/input/base/inline_tokens.rs b/crates/base/src/input/base/inline_tokens.rs new file mode 100644 index 0000000000..788219177d --- /dev/null +++ b/crates/base/src/input/base/inline_tokens.rs @@ -0,0 +1,435 @@ +//! Document annotations for atomic inline objects. Coordinates are always source bytes. +use std::{collections::HashSet, ops::Range}; + +use gpui::{Context, EntityInputHandler as _, SharedString, Window}; +use unicode_segmentation::UnicodeSegmentation as _; + +use super::{InputBaseState, InputModeKind, undo_manager::EditIntent}; + +/// An application-defined occurrence rendered as one inline editing unit. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InlineToken { + id: SharedString, + text: SharedString, + label: SharedString, +} + +impl InlineToken { + pub fn new(id: impl Into, text: impl Into) -> Self { + let text = text.into(); + Self { + id: id.into(), + label: text.clone(), + text, + } + } + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = label.into(); + self + } + pub fn id(&self) -> &SharedString { + &self.id + } + pub fn text(&self) -> &SharedString { + &self.text + } + pub fn label(&self) -> &SharedString { + &self.label + } + + fn validate(&self) -> Result<(), InlineTokenError> { + if self.id.trim().is_empty() + || [&self.text, &self.label].iter().any(|s| { + s.is_empty() + || s.chars() + .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}')) + }) + { + return Err(InlineTokenError::InvalidToken); + } + Ok(()) + } +} + +/// A token and its current half-open UTF-8 byte range. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InlineTokenSpan { + range: Range, + token: InlineToken, +} +impl InlineTokenSpan { + pub fn range(&self) -> Range { + self.range.clone() + } + pub fn token(&self) -> &InlineToken { + &self.token + } + fn shifted(&self, delta: isize) -> Self { + Self { + range: self + .range + .start + .checked_add_signed(delta) + .expect("valid token start") + ..self + .range + .end + .checked_add_signed(delta) + .expect("valid token end"), + token: self.token.clone(), + } + } +} + +/// An owned, coherent text-and-token snapshot, suitable for saving a draft. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InputContent { + text: SharedString, + tokens: Vec, +} +impl InputContent { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + tokens: vec![], + } + } + pub fn with_token(mut self, range: Range, token: InlineToken) -> Self { + self.tokens.push(InlineTokenSpan { range, token }); + self + } + pub fn text(&self) -> &SharedString { + &self.text + } + pub fn tokens(&self) -> &[InlineTokenSpan] { + &self.tokens + } + + fn validate(&mut self) -> Result<(), InlineTokenError> { + self.tokens.sort_by_key(|span| span.range.start); + let mut end = 0; + let mut ids = HashSet::new(); + for span in &self.tokens { + span.token.validate()?; + validate_range(&self.text, &span.range)?; + if span.range.is_empty() { + return Err(InlineTokenError::InvalidRange); + } + if span.range.start < end { + return Err(InlineTokenError::OverlappingTokens); + } + if !ids.insert(span.token.id()) { + return Err(InlineTokenError::DuplicateId); + } + if &self.text[span.range.clone()] != span.token.text.as_ref() { + return Err(InlineTokenError::TextMismatch); + } + end = span.range.end; + } + Ok(()) + } +} + +/// A rejected token operation never partially changes the document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum InlineTokenError { + InvalidRange, + InvalidBoundary, + InvalidToken, + OverlappingTokens, + DuplicateId, + TextMismatch, + UnsupportedMode, + ValidationRejected, + CompositionActive, +} + +impl std::error::Error for InlineTokenError {} +impl std::fmt::Display for InlineTokenError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::InvalidRange => "token range is outside the document or empty", + Self::InvalidBoundary => "token range splits a Unicode grapheme", + Self::InvalidToken => { + "token requires a nonempty ID, text and label without control characters" + } + Self::OverlappingTokens => "token ranges overlap", + Self::DuplicateId => "token occurrence ID is already in use", + Self::TextMismatch => "token text does not match its range or input normalization", + Self::UnsupportedMode => "tokens are not supported by this input mode", + Self::ValidationRejected => "input validation rejected the content", + Self::CompositionActive => "finish the active IME composition before editing tokens", + }) + } +} + +fn validate_range(text: &str, range: &Range) -> Result<(), InlineTokenError> { + if range.start > range.end || range.end > text.len() { + return Err(InlineTokenError::InvalidRange); + } + let boundary = + |offset| offset == text.len() || text.grapheme_indices(true).any(|(ix, _)| ix == offset); + if !boundary(range.start) || !boundary(range.end) { + return Err(InlineTokenError::InvalidBoundary); + } + Ok(()) +} + +#[derive(Default)] +pub(super) struct InlineTokenStore { + spans: Vec, +} + +/// Only affected records are retained in history. Ranges are relative to the edit start. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct TokenDelta { + removed: Vec, + inserted: Vec, +} + +impl InlineTokenStore { + fn replace( + &mut self, + range: &Range, + new_len: usize, + inserted: &[InlineTokenSpan], + ) -> Option> { + let mut removed = Vec::new(); + let shift = new_len as isize - range.len() as isize; + self.spans.retain_mut(|span| { + if span.range.start < range.end && range.start < span.range.end { + removed.push(span.shifted(-(range.start as isize))); + false + } else { + if span.range.start >= range.end { + *span = span.shifted(shift); + } + true + } + }); + self.spans + .extend(inserted.iter().map(|s| s.shifted(range.start as isize))); + if !inserted.is_empty() { + self.spans.sort_by_key(|s| s.range.start); + } + (!removed.is_empty() || !inserted.is_empty()).then(|| { + Box::new(TokenDelta { + removed, + inserted: inserted.to_vec(), + }) + }) + } +} + +impl InputBaseState { + pub(super) fn token_spans(&self) -> &[InlineTokenSpan] { + self.inline_tokens + .as_ref() + .map_or(&[], |store| &store.spans) + } + pub(super) fn token_boundary(&self, offset: usize, bias: sum_tree::Bias) -> usize { + let spans = self.token_spans(); + let ix = spans.partition_point(|s| s.range.end <= offset); + if let Some(span) = spans.get(ix).filter(|s| s.range.start < offset) { + if bias == sum_tree::Bias::Left { + span.range.start + } else { + span.range.end + } + } else { + offset + } + } + pub(super) fn normalize_token_range(&self, range: Range) -> Range { + if self.replaying_history { + return range; + } + if range.is_empty() { + let offset = self.token_boundary(range.start, sum_tree::Bias::Right); + offset..offset + } else { + self.token_boundary(range.start, sum_tree::Bias::Left) + ..self.token_boundary(range.end, sum_tree::Bias::Right) + } + } + pub(super) fn edit_tokens( + &mut self, + range: &Range, + new_len: usize, + ) -> Option> { + if self.replaying_history { + return None; + } + let inserted = self.pending_token.take().map(|token| InlineTokenSpan { + range: 0..new_len, + token, + }); + if inserted.is_some() && self.inline_tokens.is_none() { + self.inline_tokens = Some(Box::default()); + } + self.inline_tokens + .as_mut()? + .replace(range, new_len, inserted.as_slice()) + } + pub(super) fn replay_tokens( + &mut self, + range: &Range, + new_len: usize, + delta: Option<&TokenDelta>, + undo: bool, + ) { + let inserted = delta + .map(|d| { + if undo { + &d.removed[..] + } else { + &d.inserted[..] + } + }) + .unwrap_or_default(); + if !inserted.is_empty() && self.inline_tokens.is_none() { + self.inline_tokens = Some(Box::default()); + } + if let Some(store) = self.inline_tokens.as_mut() { + store.replace(range, new_len, inserted); + } + } + fn check_token_mode(&self) -> Result<(), InlineTokenError> { + if self.ime_marked_range.is_some() { + return Err(InlineTokenError::CompositionActive); + } + if M::CODE_EDITOR || self.masked || self.token_is_secret() || !self.mask_pattern.is_none() { + return Err(InlineTokenError::UnsupportedMode); + } + Ok(()) + } + fn replace_token( + &mut self, + range: Range, + token: InlineToken, + window: &mut Window, + cx: &mut Context, + ) -> Result<(), InlineTokenError> { + self.check_token_mode()?; + token.validate()?; + let text = self.text.to_string(); + validate_range(&text, &range)?; + let range = self.normalize_token_range(range); + if self.token_spans().iter().any(|s| { + s.token.id == token.id && !(s.range.start < range.end && range.start < s.range.end) + }) { + return Err(InlineTokenError::DuplicateId); + } + let mut next = text; + next.replace_range(range.clone(), &token.text); + validate_range(&next, &(range.start..range.start + token.text.len()))?; + if self.normalize_input(&next) != next { + return Err(InlineTokenError::TextMismatch); + } + if !self.is_valid_input(&next, cx) { + return Err(InlineTokenError::ValidationRejected); + } + if self + .token_spans() + .iter() + .any(|s| s.range == range && s.token == token) + { + return Ok(()); + } + let new_text = token.text.clone(); + self.pending_token = Some(token); + self.undo_manager.break_transaction_coalescing(); + self.undo_manager.set_pending_intent(EditIntent::Atomic); + let range_utf16 = self.range_to_utf16(&range); + self.validated_token_edit = true; + self.with_edits_allowed(|state| { + state.replace_text_in_range(Some(range_utf16), &new_text, window, cx) + }); + self.validated_token_edit = false; + self.pending_token = None; + Ok(()) + } + fn restore_content( + &mut self, + mut content: InputContent, + window: &mut Window, + cx: &mut Context, + ) -> Result<(), InlineTokenError> { + self.check_token_mode()?; + content.validate()?; + if self.normalize_input(&content.text) != content.text.as_ref() { + return Err(InlineTokenError::TextMismatch); + } + if !self.is_valid_input(&content.text, cx) { + return Err(InlineTokenError::ValidationRejected); + } + self.validated_token_edit = true; + self.set_value(content.text, window, cx); + self.validated_token_edit = false; + self.inline_tokens = (!content.tokens.is_empty()).then(|| { + Box::new(InlineTokenStore { + spans: content.tokens, + }) + }); + self.refresh(cx); + Ok(()) + } +} + +macro_rules! token_api { + ($mode:ty) => { + impl InputBaseState<$mode> { + /// Replace the selection with an atomic token; no delimiter is added. + pub fn replace_with_token( + &mut self, + token: InlineToken, + window: &mut Window, + cx: &mut Context, + ) -> Result<(), InlineTokenError> { + self.replace_token(self.selected_range(), token, window, cx) + } + /// Replace a UTF-8 byte range, expanding overlaps to whole tokens. + pub fn replace_range_with_token( + &mut self, + range: Range, + token: InlineToken, + window: &mut Window, + cx: &mut Context, + ) -> Result<(), InlineTokenError> { + self.replace_token(range, token, window, cx) + } + pub fn tokens(&self) -> &[InlineTokenSpan] { + self.token_spans() + } + pub fn content(&self) -> InputContent { + InputContent { + text: self.value(), + tokens: self.token_spans().to_vec(), + } + } + /// Restore a draft silently, clearing editing history like `set_value`. + pub fn set_content( + &mut self, + content: InputContent, + window: &mut Window, + cx: &mut Context, + ) -> Result<(), InlineTokenError> { + self.restore_content(content, window, cx) + } + /// Invalidate the geometry of a token whose external presentation changed. + pub fn refresh_token(&mut self, id: &str, cx: &mut Context) -> bool { + if !self.token_spans().iter().any(|s| s.token.id.as_ref() == id) { + return false; + } + if let Some(cache) = self.token_layout_cache.as_mut() { + cache.widths.remove(id); + } + cx.notify(); + true + } + } + }; +} +token_api!(super::InputMode); +token_api!(super::TextareaMode); diff --git a/crates/base/src/input/base/mod.rs b/crates/base/src/input/base/mod.rs index 605501189f..b8d2538f43 100644 --- a/crates/base/src/input/base/mod.rs +++ b/crates/base/src/input/base/mod.rs @@ -30,6 +30,7 @@ pub struct InputContextMenuCapabilities { masked: bool, go_to_definition: bool, code_actions: bool, + token_activation: bool, } impl InputContextMenuCapabilities { @@ -114,6 +115,14 @@ impl InputContextMenuCapabilities { self.go_to_definition } + pub fn token_activation(mut self, available: bool) -> Self { + self.token_activation = available; + self + } + pub fn has_token_activation(&self) -> bool { + self.token_activation + } + pub fn has_code_actions(&self) -> bool { self.code_actions } diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index c7bc92e074..9531655042 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -71,6 +71,7 @@ impl Enter { actions!( input, [ + ActivateToken, Backspace, Delete, DeleteToBeginningOfLine, @@ -342,6 +343,14 @@ pub struct InputBaseState { pub(super) text: Rope, pub(super) display_map: DisplayMap, pub(super) undo_manager: UndoManager, + pub(super) inline_tokens: Option>, + pub(super) pending_token: Option, + pub(super) replaying_history: bool, + pub(super) validated_token_edit: bool, + pub(super) document_revision: u64, + pub(super) token_presentation: super::InlineTokenPresentation, + pub(super) token_layout_cache: Option>, + pub(super) pressed_token: Option<(SharedString, u64, Point)>, pub(super) search_session: super::SearchSession, /// Advances every time search is explicitly invoked. See /// [`InputBaseState::search_activation_revision`]. @@ -610,6 +619,14 @@ impl InputBaseState { .masked(self.masked) .go_to_definition(go_to_definition) .code_actions(code_actions) + .token_activation( + self.tokens_visible() + && self.token_presentation.has_listener() + && self + .token_spans() + .iter() + .any(|s| s.range() == self.selected_range()), + ) } pub fn set_text_align(&mut self, text_align: TextAlign, cx: &mut Context) { @@ -692,6 +709,14 @@ impl InputBaseState { cursor_surrounding_lines: None, blink_cursor, undo_manager, + inline_tokens: None, + pending_token: None, + replaying_history: false, + validated_token_edit: false, + document_revision: 0, + token_presentation: Default::default(), + token_layout_cache: None, + pressed_token: None, selections: Selections::default(), selected_word_range: None, ime_marked_range: None, @@ -806,6 +831,7 @@ impl InputBaseState { ) { self.mode.set_highlighter_factory(factory); self._pending_update = true; + self.token_layout_cache = None; cx.notify(); } @@ -900,6 +926,7 @@ impl InputBaseState { window: &mut Window, cx: &mut Context, ) { + self.inline_tokens = None; self.undo_manager.set_ignoring(true); self.emit_events = false; self.replace_text(value, window, cx); @@ -941,7 +968,7 @@ impl InputBaseState { /// /// The `disabled` and `readonly` modes only reject the changes made by the /// user, the programmatic APIs must always be able to update the text. - fn with_edits_allowed(&mut self, f: impl FnOnce(&mut Self)) { + pub(super) fn with_edits_allowed(&mut self, f: impl FnOnce(&mut Self)) { let (was_disabled, was_readonly) = (self.disabled, self.readonly); (self.disabled, self.readonly) = (false, false); f(self); @@ -1414,10 +1441,11 @@ impl InputBaseState { // FIXME: Avoid to_string let left_part = self.text.slice(0..offset).to_string(); - UnicodeSegmentation::split_word_bound_indices(left_part.as_str()) + let target = UnicodeSegmentation::split_word_bound_indices(left_part.as_str()) .rfind(|(_, s)| !s.trim_start().is_empty()) .map(|(i, _)| i) - .unwrap_or(0) + .unwrap_or(0); + self.token_boundary(target, Bias::Left) } /// Return the next end offset of the word after `offset`. @@ -1430,10 +1458,11 @@ impl InputBaseState { let offset = self.offset_from_utf16(self.offset_to_utf16(offset)); let right_part = self.text.slice(offset..self.text.len()).to_string(); - UnicodeSegmentation::split_word_bound_indices(right_part.as_str()) + let target = UnicodeSegmentation::split_word_bound_indices(right_part.as_str()) .find(|(_, s)| !s.trim_start().is_empty()) .map(|(i, s)| offset + i + s.len()) - .unwrap_or(self.text.len()) + .unwrap_or(self.text.len()); + self.token_boundary(target, Bias::Right) } /// Get start of line byte offset for the given `offset`. @@ -2226,6 +2255,9 @@ impl InputBaseState { window: &mut Window, cx: &mut Context, ) { + if self.disabled || window.default_prevented() { + return; + } self.undo_manager.break_transaction_coalescing(); // Input has its own text selection; suppress the window-level text // selection (Root) so it does not start a drag from here. @@ -2671,6 +2703,8 @@ impl InputBaseState { selection_before: CursorSelection, selection_after: Option, ) -> bool { + self.document_revision = self.document_revision.wrapping_add(1); + let token_delta = self.edit_tokens(range, new_text.len()); if self.undo_manager.is_ignoring() { return false; } @@ -2697,9 +2731,9 @@ impl InputBaseState { selection_after.unwrap_or_else(|| (new_range.end..new_range.end).into()); let open_transaction = self.undo_manager.has_open_transaction(); - let recorded = self - .undo_manager - .record_transaction(Change::new(range, &old_text, new_range, new_text), intent); + let mut change = Change::new(range, &old_text, new_range, new_text); + change.token_delta = token_delta; + let recorded = self.undo_manager.record_transaction(change, intent); // A batch records its own cursor sets. This covers a change that is a // transaction on its own. if recorded && !open_transaction { @@ -2710,33 +2744,57 @@ impl InputBaseState { } pub(super) fn undo(&mut self, _: &Undo, window: &mut Window, cx: &mut Context) { - self.undo_manager.set_ignoring(true); - // The manager hands the changes back in reverse application order. - if let Some(replay) = self.undo_manager.undo() { - for change in &replay.changes { - let range_utf16 = self.range_to_utf16(&change.new_range.into()); - self.replace_text_in_range_silent(Some(range_utf16), &change.old_text, window, cx); - } - self.restore_selections(replay.selections); - self.mode - .restore_auto_closed_pairs(replay.auto_closed_pairs.unwrap_or_default()); - } - self.undo_manager.set_ignoring(false); + self.replay_history(true, window, cx); } pub(super) fn redo(&mut self, _: &Redo, window: &mut Window, cx: &mut Context) { + self.replay_history(false, window, cx); + } + + fn replay_history(&mut self, undo: bool, window: &mut Window, cx: &mut Context) { + if !self.is_editable() { + return; + } + let replay = if undo { + self.undo_manager.undo() + } else { + self.undo_manager.redo() + }; + let Some(replay) = replay else { + return; + }; + let token_aware = self.inline_tokens.is_some() + || replay + .changes + .iter() + .any(|change| change.token_delta.is_some()); + let emit_events = self.emit_events; + if token_aware { + self.emit_events = false; + } + self.replaying_history = token_aware; self.undo_manager.set_ignoring(true); - // Redo replays in forward application order. - if let Some(replay) = self.undo_manager.redo() { - for change in &replay.changes { - let range_utf16 = self.range_to_utf16(&change.old_range.into()); - self.replace_text_in_range_silent(Some(range_utf16), &change.new_text, window, cx); - } - self.restore_selections(replay.selections); - self.mode - .restore_auto_closed_pairs(replay.auto_closed_pairs.unwrap_or_default()); + for change in &replay.changes { + let (range, text) = if undo { + (change.new_range.into(), &change.old_text) + } else { + (change.old_range.into(), &change.new_text) + }; + let range_utf16 = self.range_to_utf16(&range); + self.replace_text_in_range_silent(Some(range_utf16), text, window, cx); + self.replay_tokens(&range, text.len(), change.token_delta.as_deref(), undo); } + self.restore_selections(replay.selections); + self.mode + .restore_auto_closed_pairs(replay.auto_closed_pairs.unwrap_or_default()); + self.ime_marked_range = None; self.undo_manager.set_ignoring(false); + self.replaying_history = false; + self.emit_events = emit_events; + if token_aware && emit_events { + cx.emit(InputEvent::Change); + } + cx.notify(); } /// Restore a set of selections captured in a transaction, clamping offsets @@ -2784,6 +2842,8 @@ impl InputBaseState { /// Sets the active selection to the given range, keeping its `reversed` /// and `column_anchor` state untouched. pub(super) fn set_selection(&mut self, start: usize, end: usize) { + let range = self.normalize_token_range(start..end); + let (start, end) = (range.start, range.end); let active = self.active_selection_mut(); active.start = start; active.end = end; @@ -2792,6 +2852,7 @@ impl InputBaseState { /// Collapses the active selection to a cursor at the given offset, /// clearing `reversed`. pub(super) fn set_cursor_to(&mut self, offset: usize) { + let offset = self.token_boundary(offset, Bias::Right); let active = self.active_selection_mut(); active.start = offset; active.end = offset; @@ -2844,6 +2905,7 @@ impl InputBaseState { /// Non-empty ranges expand to character boundaries. Empty ranges remain empty and are /// clipped to the preceding character boundary. pub fn set_selected_range(&mut self, range: Range, cx: &mut Context) { + let range = self.normalize_token_range(range); let end_bias = if range.start == range.end { Bias::Left } else { @@ -3034,6 +3096,8 @@ impl InputBaseState { let offset = self.cursor_boundary(offset, Bias::Left); let word_range = self.selected_word_range; Self::extend_selection(self.active_selection_mut(), offset, word_range); + let range = self.normalize_token_range(self.selected_range()); + self.set_selection(range.start, range.end); if self.active_selection().is_empty() { self.update_preferred_column(); @@ -3059,6 +3123,9 @@ impl InputBaseState { let offset = self.cursor_boundary(f(self, sel), Bias::Left); let mut new_sel = *sel; Self::extend_selection(&mut new_sel, offset, None); + let range = self.normalize_token_range(new_sel.start..new_sel.end); + new_sel.start = range.start; + new_sel.end = range.end; new_sel }) .collect(); @@ -3133,7 +3200,7 @@ impl InputBaseState { /// Clip a cursor/selection offset without splitting a CRLF newline. This does /// not alter the rope or its byte/UTF-16 conversion used for exact source APIs. pub(super) fn cursor_boundary(&self, offset: usize, bias: Bias) -> usize { - let offset = self.text.clip_offset(offset, bias); + let offset = self.token_boundary(self.text.clip_offset(offset, bias), bias); if offset > 0 && self.text.char_at(offset - 1) == Some('\r') && self.text.char_at(offset) == Some('\n') @@ -3295,7 +3362,7 @@ impl InputBaseState { /// For number inputs (with [`MaskPattern::Number`]), this converts /// full-width number characters into their ASCII equivalents, /// e.g. `12。5` -> `12.5`. - fn normalize_input<'a>(&self, new_text: &'a str) -> Cow<'a, str> { + pub(super) fn normalize_input<'a>(&self, new_text: &'a str) -> Cow<'a, str> { let normalized = if matches!(self.mask_pattern, MaskPattern::Number { .. }) { normalize_number_input(new_text) } else { @@ -3468,9 +3535,24 @@ impl InputBaseState { // highest offsets first, leaving lower offsets unchanged. let mut sorted: Vec<(Range, &str)> = edits .iter() - .map(|(range, text)| (range.clone(), text.as_str())) + .map(|(range, text)| (self.normalize_token_range(range.clone()), text.as_str())) .collect(); sorted.sort_by_key(|edit| std::cmp::Reverse(edit.0.start)); + if !self.token_spans().is_empty() { + let mut merged: Vec<(Range, &str)> = Vec::with_capacity(sorted.len()); + for (range, text) in sorted { + if let Some(last) = merged + .last_mut() + .filter(|last| range.end > last.0.start || range == last.0) + { + last.0.start = range.start.min(last.0.start); + last.0.end = range.end.max(last.0.end); + } else { + merged.push((range, text)); + } + } + sorted = merged; + } #[cfg(debug_assertions)] for pair in sorted.windows(2) { @@ -3728,7 +3810,11 @@ impl EntityInputHandler for InputBaseState { // NOTE: The normalization keeps the UTF-16 length, but may change the // UTF-8 byte length, so all the byte-offset calculations below must // use the normalized text. - let new_text = self.normalize_input(new_text); + let new_text = if self.replaying_history { + Cow::Borrowed(new_text) + } else { + self.normalize_input(new_text) + }; let new_text: &str = &new_text; let range = range_utf16 @@ -3739,6 +3825,7 @@ impl EntityInputHandler for InputBaseState { self.range_from_utf16(&range) })) .unwrap_or(self.selected_range()); + let range = self.normalize_token_range(range); // Skip-over as a pure cursor move: a typed closer that already follows // the cursor moves past it without touching text or history, so Undo @@ -3842,14 +3929,16 @@ impl EntityInputHandler for InputBaseState { // Only reject the edit if the old text was valid, to avoid // trapping a pre-existing invalid text (e.g. a `default_value` // that does not conform), the user can still edit to fix it. - if !self.is_valid_input(&pending_text, cx) + if !self.replaying_history + && !self.validated_token_edit + && !self.is_valid_input(&pending_text, cx) && self.is_valid_input(&old_text.to_string(), cx) { self.text = old_text; return; } - if !self.mask_pattern.is_none() { + if !self.replaying_history && !self.mask_pattern.is_none() { let mask_text = self.mask_pattern.mask(&pending_text); mask_changed = mask_text.as_str() != pending_text; self.text = Rope::from(mask_text.as_str()); @@ -3964,7 +4053,11 @@ impl EntityInputHandler for InputBaseState { M::reset_language_features(self); // See the same NOTE in `replace_text_in_range`. - let new_text = self.normalize_input(new_text); + let new_text = if self.replaying_history { + Cow::Borrowed(new_text) + } else { + self.normalize_input(new_text) + }; let new_text: &str = &new_text; let range = range_utf16 @@ -3975,6 +4068,7 @@ impl EntityInputHandler for InputBaseState { self.range_from_utf16(&range) })) .unwrap_or(self.selected_range()); + let range = self.normalize_token_range(range); let auto_closed_pairs_before = self.mode.auto_closed_pairs().clone(); let old_text = self.text.clone(); @@ -3984,7 +4078,9 @@ impl EntityInputHandler for InputBaseState { if self.is_single_line() { let pending_text = self.text.to_string(); // See the same NOTE in `replace_text_in_range`. - if !self.is_valid_input(&pending_text, cx) + if !self.replaying_history + && !self.validated_token_edit + && !self.is_valid_input(&pending_text, cx) && self.is_valid_input(&old_text.to_string(), cx) { self.text = old_text; @@ -4225,6 +4321,31 @@ impl Render for InputBaseState { .on_action(window.listener_for(&entity, InputBaseState::select_to_start)) .on_action(window.listener_for(&entity, InputBaseState::select_to_end)) .on_action(window.listener_for(&entity, InputBaseState::show_character_palette)) + .on_action({ + let entity = entity.clone(); + move |_: &ActivateToken, window, cx| { + let state = entity.read(cx); + let activation = state + .token_spans() + .iter() + .find(|span| span.range() == state.selected_range()) + .and_then(|span| { + state.range_to_bounds(&span.range()).and_then(|bounds| { + state.token_activation( + span.token().id(), + bounds, + gpui::ClickEvent::Keyboard(gpui::KeyboardClickEvent { + bounds, + ..Default::default() + }), + ) + }) + }); + if let Some((listener, event)) = activation { + listener(&event, window, cx); + } + } + }) .on_action(window.listener_for(&entity, InputBaseState::copy)) .on_action(window.listener_for(&entity, InputBaseState::on_action_search)) .on_action(window.listener_for(&entity, InputBaseState::on_action_replace)) @@ -4280,6 +4401,212 @@ impl Render for InputBaseState { #[cfg(test)] mod tests { use super::*; + #[gpui::test] + fn test_inline_token_wrap_and_size_refresh(cx: &mut TestAppContext) { + use crate::input::{InlineToken, InlineTokenPresentation}; + let width = Rc::new(Cell::new(4000.)); + let render_width = width.clone(); + let view = InputView::build_textarea(cx, |state| state.rows(4).default_value("@a@b")); + view.window_handle + .update(cx, |_, window, cx| { + view.input.update(cx, |state, cx| { + state + .replace_range_with_token(0..2, InlineToken::new("a", "@a"), window, cx) + .unwrap(); + state + .replace_range_with_token(2..4, InlineToken::new("b", "@b"), window, cx) + .unwrap(); + state.set_token_presentation( + InlineTokenPresentation::new() + .render_token(move |_, _, _| div().w(px(render_width.get()))), + ); + }); + }) + .unwrap(); + let mut visual = VisualTestContext::from_window(view.window_handle.into(), cx); + visual.update(|window, cx| window.draw(cx).clear(cx)); + view.input.read_with(&visual, |state, _| { + assert_eq!(state.display_map.wrap_row_count(), 2); + let first = state.range_to_bounds(&(0..2)).unwrap(); + let second = state.range_to_bounds(&(2..4)).unwrap(); + assert!(second.origin.y > first.origin.y); + assert_eq!( + state.last_layout.as_ref().unwrap().lines[0].wrapped_lines[0].len, + 2 + ); + }); + width.set(100.); + view.input.update(&mut visual, |state, cx| { + assert!(state.refresh_token("a", cx)); + }); + visual.update(|window, cx| window.draw(cx).clear(cx)); + view.input.read_with(&visual, |state, _| { + assert_eq!(state.display_map.wrap_row_count(), 1) + }); + } + + #[gpui::test] + fn test_inline_token_geometry_and_reentrant_activation(cx: &mut TestAppContext) { + use crate::input::{InlineToken, InlineTokenPresentation}; + let view = InputView::build(cx, |state| state.default_value("before @alice after")); + let target = view.input.clone(); + view.window_handle + .update(cx, |_, window, cx| { + view.input.update(cx, |state, cx| { + state + .replace_range_with_token( + 7..13, + InlineToken::new("a", "@alice"), + window, + cx, + ) + .unwrap(); + state.set_token_presentation( + InlineTokenPresentation::new() + .render_token(|_, _, _| div().w(px(100.)).h(px(20.))) + .on_token_click(move |_, window, cx| { + target.update(cx, |state, cx| { + state.set_value("activated", window, cx) + }) + }), + ); + state.focus(window, cx); + state.set_selected_range(7..13, cx); + }); + }) + .unwrap(); + let mut visual = VisualTestContext::from_window(view.window_handle.into(), cx); + visual.update(|window, cx| window.draw(cx).clear(cx)); + visual.update(|window, cx| { + let state = view.input.read(cx); + let bounds = state.range_to_bounds(&(7..13)).unwrap(); + assert!((bounds.size.width - px(100.)).abs() < px(1.), "{bounds:?}"); + let (left, _) = state.index_for_mouse_position(bounds.origin + point(px(10.), px(5.))); + let (right, _) = state.index_for_mouse_position(bounds.origin + point(px(90.), px(5.))); + assert_eq!((left, right), (7, 13)); + window.dispatch_action(Box::new(ActivateToken), cx); + }); + visual.run_until_parked(); + view.input.read_with(&visual, |state, _| { + assert_eq!(state.value().as_ref(), "activated") + }); + } + + #[gpui::test] + fn test_inline_token_edit_history_and_validation(cx: &mut TestAppContext) { + let view = InputView::build(cx, |state| state.default_value("问 @alice!")); + view.window_handle + .update(cx, |_, window, cx| { + view.input.update(cx, |state, cx| { + let token = + crate::input::InlineToken::new("alice-1", "@alice").with_label("Alice"); + state + .replace_range_with_token(4..10, token.clone(), window, cx) + .unwrap(); + assert_eq!(state.value().as_ref(), "问 @alice!"); + assert_eq!(state.tokens()[0].range(), 4..10); + assert_eq!(state.next_end_of_word_at(4), 10); + assert_eq!(state.previous_start_of_word_at(10), 4); + state.undo(&Undo, window, cx); + assert!( + state.tokens().is_empty(), + "identity-only association is undoable" + ); + assert_eq!(state.value().as_ref(), "问 @alice!"); + state.redo(&Redo, window, cx); + assert_eq!(state.tokens()[0].token(), &token); + state.set_selected_range(6..7, cx); + assert_eq!(state.selected_range(), 4..10); + state.replace("", window, cx); + assert_eq!(state.value().as_ref(), "问 !"); + assert!(state.tokens().is_empty()); + state.undo(&Undo, window, cx); + assert_eq!(state.tokens()[0].token(), &token); + state.set_selected_range(0..0, cx); + state.replace("🙂", window, cx); + assert_eq!(state.tokens()[0].range(), 8..14); + state.undo(&Undo, window, cx); + assert_eq!(state.tokens()[0].range(), 4..10); + let before = state.content(); + let selected = state.selected_range(); + assert_eq!( + state.replace_range_with_token(0..0, token, window, cx), + Err(crate::input::InlineTokenError::DuplicateId) + ); + assert_eq!(state.content(), before); + assert_eq!(state.selected_range(), selected); + state.set_value(before.text().clone(), window, cx); + assert!(state.tokens().is_empty()); + assert!(!state.undo_manager.has_undos()); + }); + }) + .unwrap(); + } + + #[gpui::test] + fn test_inline_token_textarea_ime_and_modes(cx: &mut TestAppContext) { + use crate::input::{InlineToken, InlineTokenError, InputContent}; + let view = InputView::build_textarea(cx, |state| state); + view.window_handle + .update(cx, |_, window, cx| { + view.input.update(cx, |state, cx| { + let content = InputContent::new("@a@b\n后面") + .with_token(0..2, InlineToken::new("a", "@a")) + .with_token(2..4, InlineToken::new("b", "@b")); + state.set_content(content.clone(), window, cx).unwrap(); + state.set_selected_range(2..2, cx); + assert_eq!(state.previous_boundary(2), 0); + assert_eq!(state.next_boundary(2), 4); + state.replace_and_mark_text_in_range(Some(1..2), "中", Some(1..1), window, cx); + assert_eq!(state.value().as_ref(), "中@b\n后面"); + assert_eq!(state.tokens().len(), 1); + assert_eq!( + state.replace_with_token(InlineToken::new("x", "x"), window, cx), + Err(InlineTokenError::CompositionActive) + ); + state.replace_text_in_range(None, "中文", window, cx); + state.undo(&Undo, window, cx); + assert_eq!(state.content(), content); + state.redo(&Redo, window, cx); + assert_eq!(state.value().as_ref(), "中文@b\n后面"); + state.undo(&Undo, window, cx); + state.replace_all("plain", window, cx); + assert!(state.tokens().is_empty()); + state.undo(&Undo, window, cx); + assert_eq!(state.content(), content); + }); + }) + .unwrap(); + let view = InputView::build(cx, |state| state); + view.window_handle + .update(cx, |_, window, cx| { + view.input.update(cx, |state, cx| { + let content = InputContent::new("a\u{301}") + .with_token(0..1, InlineToken::new("bad", "a")); + assert_eq!( + state.set_content(content, window, cx), + Err(InlineTokenError::InvalidBoundary) + ); + state + .replace_with_token(InlineToken::new("a", "@a"), window, cx) + .unwrap(); + let before = state.content(); + state.set_masked(true, window, cx); + assert!(!state.tokens_visible()); + assert_eq!( + state.replace_with_token(InlineToken::new("b", "b"), window, cx), + Err(InlineTokenError::UnsupportedMode) + ); + assert_eq!(state.content(), before); + state.undo(&Undo, window, cx); + assert!(state.tokens().is_empty()); + state.redo(&Redo, window, cx); + assert_eq!(state.content(), before); + assert!(!state.tokens_visible()); + }); + }) + .unwrap(); + } use crate::theme::Theme; use gpui::{TestAppContext, VisualTestContext, size}; diff --git a/crates/base/src/input/base/token_presentation.rs b/crates/base/src/input/base/token_presentation.rs new file mode 100644 index 0000000000..4e675248ae --- /dev/null +++ b/crates/base/src/input/base/token_presentation.rs @@ -0,0 +1,190 @@ +//! Presentation callbacks and measured geometry. None of this enters document history. +use super::{InlineToken, InlineTokenSpan, InputBaseState, InputModeKind}; +use gpui::{AnyElement, App, Bounds, ClickEvent, Font, IntoElement, Pixels, Window}; +use std::{collections::HashMap, ops::Range, rc::Rc}; + +/// Read-only context for a single inline renderer. Width is the full available row. +#[derive(Clone)] +pub struct InlineTokenContext { + span: InlineTokenSpan, + selected: bool, + disabled: bool, + readonly: bool, + line_height: Pixels, + available_width: Pixels, +} +impl InlineTokenContext { + pub fn token(&self) -> &InlineToken { + self.span.token() + } + pub fn range(&self) -> Range { + self.span.range() + } + pub fn is_selected(&self) -> bool { + self.selected + } + pub fn is_disabled(&self) -> bool { + self.disabled + } + pub fn is_readonly(&self) -> bool { + self.readonly + } + pub fn line_height(&self) -> Pixels { + self.line_height + } + pub fn available_width(&self) -> Pixels { + self.available_width + } +} + +/// Current token snapshot delivered after releasing the editor's update borrow. +#[derive(Clone)] +pub struct InlineTokenClickEvent { + span: InlineTokenSpan, + bounds: Bounds, + event: ClickEvent, +} +impl InlineTokenClickEvent { + pub fn token(&self) -> &InlineToken { + self.span.token() + } + pub fn range(&self) -> Range { + self.span.range() + } + pub fn bounds(&self) -> Bounds { + self.bounds + } + pub fn event(&self) -> &ClickEvent { + &self.event + } +} + +type Renderer = Rc AnyElement>; +type Listener = Rc; +type Fallback = fn(&InlineTokenContext, &mut Window, &mut App) -> AnyElement; + +/// Presentation adapter shared by Base and styled controls. It owns no content. +#[derive(Clone, Default)] +pub struct InlineTokenPresentation { + renderer: Option, + listener: Option, + fallback: Option, + secret: bool, +} +impl InlineTokenPresentation { + pub fn new() -> Self { + Self::default() + } + pub fn render_token( + mut self, + render: impl Fn(&InlineTokenContext, &mut Window, &mut App) -> R + 'static, + ) -> Self { + self.renderer = Some(Rc::new(move |token, window, cx| { + render(token, window, cx).into_any_element() + })); + self + } + pub fn on_token_click( + mut self, + listener: impl Fn(&InlineTokenClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.listener = Some(Rc::new(listener)); + self + } + /// Set a default skin without allocating a callback on every plain-text render. + pub fn with_fallback(mut self, fallback: Fallback) -> Self { + self.fallback = Some(fallback); + self + } + /// Suppress token labels and actions when the host is a secret field. + pub fn secret(mut self, secret: bool) -> Self { + self.secret = secret; + self + } + pub(super) fn has_listener(&self) -> bool { + self.listener.is_some() + } + pub(super) fn render( + &self, + token: &InlineTokenContext, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + if let Some(render) = &self.renderer { + render(token, window, cx) + } else if let Some(render) = self.fallback { + render(token, window, cx) + } else { + gpui::div() + .child(token.token().label().clone()) + .into_any_element() + } + } +} +use gpui::ParentElement as _; + +#[derive(Default)] +pub(super) struct TokenLayoutCache { + pub(super) key: Option<(Font, Pixels, Pixels, Pixels, bool)>, + pub(super) revision: u64, + pub(super) unwrapped_width: Pixels, + pub(super) metrics: Rc<[(Range, Pixels)]>, + pub(super) widths: HashMap, +} + +impl InputBaseState { + /// Inject presentation from a view without editing or notifying the document. + #[doc(hidden)] + pub fn set_token_presentation(&mut self, presentation: InlineTokenPresentation) { + self.token_presentation = presentation; + } + pub(super) fn tokens_visible(&self) -> bool { + !self.masked + && !self.token_presentation.secret + && self.mask_pattern.is_none() + && !self.token_spans().is_empty() + } + pub(super) fn token_context( + &self, + span: &InlineTokenSpan, + line_height: Pixels, + width: Pixels, + ) -> InlineTokenContext { + let range = span.range(); + let selection = self.selected_range(); + InlineTokenContext { + span: span.clone(), + selected: selection.start < range.end && range.start < selection.end, + disabled: self.disabled, + readonly: self.readonly, + line_height, + available_width: width, + } + } + pub(super) fn token_activation( + &self, + id: &str, + bounds: Bounds, + event: ClickEvent, + ) -> Option<(Listener, InlineTokenClickEvent)> { + if self.disabled || !self.tokens_visible() { + return None; + } + let span = self + .token_spans() + .iter() + .find(|span| span.token().id().as_ref() == id)? + .clone(); + Some(( + self.token_presentation.listener.clone()?, + InlineTokenClickEvent { + span, + bounds, + event, + }, + )) + } + pub(super) fn token_is_secret(&self) -> bool { + self.token_presentation.secret + } +} diff --git a/crates/base/src/input/base/undo_manager.rs b/crates/base/src/input/base/undo_manager.rs index 696d412400..9c25611105 100644 --- a/crates/base/src/input/base/undo_manager.rs +++ b/crates/base/src/input/base/undo_manager.rs @@ -97,7 +97,10 @@ impl UndoManager { if self.ignoring { return false; } - if change.old_range == change.new_range && change.old_text == change.new_text { + if change.token_delta.is_none() + && change.old_range == change.new_range + && change.old_text == change.new_text + { self.break_transaction_coalescing(); return false; } @@ -367,6 +370,9 @@ impl UndoTransaction { /// Changes that do not form such a chain (multi-cursor batches, for one) always /// report `false`, so this only ever collapses the single-region case. fn is_noop_batch(changes: &[Change]) -> bool { + if changes.iter().any(|c| c.token_delta.is_some()) { + return false; + } let Some(first) = changes.first() else { return true; }; @@ -412,6 +418,9 @@ fn is_adjacent_batch(intent: EditIntent, previous: &[Change], current: &[Change] } fn is_adjacent(intent: EditIntent, previous: &Change, current: &Change) -> bool { + if previous.token_delta.is_some() || current.token_delta.is_some() { + return false; + } match intent { EditIntent::Typing => { previous.old_range.is_empty() diff --git a/crates/base/src/input/editor/display_map/display_map.rs b/crates/base/src/input/editor/display_map/display_map.rs index 15f7f6223d..650cd71802 100644 --- a/crates/base/src/input/editor/display_map/display_map.rs +++ b/crates/base/src/input/editor/display_map/display_map.rs @@ -226,6 +226,15 @@ impl DisplayMap { } /// Update layout parameters (wrap width or font) + pub(crate) fn set_inline_metrics( + &mut self, + metrics: std::rc::Rc<[(std::ops::Range, Pixels)]>, + cx: &mut App, + ) { + self.wrap_map.set_inline_metrics(metrics, cx); + self.rebuild_fold_projection(); + } + pub fn on_layout_changed(&mut self, wrap_width: Option, cx: &mut App) { self.wrap_map.on_layout_changed(wrap_width, cx); self.rebuild_fold_projection(); diff --git a/crates/base/src/input/editor/display_map/inline_line.rs b/crates/base/src/input/editor/display_map/inline_line.rs new file mode 100644 index 0000000000..b45fd3bab7 --- /dev/null +++ b/crates/base/src/input/editor/display_map/inline_line.rs @@ -0,0 +1,156 @@ +//! A visual row whose offsets remain in source UTF-8 bytes. +use gpui::{App, Pixels, Point, ShapedLine, SharedString, TextAlign, Window, point, px}; +use std::ops::Range; + +pub(crate) struct InlineFragment { + pub(crate) range: Range, + pub(crate) x: Pixels, + pub(crate) width: Pixels, + pub(crate) text: Option, +} + +pub(crate) struct InputLine { + pub(crate) len: usize, + pub(crate) width: Pixels, + pub(crate) text: SharedString, + content: Content, +} +// Keep the ordinary shaped row inline: boxing it would add an allocation to +// every existing plain-text row. Only token rows allocate fragment storage. +#[allow(clippy::large_enum_variant)] +enum Content { + Text(ShapedLine), + Inline(Vec), +} +impl From for InputLine { + fn from(line: ShapedLine) -> Self { + Self { + len: line.len, + width: line.width, + text: line.text.clone(), + content: Content::Text(line), + } + } +} +impl InputLine { + pub(crate) fn inline(text: SharedString, fragments: Vec) -> Self { + let width = fragments.last().map_or(px(0.), |f| f.x + f.width); + Self { + len: text.len(), + text, + width, + content: Content::Inline(fragments), + } + } + pub(crate) fn x_for_index(&self, ix: usize) -> Pixels { + match &self.content { + Content::Text(line) => line.x_for_index(ix), + Content::Inline(fragments) => { + for f in fragments { + if ix < f.range.end { + return f.x + + f.text.as_ref().map_or( + if ix <= f.range.start { px(0.) } else { f.width }, + |line| line.x_for_index(ix.saturating_sub(f.range.start)), + ); + } + } + self.width + } + } + } + pub(crate) fn closest_index_for_x(&self, x: Pixels) -> usize { + match &self.content { + Content::Text(line) => line.closest_index_for_x(x), + Content::Inline(fragments) => { + for f in fragments { + if x <= f.x + f.width { + return f.text.as_ref().map_or( + if x - f.x < f.width / 2. { + f.range.start + } else { + f.range.end + }, + |line| f.range.start + line.closest_index_for_x(x - f.x), + ); + } + } + self.len + } + } + } + pub(crate) fn index_for_x(&self, x: Pixels) -> Option { + match &self.content { + Content::Text(line) => line.index_for_x(x), + Content::Inline(_) => { + (x >= px(0.) && x <= self.width).then(|| self.closest_index_for_x(x)) + } + } + } + #[allow(clippy::too_many_arguments)] + pub(crate) fn paint( + &self, + pos: Point, + height: Pixels, + align: TextAlign, + width: Option, + window: &mut Window, + cx: &mut App, + ) { + self.paint_pass(pos, height, align, width, false, window, cx); + } + #[allow(clippy::too_many_arguments)] + pub(crate) fn paint_background( + &self, + pos: Point, + height: Pixels, + align: TextAlign, + width: Option, + window: &mut Window, + cx: &mut App, + ) { + self.paint_pass(pos, height, align, width, true, window, cx); + } + #[allow(clippy::too_many_arguments)] + fn paint_pass( + &self, + pos: Point, + height: Pixels, + align: TextAlign, + width: Option, + background: bool, + window: &mut Window, + cx: &mut App, + ) { + let paint = |line: &ShapedLine, pos, align, width, window: &mut Window, cx: &mut App| { + if background { + let _ = line.paint_background(pos, height, align, width, window, cx); + } else { + let _ = line.paint(pos, height, align, width, window, cx); + } + }; + match &self.content { + Content::Text(line) => paint(line, pos, align, width, window, cx), + Content::Inline(fragments) => { + let remaining = (width.unwrap_or(self.width) - self.width).max(px(0.)); + let offset = match align { + TextAlign::Right => remaining, + TextAlign::Center => remaining / 2., + _ => px(0.), + }; + for f in fragments { + if let Some(line) = &f.text { + paint( + line, + pos + point(offset + f.x, px(0.)), + TextAlign::Left, + None, + window, + cx, + ); + } + } + } + } + } +} diff --git a/crates/base/src/input/editor/display_map/mod.rs b/crates/base/src/input/editor/display_map/mod.rs index b5afe681a3..0ebaf62215 100644 --- a/crates/base/src/input/editor/display_map/mod.rs +++ b/crates/base/src/input/editor/display_map/mod.rs @@ -8,6 +8,8 @@ /// The goal is to provide a clean, unified API where Editor only needs to know /// about `BufferPoint ↔ DisplayPoint` mapping, without worrying about internal wrap/fold complexity. mod display_map; +mod inline_line; +pub(crate) use inline_line::{InlineFragment, InputLine}; mod fold_map; mod folding; mod text_wrapper; diff --git a/crates/base/src/input/editor/display_map/text_wrapper.rs b/crates/base/src/input/editor/display_map/text_wrapper.rs index 071c5bfa1f..99388fac36 100644 --- a/crates/base/src/input/editor/display_map/text_wrapper.rs +++ b/crates/base/src/input/editor/display_map/text_wrapper.rs @@ -1,6 +1,8 @@ +use super::inline_line::InputLine; use gpui::Half; use std::borrow::Cow; use std::ops::Range; +use std::rc::Rc; use gpui::{ App, Font, LineFragment, Pixels, Point, ShapedLine, Size, TextAlign, Window, point, px, size, @@ -149,6 +151,7 @@ pub(crate) struct TextWrapper { /// The lines by split \n pub(crate) lines: SumTree, + inline_metrics: Rc<[(Range, Pixels)]>, _initialized: bool, } @@ -162,6 +165,7 @@ impl TextWrapper { wrap_width, wrapping_indent: WrappingIndent::default(), lines: SumTree::new(&()), + inline_metrics: Rc::from([]), _initialized: false, } } @@ -292,18 +296,124 @@ impl TextWrapper { let mut line_wrapper = cx .text_system() .line_wrapper(self.font.clone(), self.font_size); + let metrics = self.inline_metrics.clone(); self._update( changed_text, range, new_text, - &mut |line_str, wrap_width| { - line_wrapper - .wrap_line(&[LineFragment::text(line_str)], wrap_width) - .collect() + &mut |line_str, wrap_width, line_start| { + let mut fragments = Vec::new(); + let mut offset = 0; + let first = metrics.partition_point(|(r, _)| r.end <= line_start); + for (range, width) in &metrics[first..] { + if range.start >= line_start + line_str.len() { + break; + } + if range.start < line_start || range.end > line_start + line_str.len() { + continue; + } + let range = range.start - line_start..range.end - line_start; + if !line_str.is_char_boundary(range.start) + || !line_str.is_char_boundary(range.end) + { + continue; + } + if offset < range.start { + fragments.push(LineFragment::text(&line_str[offset..range.start])); + } + fragments.push(LineFragment::element(*width, range.len())); + offset = range.end; + } + if fragments.is_empty() { + return line_wrapper + .wrap_line(&[LineFragment::text(line_str)], wrap_width) + .collect(); + } + if offset < line_str.len() { + fragments.push(LineFragment::text(&line_str[offset..])); + } + line_wrapper.wrap_line(&fragments, wrap_width).collect() }, ); } + pub(crate) fn adjust_inline_metrics(&mut self, range: &Range, new_len: usize) { + if self.inline_metrics.is_empty() { + return; + } + let shift = new_len as isize - range.len() as isize; + self.inline_metrics = self + .inline_metrics + .iter() + .filter_map(|(token, width)| { + if token.start < range.end && range.start < token.end { + return None; + } + let token = if token.start >= range.end { + token.start.checked_add_signed(shift)?..token.end.checked_add_signed(shift)? + } else { + token.clone() + }; + Some((token, *width)) + }) + .collect(); + } + + pub(crate) fn set_inline_metrics( + &mut self, + metrics: Rc<[(Range, Pixels)]>, + cx: &mut App, + ) { + if self.inline_metrics == metrics { + return; + } + // Only rows whose element geometry changed need another wrap pass. + let mut affected = Vec::new(); + let (mut old, mut new) = ( + self.inline_metrics.iter().peekable(), + metrics.iter().peekable(), + ); + while old.peek().is_some() || new.peek().is_some() { + match (old.peek(), new.peek()) { + (Some(a), Some(b)) if a == b => { + old.next(); + new.next(); + } + (Some(a), Some(b)) if a.0.start <= b.0.start => { + affected.push(a.0.clone()); + old.next(); + } + (Some(_), Some(b)) | (None, Some(b)) => { + affected.push(b.0.clone()); + new.next(); + } + (Some(a), None) => { + affected.push(a.0.clone()); + old.next(); + } + (None, None) => break, + } + } + self.inline_metrics = metrics; + let text = self.text.clone(); + let mut rows: Vec = affected + .iter() + .map(|r| text.offset_to_point(r.start.min(text.len())).row) + .collect(); + rows.sort_unstable(); + rows.dedup(); + for row in rows { + let start = text.line_start_offset(row); + let end = text.line_end_offset(row); + self.update( + &text, + &(start..end), + &Rope::from(text.slice(start..end).to_string()), + cx, + ); + } + } + fn _update( &mut self, changed_text: &Rope, @@ -311,7 +421,7 @@ impl TextWrapper { new_text: &Rope, wrap_line: &mut F, ) where - F: FnMut(&str, Pixels) -> Vec, + F: FnMut(&str, Pixels, usize) -> Vec, { // Remove the old changed lines. let buffer_line_count = self.lines_count(); @@ -344,7 +454,9 @@ impl TextWrapper { WrappingIndent::Same => { // Here only have wrapped line, if there is no wrap meet, the `line_wraps` // result will empty. - for boundary in wrap_line(&line_str, wrap_width) { + for boundary in + wrap_line(&line_str, wrap_width, changed_text.line_start_offset(row)) + { wrapped_lines.push(prev_boundary_ix..boundary.ix); prev_boundary_ix = boundary.ix; indent_chars = boundary.next_indent; @@ -353,12 +465,17 @@ impl TextWrapper { WrappingIndent::None => { // The first visual line keeps the line's leading indentation, so it is // wrapped as is. - let boundaries = wrap_line(&line_str, wrap_width); + let boundaries = + wrap_line(&line_str, wrap_width, changed_text.line_start_offset(row)); if let Some(first_ix) = boundaries.first().map(|b| b.ix) { wrapped_lines.push(prev_boundary_ix..first_ix); prev_boundary_ix = first_ix; - for boundary in wrap_line(&line_str[first_ix..], wrap_width) { + for boundary in wrap_line( + &line_str[first_ix..], + wrap_width, + changed_text.line_start_offset(row) + first_ix, + ) { let ix = first_ix + boundary.ix; wrapped_lines.push(prev_boundary_ix..ix); prev_boundary_ix = ix; @@ -520,7 +637,7 @@ pub(crate) struct LineLayout { /// Total bytes length of this line. len: usize, /// The soft wrapped lines of this line (Include the first line). - pub(crate) wrapped_lines: SmallVec<[ShapedLine; 1]>, + pub(crate) wrapped_lines: SmallVec<[InputLine; 1]>, /// Extra left offset applied to continuation wrapped lines, used to reserve the first line's /// indentation when [`WrappingIndent::Same`] is used. pub(crate) wrap_indent: Pixels, @@ -582,7 +699,18 @@ impl LineLayout { .max() .unwrap_or_default(); self.longest_width = width; - self.wrapped_lines = wrapped_lines; + self.wrapped_lines = wrapped_lines.into_iter().map(InputLine::from).collect(); + } + + pub(crate) fn inline_lines(mut self, lines: SmallVec<[InputLine; 1]>) -> Self { + self.len = lines.iter().map(|line| line.len).sum(); + self.longest_width = lines + .iter() + .map(|line| line.width) + .max() + .unwrap_or_default(); + self.wrapped_lines = lines; + self } pub(crate) fn with_whitespaces(mut self, indicators: Option) -> Self { @@ -880,7 +1008,7 @@ mod tests { "Hello, 世界!\r\nThis is second line.\nThis is third line.\n这里是第 4 行。", ); - fn fake_wrap_line(_line: &str, _wrap_width: Pixels) -> Vec { + fn fake_wrap_line(_line: &str, _wrap_width: Pixels, _: usize) -> Vec { vec![] } @@ -1077,7 +1205,7 @@ mod tests { fn test_longest_row_after_shrink() { let mut wrapper = TextWrapper::new(test_font(), px(14.), None); let mut text = Rope::from("aa\nthis is the longest line\nbb"); - wrapper._update(&text, &(0..text.len()), &text, &mut |_, _| vec![]); + wrapper._update(&text, &(0..text.len()), &text, &mut |_, _, _| vec![]); assert_eq!(wrapper.longest_row(), 1); // Shrink line 1 so line 2-equivalent isn't longest. @@ -1087,7 +1215,7 @@ mod tests { let range = start..end; let new_text = "a very very long first line now"; text.replace(range.clone(), new_text); - wrapper._update(&text, &range, &Rope::from(new_text), &mut |_, _| vec![]); + wrapper._update(&text, &range, &Rope::from(new_text), &mut |_, _, _| vec![]); assert_eq!(wrapper.longest_row(), 0); } @@ -1096,7 +1224,7 @@ mod tests { fn test_edit_last_line_and_full_delete() { let mut wrapper = TextWrapper::new(test_font(), px(14.), None); let mut text = Rope::from("one\ntwo\nthree"); - wrapper._update(&text, &(0..text.len()), &text, &mut |_, _| vec![]); + wrapper._update(&text, &(0..text.len()), &text, &mut |_, _, _| vec![]); assert_eq!(wrapper.lines_count(), 3); // Replace the last line only. @@ -1104,14 +1232,14 @@ mod tests { let range = start..text.len(); let new_text = "THREE EDITED"; text.replace(range.clone(), new_text); - wrapper._update(&text, &range, &Rope::from(new_text), &mut |_, _| vec![]); + wrapper._update(&text, &range, &Rope::from(new_text), &mut |_, _, _| vec![]); assert_eq!(wrapper.lines_count(), 3); assert_eq!(wrapper.line(2).unwrap().len(), "THREE EDITED".len()); // Delete everything. let range = 0..text.len(); text.replace(range.clone(), ""); - wrapper._update(&text, &range, &Rope::from(""), &mut |_, _| vec![]); + wrapper._update(&text, &range, &Rope::from(""), &mut |_, _, _| vec![]); assert_eq!(wrapper.lines_count(), 1); assert_eq!(wrapper.len(), 1); assert_eq!(wrapper.line(0).unwrap().wrapped_lines.as_slice(), [0..0]); @@ -1166,7 +1294,7 @@ mod tests { fn test_wrap_row_queries_after_incremental_splice() { let mut wrapper = TextWrapper::new(test_font(), px(14.), Some(px(10.))); let mut text = Rope::from("aa\nbbbb\nc"); - let mut fake_wrap_line = |line: &str, _wrap_width: Pixels| { + let mut fake_wrap_line = |line: &str, _wrap_width: Pixels, _: usize| { if line.len() > 2 { vec![Boundary { ix: 2, @@ -1446,7 +1574,7 @@ mod tests { let mut wrapper = TextWrapper::new(test_font(), px(14.0), Some(px(10.))); wrapper.wrapping_indent = WrappingIndent::Same; let text = Rope::from(" abcdefghijklmnopqrstuv"); - let mut fake_wrap_line = |line: &str, _wrap_width: Pixels| { + let mut fake_wrap_line = |line: &str, _wrap_width: Pixels, _: usize| { if line.starts_with(' ') { vec![Boundary { ix: 5, @@ -1478,7 +1606,7 @@ mod tests { let mut wrapper = TextWrapper::new(test_font(), px(14.0), Some(px(10.))); wrapper.wrapping_indent = WrappingIndent::None; let text = Rope::from(" abcdefghijklmnopqrstuv"); - let mut fake_wrap_line = |line: &str, _wrap_width: Pixels| { + let mut fake_wrap_line = |line: &str, _wrap_width: Pixels, _: usize| { if line.starts_with(' ') { vec![Boundary { ix: 5, diff --git a/crates/base/src/input/editor/display_map/wrap_map.rs b/crates/base/src/input/editor/display_map/wrap_map.rs index cf593eb4d0..0cbb3d040b 100644 --- a/crates/base/src/input/editor/display_map/wrap_map.rs +++ b/crates/base/src/input/editor/display_map/wrap_map.rs @@ -107,10 +107,19 @@ impl WrapMap { new_text: &Rope, cx: &mut App, ) { + self.wrapper.adjust_inline_metrics(range, new_text.len()); self.wrapper.update(changed_text, range, new_text, cx); } /// Update layout parameters (wrap width or font) + pub(super) fn set_inline_metrics( + &mut self, + metrics: std::rc::Rc<[(std::ops::Range, Pixels)]>, + cx: &mut App, + ) { + self.wrapper.set_inline_metrics(metrics, cx); + } + pub(super) fn on_layout_changed(&mut self, wrap_width: Option, cx: &mut App) { self.wrapper.set_wrap_width(wrap_width, cx); } diff --git a/crates/base/src/input/input/mod.rs b/crates/base/src/input/input/mod.rs index f5bb65c7bd..1f201e3148 100644 --- a/crates/base/src/input/input/mod.rs +++ b/crates/base/src/input/input/mod.rs @@ -15,6 +15,7 @@ pub type InputState = InputBaseState; /// their own presentation or use `gpui-component::Input`. #[derive(IntoElement)] pub struct Input { + presentation: super::InlineTokenPresentation, state: Entity, } @@ -22,12 +23,35 @@ impl Input { pub fn new(state: &Entity) -> Self { Self { state: state.clone(), + presentation: Default::default(), } } + #[doc(hidden)] + pub fn token_presentation(mut self, presentation: super::InlineTokenPresentation) -> Self { + self.presentation = presentation; + self + } + pub fn render_token( + mut self, + render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, + ) -> Self { + self.presentation = self.presentation.render_token(render); + self + } + pub fn on_token_click( + mut self, + listener: impl Fn(&super::InlineTokenClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.presentation = self.presentation.on_token_click(listener); + self + } } impl RenderOnce for Input { - fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + self.state.update(cx, |state, _| { + state.set_token_presentation(self.presentation) + }); self.state } } diff --git a/crates/base/src/input/mod.rs b/crates/base/src/input/mod.rs index 78b0e6f3dd..cb3ee9621a 100644 --- a/crates/base/src/input/mod.rs +++ b/crates/base/src/input/mod.rs @@ -32,7 +32,13 @@ mod element; mod highlighting; #[path = "editor/indent.rs"] mod indent; +#[path = "base/inline_tokens.rs"] +mod inline_tokens; mod input; +#[path = "base/token_presentation.rs"] +mod token_presentation; +pub use inline_tokens::{InlineToken, InlineTokenError, InlineTokenSpan, InputContent}; +pub use token_presentation::{InlineTokenClickEvent, InlineTokenContext, InlineTokenPresentation}; #[path = "base/kind.rs"] mod kind; #[path = "editor/language.rs"] diff --git a/crates/base/src/input/textarea/mod.rs b/crates/base/src/input/textarea/mod.rs index 341d108e06..0d482e9550 100644 --- a/crates/base/src/input/textarea/mod.rs +++ b/crates/base/src/input/textarea/mod.rs @@ -12,6 +12,7 @@ pub type TextareaState = InputBaseState; /// An unstyled ordinary multi-line text input. #[derive(IntoElement)] pub struct Textarea { + presentation: super::InlineTokenPresentation, state: Entity, } @@ -19,12 +20,35 @@ impl Textarea { pub fn new(state: &Entity) -> Self { Self { state: state.clone(), + presentation: Default::default(), } } + #[doc(hidden)] + pub fn token_presentation(mut self, presentation: super::InlineTokenPresentation) -> Self { + self.presentation = presentation; + self + } + pub fn render_token( + mut self, + render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, + ) -> Self { + self.presentation = self.presentation.render_token(render); + self + } + pub fn on_token_click( + mut self, + listener: impl Fn(&super::InlineTokenClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.presentation = self.presentation.on_token_click(listener); + self + } } impl RenderOnce for Textarea { - fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + self.state.update(cx, |state, _| { + state.set_token_presentation(self.presentation) + }); self.state } } diff --git a/crates/component-shell/src/shell/input_group/mod.rs b/crates/component-shell/src/shell/input_group/mod.rs index 0b85785117..ed7a4cd431 100644 --- a/crates/component-shell/src/shell/input_group/mod.rs +++ b/crates/component-shell/src/shell/input_group/mod.rs @@ -219,7 +219,13 @@ impl ComponentMaterializer for Materializer { let state = request.with_state::, _>(&argument, Clone::clone)?; let binding = binding::prepare(&mut request, NativeState::Input(state.clone()), &operations)?; - let mut input = InputGroupInput::new(&state).disabled(request.disabled()); + let tokens = super::input_tokens::prepare( + &request, + super::input_tokens::State::Input(state.clone()), + )?; + let mut input = tokens + .input(InputGroupInput::new(&state)) + .disabled(request.disabled()); for operation in &operations { input = match operation { Op::AriaLabel(value) => input.aria_label(value.clone()), @@ -246,7 +252,13 @@ impl ComponentMaterializer for Materializer { NativeState::Textarea(state.clone()), &operations, )?; - let mut textarea = InputGroupTextarea::new(&state).disabled(request.disabled()); + let tokens = super::input_tokens::prepare( + &request, + super::input_tokens::State::Textarea(state.clone()), + )?; + let mut textarea = tokens + .textarea(InputGroupTextarea::new(&state)) + .disabled(request.disabled()); for operation in &operations { textarea = match operation { Op::AriaLabel(value) => textarea.aria_label(value.clone()), @@ -569,13 +581,13 @@ pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryE "InputGroupInput", state_constructor("InputGroupInput", "InputState", Part::Input), "An unframed single-line input using the existing retained InputState and native editing engine.", - input_methods, + [input_methods, super::input_tokens::methods(false)].concat(), ), ( "InputGroupTextarea", state_constructor("InputGroupTextarea", "TextareaState", Part::Textarea), "An unframed multiline input using the existing retained TextareaState and native editing engine.", - textarea_methods, + [textarea_methods, super::input_tokens::methods(false)].concat(), ), ( "InputGroupText", diff --git a/crates/component-shell/src/shell/input_tokens.rs b/crates/component-shell/src/shell/input_tokens.rs new file mode 100644 index 0000000000..0592c1b385 --- /dev/null +++ b/crates/component-shell/src/shell/input_tokens.rs @@ -0,0 +1,168 @@ +//! Inline presentation and change subscriptions shared by Input and Textarea. +use gpui_component::input::{ + InlineTokenPresentation, Input, InputEvent, InputState, InputToken, Textarea, TextareaState, +}; +use gpui_shell::{ + ArgumentDescriptor, ArgumentSchema, ComponentArgument, ComponentCallback, ComponentPayload, + MaterializeRequest, MethodDescriptor, anyhow, + gpui::{self, App, Entity, IntoElement as _, RenderOnce, Window}, +}; +use std::{cell::RefCell, rc::Rc}; + +#[derive(Clone)] +enum Op { + Render(ComponentArgument), + Click(ComponentArgument), + Change(ComponentArgument), +} +#[derive(Clone)] +pub(super) enum State { + Input(Entity), + Textarea(Entity), +} +macro_rules! dispatch { + ($state:expr, |$entity:ident| $body:expr) => { + match $state { + State::Input($entity) => $body, + State::Textarea($entity) => $body, + } + }; +} + +pub(super) fn methods(include_change: bool) -> Vec { + let mut methods = vec![ + MethodDescriptor::new( + "render_token", + vec![ArgumentDescriptor::new( + "render", + ArgumentSchema::Callback( + "(token: InlineTokenContext, cx: Context) => Element | null", + ), + )], + |args| match args { + [arg @ ComponentArgument::Callback(_)] => { + Ok(ComponentPayload::new(Op::Render(arg.clone()))) + } + _ => Err("render_token expects a renderer".into()), + }, + ) + .with_documentation( + "Renders an atomic token from its current UTF-16 range and read-only context.", + ), + MethodDescriptor::new( + "on_token_click", + vec![ArgumentDescriptor::new( + "listener", + ArgumentSchema::Callback("(event: InlineTokenClickEvent, cx: Context) => void"), + )], + |args| match args { + [arg @ ComponentArgument::Callback(_)] => { + Ok(ComponentPayload::new(Op::Click(arg.clone()))) + } + _ => Err("on_token_click expects a listener".into()), + }, + ) + .with_documentation( + "Activates a reference after a completed unconsumed click, outside the editing borrow.", + ), + ]; + if include_change { + methods.push(MethodDescriptor::new("on_change", vec![ArgumentDescriptor::new("listener", ArgumentSchema::Callback("(text: string, cx: Context) => void"))], |args| match args { + [arg @ ComponentArgument::Callback(_)] => Ok(ComponentPayload::new(Op::Change(arg.clone()))), _ => Err("on_change expects a listener".into()), + }).with_documentation("Reports user text or token identity changes; explicit draft restoration remains silent.")); + } + methods +} + +pub(super) struct Binding { + state: State, + presentation: InlineTokenPresentation, + change: Option, +} +pub(super) fn prepare(request: &MaterializeRequest<'_>, state: State) -> anyhow::Result { + let mut renderer = None; + let mut listener = None; + let mut change = None; + for op in request + .methods() + .filter_map(|m| m.payload().downcast_ref::()) + { + match op { + Op::Render(arg) => renderer = Some(request.resolve_element_callback(arg)?), + Op::Click(arg) => listener = Some(request.resolve_callback(arg)?), + Op::Change(arg) => change = Some(request.resolve_callback(arg)?), + } + } + let presentation = match &state { + State::Input(state) => gpui_shell::input_token_presentation(state, renderer, listener), + State::Textarea(state) => { + gpui_shell::textarea_token_presentation(state, renderer, listener) + } + } + .with_fallback(|token, _, _| InputToken::new(token).into_any_element()); + Ok(Binding { + state, + presentation, + change, + }) +} +impl Binding { + pub(super) fn input(&self, input: Input) -> Input { + input.token_presentation(self.presentation.clone()) + } + pub(super) fn textarea(&self, input: Textarea) -> Textarea { + input.token_presentation(self.presentation.clone()) + } + pub(super) fn wrap(self, element: gpui::AnyElement) -> gpui::AnyElement { + Bound { + element, + binding: self, + } + .into_any_element() + } +} +struct Host { + listener: Rc>>, + _subscription: gpui::Subscription, +} +#[derive(gpui::IntoElement)] +struct Bound { + element: gpui::AnyElement, + binding: Binding, +} +impl RenderOnce for Bound { + fn render(self, window: &mut Window, cx: &mut App) -> impl gpui::IntoElement { + let id = dispatch!(&self.binding.state, |state| state.entity_id()); + let state = self.binding.state.clone(); + let host = window.use_keyed_state(("shell-input-change", id), cx, move |window, cx| { + let listener = Rc::new(RefCell::new(None::)); + let callback = listener.clone(); + let subscription = dispatch!(&state, |state| window.subscribe( + state, + cx, + move |state, event: &InputEvent, window, cx| { + if !matches!(event, InputEvent::Change) { + return; + } + let listener = callback.borrow().clone(); + if let Some(listener) = listener { + listener.invoke_and_report_with( + "input change callback failed", + &[gpui_shell::ComponentCallbackArgument::String( + state.read(cx).value().to_string(), + )], + window, + cx, + ); + } + } + )); + Host { + listener, + _subscription: subscription, + } + }); + *host.read(cx).listener.borrow_mut() = self.binding.change; + self.element + } +} diff --git a/crates/component-shell/src/shell/layout/textarea.rs b/crates/component-shell/src/shell/layout/textarea.rs index d42e72f59e..0063bccf8e 100644 --- a/crates/component-shell/src/shell/layout/textarea.rs +++ b/crates/component-shell/src/shell/layout/textarea.rs @@ -29,7 +29,13 @@ impl ComponentMaterializer for Materializer { .downcast_ref::() .ok_or_else(|| anyhow::anyhow!("Textarea received an incompatible payload"))?; let state = request.with_state::, _>(argument, Clone::clone)?; - let mut textarea = Textarea::new(&state).disabled(request.disabled()); + let binding = super::super::input_tokens::prepare( + &request, + super::super::input_tokens::State::Textarea(state.clone()), + )?; + let mut textarea = binding + .textarea(Textarea::new(&state)) + .disabled(request.disabled()); for op in request .methods() .filter_map(|method| method.payload().downcast_ref::()) @@ -43,7 +49,7 @@ impl ComponentMaterializer for Materializer { } require_leaf(request.children_len())?; textarea.style().refine(&request.take_style()); - Ok(textarea.into_any_element()) + Ok(binding.wrap(textarea.into_any_element())) } } @@ -83,6 +89,7 @@ pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryE }))) }, ) + .with_methods(gpui_shell::textarea_token_state_methods()) .with_documentation("Retained multi-line editing state with an optional initial value."), )?; registry.register(ComponentDescriptor::new("Textarea", Arc::new(Materializer)) @@ -90,14 +97,14 @@ pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryE [argument @ ComponentArgument::Entity { .. }] => Ok(ComponentPayload::new(argument.clone())), _ => Err("Textarea expects one TextareaState entity".into()), })]) -.with_methods(vec![ +.with_methods([vec![ MethodDescriptor::new("disabled", vec![ArgumentDescriptor::new("disabled", ArgumentSchema::Boolean)], |_| Ok(ComponentPayload::new(()))).with_documentation("Sets the common disabled state."), bool_method("Textarea", "appearance", "Sets the corresponding native textarea presentation or editing policy.", Op::Appearance), bool_method("Textarea", "bordered", "Sets the corresponding native textarea presentation or editing policy.", Op::Bordered), bool_method("Textarea", "readonly", "Sets the corresponding native textarea presentation or editing policy.", Op::Readonly), MethodDescriptor::new("aria_label", vec![ArgumentDescriptor::new("label", ArgumentSchema::String)], |args| match args { [ComponentArgument::String(value)] if !value.trim().is_empty() => Ok(ComponentPayload::new(Op::AriaLabel(value.clone()))), _ => Err("Textarea.aria_label expects non-empty text".into()), }).with_documentation("Sets the accessibility label."), - ]) + ], super::super::input_tokens::methods(true)].concat()) .with_documentation("A retained native multi-line text editor. Shell style and common disabled state are honored; children are rejected."))?; Ok(()) } diff --git a/crates/component-shell/src/shell/mod.rs b/crates/component-shell/src/shell/mod.rs index 259a5db9fa..ddb329abb0 100644 --- a/crates/component-shell/src/shell/mod.rs +++ b/crates/component-shell/src/shell/mod.rs @@ -35,6 +35,7 @@ pub(super) fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryE Ok(()) } +mod input_tokens; mod support; mod typed_child; diff --git a/crates/component-shell/src/shell/retained_forms/mod.rs b/crates/component-shell/src/shell/retained_forms/mod.rs index 654b4a5560..55ae99bc90 100644 --- a/crates/component-shell/src/shell/retained_forms/mod.rs +++ b/crates/component-shell/src/shell/retained_forms/mod.rs @@ -252,7 +252,11 @@ struct InputMaterializer; impl ComponentMaterializer for InputMaterializer { fn materialize(&self, mut request: MaterializeRequest<'_>) -> anyhow::Result { let state = state_entity!(request, InputState); - let mut input = Input::new(&state); + let binding = super::input_tokens::prepare( + &request, + super::input_tokens::State::Input(state.clone()), + )?; + let mut input = binding.input(Input::new(&state)); for op in request .methods() .filter_map(|method| method.payload().downcast_ref::()) @@ -263,7 +267,7 @@ impl ComponentMaterializer for InputMaterializer { _ => input, }; } - finish_leaf(&mut request, input) + Ok(binding.wrap(finish_leaf(&mut request, input)?)) } } @@ -463,6 +467,7 @@ pub fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryError> { }))) }, ) + .with_methods(gpui_shell::input_token_state_methods()) .with_documentation( "Retained editable text state shared by Input and NumberInput, with optional placeholder and initial value.", ), @@ -535,7 +540,11 @@ pub fn register(registry: &mut ComponentRegistry) -> Result<(), RegistryError> { registry.register(component( "Input", "InputState", - vec![aria_label_method("Input"), disabled_method("Input")], + [ + vec![aria_label_method("Input"), disabled_method("Input")], + super::input_tokens::methods(true), + ] + .concat(), "A retained single-line text field.", InputMaterializer, ))?; diff --git a/crates/component-shell/tests/inline_tokens_host.rs b/crates/component-shell/tests/inline_tokens_host.rs new file mode 100644 index 0000000000..2372da7aee --- /dev/null +++ b/crates/component-shell/tests/inline_tokens_host.rs @@ -0,0 +1,121 @@ +use gpui::{Modifiers, TestAppContext, VisualTestContext, point, px}; +use std::{ + fs, + ops::Deref as _, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, +}; +static NEXT: AtomicU64 = AtomicU64::new(0); +struct TempApp(PathBuf); +impl TempApp { + fn new(source: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "gpui-inline-tokens-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + fs::write(path.join("main.js"), source).unwrap(); + Self(path) + } +} +impl Drop for TempApp { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +#[gpui::test] +fn inline_tokens_script_operations_and_click_reentry(cx: &mut TestAppContext) { + cx.update(gpui_component_shell::init); + let runtime = gpui_component_shell::new_isolated_runtime().unwrap(); + let app = TempApp::new( + r#" +import { div, View } from "gpui-kit"; +import { Input, InputState, Textarea, TextareaState } from "gpui-component"; +import { Button as BaseButton, InputState as BaseInputState, TextareaState as BaseTextareaState } from "gpui-base"; +function assert(value, message) { if (!value) throw new Error(message); } +function exercise(state) { + state.set_value("🙂 @a!"); + state.replace_range_with_token({start: 3, end: 5}, {id: "a", text: "@a", label: "Alice"}); + const saved = state.content(); + assert(saved.tokens[0].range.start === 3, "UTF-16 range"); + let code = ""; + try { state.replace_range_with_token({start: 1, end: 2}, {id: "bad", text: "x"}); } catch (error) { code = error.code; } + assert(code === "InvalidBoundary", "surrogate boundary must fail with code"); + assert(JSON.stringify(state.content()) === JSON.stringify(saved), "failure must be atomic"); + state.set_selected_range({start: 4, end: 5}); state.replace(""); + assert(state.value() === "🙂 !" && state.tokens().length === 0, "partial token deletion"); + state.set_content(saved); + state.set_value(state.value()); + assert(state.tokens().length === 0, "explicit same value clears identity"); + state.set_content(saved); + assert(state.refresh_token("a") && !state.refresh_token("missing"), "refresh identity"); + return state; +} +export default class TokenHost extends View { + init() { + this.input = exercise(InputState()); + this.textarea = exercise(TextareaState()); + this.child = exercise(InputState()); + this.base = exercise(BaseInputState.new()); + this.baseArea = exercise(BaseTextareaState.new()); + this.status = "verified"; + } + render() { + return div().relative().w(400).h(260) + .child(new Input(this.input).w(350).aria_label("Token input") + .render_token(token => div().w(80).h(20).child(token.token.label)) + .on_token_click((event, cx) => { + assert(event.token.id === "a", "current identity"); + this.input.set_value("opened"); this.status = "clicked"; cx.notify(); + })) + .child(new Textarea(this.textarea).w(350).h(60)) + .child(new Input(this.child).absolute().top(140).left(0).w(350) + .render_token(token => div().flex().w(100).h(20).child(div().w(70).child(token.token.label)) + .child(BaseButton.new("remove-token-child").w(30).h(20).child("×") + .on_mouse_down("left", (_event, cx) => cx.stop_propagation()) + .on_click((_event, cx) => { this.child.set_value("removed"); this.status = "child"; cx.stop_propagation(); cx.notify(); }))) + .on_token_click((_event, cx) => { this.status = "wrong body activation"; cx.notify(); })) + .child(div().child(`${this.status}:${this.input.value()}:${this.input.tokens().length};child=${this.child.tokens().length}:${this.child.value()}`)); + } +} +"#, + ); + let loaded = runtime.load_application(&app.0, "main.js").unwrap(); + let mounted = std::rc::Rc::new(std::cell::RefCell::new(None)); + let capture = mounted.clone(); + let window = cx.add_window(move |window, cx| { + let view = runtime.mount_application(&loaded, window, cx).unwrap(); + *capture.borrow_mut() = Some(view.clone()); + gpui_component::Root::new(view, window, cx) + }); + let view = mounted.borrow().clone().unwrap(); + let mut context = VisualTestContext::from_window(*window.deref(), cx); + let draw = |context: &mut VisualTestContext| { + context.run_until_parked(); + context.update(|window, cx| window.draw(cx).clear(cx)); + context.update(|_, cx| { + assert_eq!(view.read(cx).build_error(), None); + view.read(cx).snapshot().unwrap().debug_tree() + }) + }; + let first = draw(&mut context); + assert!(first.contains("verified:🙂 @a!:1"), "{first}"); + assert!( + draw(&mut context).contains("verified:🙂 @a!:1"), + "rerender preserves identity" + ); + context.simulate_click(point(px(65.), px(16.)), Modifiers::default()); + let result = draw(&mut context); + assert!(result.contains("clicked:opened:0"), "{result}"); + // The custom child's callback survives the frame and consumes its own gesture. + let child_bounds = context.update(|window, _| { + gpui_base::test_support::find(window, &[], &gpui::ElementId::from("remove-token-child")) + .expect("custom token child is laid out") + .bounds() + }); + context.simulate_click(child_bounds.center(), Modifiers::default()); + let result = draw(&mut context); + assert!(result.contains("child:opened:0"), "{result}"); +} diff --git a/crates/component-shell/tests/layout_host.rs b/crates/component-shell/tests/layout_host.rs index 399a34437e..ec4e54e232 100644 --- a/crates/component-shell/tests/layout_host.rs +++ b/crates/component-shell/tests/layout_host.rs @@ -1,3 +1,7 @@ +#[allow(dead_code)] // This isolated layout host only uses the textarea half. +#[path = "../src/shell/input_tokens.rs"] +mod input_tokens; + #[path = "../src/shell/support.rs"] mod support; diff --git a/crates/component/locales/ui.yml b/crates/component/locales/ui.yml index 3d9833fe59..c681f9fb37 100644 --- a/crates/component/locales/ui.yml +++ b/crates/component/locales/ui.yml @@ -275,6 +275,10 @@ Input: zh-CN: 全部替换 zh-HK: 全部替換 zh-TW: 全部取代 + Activate token: + en: Activate token + zh-CN: 打开引用 + zh-HK: 開啟參照 Cut: en: Cut zh-CN: 剪切 diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index 40cd0d8ae3..22af38bffa 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -109,6 +109,7 @@ pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) { /// A text input element bind to an [`InputState`]. #[derive(IntoElement)] pub struct Input { + token_presentation: super::InlineTokenPresentation, id: Option, state: TextInputState, style: StyleRefinement, @@ -170,6 +171,28 @@ impl crate::FocusableExt for Input { } impl Input { + /// Customize atomic inline tokens; state and history stay with the input. + pub fn render_token( + mut self, + render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, + ) -> Self { + self.token_presentation = self.token_presentation.render_token(render); + self + } + /// Activate a reference after a completed, unconsumed token click. + pub fn on_token_click( + mut self, + listener: impl Fn(&super::InlineTokenClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.token_presentation = self.token_presentation.on_token_click(listener); + self + } + #[doc(hidden)] + pub fn token_presentation(mut self, presentation: super::InlineTokenPresentation) -> Self { + self.token_presentation = presentation; + self + } + /// Sets the GPUI identity of the input frame. By default it uses the state entity ID. pub fn id(mut self, id: impl Into) -> Self { self.id = Some(id.into()); @@ -213,6 +236,8 @@ impl Input { aria_label: None, context_menu_builder: None, paste_handler: None, + token_presentation: super::InlineTokenPresentation::new() + .with_fallback(|token, _, _| super::InputToken::new(token).into_any_element()), } } @@ -489,6 +514,13 @@ impl RenderOnce for Input { const LINE_HEIGHT: Rems = Rems(1.25); let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left); let state = self.state.clone(); + state.set_token_presentation( + self.token_presentation.secret(matches!( + self.content_type, + Some(InputContentType::Password | InputContentType::NewPassword) + )), + cx, + ); // Which kind of input this registers as follows from the state itself. sync_focused_input_registry(&state, window, cx); @@ -569,6 +601,14 @@ impl RenderOnce for Input { ) .separator(); } + if capabilities.has_token_activation() { + menu = menu + .menu( + t!("Input.Activate token"), + Box::new(gpui_base::input::ActivateToken), + ) + .separator(); + } menu.menu_with_disabled( t!("Input.Cut"), !(editable && capabilities.is_copyable()), diff --git a/crates/component/src/input/mod.rs b/crates/component/src/input/mod.rs index 7289726e5f..9789fa0d02 100644 --- a/crates/component/src/input/mod.rs +++ b/crates/component/src/input/mod.rs @@ -1,6 +1,12 @@ mod clear_button; mod content_type; mod input; +mod token; +pub use gpui_base::input::{ + ActivateToken, InlineToken, InlineTokenClickEvent, InlineTokenContext, InlineTokenError, + InlineTokenPresentation, InlineTokenSpan, InputContent, +}; +pub use token::InputToken; pub mod language_config; mod number_input; mod otp_input; diff --git a/crates/component/src/input/state.rs b/crates/component/src/input/state.rs index 641ae253f0..4a7dc7fd67 100644 --- a/crates/component/src/input/state.rs +++ b/crates/component/src/input/state.rs @@ -52,6 +52,15 @@ macro_rules! dispatch { } impl TextInputState { + pub(crate) fn set_token_presentation( + &self, + presentation: super::InlineTokenPresentation, + cx: &mut App, + ) { + dispatch!(self, |state| state + .update(cx, |state, _| state.set_token_presentation(presentation))) + } + pub(crate) fn entity_id(&self) -> gpui::EntityId { dispatch!(self, |state| state.entity_id()) } diff --git a/crates/component/src/input/textarea.rs b/crates/component/src/input/textarea.rs index cae6c23b6d..70872df4e8 100644 --- a/crates/component/src/input/textarea.rs +++ b/crates/component/src/input/textarea.rs @@ -12,6 +12,7 @@ use crate::{RoleOverride, StyledExt as _}; /// A styled ordinary multi-line text field. #[derive(IntoElement)] pub struct Textarea { + token_presentation: super::InlineTokenPresentation, state: Entity, style: StyleRefinement, height: Option, @@ -33,6 +34,28 @@ pub struct Textarea { } impl Textarea { + /// Customize atomic inline tokens; state and history stay with the input. + pub fn render_token( + mut self, + render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, + ) -> Self { + self.token_presentation = self.token_presentation.render_token(render); + self + } + /// Activate a reference after a completed, unconsumed token click. + pub fn on_token_click( + mut self, + listener: impl Fn(&super::InlineTokenClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.token_presentation = self.token_presentation.on_token_click(listener); + self + } + + #[doc(hidden)] + pub fn token_presentation(mut self, presentation: super::InlineTokenPresentation) -> Self { + self.token_presentation = presentation; + self + } pub fn new(state: &Entity) -> Self { Self { state: state.clone(), @@ -48,6 +71,8 @@ impl Textarea { aria_label: None, context_menu_builder: None, paste_handler: None, + token_presentation: super::InlineTokenPresentation::new() + .with_fallback(|token, _, _| super::InputToken::new(token).into_any_element()), } } @@ -140,6 +165,7 @@ impl Textarea { /// it. pub(crate) fn into_input(self) -> Input { Input::from_state(self.state.clone()) + .token_presentation(self.token_presentation) .appearance(self.appearance) .bordered(self.bordered) .disabled(self.disabled) diff --git a/crates/component/src/input/token.rs b/crates/component/src/input/token.rs new file mode 100644 index 0000000000..8a26752958 --- /dev/null +++ b/crates/component/src/input/token.rs @@ -0,0 +1,72 @@ +use super::InlineTokenContext; +use crate::{ActiveTheme as _, Icon, StyledExt as _, tooltip::Tooltip}; +use gpui::{ + App, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString, + StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div, + prelude::FluentBuilder as _, +}; + +/// Default inline token skin. Editing and activation belong to the input. +#[derive(IntoElement)] +pub struct InputToken { + context: InlineTokenContext, + icon: Option, + tooltip: Option, + style: StyleRefinement, +} +impl InputToken { + pub fn new(context: &InlineTokenContext) -> Self { + Self { + context: context.clone(), + icon: None, + tooltip: None, + style: Default::default(), + } + } + pub fn with_icon(mut self, icon: impl Into) -> Self { + self.icon = Some(icon.into()); + self + } + pub fn with_tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip = Some(tooltip.into()); + self + } +} +impl Styled for InputToken { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} +impl RenderOnce for InputToken { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + div() + .id("input-token-skin") + .flex() + .items_center() + .gap_1() + .px_1() + .h(self.context.line_height()) + .max_w(self.context.available_width()) + .rounded(cx.theme().radius) + .bg(if self.context.is_selected() { + cx.theme().selection + } else { + cx.theme().muted + }) + .text_color(cx.theme().foreground) + .when(self.context.is_disabled(), |this| this.opacity(0.5)) + .when_some(self.icon, |this, icon| { + this.child(icon.size_3().flex_shrink_0()) + }) + .child( + div() + .min_w_0() + .text_ellipsis() + .child(self.context.token().label().clone()), + ) + .when_some(self.tooltip, |this, tooltip| { + this.tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) + }) + .refine_style(&self.style) + } +} diff --git a/crates/shell/src/component_registry.rs b/crates/shell/src/component_registry.rs index dc7f84f164..56a538cbdf 100644 --- a/crates/shell/src/component_registry.rs +++ b/crates/shell/src/component_registry.rs @@ -361,8 +361,80 @@ type StateFactory = dyn Fn(&[ComponentArgument], &mut Window, &mut App) -> Resul + Sync + 'static; +type PreparedStateCall = + Box anyhow::Result>; +type PrepareStateCall = dyn Fn( + &RetainedStateStore, + u64, + &'static str, + Vec, + ) -> anyhow::Result + + Send + + Sync; + +/// An opt-in operation on retained state. Clone the handle before entering GPUI, +/// so callbacks never execute with the registry's state table borrowed. +#[derive(Clone)] +pub struct StateMethodDescriptor { + name: &'static str, + signature: &'static str, + readonly: bool, + prepare: Arc, +} +impl StateMethodDescriptor { + pub fn new( + name: &'static str, + signature: &'static str, + call: impl Fn( + &T, + &[ComponentDataValue], + &mut Window, + &mut App, + ) -> anyhow::Result + + Send + + Sync + + 'static, + ) -> Self { + let call = Arc::new(call); + Self { + name, + signature, + readonly: false, + prepare: Arc::new(move |store, handle, kind, args| { + let state = store.with::(handle, kind, Clone::clone)?; + let call = call.clone(); + Ok(Box::new(move |window, cx| call(&state, &args, window, cx))) + }), + } + } + pub fn with_readonly(mut self, readonly: bool) -> Self { + self.readonly = readonly; + self + } + pub fn name(&self) -> &'static str { + self.name + } + /// TypeScript parameter list and result, e.g. `(): string`. + pub fn signature(&self) -> &'static str { + self.signature + } + pub fn is_readonly(&self) -> bool { + self.readonly + } + pub(crate) fn prepare( + &self, + store: &RetainedStateStore, + handle: u64, + kind: &'static str, + args: Vec, + ) -> anyhow::Result { + (self.prepare)(store, handle, kind, args) + } +} + #[derive(Clone)] pub struct StateDescriptor { + methods: Vec, export: &'static str, kind: &'static str, arguments: Vec, @@ -388,6 +460,7 @@ impl StateDescriptor { + 'static, ) -> Self { Self { + methods: Vec::new(), export, kind, arguments, @@ -401,6 +474,14 @@ impl StateDescriptor { self } + pub fn with_methods(mut self, methods: Vec) -> Self { + self.methods = methods; + self + } + pub fn methods(&self) -> &[StateMethodDescriptor] { + &self.methods + } + pub fn export(&self) -> &'static str { self.export } @@ -1204,7 +1285,6 @@ impl ComponentDataCallback { } impl ComponentElementCallback { - #[cfg(test)] pub(crate) fn from_runtime(runtime: &Rc, id: u64) -> Self { Self { callback: ComponentCallback::from_runtime(runtime, id), @@ -1260,6 +1340,31 @@ impl ComponentElementCallback { .ok_or_else(|| anyhow::anyhow!("component callback runtime has been released"))?; runtime.dispatch_component_element_data_callback(self.callback.id, arguments, window, cx) } + /// Build a frame-owned inline subtree whose child callbacks retire with that frame. + pub fn build_interactive_data_with( + &self, + arguments: &[ComponentDataValue], + window: &mut Window, + cx: &mut App, + ) -> anyhow::Result> { + anyhow::ensure!( + !self.active.replace(true), + "component element callback is already running" + ); + struct Reset<'a>(&'a Cell); + impl Drop for Reset<'_> { + fn drop(&mut self) { + self.0.set(false) + } + } + let _reset = Reset(&self.active); + let runtime = self + .callback + .runtime + .upgrade() + .ok_or_else(|| anyhow::anyhow!("component callback runtime has been released"))?; + runtime.dispatch_inline_element_data(self.callback.id, arguments, true, window, cx) + } } /// A generation-bound capability for native effects initiated by one GPUI event. @@ -1440,7 +1545,6 @@ impl ComponentClickCallback { } impl ComponentCallback { - #[cfg(test)] pub(crate) fn from_runtime(runtime: &Rc, id: u64) -> Self { Self { runtime: Rc::downgrade(runtime), @@ -1495,6 +1599,21 @@ impl ComponentCallback { /// /// GPUI event closures cannot return an error. Adapters should use this /// entry point instead of discarding the [`Result`] from [`Self::invoke_with`]. + pub fn invoke_data_with( + &self, + arguments: &[ComponentDataValue], + window: &mut Window, + cx: &mut App, + ) -> anyhow::Result<()> { + let runtime = self + .runtime + .upgrade() + .ok_or_else(|| anyhow::anyhow!("component callback runtime has been released"))?; + runtime + .dispatch_component_event_data(self.id, arguments, window, cx) + .map(|_| ()) + } + pub fn invoke_and_report_with( &self, context: &str, @@ -1990,6 +2109,15 @@ impl ComponentRegistry { if self.state_kinds.contains(descriptor.kind) { return Err(RegistryError::DuplicateStateKind(descriptor.kind)); } + let mut method_names = HashSet::new(); + for method in &descriptor.methods { + if !is_javascript_identifier(method.name) || !method_names.insert(method.name) { + return Err(RegistryError::InvalidMethod { + component: descriptor.kind, + method: method.name, + }); + } + } validate_arguments(descriptor.kind, descriptor.export, &descriptor.arguments)?; self.exports.insert(descriptor.export); self.state_kinds.insert(descriptor.kind); @@ -2238,7 +2366,11 @@ impl FrozenComponentRegistry { source.push_str(state.export); source.push_str("(...args) { const handle = globalThis.__gpui_components["); source.push_str(&format!("{:?}", state.export)); - source.push_str("](args); const value = Object.freeze({}); __stateHandles.set(value, handle); return value; }\nexport { "); + source.push_str("](args); const value = Object.freeze({"); + for method in state.methods() { + source.push_str(&format!("{:?}: (...args) => globalThis.__gpui_components[{:?}](__stateProof, handle, args),", method.name(), format!("{}.{}", state.export(), method.name()))); + } + source.push_str("}); __stateHandles.set(value, handle); return value; }\nexport { "); source.push_str(state.export); source.push_str(" };\n"); } diff --git a/crates/shell/src/engine/quickjs/entity_api.rs b/crates/shell/src/engine/quickjs/entity_api.rs index 4f59a42d03..1c122a4471 100644 --- a/crates/shell/src/engine/quickjs/entity_api.rs +++ b/crates/shell/src/engine/quickjs/entity_api.rs @@ -54,6 +54,52 @@ impl<'js> FromJs<'js> for Handler { pub fn install(ctx: &Ctx<'_>, module: &Object<'_>, runtime: Weak) -> JsResult<()> { let _ = module; let globals = ctx.globals(); + for multiline in [false, true] { + let token_runtime = runtime.clone(); + globals.set( + if multiline { + "__textarea_token_call" + } else { + "__input_token_call" + }, + Func::from( + move |ctx: Ctx<'_>, + handle: EntityHandle, + method: String, + args: super::PlainDataArguments| + -> JsResult { + if !crate::input_tokens::METHODS + .iter() + .any(|(name, _, readonly)| *name == method && *readonly) + { + refuse_mutation_in_render(&ctx, &method)?; + } + let runtime = alive(&ctx, &token_runtime)?; + scope::with_current(|window, cx| { + if multiline { + let state = runtime.entities().textarea(handle).ok_or_else(|| { + anyhow::anyhow!("textarea state has been released") + })?; + crate::input_tokens::invoke_textarea( + &state, &method, &args.0, window, cx, + ) + } else { + let state = runtime + .entities() + .input(handle) + .ok_or_else(|| anyhow::anyhow!("input state has been released"))?; + crate::input_tokens::invoke_input(&state, &method, &args.0, window, cx) + } + }) + .ok_or_else(|| { + Exception::throw_type(&ctx, "input operation requires a live host call") + })? + .map(super::DataResult) + .map_err(|e| super::state_operation_error(&ctx, e)) + }, + ), + )?; + } // Every entity call reaches its store through the runtime, because the // store belongs to the runtime rather than to the thread — see diff --git a/crates/shell/src/engine/quickjs/mod.rs b/crates/shell/src/engine/quickjs/mod.rs index 058834dcd0..e0f7bc8622 100644 --- a/crates/shell/src/engine/quickjs/mod.rs +++ b/crates/shell/src/engine/quickjs/mod.rs @@ -484,6 +484,77 @@ mod component_callback_value_tests { ); } + #[gpui::test] + fn inline_element_callbacks_retire_with_their_element(cx: &mut TestAppContext) { + let runtime = ShellRuntime::new_isolated().unwrap(); + let (good, _) = callback( + &runtime, + r#"() => __gpui.Button.new("child").on_click(() => {})"#, + None, + ); + let (bad, _) = callback( + &runtime, + r#"() => { __gpui.Button.new("child").on_click(() => {}); throw new Error("failed render"); }"#, + None, + ); + let good = crate::ComponentElementCallback::from_runtime(&runtime, good); + let bad = crate::ComponentElementCallback::from_runtime(&runtime, bad); + let baseline = runtime.callbacks.borrow().len(); + struct Probe { + renderer: Option, + failed: bool, + } + impl gpui::Render for Probe { + fn render( + &mut self, + window: &mut Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + use gpui::IntoElement as _; + let Some(renderer) = &self.renderer else { + return gpui::div().into_any_element(); + }; + match renderer.build_interactive_data_with(&[], window, cx) { + Ok(Some(element)) => element, + result => { + self.failed = result.is_err(); + gpui::div().into_any_element() + } + } + } + } + let window = cx.add_window(|_, _| Probe { + renderer: None, + failed: false, + }); + let mut context = VisualTestContext::from_window(*window.deref(), cx); + let probe = window.root(&mut context).unwrap(); + for _ in 0..8 { + probe.update(&mut context, |probe, cx| { + probe.renderer = Some(good.clone()); + cx.notify(); + }); + context.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(runtime.callbacks.borrow().len(), baseline + 1); + probe.update(&mut context, |probe, cx| { + probe.renderer = None; + cx.notify(); + }); + // The previous frame's event table also owns the lease. + context.update(|window, cx| window.draw(cx).clear(cx)); + context.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(runtime.callbacks.borrow().len(), baseline); + probe.update(&mut context, |probe, cx| { + probe.renderer = Some(bad.clone()); + cx.notify(); + }); + context.update(|window, cx| window.draw(cx).clear(cx)); + assert!(context.update(|_, cx| probe.read(cx).failed)); + assert_eq!(runtime.callbacks.borrow().len(), baseline); + assert!(!runtime.interactive_inline_layout.get()); + } + } + #[gpui::test] fn temporary_delegate_arena_restores_after_panic_and_later_materializes( cx: &mut TestAppContext, @@ -1295,6 +1366,7 @@ pub struct ShellRuntime { callbacks: RefCell>>>, components: FrozenComponentRegistry, component_state_proof: String, + interactive_inline_layout: Cell, component_states: RefCell, pending_component_state_releases: RefCell>>, component_app_effects: RefCell>, @@ -1629,6 +1701,7 @@ impl ShellRuntime { callbacks: RefCell::new(CallbackArena::default()), components, component_state_proof, + interactive_inline_layout: Cell::new(false), component_states: RefCell::new(Default::default()), pending_component_state_releases: RefCell::new(Vec::new()), component_app_effects: RefCell::new(HashMap::new()), @@ -4504,6 +4577,25 @@ impl ShellRuntime { arguments: &[ComponentCallbackArgument], window: &mut Window, cx: &mut App, + ) -> Result { + self.dispatch_component_event(id, EventArguments::Scalar(arguments), window, cx) + } + pub(crate) fn dispatch_component_event_data( + self: &Rc, + id: CallbackId, + arguments: &[ComponentDataValue], + window: &mut Window, + cx: &mut App, + ) -> Result { + self.dispatch_component_event(id, EventArguments::Data(arguments), window, cx) + } + + fn dispatch_component_event( + self: &Rc, + id: CallbackId, + arguments: EventArguments<'_>, + window: &mut Window, + cx: &mut App, ) -> Result { let entry = self .callbacks @@ -4538,8 +4630,17 @@ impl ShellRuntime { let result = self.with_js(|ctx| { let handler = entry.value.clone().restore(ctx)?; let mut js_arguments = JsArgs::new(ctx.clone(), arguments.len() + 1); - for argument in arguments { - js_arguments.push_arg(callback_argument_to_js(ctx, argument)?)?; + match arguments { + EventArguments::Scalar(values) => { + for value in values { + js_arguments.push_arg(callback_argument_to_js(ctx, value)?)?; + } + } + EventArguments::Data(values) => { + for value in values { + js_arguments.push_arg(component_data_into_js(ctx, value)?)?; + } + } } js_arguments.push_arg(context_object(ctx, ContextBinding::Call(generation))?)?; let value: Value<'_> = handler.call_arg(js_arguments)?; @@ -4677,6 +4778,17 @@ impl ShellRuntime { arguments: &[ComponentDataValue], window: &mut Window, cx: &mut App, + ) -> Result> { + self.dispatch_inline_element_data(id, arguments, false, window, cx) + } + + pub(crate) fn dispatch_inline_element_data( + self: &Rc, + id: CallbackId, + arguments: &[ComponentDataValue], + interactive: bool, + window: &mut Window, + cx: &mut App, ) -> Result> { let entry = self .callbacks @@ -4707,6 +4819,33 @@ impl ShellRuntime { ); scope::adopt(entry.registered_in); let temporary = TemporarySpecArena::enter(self); + let callback_generation = if interactive { + anyhow::ensure!( + !self.callbacks.borrow().is_building(), + "inline renderer cannot nest callback recording" + ); + Some(self.callbacks.borrow_mut().begin()) + } else { + None + }; + struct CallbackGuard<'a> { + runtime: &'a ShellRuntime, + previous: bool, + active: bool, + } + impl Drop for CallbackGuard<'_> { + fn drop(&mut self) { + self.runtime.interactive_inline_layout.set(self.previous); + if self.active { + self.runtime.callbacks.borrow_mut().abort(); + } + } + } + let mut callbacks = CallbackGuard { + runtime: self, + previous: self.interactive_inline_layout.replace(interactive), + active: interactive, + }; let described = self.with_js(|ctx| { let handler = entry.value.clone().restore(ctx)?; let mut args = JsArgs::new(ctx.clone(), arguments.len() + 1); @@ -4724,8 +4863,38 @@ impl ShellRuntime { let arena = temporary.finish(); match described { Ok(Some(root)) => { - crate::materialize::try_materialize_subtree(self, &arena, root, window, cx) - .map(Some) + if let Some(generation) = callback_generation { + self.callbacks.borrow_mut().commit(); + callbacks.active = false; + let snapshot = RenderSnapshot::new( + self, + generation, + root, + arena, + entry.application.clone(), + entry.view.clone(), + ); + let element = crate::materialize::try_materialize_subtree( + self, + snapshot.arena(), + root, + window, + cx, + )?; + use gpui::{InteractiveElement as _, IntoElement as _, ParentElement as _}; + // The frame's event table holds the lease until the next frame replaces it. + Ok(Some( + gpui::div() + .child(element) + .on_mouse_move(move |_, _, _| { + let _ = &snapshot; + }) + .into_any_element(), + )) + } else { + crate::materialize::try_materialize_subtree(self, &arena, root, window, cx) + .map(Some) + } } Ok(None) => Ok(None), Err(error) => Err(error.into()), @@ -5844,7 +6013,12 @@ globalThis.__gpui = (() => { // Retained state is held by handle; the methods close over it so nothing has // to read it back off `this`. + const tokenStateMethods = (handle, invoke) => Object.fromEntries([ + "content", "tokens", "set_content", "replace_with_token", "replace_range_with_token", + "refresh_token", "refresh", "set_selected_range", "replace" + ].map(name => [name, (...args) => invoke(handle, name, args)])); const inputState = (handle) => ({ + ...tokenStateMethods(handle, __input_token_call), __handle: handle, value: () => __input_value(handle), set_value: (next) => __input_set_value(handle, String(next ?? "")), @@ -5863,6 +6037,7 @@ globalThis.__gpui = (() => { // The multi-line state shares almost all of its surface with the single-line // one, and adds the three calls that only mean anything once text can wrap. const textareaState = (handle) => ({ + ...tokenStateMethods(handle, __textarea_token_call), __handle: handle, value: () => __textarea_value(handle), set_value: (next) => __textarea_set_value(handle, String(next ?? "")), @@ -6866,6 +7041,8 @@ impl ShellRuntime { "on_item_click", "on_item_secondary_click", "on_change", + "render_token", + "on_token_click", "on_open_change", "on_confirm", "on_dismiss", @@ -7459,6 +7636,26 @@ impl ShellRuntime { ctx.globals() .set("__gpui_components", component_module.clone())?; for descriptor in self.components.states() { + for method in descriptor.methods() { + let method_runtime = runtime.clone(); + let kind = descriptor.kind(); + let method = method.clone(); + component_module.set(format!("{}.{}", descriptor.export(), method.name()), Func::from(move |ctx: Ctx<'_>, proof: String, handle: u64, arguments: PlainDataArguments| -> JsResult { + let runtime = upgrade(&method_runtime, &ctx)?; + if proof != runtime.component_state_proof { return Err(Exception::throw_type(&ctx, "state belongs to another runtime")); } + if !method.is_readonly() && matches!(scope::current_phase(), Some(ScopePhase::Render | ScopePhase::Layout)) { + return Err(Exception::throw_type(&ctx, "state cannot be changed during render or layout")); + } + runtime.flush_component_state_releases(); + let call = { + let store = runtime.component_states.try_borrow().map_err(|_| Exception::throw_type(&ctx, "state registry is already borrowed"))?; + method.prepare(&store, handle, kind, arguments.0).map_err(|e| state_operation_error(&ctx, e))? + }; + scope::with_current(move |window, cx| call(window, cx)) + .ok_or_else(|| Exception::throw_type(&ctx, "state operation requires a live host call"))? + .map(DataResult).map_err(|e| state_operation_error(&ctx, e)) + }))?; + } let state_runtime = runtime.clone(); let descriptor = descriptor.clone(); component_module.set( @@ -7717,7 +7914,9 @@ impl ShellRuntime { // a `Callback` op because the name is discovered at run time and a // `Callback` holds a `&'static str`; see `SpecOp::ActionCallback`. "on_action" => { - if scope::current_phase() == Some(ScopePhase::Layout) { + if scope::current_phase() == Some(ScopePhase::Layout) + && !self.interactive_inline_layout.get() + { return Err(Exception::throw_type( ctx, "`on_action` cannot be registered from a virtual list's item \ @@ -7758,7 +7957,9 @@ impl ShellRuntime { // fixed names GPUI's own `MouseButton` maps onto — so the op stays // the `(&'static str, CallbackId)` pair every other callback uses. "on_mouse_down" | "on_mouse_up" => { - if scope::current_phase() == Some(ScopePhase::Layout) { + if scope::current_phase() == Some(ScopePhase::Layout) + && !self.interactive_inline_layout.get() + { return Err(Exception::throw_type( ctx, &format!( @@ -7815,6 +8016,8 @@ impl ShellRuntime { | "on_link_click" | "on_resize" | "on_change" + | "render_token" + | "on_token_click" | "on_open_change" | "on_confirm" | "on_dismiss" @@ -7842,7 +8045,9 @@ impl ShellRuntime { // leaked quietly. `on_item_click` on the list is the one // handler that covers the rows, and it is registered from // `render()` like every other. - if scope::current_phase() == Some(ScopePhase::Layout) { + if scope::current_phase() == Some(ScopePhase::Layout) + && !self.interactive_inline_layout.get() + { return Err(Exception::throw_type( ctx, &format!( @@ -9405,6 +9610,47 @@ enum Argument { Slot(u16), } +enum EventArguments<'a> { + Scalar(&'a [ComponentCallbackArgument]), + Data(&'a [ComponentDataValue]), +} +impl EventArguments<'_> { + fn len(&self) -> usize { + match self { + Self::Scalar(values) => values.len(), + Self::Data(values) => values.len(), + } + } +} + +struct PlainDataArguments(Vec); +impl<'js> FromJs<'js> for PlainDataArguments { + fn from_js(ctx: &Ctx<'js>, value: Value<'js>) -> JsResult { + match component_data_from_js(ctx, value, 0, &mut ComponentDataBudget::default())? { + ComponentDataValue::Array(values) => Ok(Self(values)), + _ => Err(Exception::throw_type( + ctx, + "state operation expects an argument array", + )), + } + } +} +struct DataResult(ComponentDataValue); +impl<'js> rquickjs::IntoJs<'js> for DataResult { + fn into_js(self, ctx: &Ctx<'js>) -> JsResult> { + component_data_into_js(ctx, &self.0) + } +} +fn state_operation_error(ctx: &Ctx<'_>, error: anyhow::Error) -> rquickjs::Error { + if let Some(token) = error.downcast_ref::() { + if let Ok(exception) = Exception::from_message(ctx.clone(), &token.to_string()) { + let _ = exception.as_object().set("code", format!("{token:?}")); + return exception.throw(); + } + } + Exception::throw_type(ctx, &error.to_string()) +} + struct Arguments(SmallVec<[Argument; 2]>); /// The single argument of a parametric style method. @@ -9718,6 +9964,8 @@ fn callback_op_name(method: &str) -> Option<&'static str> { "drop_indicator" => "drop_indicator", "dock" => "dock", "on_change" => "on_change", + "render_token" => "render_token", + "on_token_click" => "on_token_click", "on_confirm" => "on_confirm", "on_dismiss" => "on_dismiss", "on_step" => "on_step", diff --git a/crates/shell/src/input_tokens.rs b/crates/shell/src/input_tokens.rs new file mode 100644 index 0000000000..58f64bd7f3 --- /dev/null +++ b/crates/shell/src/input_tokens.rs @@ -0,0 +1,330 @@ +//! Shared token data conversion for built-in and registered input state bindings. +use crate::{ComponentDataValue as Data, StateMethodDescriptor}; +use anyhow::{Result, anyhow, bail}; +use gpui::{App, Entity, Window}; +use gpui_base::input::{ + InlineToken, InlineTokenClickEvent, InlineTokenContext, InlineTokenError, InputContent, + InputState, Rope, RopeExt as _, TextareaState, +}; + +fn object(fields: impl IntoIterator) -> Data { + Data::Object( + fields + .into_iter() + .map(|(key, value)| (key.into(), value)) + .collect(), + ) +} +fn field<'a>(value: &'a Data, name: &str) -> Result<&'a Data> { + let Data::Object(fields) = value else { + bail!("expected a plain object"); + }; + fields + .iter() + .find(|(key, _)| key == name) + .map(|(_, v)| v) + .ok_or_else(|| anyhow!("missing `{name}`")) +} +fn string(value: &Data) -> Result<&str> { + if let Data::String(value) = value { + Ok(value) + } else { + bail!("expected a string") + } +} +fn token(value: &Data) -> Result { + let result = InlineToken::new( + string(field(value, "id")?)?.to_owned(), + string(field(value, "text")?)?.to_owned(), + ); + Ok(if let Ok(label) = field(value, "label") { + result.with_label(string(label)?.to_owned()) + } else { + result + }) +} +fn byte_offset(text: &str, value: &Data) -> Result { + let Data::Number(offset) = value else { + return Err(InlineTokenError::InvalidRange.into()); + }; + if !offset.is_finite() || *offset < 0. || offset.fract() != 0. || *offset > text.len() as f64 { + return Err(InlineTokenError::InvalidRange.into()); + } + let target = *offset as usize; + let mut utf16 = 0; + for (ix, ch) in text.char_indices() { + if utf16 == target { + return Ok(ix); + } + utf16 += ch.len_utf16(); + if utf16 > target { + return Err(InlineTokenError::InvalidBoundary.into()); + } + } + if utf16 == target { + Ok(text.len()) + } else { + Err(InlineTokenError::InvalidRange.into()) + } +} +fn range(text: &str, value: &Data) -> Result> { + let range = + byte_offset(text, field(value, "start")?)?..byte_offset(text, field(value, "end")?)?; + if range.start > range.end { + return Err(InlineTokenError::InvalidRange.into()); + } + Ok(range) +} +fn decode_content(value: &Data) -> Result { + let text = string(field(value, "text")?)?; + let Data::Array(tokens) = field(value, "tokens")? else { + bail!("content.tokens must be an array"); + }; + let mut content = InputContent::new(text.to_owned()); + for span in tokens { + content = content.with_token( + range(text, field(span, "range")?)?, + token(field(span, "token")?)?, + ); + } + Ok(content) +} +fn token_data(token: &InlineToken) -> Data { + object([ + ("id", Data::String(token.id().to_string())), + ("text", Data::String(token.text().to_string())), + ("label", Data::String(token.label().to_string())), + ]) +} +fn range_data(text: &str, range: std::ops::Range) -> Data { + object([ + ( + "start", + Data::Number(text[..range.start].encode_utf16().count() as f64), + ), + ( + "end", + Data::Number(text[..range.end].encode_utf16().count() as f64), + ), + ]) +} +fn content_data(content: &InputContent) -> Data { + object([ + ("text", Data::String(content.text().to_string())), + ( + "tokens", + Data::Array( + content + .tokens() + .iter() + .map(|span| { + object([ + ("range", range_data(content.text(), span.range())), + ("token", token_data(span.token())), + ]) + }) + .collect(), + ), + ), + ]) +} + +fn rope_range_data(text: &Rope, range: std::ops::Range) -> Data { + object([ + ( + "start", + Data::Number(text.offset_to_offset_utf16(range.start) as f64), + ), + ( + "end", + Data::Number(text.offset_to_offset_utf16(range.end) as f64), + ), + ]) +} + +/// Plain JS renderer context. Range coordinates follow JavaScript strings. +pub fn inline_token_context_data(token: &InlineTokenContext, text: &Rope) -> Data { + object([ + ("token", token_data(token.token())), + ("range", rope_range_data(text, token.range())), + ("selected", Data::Boolean(token.is_selected())), + ("disabled", Data::Boolean(token.is_disabled())), + ("readonly", Data::Boolean(token.is_readonly())), + ( + "line_height", + Data::Number(f32::from(token.line_height()) as f64), + ), + ( + "available_width", + Data::Number(f32::from(token.available_width()) as f64), + ), + ]) +} +/// Plain JS activation event, with current token identity and pointer modifiers. +pub fn inline_token_click_data(event: &InlineTokenClickEvent, text: &Rope) -> Data { + let modifiers = event.event().modifiers(); + let bounds = event.bounds(); + object([ + ("token", token_data(event.token())), + ("range", rope_range_data(text, event.range())), + ( + "bounds", + object([ + ("x", Data::Number(f32::from(bounds.origin.x) as f64)), + ("y", Data::Number(f32::from(bounds.origin.y) as f64)), + ("width", Data::Number(f32::from(bounds.size.width) as f64)), + ("height", Data::Number(f32::from(bounds.size.height) as f64)), + ]), + ), + ( + "modifiers", + object([ + ("shift", Data::Boolean(modifiers.shift)), + ("alt", Data::Boolean(modifiers.alt)), + ("control", Data::Boolean(modifiers.control)), + ("platform", Data::Boolean(modifiers.platform)), + ]), + ), + ]) +} + +pub(crate) const METHODS: &[(&str, &str, bool)] = &[ + ("value", "(): string", true), + ("set_value", "(text: string): void", false), + ("content", "(): InputContent", true), + ("tokens", "(): InlineTokenSpan[]", true), + ("set_content", "(content: InputContent): void", false), + ("replace_with_token", "(token: InlineToken): void", false), + ( + "replace_range_with_token", + "(range: InputRange, token: InlineToken): void", + false, + ), + ("refresh_token", "(id: string): boolean", false), + ("refresh", "(): void", false), + ("set_selected_range", "(range: InputRange): void", false), + ("replace", "(text: string): void", false), +]; + +macro_rules! state_binding { + ($state:ty, $invoke:ident, $methods:ident) => { + pub(crate) fn $invoke( + entity: &Entity<$state>, + name: &str, + args: &[Data], + window: &mut Window, + cx: &mut App, + ) -> Result { + match (name, args) { + ("value", []) => Ok(Data::String(entity.read(cx).value().to_string())), + ("content", []) => Ok(content_data(&entity.read(cx).content())), + ("tokens", []) => { + Ok(field(&content_data(&entity.read(cx).content()), "tokens")?.clone()) + } + ("set_value", [text]) => { + let text = string(text)?.to_owned(); + entity.update(cx, |state, cx| state.set_value(text, window, cx)); + Ok(Data::Null) + } + ("set_content", [content]) => { + let content = decode_content(content)?; + entity.update(cx, |state, cx| state.set_content(content, window, cx))?; + Ok(Data::Null) + } + ("replace_with_token", [value]) => { + let token = token(value)?; + entity.update(cx, |state, cx| state.replace_with_token(token, window, cx))?; + Ok(Data::Null) + } + ("replace_range_with_token", [value, new_token]) => { + let range = range(&entity.read(cx).value(), value)?; + let token = token(new_token)?; + entity.update(cx, |state, cx| { + state.replace_range_with_token(range, token, window, cx) + })?; + Ok(Data::Null) + } + ("refresh_token", [id]) => { + let id = string(id)?; + Ok(Data::Boolean( + entity.update(cx, |state, cx| state.refresh_token(id, cx)), + )) + } + ("refresh", []) => { + entity.update(cx, |state, cx| state.refresh(cx)); + Ok(Data::Null) + } + ("set_selected_range", [value]) => { + let range = range(&entity.read(cx).value(), value)?; + entity.update(cx, |state, cx| state.set_selected_range(range, cx)); + Ok(Data::Null) + } + ("replace", [text]) => { + let text = string(text)?; + entity.update(cx, |state, cx| state.replace(text, window, cx)); + Ok(Data::Null) + } + _ => bail!("invalid arguments for input state operation `{name}`"), + } + } + /// Retained input operations for component adapters. All ranges use UTF-16. + pub fn $methods() -> Vec { + METHODS + .iter() + .map(|&(name, signature, readonly)| { + StateMethodDescriptor::new::>( + name, + signature, + move |entity, args, window, cx| $invoke(entity, name, args, window, cx), + ) + .with_readonly(readonly) + }) + .collect() + } + }; +} +state_binding!(InputState, invoke_input, input_token_state_methods); +state_binding!(TextareaState, invoke_textarea, textarea_token_state_methods); + +macro_rules! presentation_binding { + ($state:ty, $name:ident) => { + /// Build a native presentation adapter with generation-bound script callbacks. + pub fn $name( + state: &Entity<$state>, + renderer: Option, + listener: Option, + ) -> gpui_base::input::InlineTokenPresentation { + use gpui::{IntoElement as _, ParentElement as _}; + let mut presentation = gpui_base::input::InlineTokenPresentation::new(); + if let Some(renderer) = renderer { + let state = state.clone(); + presentation = presentation.render_token(move |token, window, cx| { + let data = inline_token_context_data(token, state.read(cx).text()); + match renderer.build_interactive_data_with(&[data], window, cx) { + Ok(Some(element)) => element, + result => { + if let Err(error) = result { + tracing::error!("inline token renderer failed: {error:#}"); + } + gpui::div() + .child(token.token().label().clone()) + .into_any_element() + } + } + }); + } + if let Some(listener) = listener { + let state = state.clone(); + presentation = presentation.on_token_click(move |event, window, cx| { + let data = inline_token_click_data(event, state.read(cx).text()); + if let Err(error) = listener.invoke_data_with(&[data], window, cx) { + tracing::error!("inline token activation failed: {error:#}"); + } + }); + } + presentation + } + }; +} +presentation_binding!(InputState, input_token_presentation); +presentation_binding!(TextareaState, textarea_token_presentation); diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index ee46429789..9a01ff4e20 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -58,6 +58,11 @@ pub(crate) mod assets; pub(crate) mod capability; mod component; mod component_registry; +mod input_tokens; +pub use input_tokens::{ + inline_token_click_data, inline_token_context_data, input_token_presentation, + input_token_state_methods, textarea_token_presentation, textarea_token_state_methods, +}; pub(crate) mod dependencies; pub mod dock; pub(crate) mod engine; @@ -100,7 +105,7 @@ pub use component_registry::{ ComponentDelegateSnapshot, ComponentDescriptor, ComponentElementCallback, ComponentElementFactory, ComponentMaterializer, ComponentPayload, ComponentRegistry, ConstructorDescriptor, DEFAULT_COMPONENT_MODULE, FrozenComponentRegistry, MaterializeRequest, - MethodDescriptor, RegistryError, StateDescriptor, + MethodDescriptor, RegistryError, StateDescriptor, StateMethodDescriptor, }; pub(crate) use component_registry::{ComponentCallbackValue, ComponentId, RecordedComponentMethod}; pub use engine::{LoadedApplication, ShellRuntime}; diff --git a/crates/shell/src/materialize.rs b/crates/shell/src/materialize.rs index 2541b15237..54c5e7acb9 100644 --- a/crates/shell/src/materialize.rs +++ b/crates/shell/src/materialize.rs @@ -334,6 +334,8 @@ struct Behavior { href: Option, on_click: Option, on_change: Option, + render_token: Option, + on_token_click: Option, on_mouse_move: Option, on_hover: Option, /// Reports a key press that reached this element. @@ -1499,7 +1501,18 @@ fn materialize_component( frame.extend(children); let frame = with_hover(frame, &states); let frame = with_active_and_focus(frame, &states); - frame.child(Input::new(&state)).into_any_element() + let presentation = crate::input_token_presentation( + &state, + behavior + .render_token + .map(|id| crate::ComponentElementCallback::from_runtime(runtime, id)), + behavior + .on_token_click + .map(|id| crate::ComponentCallback::from_runtime(runtime, id)), + ); + frame + .child(Input::new(&state).token_presentation(presentation)) + .into_any_element() } Component::OtpInput(handle) => components::otp_input::otp_input( runtime, handle, refinement, behavior, states, children, window, cx, @@ -2494,6 +2507,8 @@ pub(in crate::materialize) fn resolve_ops( "on_scroll_wheel" => behavior.on_scroll_wheel = Some(*id), "on_resize" => behavior.on_resize = Some(*id), "on_change" => behavior.on_change = Some(*id), + "render_token" => behavior.render_token = Some(*id), + "on_token_click" => behavior.on_token_click = Some(*id), "on_step" => behavior.on_step = Some(*id), "on_open_change" => behavior.on_open_change = Some(*id), "on_confirm" => behavior.on_confirm = Some(*id), diff --git a/crates/shell/src/materialize/components/textarea.rs b/crates/shell/src/materialize/components/textarea.rs index 49f634f2f6..420c17b161 100644 --- a/crates/shell/src/materialize/components/textarea.rs +++ b/crates/shell/src/materialize/components/textarea.rs @@ -58,5 +58,16 @@ pub(in crate::materialize) fn textarea( frame.extend(children); let frame = with_hover(frame, &states); let frame = with_active_and_focus(frame, &states); - frame.child(Textarea::new(&state)).into_any_element() + let presentation = crate::textarea_token_presentation( + &state, + behavior + .render_token + .map(|id| crate::ComponentElementCallback::from_runtime(runtime, id)), + behavior + .on_token_click + .map(|id| crate::ComponentCallback::from_runtime(runtime, id)), + ); + frame + .child(Textarea::new(&state).token_presentation(presentation)) + .into_any_element() } diff --git a/crates/shell/src/runtime.rs b/crates/shell/src/runtime.rs index fd8f8fa1cf..f6a6d23924 100644 --- a/crates/shell/src/runtime.rs +++ b/crates/shell/src/runtime.rs @@ -303,6 +303,10 @@ impl Default for CallbackArena { } impl CallbackArena { + pub(crate) fn is_building(&self) -> bool { + self.building.is_some() + } + pub(crate) fn checkpoint(&self) -> usize { self.building .as_ref() diff --git a/crates/shell/src/typings.rs b/crates/shell/src/typings.rs index d864352cfd..b2f6b94192 100644 --- a/crates/shell/src/typings.rs +++ b/crates/shell/src/typings.rs @@ -215,6 +215,7 @@ pub(crate) fn declarations_with_components(components: &crate::FrozenComponentRe out.push_str(" /** The fluent builder returned by native and Base element factories. */\n"); out.push_str(" export interface NativeElement {\n"); out.push_str(ELEMENT_METHODS); + out.push_str(" render_token(render: (token: import(\"gpui-base\").InlineTokenContext, cx: Context) => Element | null): this;\n on_token_click(listener: (event: import(\"gpui-base\").InlineTokenClickEvent, cx: Context) => void): this;\n"); out.push_str(¶metric_styles(¶metric)); out.push_str(&nullary_styles(&nullary)); out.push_str(" }\n"); @@ -228,15 +229,23 @@ pub(crate) fn declarations_with_components(components: &crate::FrozenComponentRe out.push_str(BASE_IMPORTS); out.push_str(&base_color_token_type()); out.push_str(BASE_SHARED_TYPES); + out.push_str(INLINE_TOKEN_TYPES); out.push_str(BASE); out.push_str("}\n\n"); out.push_str("declare module \"gpui-component\" {\n"); out.push_str(" import { ClickEvent, Context, Element, NativeElement } from \"gpui-kit\";\n"); + out.push_str(INLINE_TOKEN_TYPES); for state in components.states() { push_jsdoc(&mut out, state.documentation(), None, " "); out.push_str(" export interface "); out.push_str(state.kind()); - out.push_str(" { readonly __gpuiComponentState: unique symbol }\n"); + out.push_str(" { readonly __gpuiComponentState: unique symbol"); + for method in state.methods() { + out.push_str(";\n "); + out.push_str(method.name()); + out.push_str(method.signature()); + } + out.push_str(" }\n"); push_jsdoc(&mut out, state.documentation(), None, " "); out.push_str(" export function "); out.push_str(state.export()); @@ -2328,6 +2337,22 @@ const WINDOW: &str = r#" /// Everything `gpui-base` provides: its layout helpers, its components and its /// theme. Emitted into `declare module "gpui-base"`, so an import says which /// layer a script is reaching for. +const INLINE_TOKEN_TYPES: &str = r#" + /** Half-open JavaScript UTF-16 string offsets, as used by slice(). */ + export interface InputRange { start: number; end: number } + export interface InlineToken { id: string; text: string; label?: string } + export interface InlineTokenSpan { range: InputRange; token: InlineToken } + export interface InputContent { text: string; tokens: InlineTokenSpan[] } + export interface InlineTokenContext extends InlineTokenSpan { + selected: boolean; disabled: boolean; readonly: boolean; + line_height: number; available_width: number; + } + export interface InlineTokenClickEvent extends InlineTokenSpan { + bounds: { x: number; y: number; width: number; height: number }; + modifiers: { shift: boolean; alt: boolean; control: boolean; platform: boolean }; + } +"#; + const BASE: &str = r#" /** A row. */ export function h_flex(): NativeElement; /** A column. */ @@ -2972,6 +2997,15 @@ const BASE: &str = r#" /** A row. */ * an event handler — never in `render`. */ export interface InputState { + content(): InputContent; + tokens(): InlineTokenSpan[]; + set_content(content: InputContent): void; + replace_with_token(token: InlineToken): void; + replace_range_with_token(range: InputRange, token: InlineToken): void; + refresh_token(id: string): boolean; + refresh(): void; + set_selected_range(range: InputRange): void; + replace(text: string): void; value(): string; set_value(next: string): void; /** `change`, `submit`, `focus` or `blur`. */ @@ -3032,6 +3066,15 @@ const BASE: &str = r#" /** A row. */ * call `set_auto_grow(...)`, or size the element with `.h(...)`. */ export interface TextareaState { + content(): InputContent; + tokens(): InlineTokenSpan[]; + set_content(content: InputContent): void; + replace_with_token(token: InlineToken): void; + replace_range_with_token(range: InputRange, token: InlineToken): void; + refresh_token(id: string): boolean; + refresh(): void; + set_selected_range(range: InputRange): void; + replace(text: string): void; value(): string; set_value(next: string): void; /** `change`, `submit`, `focus` or `blur`. */ diff --git a/crates/story/src/stories/input_story.rs b/crates/story/src/stories/input_story.rs index 1c89b48787..e986fd2f3d 100644 --- a/crates/story/src/stories/input_story.rs +++ b/crates/story/src/stories/input_story.rs @@ -9,6 +9,7 @@ use gpui_kit::component::{button::*, input::*, label::Label, *}; pub fn init(_: &mut App) {} pub struct InputStory { + tokens: Entity, input1: Entity, input2: Entity, input_esc: Entity, @@ -259,6 +260,7 @@ impl InputStory { ]; Self { + tokens: super::input_tokens::TokenExample::new(false, window, cx), input1, input2, input_esc, @@ -614,5 +616,8 @@ impl Render for InputStory { .text_color(cx.theme().info), ), ) + .child(section("Atomic inline tokens") + .description("References keep their identity through selection, deletion and undo. Copy returns the underlying text.") + .w_full().child(self.tokens.clone())) } } diff --git a/crates/story/src/stories/input_tokens.rs b/crates/story/src/stories/input_tokens.rs new file mode 100644 index 0000000000..10a4d92640 --- /dev/null +++ b/crates/story/src/stories/input_tokens.rs @@ -0,0 +1,314 @@ +//! A retained example shared by the Input and Textarea stories. +use gpui_kit::component::{ + ActiveTheme as _, Disableable as _, IconName, Sizable as _, + button::Button, + checkbox::Checkbox, + h_flex, + input::{ + InlineToken, Input, InputContent, InputEvent, InputGroup, InputState, InputToken, Textarea, + TextareaState, + }, + v_flex, +}; +use gpui_kit::{ + App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled, Window, + div, +}; + +pub(super) struct TokenExample { + state: State, + saved: InputContent, + next_id: usize, + readonly: bool, + disabled: bool, + result: String, + _subscription: gpui_kit::Subscription, +} +#[derive(Clone)] +enum State { + Input(Entity), + Textarea(Entity), +} +macro_rules! dispatch { + ($state:expr, |$input:ident| $body:expr) => { + match $state { + State::Input($input) => $body, + State::Textarea($input) => $body, + } + }; +} + +impl TokenExample { + pub(super) fn new(multiline: bool, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| { + let state = if multiline { State::Textarea(cx.new(|cx| TextareaState::new(window, cx).auto_grow(2, 6))) } + else { State::Input(cx.new(|cx| InputState::new(window, cx))) }; + let text = if multiline { "/translate @alice@bob 请检查这段文字 🙂\nTry selecting part of a reference, deleting it, and undoing." } + else { "Ask @alice@bob to review 🙂" }; + let saved = if multiline { + InputContent::new(text).with_token(0..10, InlineToken::new("command", "/translate").with_label("Translate")) + .with_token(11..17, InlineToken::new("alice", "@alice").with_label("Alice")) + .with_token(17..21, InlineToken::new("bob", "@bob").with_label("Bob")) + } else { + InputContent::new(text).with_token(4..10, InlineToken::new("alice", "@alice").with_label("Alice")) + .with_token(10..14, InlineToken::new("bob", "@bob").with_label("Bob")) + }; + dispatch!(&state, |input| input.update(cx, |input, cx| input.set_content(saved.clone(), window, cx).expect("valid demo content"))); + let subscription = dispatch!(&state, |input| cx.subscribe(input, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { cx.notify(); } + })); + Self { state, saved, next_id: 1, readonly: false, disabled: false, result: String::new(), _subscription: subscription } + }) + } +} +impl Render for TokenExample { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let content = dispatch!(&self.state, |input| input.read(cx).content()); + let input = match &self.state { + State::Input(input) => Input::new(input) + .aria_label("Atomic reference input") + .readonly(self.readonly) + .disabled(self.disabled) + .on_token_click(cx.listener( + |this, event: &gpui_kit::component::input::InlineTokenClickEvent, _, cx| { + this.result = format!("Opened {}", event.token().label()); + cx.notify(); + }, + )) + .into_any_element(), + State::Textarea(input) => InputGroup::new("token-composer") + .input( + Textarea::new(input) + .aria_label("Atomic reference textarea") + .readonly(self.readonly) + .disabled(self.disabled) + .render_token(|token, _, _| { + InputToken::new(token) + .with_icon(IconName::File) + .with_tooltip("Open reference") + }) + .on_token_click(cx.listener( + |this, + event: &gpui_kit::component::input::InlineTokenClickEvent, + _, + cx| { + this.result = format!("Opened {}", event.token().label()); + cx.notify(); + }, + )), + ) + .into_any_element(), + }; + v_flex() + .w_full() + .gap_2() + .child(input) + .child( + h_flex() + .gap_2() + .child( + Button::new("insert-token") + .small() + .label("Insert reference") + .disabled(self.readonly || self.disabled) + .on_click(cx.listener(|this, _, window, cx| { + let id = format!("reference-{}", this.next_id); + this.next_id += 1; + let result = dispatch!(&this.state, |input| input.update( + cx, + |input, cx| input.replace_with_token( + InlineToken::new(id, "@reference").with_label("Reference"), + window, + cx + ) + )); + if let Err(error) = result { + this.result = error.to_string(); + } + dispatch!(&this.state, |input| input + .update(cx, |input, cx| input.focus(window, cx))); + cx.notify(); + })), + ) + .child( + Button::new("save-tokens") + .small() + .label("Save draft") + .on_click(cx.listener(|this, _, _, cx| { + this.saved = + dispatch!(&this.state, |input| input.read(cx).content()); + this.result = "Draft saved".into(); + cx.notify(); + })), + ) + .child( + Button::new("restore-tokens") + .small() + .label("Restore draft") + .disabled(self.readonly || self.disabled) + .on_click(cx.listener(|this, _, window, cx| { + let saved = this.saved.clone(); + let result = dispatch!(&this.state, |input| input + .update(cx, |input, cx| input.set_content(saved, window, cx))); + this.result = result.map_or_else( + |error| error.to_string(), + |_| "Draft restored".into(), + ); + cx.notify(); + })), + ) + .child( + Button::new("submit-tokens") + .small() + .label("Submit") + .disabled(self.disabled) + .on_click(cx.listener(|this, _, _, cx| { + let content = + dispatch!(&this.state, |input| input.read(cx).content()); + this.result = format!( + "Submitted {} active references: {}", + content.tokens().len(), + content.text() + ); + cx.notify(); + })), + ), + ) + .child( + h_flex() + .gap_3() + .child( + Checkbox::new("tokens-readonly") + .label("Read-only") + .checked(self.readonly) + .on_click(cx.listener(|this, value, _, cx| { + this.readonly = *value; + cx.notify(); + })), + ) + .child( + Checkbox::new("tokens-disabled") + .label("Disabled") + .checked(self.disabled) + .on_click(cx.listener(|this, value, _, cx| { + this.disabled = *value; + cx.notify(); + })), + ), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(format!("Text: {}", content.text())), + ) + .child(div().text_sm().child(format!( + "Tokens: {}", + content + .tokens() + .iter() + .map(|s| format!("{} {:?}", s.token().id(), s.range())) + .collect::>() + .join(", ") + ))) + .child(div().text_sm().child(self.result.clone())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui_kit::{Modifiers, TestAppContext, VisualTestContext}; + use std::{cell::RefCell, ops::Deref as _, rc::Rc}; + + fn draw(cx: &mut VisualTestContext) { + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + } + + fn click(id: &'static str, cx: &mut VisualTestContext) { + let bounds = cx.update(|window, _| { + gpui_kit::base::test_support::find(window, &[], &id.into()) + .expect("story control is laid out") + .bounds() + }); + cx.simulate_click(bounds.center(), Modifiers::default()); + draw(cx); + } + + fn content(story: &Entity, cx: &mut VisualTestContext) -> InputContent { + story.read_with(cx, |story, cx| { + dispatch!(&story.state, |input| input.read(cx).content()) + }) + } + + #[gpui_kit::test] + fn token_story_insert_delete_undo_restore_and_permissions(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + for multiline in [false, true] { + let mounted = Rc::new(RefCell::new(None)); + let capture = mounted.clone(); + let window = cx.add_window(move |window, cx| { + let story = TokenExample::new(multiline, window, cx); + *capture.borrow_mut() = Some(story.clone()); + gpui_kit::component::Root::new(story, window, cx) + }); + let story = mounted.borrow().clone().unwrap(); + let mut visual = VisualTestContext::from_window(*window.deref(), cx); + draw(&mut visual); + let initial = content(&story, &mut visual); + click("insert-token", &mut visual); + let inserted = content(&story, &mut visual); + assert_eq!(inserted.tokens().len(), initial.tokens().len() + 1); + click("save-tokens", &mut visual); + let range = inserted + .tokens() + .iter() + .find(|span| span.token().id().as_ref() == "reference-1") + .unwrap() + .range(); + visual.update(|window, cx| { + story.update(cx, |story, cx| { + dispatch!(&story.state, |input| input.update(cx, |input, cx| { + input.set_selected_range(range.start + 1..range.end, cx); + input.focus(window, cx); + })); + }) + }); + visual.simulate_keystrokes("backspace"); + draw(&mut visual); + assert_eq!( + content(&story, &mut visual).tokens().len(), + initial.tokens().len() + ); + #[cfg(target_os = "macos")] + visual.simulate_keystrokes("cmd-z"); + #[cfg(not(target_os = "macos"))] + visual.simulate_keystrokes("ctrl-z"); + draw(&mut visual); + assert_eq!(content(&story, &mut visual), inserted); + click("insert-token", &mut visual); + click("restore-tokens", &mut visual); + assert_eq!(content(&story, &mut visual), inserted); + click("submit-tokens", &mut visual); + story.read_with(&visual, |story, _| { + assert_eq!( + story.result, + format!( + "Submitted {} active references: {}", + inserted.tokens().len(), + inserted.text() + ) + ); + }); + click("tokens-readonly", &mut visual); + click("insert-token", &mut visual); + assert_eq!(content(&story, &mut visual), inserted); + click("tokens-readonly", &mut visual); + click("tokens-disabled", &mut visual); + click("restore-tokens", &mut visual); + click("insert-token", &mut visual); + assert_eq!(content(&story, &mut visual), inserted); + } + } +} diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs index 3b0efb6679..6846b1af1e 100644 --- a/crates/story/src/stories/mod.rs +++ b/crates/story/src/stories/mod.rs @@ -34,6 +34,7 @@ mod icon_story; mod image_story; mod input_group_story; mod input_story; +mod input_tokens; mod kbd_story; mod label_story; mod list_story; diff --git a/crates/story/src/stories/textarea_story.rs b/crates/story/src/stories/textarea_story.rs index c99da4ac37..b6e8c8078f 100644 --- a/crates/story/src/stories/textarea_story.rs +++ b/crates/story/src/stories/textarea_story.rs @@ -28,6 +28,7 @@ struct ComposerAttachment { } pub struct TextareaStory { + tokens: Entity, textarea: Entity, textarea_auto_grow: Entity, textarea_no_wrap: Entity, @@ -159,6 +160,7 @@ impl TextareaStory { )]; Self { + tokens: super::input_tokens::TokenExample::new(true, window, cx), textarea, textarea_auto_grow, textarea_no_wrap, @@ -364,5 +366,8 @@ impl Render for TextareaStory { }), ), ) + .child(section("Atomic inline tokens") + .description("References keep their identity through selection, deletion and undo. Copy returns the underlying text.") + .w_full().child(self.tokens.clone())) } } diff --git a/examples/js_story/stories/registered.js b/examples/js_story/stories/registered.js index 28b4beeaa6..9cdcee45c1 100644 --- a/examples/js_story/stories/registered.js +++ b/examples/js_story/stories/registered.js @@ -225,7 +225,18 @@ const retained = (key, create) => { * GPUI input state cannot be created from render, and every descriptor here * needs a stable identity so interaction survives subsequent frames. */ +/** @type {import("gpui-component").InputContent} */ +const tokenDraft = { + text: "🙂 Ask @alice@bob to review", + tokens: [ + { range: { start: 7, end: 13 }, token: { id: "alice", text: "@alice", label: "Alice" } }, + { range: { start: 13, end: 17 }, token: { id: "bob", text: "@bob", label: "Bob" } }, + ], +}; + export function initializeRegisteredExamples() { + retained("token-input", () => { const input = InputState(); input.set_content(tokenDraft); return input; }); + retained("token-textarea", () => { const input = TextareaState(); input.set_content(tokenDraft); return input; }); for (const [id, placeholder, value] of inputGroupFields) { retained(`input-group-extra:${id}`, () => InputState(placeholder, value)); } @@ -491,6 +502,46 @@ function expandedInputGroupExamples(cx) { } +/** @param {boolean} multiline @param {import("gpui-kit").Context} cx */ +function tokenExample(multiline, cx) { + const key = multiline ? "token-textarea" : "token-input"; + const input = /** @type {import("gpui-component").InputState | import("gpui-component").TextareaState} */ (demo.get(key)); + const content = input.content(); + const control = multiline + ? new Textarea(/** @type {import("gpui-component").TextareaState} */ (input)).w_full().h(100) + .render_token(token => h_flex().gap(4).px(4).h(token.line_height) + .child("◆").child(token.token.label ?? token.token.text)) + : new Input(/** @type {import("gpui-component").InputState} */ (input)).w_full(); + return { + label: "Atomic inline references", + description: "Delete a reference and undo. Drafts preserve identity; copied text stays plain.", + element: v_flex().w(520).max_w_full().gap(8) + .child(control.on_token_click((event, cx) => setState(`${key}-status`, `Opened ${event.token.label}`, cx)) + .on_change((_text, cx) => cx.notify())) + .child(h_flex().gap(8) + .child(new Button(`${key}-insert`).label("Insert reference").on_click((_event, cx) => { + const serial = Number(state(`${key}-serial`, 0)) + 1; + setState(`${key}-serial`, serial, cx); + input.replace_with_token({ id: `ref-${serial}`, text: "@reference", label: "Reference" }); + cx.notify(); + })) + .child(new Button(`${key}-save`).label("Save draft").on_click((_event, cx) => { + setState(`${key}-saved`, input.content(), cx); + })) + .child(new Button(`${key}-restore`).label("Restore draft").on_click((_event, cx) => { + input.set_content(/** @type {import("gpui-component").InputContent} */ (state(`${key}-saved`, tokenDraft))); + cx.notify(); + })) + .child(new Button(`${key}-submit`).label("Submit").on_click((_event, cx) => { + const current = input.content(); + setState(`${key}-status`, `Submitted ${current.tokens.length} references: ${current.text}`, cx); + }))) + .child(div().text_sm().child(`Text: ${content.text}`)) + .child(div().text_sm().child(`Tokens: ${content.tokens.map(span => `${span.token.id} [${span.range.start}, ${span.range.end})`).join(", ")}`)) + .child(div().text_sm().child(String(state(`${key}-status`, "")))), + }; +} + /** * The cases shown for one registered surface. * @@ -1241,6 +1292,7 @@ export function registeredExamples(surface, cx) { ), ), }, + tokenExample(false, cx), ]; case "NumberInput": return [ @@ -1283,6 +1335,7 @@ export function registeredExamples(surface, cx) { .h(120), ), }, + tokenExample(true, cx), ]; case "Checkbox": return [ diff --git a/website/base/primitives/input.md b/website/base/primitives/input.md index 26da72d632..b0ea6bbc9e 100644 --- a/website/base/primitives/input.md +++ b/website/base/primitives/input.md @@ -86,3 +86,30 @@ cargo run -p gpui-base-examples -- input The implementation is in [`crates/base/examples/showcase/components/input.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/input.rs). + +## Atomic inline tokens + +Use tokens for mentions or references that users select and delete as a whole. +Insert one through the InputState you already use for this control: + +```rust +use gpui_kit::base::input::InlineToken; + +input.update(cx, |state, cx| { + state.replace_with_token( + InlineToken::new("person-1", "@alice").with_label("Alice"), + window, + cx, + ).expect("valid reference"); +}); +``` + +Tokens display as unstyled labels. Use `render_token` to supply your own single-row +element and `on_token_click` to open a reference. Copy and `value()` return the +real text, such as `@alice`. Save drafts with `content()` and restore them with +`set_content()` to keep their references. + +See [Input's token examples](../../component/input.md#atomic-inline-tokens) for +custom rendering, draft restoration and range units. Import the data types from +`gpui_kit::base::input`. In JavaScript, use `InputState.new()` from `gpui-base`; +it provides the same token methods. diff --git a/website/base/primitives/textarea.md b/website/base/primitives/textarea.md index 52b778e8fd..8bcebd4c2b 100644 --- a/website/base/primitives/textarea.md +++ b/website/base/primitives/textarea.md @@ -71,3 +71,30 @@ padding, and `InputEditorStyle`. For a styled control, see the ```bash cargo run -p gpui-base-examples -- textarea ``` + +## Atomic inline tokens + +Use tokens for mentions or references that users select and delete as a whole. +Insert one through the TextareaState you already use for this control: + +```rust +use gpui_kit::base::input::InlineToken; + +notes.update(cx, |state, cx| { + state.replace_with_token( + InlineToken::new("person-1", "@alice").with_label("Alice"), + window, + cx, + ).expect("valid reference"); +}); +``` + +Tokens display as unstyled labels. Use `render_token` to supply your own single-row +element and `on_token_click` to open a reference. Copy and `value()` return the +real text, such as `@alice`. Save drafts with `content()` and restore them with +`set_content()` to keep their references. + +See [Input's token examples](../../component/input.md#atomic-inline-tokens) for +custom rendering, draft restoration and range units. Import the data types from +`gpui_kit::base::input`. In JavaScript, use `TextareaState.new()` from `gpui-base`; +it provides the same token methods. diff --git a/website/component/input-group.md b/website/component/input-group.md index 95c3104ec4..5f33f1331f 100644 --- a/website/component/input-group.md +++ b/website/component/input-group.md @@ -358,3 +358,23 @@ new InputGroupButton("clear").label("Clear").icon("icons/x.svg").font_semibold() ``` Run `gpui-component-shell types ` to generate editor completion. + +## Inline references + +To combine inline references with attachments or send buttons, pass an Input or +Textarea containing tokens to `InputGroup`. You can customize its labels as usual: + +```rust +use gpui_kit::component::{ + IconName, + input::{InputGroup, InputToken, Textarea}, +}; + +InputGroup::new("composer") + .input(Textarea::new(&state) + .render_token(|token, _, _| InputToken::new(token).with_icon(IconName::File))) +``` + +The JavaScript group controls also expose `render_token` and `on_token_click`. +Retain the same input state across redraws; call `set_content` explicitly to +restore a draft. See [atomic inline tokens](./input.md#atomic-inline-tokens). diff --git a/website/component/input.md b/website/component/input.md index e15c15d12e..d3a6df148c 100644 --- a/website/component/input.md +++ b/website/component/input.md @@ -331,3 +331,165 @@ v_flex() .child(Input::new(&self.name_input)) .child(Input::new(&self.email_input)) ``` + +## Atomic inline tokens + +Use inline tokens for mentions, file references or commands that should be selected +and deleted as a whole. For example, an input can display “Alice” as a token while +`value()` and Copy return its text, `@alice`. + +### Insert a reference + +Create the input state once, then insert a token when the user picks a reference: + +```rust +use gpui_kit::component::input::{InlineToken, Input, InputState}; + +let input = cx.new(|cx| InputState::new(window, cx)); + +input.update(cx, |state, cx| { + state.replace_with_token( + InlineToken::new("person-1", "@alice").with_label("Alice"), + window, + cx, + ).expect("valid reference"); +}); + +Input::new(&input) +``` + +`replace_with_token` replaces the selection, or inserts at the caret. It does not +add a space. To replace a completion query such as `@ali`, use +`replace_range_with_token(range, token, window, cx)`. Rust ranges are half-open +UTF-8 byte ranges; use byte offsets such as those returned by `str::find`. + +Give each occurrence a unique ID, even when two tokens refer to the same resource. +Use `text` for the value to copy or submit and `with_label` for its displayed name. +Omit `with_label` to display the text itself. + +Users can move the caret to either side of a token, select it, or delete it with +Backspace/Delete. A selection that crosses part of a token includes the whole +token. Undo/Redo restores both its text and reference. Pasting inserts plain text. + +### Customize appearance and opening a reference + +The default label needs no renderer. To add an icon or tooltip, return an +`InputToken` from `render_token`, and use `on_token_click` to open the reference: + +```rust +use gpui_kit::component::{ + IconName, + input::{InlineTokenClickEvent, InputToken}, +}; + +Input::new(&input) + .render_token(|token, _, _| { + InputToken::new(token) + .with_icon(IconName::File) + .with_tooltip("Open reference") + }) + .on_token_click(|event: &InlineTokenClickEvent, _, _| { + // Look up event.token().id() and open its resource. + }); +``` + +You can also return your own single-row element. Keep it within the input's line +height; content wider than the available row is clipped. Read selection and +read-only/disabled state from the renderer's context. Do not edit the input from +the renderer; event callbacks may update it. Keep hover and selection styles the +same size. If external data changes a token's size, call `refresh_token(id, cx)`; +use `refresh(cx)` when the size of all tokens may have changed. + +Dragging or Shift-selecting a token does not open it. Read-only inputs allow +opening references; disabled inputs do not. To offer a keyboard shortcut for +opening an exactly selected token, bind `ActivateToken` to a key of your choice. +The action is also available in the input's context menu and through accessibility. + +If your token includes a button, consume its mouse-down and click events so that +it does not also open the reference. Apply `token.is_disabled()` to every child +action, including accessibility actions, and `token.is_readonly()` to actions +that change the content. + +### Save, restore and submit + +Use `content()` to keep the text and references together when saving a draft: + +```rust +let draft = input.read(cx).content(); + +// Restore the saved draft later. +input.update(cx, |state, cx| { + state.set_content(draft, window, cx).expect("valid draft"); +}); +``` + +To restore data from your own storage, build an `InputContent` with matching text +and token ranges: + +```rust +use gpui_kit::component::input::InputContent; + +let draft = InputContent::new("Ask @alice") + .with_token(4..10, InlineToken::new("person-1", "@alice").with_label("Alice")); +``` + +At submission time, read a fresh `content()`: `text()` is the message, and +`tokens()` contains the references still present in it. Use each token's ID to +look up its resource and handle missing resources before sending. + +`set_content` clears undo history and does not emit `InputEvent::Change`. +Use `set_value` to reset to plain text; it removes tokens even if the text is +unchanged. Use `replace_all` for an undoable plain-text replacement. Token edits, +including adding a reference to existing text, emit `InputEvent::Change`. +Programmatic setters can update read-only or disabled inputs, so check these +states in application commands that should be unavailable to users. + +### Validation and supported inputs + +Tokens work with Input and Textarea. They are not available in Editor, +NumberInput, formatted masks or password fields. A token's ID must not be blank; +its text and label must be nonempty, single-line strings without control +characters. Ranges cannot overlap or split a Unicode grapheme (such as an emoji +or a character with a combining accent), and each token's text must match its +range when restoring a draft. + +Token operations return `Result<_, InlineTokenError>`. A rejected operation leaves +the input unchanged. If insertion returns `CompositionActive`, wait until the +user finishes composing with their input method before inserting the token. + +### JavaScript + +Create and keep an `InputState` in `init()`, then render an Input with that state. +JavaScript ranges use **UTF-16 string offsets**, matching `slice()` and `indexOf()`: + +```javascript +import { Input, InputState } from "gpui-component"; + +// In init(): +this.input = InputState(); +this.input.set_content({ + text: "🙂 @alice", + tokens: [{ + range: { start: 3, end: 9 }, + token: { id: "person-1", text: "@alice", label: "Alice" }, + }], +}); + +// In render(): +new Input(this.input) + .on_token_click((event, cx) => { + // Look up event.token.id and open its resource. + }); +``` + +Use `replace_with_token` or `replace_range_with_token` to insert references, +`content()` and `set_content()` to save and restore drafts, and `tokens()` to read +the current references. To remove a reference, pass its current range to +`set_selected_range`, then call `replace("")`. Returned snapshots are independent +objects; changing one does not update the input. Make edits from initialization, +event or task callbacks, not from renderers. + +Token validation errors expose an `error.code`, such as `DuplicateId` or +`CompositionActive`; invalid argument shapes also throw. Textarea offers the same +methods on `TextareaState()`. If you use `gpui-base`, construct these states with +`InputState.new()` or `TextareaState.new()` instead. diff --git a/website/component/textarea.md b/website/component/textarea.md index 9bc6bcb716..015d60ad00 100644 --- a/website/component/textarea.md +++ b/website/component/textarea.md @@ -79,3 +79,21 @@ user. `Textarea` deliberately does not expose Input-only adornments such as `prefix`, `suffix`, mask toggle, or the clear button. Compose related actions beside the textarea. + +## Atomic inline tokens + +Use tokens to include mentions, file references or commands in a multi-line +message. Insert a token with `TextareaState::replace_with_token`, or restore a +saved draft with `set_content`. The default label is ready to use; add a renderer +when you want an icon or other custom content: + +```rust +Textarea::new(&state) + .render_token(|token, _, _| InputToken::new(token).with_icon(IconName::File)) +``` + +Import `InputToken` from `gpui_kit::component::input` and `IconName` from +`gpui_kit::component`. A token wraps onto the next line as a whole, and auto-grow adjusts the textarea +height to fit. Put line breaks in the text between tokens. See +[Input: atomic inline tokens](./input.md#atomic-inline-tokens) for editing, +activation, draft persistence, mode restrictions and JavaScript APIs. diff --git a/website/zh-CN/base/primitives/input.md b/website/zh-CN/base/primitives/input.md index 5d4857ffaf..a3a2f8c6bb 100644 --- a/website/zh-CN/base/primitives/input.md +++ b/website/zh-CN/base/primitives/input.md @@ -63,3 +63,23 @@ cargo run -p gpui-base-examples -- input ``` 实现位于 [`input.rs`](https://github.com/longbridge/gpui-kit/blob/main/crates/base/examples/showcase/components/input.rs)。 + +## 原子行内 token + +需要让用户整块选中、删除人员提及或资源引用时,可以使用 token。通过已有的 InputState 插入: + +```rust +use gpui_kit::base::input::InlineToken; + +input.update(cx, |state, cx| { + state.replace_with_token( + InlineToken::new("person-1", "@alice").with_label("Alice"), + window, + cx, + ).expect("有效的引用"); +}); +``` + +token 默认显示为无装饰标签。使用 `render_token` 提供自己的单行元素,通过 `on_token_click` 打开引用。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_content()` 恢复草稿,可以保留其中的引用。 + +自定义展示、草稿恢复和范围单位见 [Input 的 token 示例](../../component/input.md#原子行内-token)。数据类型从 `gpui_kit::base::input` 导入。JavaScript 使用 `gpui-base` 中的 `InputState.new()`,它提供相同的 token 方法。 diff --git a/website/zh-CN/base/primitives/textarea.md b/website/zh-CN/base/primitives/textarea.md index 4d04b923d9..17d792bdc3 100644 --- a/website/zh-CN/base/primitives/textarea.md +++ b/website/zh-CN/base/primitives/textarea.md @@ -58,3 +58,23 @@ let value = notes.read(cx).value(); ```bash cargo run -p gpui-base-examples -- textarea ``` + +## 原子行内 token + +需要让用户整块选中、删除人员提及或资源引用时,可以使用 token。通过已有的 TextareaState 插入: + +```rust +use gpui_kit::base::input::InlineToken; + +notes.update(cx, |state, cx| { + state.replace_with_token( + InlineToken::new("person-1", "@alice").with_label("Alice"), + window, + cx, + ).expect("有效的引用"); +}); +``` + +token 默认显示为无装饰标签。使用 `render_token` 提供自己的单行元素,通过 `on_token_click` 打开引用。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_content()` 恢复草稿,可以保留其中的引用。 + +自定义展示、草稿恢复和范围单位见 [Input 的 token 示例](../../component/input.md#原子行内-token)。数据类型从 `gpui_kit::base::input` 导入。JavaScript 使用 `gpui-base` 中的 `TextareaState.new()`,它提供相同的 token 方法。 diff --git a/website/zh-CN/component/input-group.md b/website/zh-CN/component/input-group.md index b6ae3e58fb..b92fcca39b 100644 --- a/website/zh-CN/component/input-group.md +++ b/website/zh-CN/component/input-group.md @@ -341,3 +341,20 @@ new InputGroupButton("clear").label("清空").icon("icons/x.svg").font_semibold( ``` 执行 `gpui-component-shell types <应用目录>` 可生成编辑器补全声明。 + +## 行内引用 + +需要将行内引用与附件、发送按钮等组合时,将包含 token 的 Input 或 Textarea 传给 `InputGroup`,并按需自定义标签: + +```rust +use gpui_kit::component::{ + IconName, + input::{InputGroup, InputToken, Textarea}, +}; + +InputGroup::new("composer") + .input(Textarea::new(&state) + .render_token(|token, _, _| InputToken::new(token).with_icon(IconName::File))) +``` + +JavaScript 的分组输入也提供 `render_token` 和 `on_token_click`。重绘时保留原输入状态,需要恢复草稿时显式调用 `set_content`。详见[原子行内 token](./input.md#原子行内-token)。 diff --git a/website/zh-CN/component/input.md b/website/zh-CN/component/input.md index e374ee1295..47926f3202 100644 --- a/website/zh-CN/component/input.md +++ b/website/zh-CN/component/input.md @@ -279,3 +279,120 @@ v_flex() .child(Input::new(&self.name_input)) .child(Input::new(&self.email_input)) ``` + +## 原子行内 token + +使用行内 token,可以在输入中加入需要整块选中、删除的人员提及、文件引用或命令。例如,输入框显示一个名为“Alice”的标签,而 `value()` 和复制操作返回它的真实文本 `@alice`。 + +### 插入引用 + +创建并保留输入状态,在用户选中引用时插入 token: + +```rust +use gpui_kit::component::input::{InlineToken, Input, InputState}; + +let input = cx.new(|cx| InputState::new(window, cx)); + +input.update(cx, |state, cx| { + state.replace_with_token( + InlineToken::new("person-1", "@alice").with_label("Alice"), + window, + cx, + ).expect("有效的引用"); +}); + +Input::new(&input) +``` + +`replace_with_token` 替换当前选区,空选区则在光标处插入,不自动添加空格。要替换 `@ali` 这样的补全查询,使用 `replace_range_with_token(range, token, window, cx)`。Rust 范围是 UTF-8 字节半开区间,可以使用 `str::find` 等方法返回的字节偏移。 + +每次出现的 token 都需要唯一 ID,即使它们指向同一资源。`text` 用于复制和提交,`with_label` 指定显示名称;省略 `with_label` 时直接显示真实文本。 + +用户可以将光标移到 token 两侧,选中它,或用 Backspace/Delete 删除它。选区覆盖部分 token 时会包含整块 token。Undo/Redo 同时恢复文本和引用。粘贴得到普通文本。 + +### 自定义外观与打开引用 + +使用默认标签时无需设置 renderer。需要图标或 tooltip 时,让 `render_token` 返回 `InputToken`,并通过 `on_token_click` 打开引用: + +```rust +use gpui_kit::component::{ + IconName, + input::{InlineTokenClickEvent, InputToken}, +}; + +Input::new(&input) + .render_token(|token, _, _| { + InputToken::new(token) + .with_icon(IconName::File) + .with_tooltip("打开引用") + }) + .on_token_click(|event: &InlineTokenClickEvent, _, _| { + // 根据 event.token().id() 查找并打开资源。 + }); +``` + +也可以返回自己的单行元素。元素应保持在输入框行高内,超出可用行宽的内容会被裁切。通过 renderer 的上下文读取选中、只读和禁用状态。在事件回调中修改输入,不要在 renderer 中修改。悬停和选中样式应保持尺寸稳定。外部数据改变某个 token 的尺寸时调用 `refresh_token(id, cx)`;所有 token 的尺寸都可能变化时调用 `refresh(cx)`。 + +拖选或 Shift 扩选不会打开引用。只读输入允许打开引用,禁用输入不允许。若要为打开选中的整块 token 提供快捷键,可以将 `ActivateToken` 绑定到自己选择的按键。输入框的上下文菜单和无障碍操作也提供此功能。 + +如果 token 内含按钮,应消费按钮的 mouse-down 和 click 事件,避免同时打开引用。所有子操作(包括无障碍操作)都应遵守 `token.is_disabled()`;会修改内容的操作还应遵守 `token.is_readonly()`。 + +### 保存、恢复与提交 + +保存草稿时,使用 `content()` 一起保留文本和引用: + +```rust +let draft = input.read(cx).content(); + +// 稍后恢复保存的草稿。 +input.update(cx, |state, cx| { + state.set_content(draft, window, cx).expect("有效的草稿"); +}); +``` + +从应用自己的存储中恢复数据时,可以用相匹配的文本和 token 范围构造 `InputContent`: + +```rust +use gpui_kit::component::input::InputContent; + +let draft = InputContent::new("Ask @alice") + .with_token(4..10, InlineToken::new("person-1", "@alice").with_label("Alice")); +``` + +提交时重新读取 `content()`:`text()` 是消息文本,`tokens()` 是其中仍然存在的引用。根据 token ID 查找资源,并在发送前处理资源已不存在的情况。 + +`set_content` 清空撤销历史,不触发 `InputEvent::Change`。要重置为普通文本,使用 `set_value`;即使文字未变,它也会移除 token。需要可撤销的纯文本替换时使用 `replace_all`。token 编辑会触发 `InputEvent::Change`,包括为已有文本添加引用。程序化 setter 可以修改只读或禁用的输入,因此不应对用户开放的应用命令需要自行检查这些状态。 + +### 校验与适用范围 + +token 适用于 Input 和 Textarea,不支持 Editor、NumberInput、格式化 mask 或密码输入。ID 不能全为空白;真实文本和标签必须非空、单行且不含控制字符。范围不能重叠或切开 Unicode grapheme(例如 emoji 或带组合重音的字符);恢复草稿时,每个 token 的真实文本必须与对应范围匹配。 + +token 操作返回 `Result<_, InlineTokenError>`,失败时输入保持原样。如果插入返回 `CompositionActive`,应等待用户完成当前输入法组合后再插入。 + +### JavaScript + +在 `init()` 中创建并保留 `InputState`,渲染时将它传给 Input。JavaScript 范围使用 **UTF-16 字符串偏移**,与 `slice()`、`indexOf()` 一致: + +```javascript +import { Input, InputState } from "gpui-component"; + +// 在 init() 中: +this.input = InputState(); +this.input.set_content({ + text: "🙂 @alice", + tokens: [{ + range: { start: 3, end: 9 }, + token: { id: "person-1", text: "@alice", label: "Alice" }, + }], +}); + +// 在 render() 中: +new Input(this.input) + .on_token_click((event, cx) => { + // 根据 event.token.id 查找并打开资源。 + }); +``` + +用 `replace_with_token` 或 `replace_range_with_token` 插入引用,用 `content()` 和 `set_content()` 保存、恢复草稿,用 `tokens()` 读取当前引用。要删除引用,将它的当前范围传给 `set_selected_range`,再调用 `replace("")`。返回的快照是独立对象,修改快照不会更新输入。应在初始化、事件或任务回调中编辑,不要在 renderer 中编辑。 + +token 校验异常提供 `error.code`,例如 `DuplicateId` 或 `CompositionActive`;参数形状无效时也会抛出异常。Textarea 的 `TextareaState()` 提供相同方法。使用 `gpui-base` 时,改用 `InputState.new()` 或 `TextareaState.new()` 构造状态。 diff --git a/website/zh-CN/component/textarea.md b/website/zh-CN/component/textarea.md index 368c07f03e..badd308bf3 100644 --- a/website/zh-CN/component/textarea.md +++ b/website/zh-CN/component/textarea.md @@ -73,3 +73,14 @@ Textarea::new(¬es) 与 `disabled` 不同,只读 Textarea 保持正常外观,仍然可以聚焦、选中和复制,只是拒绝用户对内容的修改。 `Textarea` 不提供只适用于单行 Input 的前后缀、密码显示切换和清除按钮;相关操作应组合在 Textarea 外部。 + +## 原子行内 token + +在多行消息中加入人员提及、文件引用或命令时,可以使用 token。通过 `TextareaState::replace_with_token` 插入,或用 `set_content` 恢复已有草稿。默认标签可以直接使用,需要图标等自定义内容时再设置 renderer: + +```rust +Textarea::new(&state) + .render_token(|token, _, _| InputToken::new(token).with_icon(IconName::File)) +``` + +从 `gpui_kit::component::input` 导入 `InputToken`,从 `gpui_kit::component` 导入 `IconName`。token 会整块换到下一行,自动增高会相应调整输入框高度。换行符应放在 token 之间的普通文本中。编辑、激活、草稿保存、模式限制和 JavaScript API 见 [Input:原子行内 token](./input.md#原子行内-token)。 From 9dde5e5d1066b8081af4c4e94cf06985f600d89f Mon Sep 17 00:00:00 2001 From: suxiaoshao <48886207+suxiaoshao@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:31:16 +0800 Subject: [PATCH 02/11] shell: Classify inline token callbacks as non-style methods --- crates/shell/src/typings.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/shell/src/typings.rs b/crates/shell/src/typings.rs index b2f6b94192..0119707433 100644 --- a/crates/shell/src/typings.rs +++ b/crates/shell/src/typings.rs @@ -3895,6 +3895,8 @@ mod tests { "controls_right", "when", "on_click", + "render_token", + "on_token_click", "on_mouse_move", "on_hover", "on_key_down", From fedecca7523be0678c53b9664af0e71ff2f0d188 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 18 Sep 2026 18:00:56 +0800 Subject: [PATCH 03/11] input: Let a token ID name its resource and trim the token API A token's ID now identifies the referenced resource, so the same person mentioned twice carries the same ID. Tokens are addressed by their start offset and widths are cached by token value, which removes `DuplicateId`. `refresh_token` is gone: visible tokens are measured on every render and a changed width already reflows the input. `InlineTokenPresentation` becomes crate-private; styled controls install their renderer and click listener through a hidden entry point, and the Shell adapts script callbacks through the elements' own `render_token` / `on_token_click` builders. The default skin is `InlineTokenTag`, and the "Activate token" context menu item is dropped in favor of an application-provided entry; `ActivateToken` remains for key bindings and accessibility. Disabled inputs keep mouse caret placement, and undo only clears the IME marked range on the token-aware path. Co-Authored-By: Claude Opus 5 (1M context) --- crates/base/src/input/base/element.rs | 73 +++++++++--------- crates/base/src/input/base/inline_tokens.rs | 29 +------- crates/base/src/input/base/mod.rs | 9 --- crates/base/src/input/base/state.rs | 45 ++++++----- .../base/src/input/base/token_presentation.rs | 64 ++++++++-------- crates/base/src/input/input/mod.rs | 7 +- crates/base/src/input/mod.rs | 5 +- crates/base/src/input/textarea/mod.rs | 7 +- .../component-shell/src/shell/input_tokens.rs | 36 +++++---- .../tests/inline_tokens_host.rs | 1 - crates/component/locales/ui.yml | 4 - .../input/{token.rs => inline_token_tag.rs} | 13 ++-- crates/component/src/input/input.rs | 42 +++++------ crates/component/src/input/mod.rs | 6 +- crates/component/src/input/state.rs | 10 ++- crates/component/src/input/textarea.rs | 31 ++++---- crates/shell/src/engine/quickjs/mod.rs | 2 +- crates/shell/src/input_tokens.rs | 74 +++++++++++-------- crates/shell/src/lib.rs | 4 +- crates/shell/src/materialize.rs | 15 +++- .../src/materialize/components/textarea.rs | 11 ++- crates/shell/src/typings.rs | 2 - crates/story/src/stories/input_tokens.rs | 18 ++--- examples/js_story/stories/registered.js | 5 +- website/component/input-group.md | 4 +- website/component/input.md | 26 ++++--- website/component/textarea.md | 4 +- website/zh-CN/component/input-group.md | 4 +- website/zh-CN/component/input.md | 14 ++-- website/zh-CN/component/textarea.md | 4 +- 30 files changed, 281 insertions(+), 288 deletions(-) rename crates/component/src/input/{token.rs => inline_token_tag.rs} (88%) diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index 4e6cb9b24f..b6a67c01ee 100644 --- a/crates/base/src/input/base/element.rs +++ b/crates/base/src/input/base/element.rs @@ -1383,17 +1383,18 @@ impl TextElement { use gpui::{StatefulInteractiveElement as _, prelude::FluentBuilder as _}; let presentation = self.state.read(cx).token_presentation.clone(); let child = presentation.render(token, window, cx); - let id = token.token().id().clone(); - let down_id = id.clone(); + // A token is addressed by where it starts: the same reference may occur + // more than once, and the document revision guards a press against + // edits that move it. + let start = token.range().start; let down_state = self.state.clone(); let click_state = self.state.clone(); let move_state = self.state.clone(); let disabled = token.is_disabled(); let accessible = presentation.has_listener() && !disabled; let accessible_state = self.state.clone(); - let accessible_id = id.clone(); gpui::div() - .id((ElementId::Name("inline-token".into()), id.clone())) + .id(("inline-token", start)) .flex() .items_center() .h(token.line_height()) @@ -1407,11 +1408,11 @@ impl TextElement { let activation = state .token_spans() .iter() - .find(|s| s.token().id() == &accessible_id) + .find(|s| s.range().start == start) .and_then(|span| { state.range_to_bounds(&span.range()).and_then(|bounds| { state.token_activation( - &accessible_id, + start, bounds, gpui::ClickEvent::Keyboard(gpui::KeyboardClickEvent { bounds, @@ -1452,7 +1453,7 @@ impl TextElement { if let Some(span) = state .token_spans() .iter() - .find(|s| s.token().id() == &down_id) + .find(|s| s.range().start == start) { state.set_selected_range(span.range(), cx); state.focus(window, cx); @@ -1463,7 +1464,7 @@ impl TextElement { && !event.modifiers.alt { state.pressed_token = - Some((down_id.clone(), state.document_revision, event.position)); + Some((start, state.document_revision, event.position)); } }); }) @@ -1479,15 +1480,18 @@ impl TextElement { .on_click(move |event, window, cx| { let activation = click_state.update(cx, |state, _| { let (pressed, revision, _) = state.pressed_token.take()?; - if pressed != id || revision != state.document_revision { + if pressed != start || revision != state.document_revision { return None; } if event.modifiers().shift || event.modifiers().alt { return None; } - let span = state.token_spans().iter().find(|s| s.token().id() == &id)?; + let span = state + .token_spans() + .iter() + .find(|s| s.range().start == start)?; let bounds = state.range_to_bounds(&span.range())?; - state.token_activation(&id, bounds, event.clone()) + state.token_activation(start, bounds, event.clone()) }); if let Some((listener, event)) = activation { listener(&event, window, cx); @@ -1504,7 +1508,7 @@ impl TextElement { viewport: Pixels, window: &mut Window, cx: &mut App, - ) -> std::collections::HashMap { + ) -> std::collections::HashMap { let style = window.text_style(); let state = self.state.read(cx); let key = ( @@ -1528,7 +1532,10 @@ impl TextElement { let all = cache.is_none_or(|cache| { cache.key.as_ref() != Some(&key) || cache.revision != revision - || cache.widths.len() != state.token_spans().len() + || state + .token_spans() + .iter() + .any(|span| !cache.widths.contains_key(span.token())) }); let (visible, _, _) = self.calculate_visible_range(state, line_height, viewport); let start = state.text.line_start_offset(visible.start); @@ -1546,11 +1553,7 @@ impl TextElement { let range = span.range(); (range.start <= end && range.end >= start) || cache.is_none_or(|cache| { - cache.key.as_ref() != Some(&key) - || cache - .widths - .get(span.token().id()) - .is_none_or(|(t, _)| t != span.token()) + cache.key.as_ref() != Some(&key) || !cache.widths.contains_key(span.token()) }) }) .map(|span| state.token_context(span, line_height, width)) @@ -1568,7 +1571,7 @@ impl TextElement { cx, ); measured.push((token.token().clone(), size.width.min(width).max(px(1.)))); - elements.insert(token.token().id().clone(), element); + elements.insert(token.range().start, element); } self.state.update(cx, |state, cx| { let mut cache = state.token_layout_cache.take().unwrap_or_default(); @@ -1577,25 +1580,22 @@ impl TextElement { cache.widths.clear(); } for (token, width) in measured { - changed |= cache - .widths - .get(token.id()) - .is_none_or(|(old, w)| old != &token || *w != width); - cache.widths.insert(token.id().clone(), (token, width)); + changed |= cache.widths.get(&token) != Some(&width); + cache.widths.insert(token, width); } cache.key = Some(key); if changed { let spans = state.token_spans(); - let ids: std::collections::HashSet<_> = - spans.iter().map(|s| s.token().id()).collect(); - cache.widths.retain(|id, _| ids.contains(id)); + let tokens: std::collections::HashSet<_> = + spans.iter().map(|s| s.token()).collect(); + cache.widths.retain(|token, _| tokens.contains(token)); cache.metrics = spans .iter() .filter_map(|span| { cache .widths - .get(span.token().id()) - .map(|(_, width)| (span.range(), *width)) + .get(span.token()) + .map(|width| (span.range(), *width)) }) .collect(); cache.revision = revision; @@ -1630,10 +1630,8 @@ impl TextElement { None, ) .width; - width += cache - .widths - .get(span.token().id()) - .map_or(px(0.), |(_, w)| *w); + width += + cache.widths.get(span.token()).copied().unwrap_or_default(); offset = local.end; } let part = &text[offset..]; @@ -1725,10 +1723,7 @@ impl TextElement { }); x += width; } - let width = cache - .widths - .get(span.token().id()) - .map_or(px(0.), |(_, width)| *width); + let width = cache.widths.get(span.token()).copied().unwrap_or_default(); fragments.push(InlineFragment { range: local.start - range.start..local.end - range.start, x, @@ -1778,7 +1773,7 @@ impl TextElement { &self, layout: &LastLayout, bounds: Bounds, - mut measured: std::collections::HashMap, + mut measured: std::collections::HashMap, window: &mut Window, cx: &mut App, ) -> Vec { @@ -1813,7 +1808,7 @@ impl TextElement { placements .into_iter() .map(|(token, origin)| { - let mut element = measured.remove(token.token().id()).unwrap_or_else(|| { + let mut element = measured.remove(&token.range().start).unwrap_or_else(|| { let mut element = self.token_element(&token, window, cx); element.layout_as_root( size( diff --git a/crates/base/src/input/base/inline_tokens.rs b/crates/base/src/input/base/inline_tokens.rs index 788219177d..9c0bc0b5b1 100644 --- a/crates/base/src/input/base/inline_tokens.rs +++ b/crates/base/src/input/base/inline_tokens.rs @@ -1,13 +1,14 @@ //! Document annotations for atomic inline objects. Coordinates are always source bytes. -use std::{collections::HashSet, ops::Range}; +use std::ops::Range; use gpui::{Context, EntityInputHandler as _, SharedString, Window}; use unicode_segmentation::UnicodeSegmentation as _; use super::{InputBaseState, InputModeKind, undo_manager::EditIntent}; -/// An application-defined occurrence rendered as one inline editing unit. -#[derive(Clone, Debug, PartialEq, Eq)] +/// An application-defined reference rendered as one inline editing unit. The +/// ID names the referenced resource; the same ID may occur more than once. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct InlineToken { id: SharedString, text: SharedString, @@ -108,7 +109,6 @@ impl InputContent { fn validate(&mut self) -> Result<(), InlineTokenError> { self.tokens.sort_by_key(|span| span.range.start); let mut end = 0; - let mut ids = HashSet::new(); for span in &self.tokens { span.token.validate()?; validate_range(&self.text, &span.range)?; @@ -118,9 +118,6 @@ impl InputContent { if span.range.start < end { return Err(InlineTokenError::OverlappingTokens); } - if !ids.insert(span.token.id()) { - return Err(InlineTokenError::DuplicateId); - } if &self.text[span.range.clone()] != span.token.text.as_ref() { return Err(InlineTokenError::TextMismatch); } @@ -138,7 +135,6 @@ pub enum InlineTokenError { InvalidBoundary, InvalidToken, OverlappingTokens, - DuplicateId, TextMismatch, UnsupportedMode, ValidationRejected, @@ -155,7 +151,6 @@ impl std::fmt::Display for InlineTokenError { "token requires a nonempty ID, text and label without control characters" } Self::OverlappingTokens => "token ranges overlap", - Self::DuplicateId => "token occurrence ID is already in use", Self::TextMismatch => "token text does not match its range or input normalization", Self::UnsupportedMode => "tokens are not supported by this input mode", Self::ValidationRejected => "input validation rejected the content", @@ -316,11 +311,6 @@ impl InputBaseState { let text = self.text.to_string(); validate_range(&text, &range)?; let range = self.normalize_token_range(range); - if self.token_spans().iter().any(|s| { - s.token.id == token.id && !(s.range.start < range.end && range.start < s.range.end) - }) { - return Err(InlineTokenError::DuplicateId); - } let mut next = text; next.replace_range(range.clone(), &token.text); validate_range(&next, &(range.start..range.start + token.text.len()))?; @@ -417,17 +407,6 @@ macro_rules! token_api { ) -> Result<(), InlineTokenError> { self.restore_content(content, window, cx) } - /// Invalidate the geometry of a token whose external presentation changed. - pub fn refresh_token(&mut self, id: &str, cx: &mut Context) -> bool { - if !self.token_spans().iter().any(|s| s.token.id.as_ref() == id) { - return false; - } - if let Some(cache) = self.token_layout_cache.as_mut() { - cache.widths.remove(id); - } - cx.notify(); - true - } } }; } diff --git a/crates/base/src/input/base/mod.rs b/crates/base/src/input/base/mod.rs index b8d2538f43..605501189f 100644 --- a/crates/base/src/input/base/mod.rs +++ b/crates/base/src/input/base/mod.rs @@ -30,7 +30,6 @@ pub struct InputContextMenuCapabilities { masked: bool, go_to_definition: bool, code_actions: bool, - token_activation: bool, } impl InputContextMenuCapabilities { @@ -115,14 +114,6 @@ impl InputContextMenuCapabilities { self.go_to_definition } - pub fn token_activation(mut self, available: bool) -> Self { - self.token_activation = available; - self - } - pub fn has_token_activation(&self) -> bool { - self.token_activation - } - pub fn has_code_actions(&self) -> bool { self.code_actions } diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index 9531655042..37b283b22e 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -350,7 +350,9 @@ pub struct InputBaseState { pub(super) document_revision: u64, pub(super) token_presentation: super::InlineTokenPresentation, pub(super) token_layout_cache: Option>, - pub(super) pressed_token: Option<(SharedString, u64, Point)>, + /// The start offset of a pressed token, with the document revision and + /// pointer position at the press. + pub(super) pressed_token: Option<(usize, u64, Point)>, pub(super) search_session: super::SearchSession, /// Advances every time search is explicitly invoked. See /// [`InputBaseState::search_activation_revision`]. @@ -619,14 +621,6 @@ impl InputBaseState { .masked(self.masked) .go_to_definition(go_to_definition) .code_actions(code_actions) - .token_activation( - self.tokens_visible() - && self.token_presentation.has_listener() - && self - .token_spans() - .iter() - .any(|s| s.range() == self.selected_range()), - ) } pub fn set_text_align(&mut self, text_align: TextAlign, cx: &mut Context) { @@ -2255,7 +2249,9 @@ impl InputBaseState { window: &mut Window, cx: &mut Context, ) { - if self.disabled || window.default_prevented() { + // A token that consumed the press (a double click selecting it) must + // not also place the caret. + if window.default_prevented() { return; } self.undo_manager.break_transaction_coalescing(); @@ -2787,7 +2783,11 @@ impl InputBaseState { self.restore_selections(replay.selections); self.mode .restore_auto_closed_pairs(replay.auto_closed_pairs.unwrap_or_default()); - self.ime_marked_range = None; + if token_aware { + // Token replay bypasses IME normalization; a stale marked range + // would otherwise describe text that no longer exists. + self.ime_marked_range = None; + } self.undo_manager.set_ignoring(false); self.replaying_history = false; self.emit_events = emit_events; @@ -4332,7 +4332,7 @@ impl Render for InputBaseState { .and_then(|span| { state.range_to_bounds(&span.range()).and_then(|bounds| { state.token_activation( - span.token().id(), + span.range().start, bounds, gpui::ClickEvent::Keyboard(gpui::KeyboardClickEvent { bounds, @@ -4417,7 +4417,7 @@ mod tests { .replace_range_with_token(2..4, InlineToken::new("b", "@b"), window, cx) .unwrap(); state.set_token_presentation( - InlineTokenPresentation::new() + InlineTokenPresentation::default() .render_token(move |_, _, _| div().w(px(render_width.get()))), ); }); @@ -4436,9 +4436,7 @@ mod tests { ); }); width.set(100.); - view.input.update(&mut visual, |state, cx| { - assert!(state.refresh_token("a", cx)); - }); + view.input.update(&mut visual, |_, cx| cx.notify()); visual.update(|window, cx| window.draw(cx).clear(cx)); view.input.read_with(&visual, |state, _| { assert_eq!(state.display_map.wrap_row_count(), 1) @@ -4462,7 +4460,7 @@ mod tests { ) .unwrap(); state.set_token_presentation( - InlineTokenPresentation::new() + InlineTokenPresentation::default() .render_token(|_, _, _| div().w(px(100.)).h(px(20.))) .on_token_click(move |_, window, cx| { target.update(cx, |state, cx| { @@ -4528,13 +4526,14 @@ mod tests { state.undo(&Undo, window, cx); assert_eq!(state.tokens()[0].range(), 4..10); let before = state.content(); - let selected = state.selected_range(); - assert_eq!( - state.replace_range_with_token(0..0, token, window, cx), - Err(crate::input::InlineTokenError::DuplicateId) - ); + state + .replace_range_with_token(0..0, token.clone(), window, cx) + .expect("the same reference may occur twice"); + assert_eq!(state.value().as_ref(), "@alice问 @alice!"); + assert_eq!(state.tokens().len(), 2); + assert_eq!(state.tokens()[1].token(), &token); + state.undo(&Undo, window, cx); assert_eq!(state.content(), before); - assert_eq!(state.selected_range(), selected); state.set_value(before.text().clone(), window, cx); assert!(state.tokens().is_empty()); assert!(!state.undo_manager.has_undos()); diff --git a/crates/base/src/input/base/token_presentation.rs b/crates/base/src/input/base/token_presentation.rs index 4e675248ae..41fe1b9736 100644 --- a/crates/base/src/input/base/token_presentation.rs +++ b/crates/base/src/input/base/token_presentation.rs @@ -59,23 +59,22 @@ impl InlineTokenClickEvent { } } -type Renderer = Rc AnyElement>; -type Listener = Rc; -type Fallback = fn(&InlineTokenContext, &mut Window, &mut App) -> AnyElement; +/// A renderer installed by a styled control. Not part of the supported API. +#[doc(hidden)] +pub type InlineTokenRenderer = Rc AnyElement>; +/// A click listener installed by a styled control. Not part of the supported API. +#[doc(hidden)] +pub type InlineTokenClickListener = Rc; -/// Presentation adapter shared by Base and styled controls. It owns no content. +/// Presentation shared by Base and styled controls. It owns no content. #[derive(Clone, Default)] -pub struct InlineTokenPresentation { - renderer: Option, - listener: Option, - fallback: Option, +pub(crate) struct InlineTokenPresentation { + renderer: Option, + listener: Option, secret: bool, } impl InlineTokenPresentation { - pub fn new() -> Self { - Self::default() - } - pub fn render_token( + pub(crate) fn render_token( mut self, render: impl Fn(&InlineTokenContext, &mut Window, &mut App) -> R + 'static, ) -> Self { @@ -84,23 +83,13 @@ impl InlineTokenPresentation { })); self } - pub fn on_token_click( + pub(crate) fn on_token_click( mut self, listener: impl Fn(&InlineTokenClickEvent, &mut Window, &mut App) + 'static, ) -> Self { self.listener = Some(Rc::new(listener)); self } - /// Set a default skin without allocating a callback on every plain-text render. - pub fn with_fallback(mut self, fallback: Fallback) -> Self { - self.fallback = Some(fallback); - self - } - /// Suppress token labels and actions when the host is a secret field. - pub fn secret(mut self, secret: bool) -> Self { - self.secret = secret; - self - } pub(super) fn has_listener(&self) -> bool { self.listener.is_some() } @@ -112,8 +101,6 @@ impl InlineTokenPresentation { ) -> AnyElement { if let Some(render) = &self.renderer { render(token, window, cx) - } else if let Some(render) = self.fallback { - render(token, window, cx) } else { gpui::div() .child(token.token().label().clone()) @@ -129,15 +116,29 @@ pub(super) struct TokenLayoutCache { pub(super) revision: u64, pub(super) unwrapped_width: Pixels, pub(super) metrics: Rc<[(Range, Pixels)]>, - pub(super) widths: HashMap, + pub(super) widths: HashMap, } impl InputBaseState { /// Inject presentation from a view without editing or notifying the document. - #[doc(hidden)] - pub fn set_token_presentation(&mut self, presentation: InlineTokenPresentation) { + pub(crate) fn set_token_presentation(&mut self, presentation: InlineTokenPresentation) { self.token_presentation = presentation; } + /// Install a styled control's renderer, click listener and secrecy without + /// editing or notifying the document. Not part of the supported API. + #[doc(hidden)] + pub fn install_token_presentation( + &mut self, + renderer: Option, + listener: Option, + secret: bool, + ) { + self.token_presentation = InlineTokenPresentation { + renderer, + listener, + secret, + }; + } pub(super) fn tokens_visible(&self) -> bool { !self.masked && !self.token_presentation.secret @@ -161,19 +162,20 @@ impl InputBaseState { available_width: width, } } + /// The token starting at `start`, paired with the listener that opens it. pub(super) fn token_activation( &self, - id: &str, + start: usize, bounds: Bounds, event: ClickEvent, - ) -> Option<(Listener, InlineTokenClickEvent)> { + ) -> Option<(InlineTokenClickListener, InlineTokenClickEvent)> { if self.disabled || !self.tokens_visible() { return None; } let span = self .token_spans() .iter() - .find(|span| span.token().id().as_ref() == id)? + .find(|span| span.range().start == start)? .clone(); Some(( self.token_presentation.listener.clone()?, diff --git a/crates/base/src/input/input/mod.rs b/crates/base/src/input/input/mod.rs index 1f201e3148..0dbeba6938 100644 --- a/crates/base/src/input/input/mod.rs +++ b/crates/base/src/input/input/mod.rs @@ -26,11 +26,7 @@ impl Input { presentation: Default::default(), } } - #[doc(hidden)] - pub fn token_presentation(mut self, presentation: super::InlineTokenPresentation) -> Self { - self.presentation = presentation; - self - } + /// Render each atomic token; the input keeps editing and history. pub fn render_token( mut self, render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, @@ -38,6 +34,7 @@ impl Input { self.presentation = self.presentation.render_token(render); self } + /// Open a reference after a completed, unconsumed token click. pub fn on_token_click( mut self, listener: impl Fn(&super::InlineTokenClickEvent, &mut Window, &mut App) + 'static, diff --git a/crates/base/src/input/mod.rs b/crates/base/src/input/mod.rs index cb3ee9621a..e0cadb6c48 100644 --- a/crates/base/src/input/mod.rs +++ b/crates/base/src/input/mod.rs @@ -38,7 +38,10 @@ mod input; #[path = "base/token_presentation.rs"] mod token_presentation; pub use inline_tokens::{InlineToken, InlineTokenError, InlineTokenSpan, InputContent}; -pub use token_presentation::{InlineTokenClickEvent, InlineTokenContext, InlineTokenPresentation}; +pub(crate) use token_presentation::InlineTokenPresentation; +pub use token_presentation::{ + InlineTokenClickEvent, InlineTokenClickListener, InlineTokenContext, InlineTokenRenderer, +}; #[path = "base/kind.rs"] mod kind; #[path = "editor/language.rs"] diff --git a/crates/base/src/input/textarea/mod.rs b/crates/base/src/input/textarea/mod.rs index 0d482e9550..d0fbe22aff 100644 --- a/crates/base/src/input/textarea/mod.rs +++ b/crates/base/src/input/textarea/mod.rs @@ -23,11 +23,7 @@ impl Textarea { presentation: Default::default(), } } - #[doc(hidden)] - pub fn token_presentation(mut self, presentation: super::InlineTokenPresentation) -> Self { - self.presentation = presentation; - self - } + /// Render each atomic token; the input keeps editing and history. pub fn render_token( mut self, render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, @@ -35,6 +31,7 @@ impl Textarea { self.presentation = self.presentation.render_token(render); self } + /// Open a reference after a completed, unconsumed token click. pub fn on_token_click( mut self, listener: impl Fn(&super::InlineTokenClickEvent, &mut Window, &mut App) + 'static, diff --git a/crates/component-shell/src/shell/input_tokens.rs b/crates/component-shell/src/shell/input_tokens.rs index 0592c1b385..96b4ada570 100644 --- a/crates/component-shell/src/shell/input_tokens.rs +++ b/crates/component-shell/src/shell/input_tokens.rs @@ -1,10 +1,8 @@ //! Inline presentation and change subscriptions shared by Input and Textarea. -use gpui_component::input::{ - InlineTokenPresentation, Input, InputEvent, InputState, InputToken, Textarea, TextareaState, -}; +use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; use gpui_shell::{ ArgumentDescriptor, ArgumentSchema, ComponentArgument, ComponentCallback, ComponentPayload, - MaterializeRequest, MethodDescriptor, anyhow, + InlineTokenCallbacks, MaterializeRequest, MethodDescriptor, anyhow, gpui::{self, App, Entity, IntoElement as _, RenderOnce, Window}, }; use std::{cell::RefCell, rc::Rc}; @@ -76,7 +74,7 @@ pub(super) fn methods(include_change: bool) -> Vec { pub(super) struct Binding { state: State, - presentation: InlineTokenPresentation, + callbacks: InlineTokenCallbacks, change: Option, } pub(super) fn prepare(request: &MaterializeRequest<'_>, state: State) -> anyhow::Result { @@ -93,25 +91,33 @@ pub(super) fn prepare(request: &MaterializeRequest<'_>, state: State) -> anyhow: Op::Change(arg) => change = Some(request.resolve_callback(arg)?), } } - let presentation = match &state { - State::Input(state) => gpui_shell::input_token_presentation(state, renderer, listener), - State::Textarea(state) => { - gpui_shell::textarea_token_presentation(state, renderer, listener) - } - } - .with_fallback(|token, _, _| InputToken::new(token).into_any_element()); + let callbacks = dispatch!(&state, |state| InlineTokenCallbacks::new( + state, renderer, listener + )); Ok(Binding { state, - presentation, + callbacks, change, }) } impl Binding { pub(super) fn input(&self, input: Input) -> Input { - input.token_presentation(self.presentation.clone()) + self.callbacks.apply( + input, + |input, render| input.render_token(move |token, window, cx| render(token, window, cx)), + |input, listen| { + input.on_token_click(move |event, window, cx| listen(event, window, cx)) + }, + ) } pub(super) fn textarea(&self, input: Textarea) -> Textarea { - input.token_presentation(self.presentation.clone()) + self.callbacks.apply( + input, + |input, render| input.render_token(move |token, window, cx| render(token, window, cx)), + |input, listen| { + input.on_token_click(move |event, window, cx| listen(event, window, cx)) + }, + ) } pub(super) fn wrap(self, element: gpui::AnyElement) -> gpui::AnyElement { Bound { diff --git a/crates/component-shell/tests/inline_tokens_host.rs b/crates/component-shell/tests/inline_tokens_host.rs index 2372da7aee..b07ca92186 100644 --- a/crates/component-shell/tests/inline_tokens_host.rs +++ b/crates/component-shell/tests/inline_tokens_host.rs @@ -50,7 +50,6 @@ function exercise(state) { state.set_value(state.value()); assert(state.tokens().length === 0, "explicit same value clears identity"); state.set_content(saved); - assert(state.refresh_token("a") && !state.refresh_token("missing"), "refresh identity"); return state; } export default class TokenHost extends View { diff --git a/crates/component/locales/ui.yml b/crates/component/locales/ui.yml index c681f9fb37..3d9833fe59 100644 --- a/crates/component/locales/ui.yml +++ b/crates/component/locales/ui.yml @@ -275,10 +275,6 @@ Input: zh-CN: 全部替换 zh-HK: 全部替換 zh-TW: 全部取代 - Activate token: - en: Activate token - zh-CN: 打开引用 - zh-HK: 開啟參照 Cut: en: Cut zh-CN: 剪切 diff --git a/crates/component/src/input/token.rs b/crates/component/src/input/inline_token_tag.rs similarity index 88% rename from crates/component/src/input/token.rs rename to crates/component/src/input/inline_token_tag.rs index 8a26752958..56bbb4a185 100644 --- a/crates/component/src/input/token.rs +++ b/crates/component/src/input/inline_token_tag.rs @@ -6,15 +6,16 @@ use gpui::{ prelude::FluentBuilder as _, }; -/// Default inline token skin. Editing and activation belong to the input. +/// The default tag an [`InlineToken`](super::InlineToken) renders as. Editing +/// and activation belong to the input. #[derive(IntoElement)] -pub struct InputToken { +pub struct InlineTokenTag { context: InlineTokenContext, icon: Option, tooltip: Option, style: StyleRefinement, } -impl InputToken { +impl InlineTokenTag { pub fn new(context: &InlineTokenContext) -> Self { Self { context: context.clone(), @@ -32,15 +33,15 @@ impl InputToken { self } } -impl Styled for InputToken { +impl Styled for InlineTokenTag { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } -impl RenderOnce for InputToken { +impl RenderOnce for InlineTokenTag { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { div() - .id("input-token-skin") + .id("inline-token-tag") .flex() .items_center() .gap_1() diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index 22af38bffa..ca45dfa6e8 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -109,7 +109,8 @@ pub(crate) fn input_style(disabled: bool, cx: &App) -> (Hsla, Hsla) { /// A text input element bind to an [`InputState`]. #[derive(IntoElement)] pub struct Input { - token_presentation: super::InlineTokenPresentation, + token_renderer: Option, + token_click_listener: Option, id: Option, state: TextInputState, style: StyleRefinement, @@ -171,25 +172,24 @@ impl crate::FocusableExt for Input { } impl Input { - /// Customize atomic inline tokens; state and history stay with the input. + /// Render each atomic inline token in place of the default + /// [`InlineTokenTag`](super::InlineTokenTag); editing and history stay + /// with the input. pub fn render_token( mut self, render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, ) -> Self { - self.token_presentation = self.token_presentation.render_token(render); + self.token_renderer = Some(Rc::new(move |token, window, cx| { + render(token, window, cx).into_any_element() + })); self } - /// Activate a reference after a completed, unconsumed token click. + /// Open a reference after a completed, unconsumed token click. pub fn on_token_click( mut self, listener: impl Fn(&super::InlineTokenClickEvent, &mut Window, &mut App) + 'static, ) -> Self { - self.token_presentation = self.token_presentation.on_token_click(listener); - self - } - #[doc(hidden)] - pub fn token_presentation(mut self, presentation: super::InlineTokenPresentation) -> Self { - self.token_presentation = presentation; + self.token_click_listener = Some(Rc::new(listener)); self } @@ -236,8 +236,8 @@ impl Input { aria_label: None, context_menu_builder: None, paste_handler: None, - token_presentation: super::InlineTokenPresentation::new() - .with_fallback(|token, _, _| super::InputToken::new(token).into_any_element()), + token_renderer: None, + token_click_listener: None, } } @@ -514,11 +514,15 @@ impl RenderOnce for Input { const LINE_HEIGHT: Rems = Rems(1.25); let text_align = self.style.text.text_align.unwrap_or(TextAlign::Left); let state = self.state.clone(); - state.set_token_presentation( - self.token_presentation.secret(matches!( + state.install_token_presentation( + Some(self.token_renderer.unwrap_or_else(|| { + Rc::new(|token, _, _| super::InlineTokenTag::new(token).into_any_element()) + })), + self.token_click_listener, + matches!( self.content_type, Some(InputContentType::Password | InputContentType::NewPassword) - )), + ), cx, ); // Which kind of input this registers as follows from the state itself. @@ -601,14 +605,6 @@ impl RenderOnce for Input { ) .separator(); } - if capabilities.has_token_activation() { - menu = menu - .menu( - t!("Input.Activate token"), - Box::new(gpui_base::input::ActivateToken), - ) - .separator(); - } menu.menu_with_disabled( t!("Input.Cut"), !(editable && capabilities.is_copyable()), diff --git a/crates/component/src/input/mod.rs b/crates/component/src/input/mod.rs index 9789fa0d02..6ca6709be9 100644 --- a/crates/component/src/input/mod.rs +++ b/crates/component/src/input/mod.rs @@ -1,12 +1,12 @@ mod clear_button; mod content_type; +mod inline_token_tag; mod input; -mod token; pub use gpui_base::input::{ ActivateToken, InlineToken, InlineTokenClickEvent, InlineTokenContext, InlineTokenError, - InlineTokenPresentation, InlineTokenSpan, InputContent, + InlineTokenSpan, InputContent, }; -pub use token::InputToken; +pub use inline_token_tag::InlineTokenTag; pub mod language_config; mod number_input; mod otp_input; diff --git a/crates/component/src/input/state.rs b/crates/component/src/input/state.rs index 4a7dc7fd67..5d6cab5ca3 100644 --- a/crates/component/src/input/state.rs +++ b/crates/component/src/input/state.rs @@ -52,13 +52,15 @@ macro_rules! dispatch { } impl TextInputState { - pub(crate) fn set_token_presentation( + pub(crate) fn install_token_presentation( &self, - presentation: super::InlineTokenPresentation, + renderer: Option, + listener: Option, + secret: bool, cx: &mut App, ) { - dispatch!(self, |state| state - .update(cx, |state, _| state.set_token_presentation(presentation))) + dispatch!(self, |state| state.update(cx, |state, _| state + .install_token_presentation(renderer, listener, secret))) } pub(crate) fn entity_id(&self) -> gpui::EntityId { diff --git a/crates/component/src/input/textarea.rs b/crates/component/src/input/textarea.rs index 70872df4e8..1cf9f28cce 100644 --- a/crates/component/src/input/textarea.rs +++ b/crates/component/src/input/textarea.rs @@ -12,7 +12,8 @@ use crate::{RoleOverride, StyledExt as _}; /// A styled ordinary multi-line text field. #[derive(IntoElement)] pub struct Textarea { - token_presentation: super::InlineTokenPresentation, + token_renderer: Option, + token_click_listener: Option, state: Entity, style: StyleRefinement, height: Option, @@ -34,28 +35,27 @@ pub struct Textarea { } impl Textarea { - /// Customize atomic inline tokens; state and history stay with the input. + /// Render each atomic inline token in place of the default + /// [`InlineTokenTag`](super::InlineTokenTag); editing and history stay + /// with the input. pub fn render_token( mut self, render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, ) -> Self { - self.token_presentation = self.token_presentation.render_token(render); + self.token_renderer = Some(Rc::new(move |token, window, cx| { + render(token, window, cx).into_any_element() + })); self } - /// Activate a reference after a completed, unconsumed token click. + /// Open a reference after a completed, unconsumed token click. pub fn on_token_click( mut self, listener: impl Fn(&super::InlineTokenClickEvent, &mut Window, &mut App) + 'static, ) -> Self { - self.token_presentation = self.token_presentation.on_token_click(listener); + self.token_click_listener = Some(Rc::new(listener)); self } - #[doc(hidden)] - pub fn token_presentation(mut self, presentation: super::InlineTokenPresentation) -> Self { - self.token_presentation = presentation; - self - } pub fn new(state: &Entity) -> Self { Self { state: state.clone(), @@ -71,8 +71,8 @@ impl Textarea { aria_label: None, context_menu_builder: None, paste_handler: None, - token_presentation: super::InlineTokenPresentation::new() - .with_fallback(|token, _, _| super::InputToken::new(token).into_any_element()), + token_renderer: None, + token_click_listener: None, } } @@ -165,7 +165,12 @@ impl Textarea { /// it. pub(crate) fn into_input(self) -> Input { Input::from_state(self.state.clone()) - .token_presentation(self.token_presentation) + .when_some(self.token_renderer, |this, render| { + this.render_token(move |token, window, cx| render(token, window, cx)) + }) + .when_some(self.token_click_listener, |this, listener| { + this.on_token_click(move |event, window, cx| listener(event, window, cx)) + }) .appearance(self.appearance) .bordered(self.bordered) .disabled(self.disabled) diff --git a/crates/shell/src/engine/quickjs/mod.rs b/crates/shell/src/engine/quickjs/mod.rs index e0f7bc8622..84cca89665 100644 --- a/crates/shell/src/engine/quickjs/mod.rs +++ b/crates/shell/src/engine/quickjs/mod.rs @@ -6015,7 +6015,7 @@ globalThis.__gpui = (() => { // to read it back off `this`. const tokenStateMethods = (handle, invoke) => Object.fromEntries([ "content", "tokens", "set_content", "replace_with_token", "replace_range_with_token", - "refresh_token", "refresh", "set_selected_range", "replace" + "refresh", "set_selected_range", "replace" ].map(name => [name, (...args) => invoke(handle, name, args)])); const inputState = (handle) => ({ ...tokenStateMethods(handle, __input_token_call), diff --git a/crates/shell/src/input_tokens.rs b/crates/shell/src/input_tokens.rs index 58f64bd7f3..e576fdf806 100644 --- a/crates/shell/src/input_tokens.rs +++ b/crates/shell/src/input_tokens.rs @@ -6,6 +6,7 @@ use gpui_base::input::{ InlineToken, InlineTokenClickEvent, InlineTokenContext, InlineTokenError, InputContent, InputState, Rope, RopeExt as _, TextareaState, }; +use std::rc::Rc; fn object(fields: impl IntoIterator) -> Data { Data::Object( @@ -200,7 +201,6 @@ pub(crate) const METHODS: &[(&str, &str, bool)] = &[ "(range: InputRange, token: InlineToken): void", false, ), - ("refresh_token", "(id: string): boolean", false), ("refresh", "(): void", false), ("set_selected_range", "(range: InputRange): void", false), ("replace", "(text: string): void", false), @@ -244,12 +244,6 @@ macro_rules! state_binding { })?; Ok(Data::Null) } - ("refresh_token", [id]) => { - let id = string(id)?; - Ok(Data::Boolean( - entity.update(cx, |state, cx| state.refresh_token(id, cx)), - )) - } ("refresh", []) => { entity.update(cx, |state, cx| state.refresh(cx)); Ok(Data::Null) @@ -286,19 +280,25 @@ macro_rules! state_binding { state_binding!(InputState, invoke_input, input_token_state_methods); state_binding!(TextareaState, invoke_textarea, textarea_token_state_methods); -macro_rules! presentation_binding { - ($state:ty, $name:ident) => { - /// Build a native presentation adapter with generation-bound script callbacks. - pub fn $name( - state: &Entity<$state>, - renderer: Option, - listener: Option, - ) -> gpui_base::input::InlineTokenPresentation { - use gpui::{IntoElement as _, ParentElement as _}; - let mut presentation = gpui_base::input::InlineTokenPresentation::new(); - if let Some(renderer) = renderer { - let state = state.clone(); - presentation = presentation.render_token(move |token, window, cx| { +/// Script callbacks for one input's tokens, adapted to the native +/// `render_token` / `on_token_click` builders of any Input or Textarea element. +pub struct InlineTokenCallbacks { + renderer: Option, + listener: Option, +} +impl InlineTokenCallbacks { + /// Bind generation-scoped script callbacks to the text of `state`, whose + /// UTF-16 offsets the payloads carry. + pub fn new( + state: &Entity>, + renderer: Option, + listener: Option, + ) -> Self { + use gpui::{IntoElement as _, ParentElement as _}; + let renderer = renderer.map(|renderer| { + let state = state.clone(); + let render: gpui_base::input::InlineTokenRenderer = + Rc::new(move |token, window, cx| { let data = inline_token_context_data(token, state.read(cx).text()); match renderer.build_interactive_data_with(&[data], window, cx) { Ok(Some(element)) => element, @@ -312,19 +312,35 @@ macro_rules! presentation_binding { } } }); - } - if let Some(listener) = listener { - let state = state.clone(); - presentation = presentation.on_token_click(move |event, window, cx| { + render + }); + let listener = listener.map(|listener| { + let state = state.clone(); + let listen: gpui_base::input::InlineTokenClickListener = + Rc::new(move |event, window, cx| { let data = inline_token_click_data(event, state.read(cx).text()); if let Err(error) = listener.invoke_data_with(&[data], window, cx) { tracing::error!("inline token activation failed: {error:#}"); } }); - } - presentation + listen + }); + Self { renderer, listener } + } + /// Install the callbacks on an element through its own builders. + pub fn apply( + &self, + element: E, + render_token: impl FnOnce(E, gpui_base::input::InlineTokenRenderer) -> E, + on_token_click: impl FnOnce(E, gpui_base::input::InlineTokenClickListener) -> E, + ) -> E { + let element = match &self.renderer { + Some(renderer) => render_token(element, renderer.clone()), + None => element, + }; + match &self.listener { + Some(listener) => on_token_click(element, listener.clone()), + None => element, } - }; + } } -presentation_binding!(InputState, input_token_presentation); -presentation_binding!(TextareaState, textarea_token_presentation); diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 9a01ff4e20..1fbc249126 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -60,8 +60,8 @@ mod component; mod component_registry; mod input_tokens; pub use input_tokens::{ - inline_token_click_data, inline_token_context_data, input_token_presentation, - input_token_state_methods, textarea_token_presentation, textarea_token_state_methods, + InlineTokenCallbacks, inline_token_click_data, inline_token_context_data, + input_token_state_methods, textarea_token_state_methods, }; pub(crate) mod dependencies; pub mod dock; diff --git a/crates/shell/src/materialize.rs b/crates/shell/src/materialize.rs index 54c5e7acb9..6345776ef9 100644 --- a/crates/shell/src/materialize.rs +++ b/crates/shell/src/materialize.rs @@ -1501,7 +1501,7 @@ fn materialize_component( frame.extend(children); let frame = with_hover(frame, &states); let frame = with_active_and_focus(frame, &states); - let presentation = crate::input_token_presentation( + let callbacks = crate::InlineTokenCallbacks::new( &state, behavior .render_token @@ -1510,9 +1510,16 @@ fn materialize_component( .on_token_click .map(|id| crate::ComponentCallback::from_runtime(runtime, id)), ); - frame - .child(Input::new(&state).token_presentation(presentation)) - .into_any_element() + let input = callbacks.apply( + Input::new(&state), + |input, render| { + input.render_token(move |token, window, cx| render(token, window, cx)) + }, + |input, listen| { + input.on_token_click(move |event, window, cx| listen(event, window, cx)) + }, + ); + frame.child(input).into_any_element() } Component::OtpInput(handle) => components::otp_input::otp_input( runtime, handle, refinement, behavior, states, children, window, cx, diff --git a/crates/shell/src/materialize/components/textarea.rs b/crates/shell/src/materialize/components/textarea.rs index 420c17b161..d9939a7a73 100644 --- a/crates/shell/src/materialize/components/textarea.rs +++ b/crates/shell/src/materialize/components/textarea.rs @@ -58,7 +58,7 @@ pub(in crate::materialize) fn textarea( frame.extend(children); let frame = with_hover(frame, &states); let frame = with_active_and_focus(frame, &states); - let presentation = crate::textarea_token_presentation( + let callbacks = crate::InlineTokenCallbacks::new( &state, behavior .render_token @@ -67,7 +67,10 @@ pub(in crate::materialize) fn textarea( .on_token_click .map(|id| crate::ComponentCallback::from_runtime(runtime, id)), ); - frame - .child(Textarea::new(&state).token_presentation(presentation)) - .into_any_element() + let textarea = callbacks.apply( + Textarea::new(&state), + |input, render| input.render_token(move |token, window, cx| render(token, window, cx)), + |input, listen| input.on_token_click(move |event, window, cx| listen(event, window, cx)), + ); + frame.child(textarea).into_any_element() } diff --git a/crates/shell/src/typings.rs b/crates/shell/src/typings.rs index 0119707433..25efc3e257 100644 --- a/crates/shell/src/typings.rs +++ b/crates/shell/src/typings.rs @@ -3002,7 +3002,6 @@ const BASE: &str = r#" /** A row. */ set_content(content: InputContent): void; replace_with_token(token: InlineToken): void; replace_range_with_token(range: InputRange, token: InlineToken): void; - refresh_token(id: string): boolean; refresh(): void; set_selected_range(range: InputRange): void; replace(text: string): void; @@ -3071,7 +3070,6 @@ const BASE: &str = r#" /** A row. */ set_content(content: InputContent): void; replace_with_token(token: InlineToken): void; replace_range_with_token(range: InputRange, token: InlineToken): void; - refresh_token(id: string): boolean; refresh(): void; set_selected_range(range: InputRange): void; replace(text: string): void; diff --git a/crates/story/src/stories/input_tokens.rs b/crates/story/src/stories/input_tokens.rs index 10a4d92640..05c691eb67 100644 --- a/crates/story/src/stories/input_tokens.rs +++ b/crates/story/src/stories/input_tokens.rs @@ -5,8 +5,8 @@ use gpui_kit::component::{ checkbox::Checkbox, h_flex, input::{ - InlineToken, Input, InputContent, InputEvent, InputGroup, InputState, InputToken, Textarea, - TextareaState, + InlineToken, InlineTokenTag, Input, InputContent, InputEvent, InputGroup, InputState, + Textarea, TextareaState, }, v_flex, }; @@ -18,7 +18,6 @@ use gpui_kit::{ pub(super) struct TokenExample { state: State, saved: InputContent, - next_id: usize, readonly: bool, disabled: bool, result: String, @@ -57,7 +56,7 @@ impl TokenExample { let subscription = dispatch!(&state, |input| cx.subscribe(input, |_, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { cx.notify(); } })); - Self { state, saved, next_id: 1, readonly: false, disabled: false, result: String::new(), _subscription: subscription } + Self { state, saved, readonly: false, disabled: false, result: String::new(), _subscription: subscription } }) } } @@ -83,7 +82,7 @@ impl Render for TokenExample { .readonly(self.readonly) .disabled(self.disabled) .render_token(|token, _, _| { - InputToken::new(token) + InlineTokenTag::new(token) .with_icon(IconName::File) .with_tooltip("Open reference") }) @@ -112,12 +111,13 @@ impl Render for TokenExample { .label("Insert reference") .disabled(self.readonly || self.disabled) .on_click(cx.listener(|this, _, window, cx| { - let id = format!("reference-{}", this.next_id); - this.next_id += 1; + // The ID names the resource, so inserting the same + // reference twice reuses it. let result = dispatch!(&this.state, |input| input.update( cx, |input, cx| input.replace_with_token( - InlineToken::new(id, "@reference").with_label("Reference"), + InlineToken::new("reference", "@reference") + .with_label("Reference"), window, cx ) @@ -264,7 +264,7 @@ mod tests { let range = inserted .tokens() .iter() - .find(|span| span.token().id().as_ref() == "reference-1") + .find(|span| span.token().id().as_ref() == "reference") .unwrap() .range(); visual.update(|window, cx| { diff --git a/examples/js_story/stories/registered.js b/examples/js_story/stories/registered.js index 9cdcee45c1..1b7a00a3d6 100644 --- a/examples/js_story/stories/registered.js +++ b/examples/js_story/stories/registered.js @@ -520,9 +520,8 @@ function tokenExample(multiline, cx) { .on_change((_text, cx) => cx.notify())) .child(h_flex().gap(8) .child(new Button(`${key}-insert`).label("Insert reference").on_click((_event, cx) => { - const serial = Number(state(`${key}-serial`, 0)) + 1; - setState(`${key}-serial`, serial, cx); - input.replace_with_token({ id: `ref-${serial}`, text: "@reference", label: "Reference" }); + // The ID names the resource, so inserting it twice reuses it. + input.replace_with_token({ id: "reference", text: "@reference", label: "Reference" }); cx.notify(); })) .child(new Button(`${key}-save`).label("Save draft").on_click((_event, cx) => { diff --git a/website/component/input-group.md b/website/component/input-group.md index 5f33f1331f..e7c1162c31 100644 --- a/website/component/input-group.md +++ b/website/component/input-group.md @@ -367,12 +367,12 @@ Textarea containing tokens to `InputGroup`. You can customize its labels as usua ```rust use gpui_kit::component::{ IconName, - input::{InputGroup, InputToken, Textarea}, + input::{InlineTokenTag, InputGroup, Textarea}, }; InputGroup::new("composer") .input(Textarea::new(&state) - .render_token(|token, _, _| InputToken::new(token).with_icon(IconName::File))) + .render_token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File))) ``` The JavaScript group controls also expose `render_token` and `on_token_click`. diff --git a/website/component/input.md b/website/component/input.md index d3a6df148c..fec4d7fc03 100644 --- a/website/component/input.md +++ b/website/component/input.md @@ -363,9 +363,9 @@ add a space. To replace a completion query such as `@ali`, use `replace_range_with_token(range, token, window, cx)`. Rust ranges are half-open UTF-8 byte ranges; use byte offsets such as those returned by `str::find`. -Give each occurrence a unique ID, even when two tokens refer to the same resource. -Use `text` for the value to copy or submit and `with_label` for its displayed name. -Omit `with_label` to display the text itself. +The ID names the referenced resource, so two mentions of the same person carry +the same ID. Use `text` for the value to copy or submit and `with_label` for its +displayed name. Omit `with_label` to display the text itself. Users can move the caret to either side of a token, select it, or delete it with Backspace/Delete. A selection that crosses part of a token includes the whole @@ -373,18 +373,18 @@ token. Undo/Redo restores both its text and reference. Pasting inserts plain tex ### Customize appearance and opening a reference -The default label needs no renderer. To add an icon or tooltip, return an -`InputToken` from `render_token`, and use `on_token_click` to open the reference: +Tokens render as an `InlineTokenTag` by default. To add an icon or tooltip, +return one from `render_token`, and use `on_token_click` to open the reference: ```rust use gpui_kit::component::{ IconName, - input::{InlineTokenClickEvent, InputToken}, + input::{InlineTokenTag, InlineTokenClickEvent}, }; Input::new(&input) .render_token(|token, _, _| { - InputToken::new(token) + InlineTokenTag::new(token) .with_icon(IconName::File) .with_tooltip("Open reference") }) @@ -397,13 +397,15 @@ You can also return your own single-row element. Keep it within the input's line height; content wider than the available row is clipped. Read selection and read-only/disabled state from the renderer's context. Do not edit the input from the renderer; event callbacks may update it. Keep hover and selection styles the -same size. If external data changes a token's size, call `refresh_token(id, cx)`; -use `refresh(cx)` when the size of all tokens may have changed. +same size. Tokens are measured whenever they render, so a tag that grows once +its data arrives reflows on the next frame. Dragging or Shift-selecting a token does not open it. Read-only inputs allow opening references; disabled inputs do not. To offer a keyboard shortcut for -opening an exactly selected token, bind `ActivateToken` to a key of your choice. -The action is also available in the input's context menu and through accessibility. +opening an exactly selected token, bind `ActivateToken` to a key of your choice; +assistive technology reaches the same listener through the token's click action. +Add a menu item for it through `context_menu` when your application has a name +for the reference, such as "Open file". If your token includes a button, consume its mouse-down and click events so that it does not also open the reference. Apply `token.is_disabled()` to every child @@ -489,7 +491,7 @@ the current references. To remove a reference, pass its current range to objects; changing one does not update the input. Make edits from initialization, event or task callbacks, not from renderers. -Token validation errors expose an `error.code`, such as `DuplicateId` or +Token validation errors expose an `error.code`, such as `InvalidBoundary` or `CompositionActive`; invalid argument shapes also throw. Textarea offers the same methods on `TextareaState()`. If you use `gpui-base`, construct these states with `InputState.new()` or `TextareaState.new()` instead. diff --git a/website/component/textarea.md b/website/component/textarea.md index 015d60ad00..1530c7f8f5 100644 --- a/website/component/textarea.md +++ b/website/component/textarea.md @@ -89,10 +89,10 @@ when you want an icon or other custom content: ```rust Textarea::new(&state) - .render_token(|token, _, _| InputToken::new(token).with_icon(IconName::File)) + .render_token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File)) ``` -Import `InputToken` from `gpui_kit::component::input` and `IconName` from +Import `InlineTokenTag` from `gpui_kit::component::input` and `IconName` from `gpui_kit::component`. A token wraps onto the next line as a whole, and auto-grow adjusts the textarea height to fit. Put line breaks in the text between tokens. See [Input: atomic inline tokens](./input.md#atomic-inline-tokens) for editing, diff --git a/website/zh-CN/component/input-group.md b/website/zh-CN/component/input-group.md index b92fcca39b..7125b9758b 100644 --- a/website/zh-CN/component/input-group.md +++ b/website/zh-CN/component/input-group.md @@ -349,12 +349,12 @@ new InputGroupButton("clear").label("清空").icon("icons/x.svg").font_semibold( ```rust use gpui_kit::component::{ IconName, - input::{InputGroup, InputToken, Textarea}, + input::{InlineTokenTag, InputGroup, Textarea}, }; InputGroup::new("composer") .input(Textarea::new(&state) - .render_token(|token, _, _| InputToken::new(token).with_icon(IconName::File))) + .render_token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File))) ``` JavaScript 的分组输入也提供 `render_token` 和 `on_token_click`。重绘时保留原输入状态,需要恢复草稿时显式调用 `set_content`。详见[原子行内 token](./input.md#原子行内-token)。 diff --git a/website/zh-CN/component/input.md b/website/zh-CN/component/input.md index 47926f3202..193ea353ac 100644 --- a/website/zh-CN/component/input.md +++ b/website/zh-CN/component/input.md @@ -306,23 +306,23 @@ Input::new(&input) `replace_with_token` 替换当前选区,空选区则在光标处插入,不自动添加空格。要替换 `@ali` 这样的补全查询,使用 `replace_range_with_token(range, token, window, cx)`。Rust 范围是 UTF-8 字节半开区间,可以使用 `str::find` 等方法返回的字节偏移。 -每次出现的 token 都需要唯一 ID,即使它们指向同一资源。`text` 用于复制和提交,`with_label` 指定显示名称;省略 `with_label` 时直接显示真实文本。 +ID 标识被引用的资源,同一个人被提及两次时两个 token 使用同一个 ID。`text` 用于复制和提交,`with_label` 指定显示名称;省略 `with_label` 时直接显示真实文本。 用户可以将光标移到 token 两侧,选中它,或用 Backspace/Delete 删除它。选区覆盖部分 token 时会包含整块 token。Undo/Redo 同时恢复文本和引用。粘贴得到普通文本。 ### 自定义外观与打开引用 -使用默认标签时无需设置 renderer。需要图标或 tooltip 时,让 `render_token` 返回 `InputToken`,并通过 `on_token_click` 打开引用: +token 默认渲染为 `InlineTokenTag`。需要图标或 tooltip 时,让 `render_token` 返回自定义的 tag,并通过 `on_token_click` 打开引用: ```rust use gpui_kit::component::{ IconName, - input::{InlineTokenClickEvent, InputToken}, + input::{InlineTokenTag, InlineTokenClickEvent}, }; Input::new(&input) .render_token(|token, _, _| { - InputToken::new(token) + InlineTokenTag::new(token) .with_icon(IconName::File) .with_tooltip("打开引用") }) @@ -331,9 +331,9 @@ Input::new(&input) }); ``` -也可以返回自己的单行元素。元素应保持在输入框行高内,超出可用行宽的内容会被裁切。通过 renderer 的上下文读取选中、只读和禁用状态。在事件回调中修改输入,不要在 renderer 中修改。悬停和选中样式应保持尺寸稳定。外部数据改变某个 token 的尺寸时调用 `refresh_token(id, cx)`;所有 token 的尺寸都可能变化时调用 `refresh(cx)`。 +也可以返回自己的单行元素。元素应保持在输入框行高内,超出可用行宽的内容会被裁切。通过 renderer 的上下文读取选中、只读和禁用状态。在事件回调中修改输入,不要在 renderer 中修改。悬停和选中样式应保持尺寸稳定。token 每次渲染时都会重新测量,因此数据到达后变宽的 tag 会在下一帧重新排版。 -拖选或 Shift 扩选不会打开引用。只读输入允许打开引用,禁用输入不允许。若要为打开选中的整块 token 提供快捷键,可以将 `ActivateToken` 绑定到自己选择的按键。输入框的上下文菜单和无障碍操作也提供此功能。 +拖选或 Shift 扩选不会打开引用。只读输入允许打开引用,禁用输入不允许。若要为打开选中的整块 token 提供快捷键,可以将 `ActivateToken` 绑定到自己选择的按键;辅助技术通过 token 的 click 操作触发同一个监听器。当应用能为引用给出明确名称(例如“打开文件”)时,可以通过 `context_menu` 自行添加菜单项。 如果 token 内含按钮,应消费按钮的 mouse-down 和 click 事件,避免同时打开引用。所有子操作(包括无障碍操作)都应遵守 `token.is_disabled()`;会修改内容的操作还应遵守 `token.is_readonly()`。 @@ -395,4 +395,4 @@ new Input(this.input) 用 `replace_with_token` 或 `replace_range_with_token` 插入引用,用 `content()` 和 `set_content()` 保存、恢复草稿,用 `tokens()` 读取当前引用。要删除引用,将它的当前范围传给 `set_selected_range`,再调用 `replace("")`。返回的快照是独立对象,修改快照不会更新输入。应在初始化、事件或任务回调中编辑,不要在 renderer 中编辑。 -token 校验异常提供 `error.code`,例如 `DuplicateId` 或 `CompositionActive`;参数形状无效时也会抛出异常。Textarea 的 `TextareaState()` 提供相同方法。使用 `gpui-base` 时,改用 `InputState.new()` 或 `TextareaState.new()` 构造状态。 +token 校验异常提供 `error.code`,例如 `InvalidBoundary` 或 `CompositionActive`;参数形状无效时也会抛出异常。Textarea 的 `TextareaState()` 提供相同方法。使用 `gpui-base` 时,改用 `InputState.new()` 或 `TextareaState.new()` 构造状态。 diff --git a/website/zh-CN/component/textarea.md b/website/zh-CN/component/textarea.md index badd308bf3..12c08a5abe 100644 --- a/website/zh-CN/component/textarea.md +++ b/website/zh-CN/component/textarea.md @@ -80,7 +80,7 @@ Textarea::new(¬es) ```rust Textarea::new(&state) - .render_token(|token, _, _| InputToken::new(token).with_icon(IconName::File)) + .render_token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File)) ``` -从 `gpui_kit::component::input` 导入 `InputToken`,从 `gpui_kit::component` 导入 `IconName`。token 会整块换到下一行,自动增高会相应调整输入框高度。换行符应放在 token 之间的普通文本中。编辑、激活、草稿保存、模式限制和 JavaScript API 见 [Input:原子行内 token](./input.md#原子行内-token)。 +从 `gpui_kit::component::input` 导入 `InlineTokenTag`,从 `gpui_kit::component` 导入 `IconName`。token 会整块换到下一行,自动增高会相应调整输入框高度。换行符应放在 token 之间的普通文本中。编辑、激活、草稿保存、模式限制和 JavaScript API 见 [Input:原子行内 token](./input.md#原子行内-token)。 From fe1656136d8e46f19734d4d6d84baf074ee40227 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 18 Sep 2026 19:44:54 +0800 Subject: [PATCH 04/11] input: Restore content through set_value and rework the token story `set_value` now takes plain text or `InputContent`, so one setter resets the input the way Monaco's `setValue` and ProseMirror's `EditorState.create` do; `set_content` is gone. `InputContent::with_token` validates each token against the text as it is attached and returns a `Result`, so content is consistent before it reaches the input. The JavaScript `set_value` accepts either shape as well. A click selects the whole token before the click listener runs, and the Base token element uses the arrow cursor. The story is a chat composer: commands, images, skills and people are inserted through an input-group toolbar with a Send action, the sample inserter separates a reference from its neighbors, the tag has a border, and the readout keeps its labels on one lane. Co-Authored-By: Claude Opus 5 (1M context) --- crates/base/src/input/base/element.rs | 10 +- crates/base/src/input/base/inline_tokens.rs | 114 ++-- crates/base/src/input/base/state.rs | 64 ++- .../tests/inline_tokens_host.rs | 4 +- crates/shell/src/engine/quickjs/mod.rs | 11 +- crates/shell/src/input_tokens.rs | 19 +- crates/shell/src/typings.rs | 8 +- crates/story/src/stories/input_tokens.rs | 526 ++++++++++++------ examples/js_story/stories/registered.js | 6 +- website/base/primitives/input.md | 2 +- website/base/primitives/textarea.md | 2 +- website/component/input-group.md | 4 +- website/component/input.md | 34 +- website/component/textarea.md | 2 +- website/zh-CN/base/primitives/input.md | 2 +- website/zh-CN/base/primitives/textarea.md | 2 +- website/zh-CN/component/input-group.md | 2 +- website/zh-CN/component/input.md | 18 +- website/zh-CN/component/textarea.md | 2 +- 19 files changed, 535 insertions(+), 297 deletions(-) diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index b6a67c01ee..6b8c259c0c 100644 --- a/crates/base/src/input/base/element.rs +++ b/crates/base/src/input/base/element.rs @@ -1400,6 +1400,8 @@ impl TextElement { .h(token.line_height()) .max_w(token.available_width()) .overflow_hidden() + // A token is an object, not text: the arrow, never the I-beam. + .cursor_default() .when(accessible, |this| { this.role(gpui::Role::Button) .aria_label(token.token().label().clone()) @@ -1478,7 +1480,7 @@ impl TextElement { }); }) .on_click(move |event, window, cx| { - let activation = click_state.update(cx, |state, _| { + let activation = click_state.update(cx, |state, cx| { let (pressed, revision, _) = state.pressed_token.take()?; if pressed != start || revision != state.document_revision { return None; @@ -1489,7 +1491,11 @@ impl TextElement { let span = state .token_spans() .iter() - .find(|s| s.range().start == start)?; + .find(|s| s.range().start == start)? + .clone(); + // A click selects the whole token; opening it is the + // listener's decision. + state.set_selected_range(span.range(), cx); let bounds = state.range_to_bounds(&span.range())?; state.token_activation(start, bounds, event.clone()) }); diff --git a/crates/base/src/input/base/inline_tokens.rs b/crates/base/src/input/base/inline_tokens.rs index 9c0bc0b5b1..a3d3f71232 100644 --- a/crates/base/src/input/base/inline_tokens.rs +++ b/crates/base/src/input/base/inline_tokens.rs @@ -82,7 +82,10 @@ impl InlineTokenSpan { } } -/// An owned, coherent text-and-token snapshot, suitable for saving a draft. +/// An owned, coherent text-and-token snapshot: what `content()` returns and +/// what `set_value` accepts. Plain text converts into content without tokens, +/// and every token is validated against the text as it is attached, so a +/// content value is always consistent. #[derive(Clone, Debug, PartialEq, Eq)] pub struct InputContent { text: SharedString, @@ -95,9 +98,34 @@ impl InputContent { tokens: vec![], } } - pub fn with_token(mut self, range: Range, token: InlineToken) -> Self { - self.tokens.push(InlineTokenSpan { range, token }); - self + /// Attach a token to a half-open UTF-8 byte range of the text. The range + /// must be nonempty, sit on grapheme boundaries, contain exactly the + /// token's text and not overlap another token. + pub fn with_token( + mut self, + range: Range, + token: InlineToken, + ) -> Result { + token.validate()?; + validate_range(&self.text, &range)?; + if range.is_empty() { + return Err(InlineTokenError::InvalidRange); + } + if &self.text[range.clone()] != token.text.as_ref() { + return Err(InlineTokenError::TextMismatch); + } + let ix = self + .tokens + .partition_point(|span| span.range.end <= range.start); + if self + .tokens + .get(ix) + .is_some_and(|span| span.range.start < range.end) + { + return Err(InlineTokenError::OverlappingTokens); + } + self.tokens.insert(ix, InlineTokenSpan { range, token }); + Ok(self) } pub fn text(&self) -> &SharedString { &self.text @@ -105,27 +133,31 @@ impl InputContent { pub fn tokens(&self) -> &[InlineTokenSpan] { &self.tokens } +} - fn validate(&mut self) -> Result<(), InlineTokenError> { - self.tokens.sort_by_key(|span| span.range.start); - let mut end = 0; - for span in &self.tokens { - span.token.validate()?; - validate_range(&self.text, &span.range)?; - if span.range.is_empty() { - return Err(InlineTokenError::InvalidRange); - } - if span.range.start < end { - return Err(InlineTokenError::OverlappingTokens); - } - if &self.text[span.range.clone()] != span.token.text.as_ref() { - return Err(InlineTokenError::TextMismatch); +macro_rules! content_from_text { + ($($text:ty),* $(,)?) => { + $(impl From<$text> for InputContent { + fn from(text: $text) -> Self { + Self::new(text) } - end = span.range.end; - } - Ok(()) - } + })* + }; } +// Every text type `set_value` accepted before it took content. +content_from_text!( + &str, + &mut str, + &String, + String, + char, + Box, + std::sync::Arc, + &std::sync::Arc, + std::borrow::Cow<'_, str>, + &SharedString, + SharedString, +); /// A rejected token operation never partially changes the document. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -340,30 +372,18 @@ impl InputBaseState { self.pending_token = None; Ok(()) } - fn restore_content( - &mut self, - mut content: InputContent, - window: &mut Window, - cx: &mut Context, - ) -> Result<(), InlineTokenError> { - self.check_token_mode()?; - content.validate()?; - if self.normalize_input(&content.text) != content.text.as_ref() { - return Err(InlineTokenError::TextMismatch); - } - if !self.is_valid_input(&content.text, cx) { - return Err(InlineTokenError::ValidationRejected); - } - self.validated_token_edit = true; - self.set_value(content.text, window, cx); - self.validated_token_edit = false; - self.inline_tokens = (!content.tokens.is_empty()).then(|| { + /// Adopt the tokens of content whose text was just installed by + /// `set_value`. Tokens are dropped when this mode cannot show them or when + /// normalization changed the text, since their ranges would no longer + /// describe it. + pub(super) fn install_tokens(&mut self, content: InputContent) { + let supported = !M::CODE_EDITOR && self.mask_pattern.is_none(); + let text_kept = self.text == content.text.as_ref(); + self.inline_tokens = (supported && text_kept && !content.tokens.is_empty()).then(|| { Box::new(InlineTokenStore { spans: content.tokens, }) }); - self.refresh(cx); - Ok(()) } } @@ -392,21 +412,13 @@ macro_rules! token_api { pub fn tokens(&self) -> &[InlineTokenSpan] { self.token_spans() } + /// The text with its tokens, as `set_value` accepts it. pub fn content(&self) -> InputContent { InputContent { text: self.value(), tokens: self.token_spans().to_vec(), } } - /// Restore a draft silently, clearing editing history like `set_value`. - pub fn set_content( - &mut self, - content: InputContent, - window: &mut Window, - cx: &mut Context, - ) -> Result<(), InlineTokenError> { - self.restore_content(content, window, cx) - } } }; } diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index 37b283b22e..6f3a427aa2 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -908,7 +908,9 @@ impl InputBaseState { (0, 0, None) } - /// Set the text of the input field. + /// Set the value of the input field: plain text, or [`InputContent`] to + /// restore text together with its inline tokens. Editing history is + /// cleared and no [`InputEvent::Change`] is emitted. /// /// For single-line inputs the caret is placed at the end of the text while /// the view is scrolled back to the start, so a long value shows its @@ -916,14 +918,16 @@ impl InputBaseState { /// inputs reset the selection to `0..0`. pub fn set_value( &mut self, - value: impl Into, + value: impl Into, window: &mut Window, cx: &mut Context, ) { + let content = value.into(); self.inline_tokens = None; self.undo_manager.set_ignoring(true); self.emit_events = false; - self.replace_text(value, window, cx); + self.replace_text(content.text().clone(), window, cx); + self.install_tokens(content); self.undo_manager.set_ignoring(false); self.emit_events = true; @@ -4490,6 +4494,38 @@ mod tests { }); } + #[gpui::test] + fn test_inline_token_click_selects_it(cx: &mut TestAppContext) { + use crate::input::InlineToken; + cx.update(crate::init); + let view = InputView::build(cx, |state| state.default_value("before @alice after")); + view.window_handle + .update(cx, |_, window, cx| { + view.input.update(cx, |state, cx| { + state + .replace_range_with_token( + 7..13, + InlineToken::new("a", "@alice"), + window, + cx, + ) + .unwrap(); + state.set_selected_range(0..0, cx); + }); + }) + .unwrap(); + let mut visual = VisualTestContext::from_window(view.window_handle.into(), cx); + visual.update(|window, cx| window.draw(cx).clear(cx)); + let bounds = view + .input + .read_with(&visual, |state, _| state.range_to_bounds(&(7..13)).unwrap()); + visual.simulate_click(bounds.center(), gpui::Modifiers::default()); + visual.run_until_parked(); + view.input.read_with(&visual, |state, _| { + assert_eq!(state.selected_range(), 7..13, "a click selects the token"); + }); + } + #[gpui::test] fn test_inline_token_edit_history_and_validation(cx: &mut TestAppContext) { let view = InputView::build(cx, |state| state.default_value("问 @alice!")); @@ -4551,8 +4587,10 @@ mod tests { view.input.update(cx, |state, cx| { let content = InputContent::new("@a@b\n后面") .with_token(0..2, InlineToken::new("a", "@a")) - .with_token(2..4, InlineToken::new("b", "@b")); - state.set_content(content.clone(), window, cx).unwrap(); + .unwrap() + .with_token(2..4, InlineToken::new("b", "@b")) + .unwrap(); + state.set_value(content.clone(), window, cx); state.set_selected_range(2..2, cx); assert_eq!(state.previous_boundary(2), 0); assert_eq!(state.next_boundary(2), 4); @@ -4580,11 +4618,19 @@ mod tests { view.window_handle .update(cx, |_, window, cx| { view.input.update(cx, |state, cx| { - let content = InputContent::new("a\u{301}") - .with_token(0..1, InlineToken::new("bad", "a")); assert_eq!( - state.set_content(content, window, cx), - Err(InlineTokenError::InvalidBoundary) + InputContent::new("a\u{301}") + .with_token(0..1, InlineToken::new("bad", "a")) + .err(), + Some(InlineTokenError::InvalidBoundary) + ); + assert_eq!( + InputContent::new("@a@a") + .with_token(0..2, InlineToken::new("a", "@a")) + .unwrap() + .with_token(1..3, InlineToken::new("x", "a@")) + .err(), + Some(InlineTokenError::OverlappingTokens) ); state .replace_with_token(InlineToken::new("a", "@a"), window, cx) diff --git a/crates/component-shell/tests/inline_tokens_host.rs b/crates/component-shell/tests/inline_tokens_host.rs index b07ca92186..381f992480 100644 --- a/crates/component-shell/tests/inline_tokens_host.rs +++ b/crates/component-shell/tests/inline_tokens_host.rs @@ -46,10 +46,10 @@ function exercise(state) { assert(JSON.stringify(state.content()) === JSON.stringify(saved), "failure must be atomic"); state.set_selected_range({start: 4, end: 5}); state.replace(""); assert(state.value() === "🙂 !" && state.tokens().length === 0, "partial token deletion"); - state.set_content(saved); + state.set_value(saved); state.set_value(state.value()); assert(state.tokens().length === 0, "explicit same value clears identity"); - state.set_content(saved); + state.set_value(saved); return state; } export default class TokenHost extends View { diff --git a/crates/shell/src/engine/quickjs/mod.rs b/crates/shell/src/engine/quickjs/mod.rs index 84cca89665..78d4473d94 100644 --- a/crates/shell/src/engine/quickjs/mod.rs +++ b/crates/shell/src/engine/quickjs/mod.rs @@ -6014,14 +6014,19 @@ globalThis.__gpui = (() => { // Retained state is held by handle; the methods close over it so nothing has // to read it back off `this`. const tokenStateMethods = (handle, invoke) => Object.fromEntries([ - "content", "tokens", "set_content", "replace_with_token", "replace_range_with_token", + "content", "tokens", "replace_with_token", "replace_range_with_token", "refresh", "set_selected_range", "replace" ].map(name => [name, (...args) => invoke(handle, name, args)])); + // A value is plain text or a content snapshot with tokens. + const setValue = (handle, setText, invoke) => (next) => + next !== null && typeof next === "object" + ? invoke(handle, "set_value", [next]) + : setText(handle, String(next ?? "")); const inputState = (handle) => ({ ...tokenStateMethods(handle, __input_token_call), __handle: handle, value: () => __input_value(handle), - set_value: (next) => __input_set_value(handle, String(next ?? "")), + set_value: setValue(handle, __input_set_value, __input_token_call), on: (event, handler) => __input_on(handle, String(event), handler), // What makes a text state a number state. There is no `NumberInputState`: // the step, the bounds and the mask are fields on this one, so a plain @@ -6040,7 +6045,7 @@ globalThis.__gpui = (() => { ...tokenStateMethods(handle, __textarea_token_call), __handle: handle, value: () => __textarea_value(handle), - set_value: (next) => __textarea_set_value(handle, String(next ?? "")), + set_value: setValue(handle, __textarea_set_value, __textarea_token_call), on: (event, handler) => __textarea_on(handle, String(event), handler), set_rows: (rows) => __textarea_set_rows(handle, oneBased(rows, "set_rows(rows)")), set_auto_grow: (min_rows, max_rows) => diff --git a/crates/shell/src/input_tokens.rs b/crates/shell/src/input_tokens.rs index e576fdf806..75ff85c240 100644 --- a/crates/shell/src/input_tokens.rs +++ b/crates/shell/src/input_tokens.rs @@ -86,7 +86,7 @@ fn decode_content(value: &Data) -> Result { content = content.with_token( range(text, field(span, "range")?)?, token(field(span, "token")?)?, - ); + )?; } Ok(content) } @@ -191,10 +191,9 @@ pub fn inline_token_click_data(event: &InlineTokenClickEvent, text: &Rope) -> Da pub(crate) const METHODS: &[(&str, &str, bool)] = &[ ("value", "(): string", true), - ("set_value", "(text: string): void", false), + ("set_value", "(value: string | InputContent): void", false), ("content", "(): InputContent", true), ("tokens", "(): InlineTokenSpan[]", true), - ("set_content", "(content: InputContent): void", false), ("replace_with_token", "(token: InlineToken): void", false), ( "replace_range_with_token", @@ -221,14 +220,12 @@ macro_rules! state_binding { ("tokens", []) => { Ok(field(&content_data(&entity.read(cx).content()), "tokens")?.clone()) } - ("set_value", [text]) => { - let text = string(text)?.to_owned(); - entity.update(cx, |state, cx| state.set_value(text, window, cx)); - Ok(Data::Null) - } - ("set_content", [content]) => { - let content = decode_content(content)?; - entity.update(cx, |state, cx| state.set_content(content, window, cx))?; + ("set_value", [value]) => { + let content = match value { + Data::String(text) => InputContent::new(text.to_owned()), + value => decode_content(value)?, + }; + entity.update(cx, |state, cx| state.set_value(content, window, cx)); Ok(Data::Null) } ("replace_with_token", [value]) => { diff --git a/crates/shell/src/typings.rs b/crates/shell/src/typings.rs index 25efc3e257..68c3e5d6ec 100644 --- a/crates/shell/src/typings.rs +++ b/crates/shell/src/typings.rs @@ -2999,14 +2999,14 @@ const BASE: &str = r#" /** A row. */ export interface InputState { content(): InputContent; tokens(): InlineTokenSpan[]; - set_content(content: InputContent): void; replace_with_token(token: InlineToken): void; replace_range_with_token(range: InputRange, token: InlineToken): void; refresh(): void; set_selected_range(range: InputRange): void; replace(text: string): void; value(): string; - set_value(next: string): void; + /** Plain text, or a content snapshot to restore its tokens as well. */ + set_value(next: string | InputContent): void; /** `change`, `submit`, `focus` or `blur`. */ on(event: "change" | "submit" | "focus" | "blur", handler: (event: InputEvent, cx: Context) => void): boolean; /** @@ -3067,14 +3067,14 @@ const BASE: &str = r#" /** A row. */ export interface TextareaState { content(): InputContent; tokens(): InlineTokenSpan[]; - set_content(content: InputContent): void; replace_with_token(token: InlineToken): void; replace_range_with_token(range: InputRange, token: InlineToken): void; refresh(): void; set_selected_range(range: InputRange): void; replace(text: string): void; value(): string; - set_value(next: string): void; + /** Plain text, or a content snapshot to restore its tokens as well. */ + set_value(next: string | InputContent): void; /** `change`, `submit`, `focus` or `blur`. */ on(event: "change" | "submit" | "focus" | "blur", handler: (event: InputEvent, cx: Context) => void): boolean; /** Shows this many rows. */ diff --git a/crates/story/src/stories/input_tokens.rs b/crates/story/src/stories/input_tokens.rs index 05c691eb67..136dced933 100644 --- a/crates/story/src/stories/input_tokens.rs +++ b/crates/story/src/stories/input_tokens.rs @@ -1,33 +1,123 @@ -//! A retained example shared by the Input and Textarea stories. +//! A chat composer shared by the Input and Textarea stories. Commands, images, +//! skills and people are inserted as atomic inline tokens; the application +//! maps a token's ID back to the resource it names. +use gpui_kit::assets::IconName; use gpui_kit::component::{ - ActiveTheme as _, Disableable as _, IconName, Sizable as _, - button::Button, + ActiveTheme as _, Disableable as _, Sizable as _, + button::{Button, ButtonVariants as _}, checkbox::Checkbox, h_flex, input::{ - InlineToken, InlineTokenTag, Input, InputContent, InputEvent, InputGroup, InputState, - Textarea, TextareaState, + InlineToken, InlineTokenClickEvent, InlineTokenContext, InlineTokenError, InlineTokenTag, + InputContent, InputEvent, InputGroup, InputGroupAddon, InputGroupAddonAlignment as Align, + InputGroupButton, InputGroupInput, InputGroupTextarea, InputState, TextareaState, }, v_flex, }; use gpui_kit::{ - App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled, Window, - div, + App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, + Styled, Window, div, prelude::FluentBuilder as _, rems, }; +/// What a token refers to, recovered from the prefix of its ID. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Reference { + Command, + Image, + Skill, + Person, +} + +impl Reference { + const ALL: [Self; 4] = [Self::Command, Self::Image, Self::Skill, Self::Person]; + + fn of(token: &InlineToken) -> Option { + let (prefix, _) = token.id().split_once(':')?; + Self::ALL + .into_iter() + .find(|reference| reference.prefix() == prefix) + } + + fn prefix(self) -> &'static str { + match self { + Self::Command => "command", + Self::Image => "image", + Self::Skill => "skill", + Self::Person => "person", + } + } + + fn icon(self) -> IconName { + match self { + Self::Command => IconName::SquareTerminal, + Self::Image => IconName::Image, + Self::Skill => IconName::Sparkles, + Self::Person => IconName::AtSign, + } + } + + /// The button that inserts a sample of this reference. + fn action(self) -> (&'static str, &'static str) { + match self { + Self::Command => ("insert-command", "Insert command"), + Self::Image => ("insert-image", "Attach image"), + Self::Skill => ("insert-skill", "Insert skill"), + Self::Person => ("insert-person", "Mention someone"), + } + } + + /// The sample the story inserts. A real composer resolves this from a + /// picker or completion; the ID is what the application looks up later. + fn sample(self) -> InlineToken { + match self { + Self::Command => InlineToken::new("command:commit-pr", "/commit-pr"), + Self::Image => InlineToken::new("image:1", "[Image 1]").with_label("Image 1"), + Self::Skill => InlineToken::new("skill:gpui-kit", "$gpui-kit").with_label("gpui-kit"), + Self::Person => InlineToken::new("person:alice", "@alice").with_label("Alice"), + } + } +} + +/// Build content from prose, attaching each sample token at its first +/// occurrence so the byte ranges never drift from the text. +fn content(text: &str, references: &[Reference]) -> InputContent { + references + .iter() + .fold(InputContent::new(text), |content, reference| { + let token = reference.sample(); + let start = text + .find(token.text().as_ref()) + .expect("sample is in the text"); + content + .with_token(start..start + token.text().len(), token) + .expect("samples do not overlap") + }) +} + +fn describe(content: &InputContent) -> String { + content + .tokens() + .iter() + .map(|span| format!("{} {:?}", span.token().id(), span.range())) + .collect::>() + .join(", ") +} + pub(super) struct TokenExample { state: State, saved: InputContent, readonly: bool, disabled: bool, - result: String, + status: SharedString, _subscription: gpui_kit::Subscription, } + #[derive(Clone)] enum State { Input(Entity), Textarea(Entity), } + macro_rules! dispatch { ($state:expr, |$input:ident| $body:expr) => { match $state { @@ -40,178 +130,246 @@ macro_rules! dispatch { impl TokenExample { pub(super) fn new(multiline: bool, window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| { - let state = if multiline { State::Textarea(cx.new(|cx| TextareaState::new(window, cx).auto_grow(2, 6))) } - else { State::Input(cx.new(|cx| InputState::new(window, cx))) }; - let text = if multiline { "/translate @alice@bob 请检查这段文字 🙂\nTry selecting part of a reference, deleting it, and undoing." } - else { "Ask @alice@bob to review 🙂" }; - let saved = if multiline { - InputContent::new(text).with_token(0..10, InlineToken::new("command", "/translate").with_label("Translate")) - .with_token(11..17, InlineToken::new("alice", "@alice").with_label("Alice")) - .with_token(17..21, InlineToken::new("bob", "@bob").with_label("Bob")) + let (state, saved) = if multiline { + let state = cx.new(|cx| TextareaState::new(window, cx).auto_grow(2, 6)); + let saved = content( + "/commit-pr for the token composer, then ask @alice to review [Image 1] \ + against $gpui-kit 🙂\nSelect part of a reference, delete it, and undo.", + &Reference::ALL, + ); + (State::Textarea(state), saved) } else { - InputContent::new(text).with_token(4..10, InlineToken::new("alice", "@alice").with_label("Alice")) - .with_token(10..14, InlineToken::new("bob", "@bob").with_label("Bob")) + let state = cx.new(|cx| InputState::new(window, cx)); + let saved = content( + "Ask @alice to review [Image 1] 🙂", + &[Reference::Person, Reference::Image], + ); + (State::Input(state), saved) }; - dispatch!(&state, |input| input.update(cx, |input, cx| input.set_content(saved.clone(), window, cx).expect("valid demo content"))); - let subscription = dispatch!(&state, |input| cx.subscribe(input, |_, _, event: &InputEvent, cx| { - if matches!(event, InputEvent::Change) { cx.notify(); } - })); - Self { state, saved, readonly: false, disabled: false, result: String::new(), _subscription: subscription } + dispatch!(&state, |input| input.update(cx, |input, cx| input + .set_value(saved.clone(), window, cx))); + let subscription = dispatch!(&state, |input| cx.subscribe( + input, + |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + } + )); + Self { + state, + saved, + readonly: false, + disabled: false, + status: SharedString::default(), + _subscription: subscription, + } }) } -} -impl Render for TokenExample { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let content = dispatch!(&self.state, |input| input.read(cx).content()); - let input = match &self.state { - State::Input(input) => Input::new(input) - .aria_label("Atomic reference input") - .readonly(self.readonly) - .disabled(self.disabled) - .on_token_click(cx.listener( - |this, event: &gpui_kit::component::input::InlineTokenClickEvent, _, cx| { - this.result = format!("Opened {}", event.token().label()); - cx.notify(); - }, - )) - .into_any_element(), + + fn content(&self, cx: &App) -> InputContent { + dispatch!(&self.state, |input| input.read(cx).content()) + } + + /// Insert a reference the way a picker would: separated from the + /// surrounding words, with the caret ready for the next one. + fn insert(&mut self, reference: Reference, window: &mut Window, cx: &mut Context) { + let result = dispatch!(&self.state, |input| input.update(cx, |input, cx| { + let start = input.selected_range().start; + let text = input.value(); + if text[..start].ends_with(|c: char| !c.is_whitespace()) { + input.replace(" ", window, cx); + } + input.replace_with_token(reference.sample(), window, cx)?; + input.replace(" ", window, cx); + input.focus(window, cx); + Ok::<_, InlineTokenError>(()) + })); + if let Err(error) = result { + self.status = error.to_string().into(); + } + cx.notify(); + } + + fn open(&mut self, event: &InlineTokenClickEvent, cx: &mut Context) { + let token = event.token(); + self.status = match Reference::of(token) { + Some(Reference::Command) => format!("Would run {}", token.text()), + Some(Reference::Image) => format!("Would preview {}", token.label()), + Some(Reference::Skill) => format!("Would open the {} skill", token.label()), + Some(Reference::Person) => format!("Would open {}'s profile", token.label()), + None => format!("Unknown reference {}", token.id()), + } + .into(); + cx.notify(); + } + + fn send(&mut self, cx: &mut Context) { + let content = self.content(cx); + self.status = format!( + "Sent {:?} with {} references", + content.text().trim(), + content.tokens().len() + ) + .into(); + cx.notify(); + } + + fn render_token(token: &InlineTokenContext, cx: &App) -> InlineTokenTag { + let reference = Reference::of(token.token()); + InlineTokenTag::new(token) + .when_some(reference, |tag, reference| tag.with_icon(reference.icon())) + .with_tooltip(token.token().text().clone()) + .border_1() + .border_color(cx.theme().border) + } + + fn insert_button(&self, reference: Reference, cx: &Context) -> InputGroupButton { + let (id, label) = reference.action(); + InputGroupButton::new(id) + .icon(reference.icon()) + .accessibility_label(label) + .tooltip(label) + .disabled(self.readonly) + .on_click(cx.listener(move |this, _, window, cx| this.insert(reference, window, cx))) + } + + fn send_button(&self, cx: &Context) -> InputGroupButton { + InputGroupButton::new("send-message") + .primary() + .label("Send") + .on_click(cx.listener(|this, _, _, cx| this.send(cx))) + } + + fn composer(&self, cx: &Context) -> InputGroup { + let inserts = Reference::ALL + .into_iter() + .map(|reference| self.insert_button(reference, cx)); + let open = cx.listener(|this, event: &InlineTokenClickEvent, _, cx| this.open(event, cx)); + match &self.state { + State::Input(input) => InputGroup::new("token-composer") + .input( + InputGroupInput::new(input) + .aria_label("Message") + .render_token(|token, _, cx| Self::render_token(token, cx)) + .on_token_click(open), + ) + .addon( + InputGroupAddon::new("composer-actions") + .align(Align::InlineEnd) + .children(inserts) + .child(self.send_button(cx)), + ), State::Textarea(input) => InputGroup::new("token-composer") .input( - Textarea::new(input) - .aria_label("Atomic reference textarea") - .readonly(self.readonly) - .disabled(self.disabled) - .render_token(|token, _, _| { - InlineTokenTag::new(token) - .with_icon(IconName::File) - .with_tooltip("Open reference") - }) - .on_token_click(cx.listener( - |this, - event: &gpui_kit::component::input::InlineTokenClickEvent, - _, - cx| { - this.result = format!("Opened {}", event.token().label()); - cx.notify(); - }, - )), + InputGroupTextarea::new(input) + .aria_label("Message") + .render_token(|token, _, cx| Self::render_token(token, cx)) + .on_token_click(open), + ) + .addon( + InputGroupAddon::new("composer-actions") + .align(Align::BlockEnd) + .children(inserts) + .child(self.send_button(cx).ml_auto()), + ), + } + .readonly(self.readonly) + .disabled(self.disabled) + } + + fn readout(&self, cx: &Context) -> impl IntoElement { + let content = self.content(cx); + let row = |label: &'static str, value: String| { + h_flex() + .items_start() + .gap_3() + .child( + div() + .flex_shrink_0() + .w(rems(5.5)) + .text_color(cx.theme().muted_foreground) + .child(label), ) - .into_any_element(), + .child(div().min_w_0().flex_1().child(value)) }; v_flex() .w_full() - .gap_2() - .child(input) - .child( - h_flex() - .gap_2() - .child( - Button::new("insert-token") - .small() - .label("Insert reference") - .disabled(self.readonly || self.disabled) - .on_click(cx.listener(|this, _, window, cx| { - // The ID names the resource, so inserting the same - // reference twice reuses it. - let result = dispatch!(&this.state, |input| input.update( - cx, - |input, cx| input.replace_with_token( - InlineToken::new("reference", "@reference") - .with_label("Reference"), - window, - cx - ) - )); - if let Err(error) = result { - this.result = error.to_string(); - } - dispatch!(&this.state, |input| input - .update(cx, |input, cx| input.focus(window, cx))); - cx.notify(); - })), - ) - .child( - Button::new("save-tokens") - .small() - .label("Save draft") - .on_click(cx.listener(|this, _, _, cx| { - this.saved = - dispatch!(&this.state, |input| input.read(cx).content()); - this.result = "Draft saved".into(); - cx.notify(); - })), - ) - .child( - Button::new("restore-tokens") - .small() - .label("Restore draft") - .disabled(self.readonly || self.disabled) - .on_click(cx.listener(|this, _, window, cx| { - let saved = this.saved.clone(); - let result = dispatch!(&this.state, |input| input - .update(cx, |input, cx| input.set_content(saved, window, cx))); - this.result = result.map_or_else( - |error| error.to_string(), - |_| "Draft restored".into(), - ); - cx.notify(); - })), - ) - .child( - Button::new("submit-tokens") - .small() - .label("Submit") - .disabled(self.disabled) - .on_click(cx.listener(|this, _, _, cx| { - let content = - dispatch!(&this.state, |input| input.read(cx).content()); - this.result = format!( - "Submitted {} active references: {}", - content.tokens().len(), - content.text() - ); - cx.notify(); - })), - ), - ) + .gap_1() + .text_sm() + .child(row("Text", content.text().to_string())) + .child(row("References", describe(&content))) + .when(!self.status.is_empty(), |this| { + this.child(row("Status", self.status.to_string())) + }) + } +} + +impl Render for TokenExample { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .w_full() + .gap_4() + .child(self.composer(cx)) .child( h_flex() - .gap_3() + .gap_6() .child( - Checkbox::new("tokens-readonly") - .label("Read-only") - .checked(self.readonly) - .on_click(cx.listener(|this, value, _, cx| { - this.readonly = *value; - cx.notify(); - })), + h_flex() + .gap_2() + .child( + Button::new("save-draft") + .outline() + .small() + .label("Save draft") + .on_click(cx.listener(|this, _, _, cx| { + this.saved = this.content(cx); + this.status = "Draft saved".into(); + cx.notify(); + })), + ) + .child( + Button::new("restore-draft") + .outline() + .small() + .label("Restore draft") + .disabled(self.readonly || self.disabled) + .on_click(cx.listener(|this, _, window, cx| { + let saved = this.saved.clone(); + dispatch!(&this.state, |input| input + .update(cx, |input, cx| input + .set_value(saved, window, cx))); + this.status = "Draft restored".into(); + cx.notify(); + })), + ), ) .child( - Checkbox::new("tokens-disabled") - .label("Disabled") - .checked(self.disabled) - .on_click(cx.listener(|this, value, _, cx| { - this.disabled = *value; - cx.notify(); - })), + h_flex() + .gap_4() + .child( + Checkbox::new("tokens-readonly") + .small() + .label("Read-only") + .checked(self.readonly) + .on_click(cx.listener(|this, value, _, cx| { + this.readonly = *value; + cx.notify(); + })), + ) + .child( + Checkbox::new("tokens-disabled") + .small() + .label("Disabled") + .checked(self.disabled) + .on_click(cx.listener(|this, value, _, cx| { + this.disabled = *value; + cx.notify(); + })), + ), ), ) - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child(format!("Text: {}", content.text())), - ) - .child(div().text_sm().child(format!( - "Tokens: {}", - content - .tokens() - .iter() - .map(|s| format!("{} {:?}", s.token().id(), s.range())) - .collect::>() - .join(", ") - ))) - .child(div().text_sm().child(self.result.clone())) + .child(self.readout(cx)) } } @@ -237,9 +395,7 @@ mod tests { } fn content(story: &Entity, cx: &mut VisualTestContext) -> InputContent { - story.read_with(cx, |story, cx| { - dispatch!(&story.state, |input| input.read(cx).content()) - }) + story.read_with(cx, |story, cx| story.content(cx)) } #[gpui_kit::test] @@ -257,14 +413,26 @@ mod tests { let mut visual = VisualTestContext::from_window(*window.deref(), cx); draw(&mut visual); let initial = content(&story, &mut visual); - click("insert-token", &mut visual); + let end = initial.text().len(); + visual.update(|_, cx| { + story.update(cx, |story, cx| { + dispatch!(&story.state, |input| input + .update(cx, |input, cx| input.set_selected_range(end..end, cx))); + }) + }); + click("insert-command", &mut visual); let inserted = content(&story, &mut visual); assert_eq!(inserted.tokens().len(), initial.tokens().len() + 1); - click("save-tokens", &mut visual); + assert!( + inserted.text().ends_with(" /commit-pr "), + "a picker separates the reference from its neighbors: {:?}", + inserted.text() + ); + click("save-draft", &mut visual); let range = inserted .tokens() .iter() - .find(|span| span.token().id().as_ref() == "reference") + .find(|span| span.token().id().as_ref() == "command:commit-pr") .unwrap() .range(); visual.update(|window, cx| { @@ -287,27 +455,27 @@ mod tests { visual.simulate_keystrokes("ctrl-z"); draw(&mut visual); assert_eq!(content(&story, &mut visual), inserted); - click("insert-token", &mut visual); - click("restore-tokens", &mut visual); + click("insert-person", &mut visual); + click("restore-draft", &mut visual); assert_eq!(content(&story, &mut visual), inserted); - click("submit-tokens", &mut visual); + click("send-message", &mut visual); story.read_with(&visual, |story, _| { assert_eq!( - story.result, + story.status, format!( - "Submitted {} active references: {}", - inserted.tokens().len(), - inserted.text() + "Sent {:?} with {} references", + inserted.text().trim(), + inserted.tokens().len() ) ); }); click("tokens-readonly", &mut visual); - click("insert-token", &mut visual); + click("insert-command", &mut visual); assert_eq!(content(&story, &mut visual), inserted); click("tokens-readonly", &mut visual); click("tokens-disabled", &mut visual); - click("restore-tokens", &mut visual); - click("insert-token", &mut visual); + click("restore-draft", &mut visual); + click("insert-command", &mut visual); assert_eq!(content(&story, &mut visual), inserted); } } diff --git a/examples/js_story/stories/registered.js b/examples/js_story/stories/registered.js index 1b7a00a3d6..e709011534 100644 --- a/examples/js_story/stories/registered.js +++ b/examples/js_story/stories/registered.js @@ -235,8 +235,8 @@ const tokenDraft = { }; export function initializeRegisteredExamples() { - retained("token-input", () => { const input = InputState(); input.set_content(tokenDraft); return input; }); - retained("token-textarea", () => { const input = TextareaState(); input.set_content(tokenDraft); return input; }); + retained("token-input", () => { const input = InputState(); input.set_value(tokenDraft); return input; }); + retained("token-textarea", () => { const input = TextareaState(); input.set_value(tokenDraft); return input; }); for (const [id, placeholder, value] of inputGroupFields) { retained(`input-group-extra:${id}`, () => InputState(placeholder, value)); } @@ -528,7 +528,7 @@ function tokenExample(multiline, cx) { setState(`${key}-saved`, input.content(), cx); })) .child(new Button(`${key}-restore`).label("Restore draft").on_click((_event, cx) => { - input.set_content(/** @type {import("gpui-component").InputContent} */ (state(`${key}-saved`, tokenDraft))); + input.set_value(/** @type {import("gpui-component").InputContent} */ (state(`${key}-saved`, tokenDraft))); cx.notify(); })) .child(new Button(`${key}-submit`).label("Submit").on_click((_event, cx) => { diff --git a/website/base/primitives/input.md b/website/base/primitives/input.md index b0ea6bbc9e..da5a1896d3 100644 --- a/website/base/primitives/input.md +++ b/website/base/primitives/input.md @@ -107,7 +107,7 @@ input.update(cx, |state, cx| { Tokens display as unstyled labels. Use `render_token` to supply your own single-row element and `on_token_click` to open a reference. Copy and `value()` return the real text, such as `@alice`. Save drafts with `content()` and restore them with -`set_content()` to keep their references. +`set_value(content)` to keep their references. See [Input's token examples](../../component/input.md#atomic-inline-tokens) for custom rendering, draft restoration and range units. Import the data types from diff --git a/website/base/primitives/textarea.md b/website/base/primitives/textarea.md index 8bcebd4c2b..886b5f8d39 100644 --- a/website/base/primitives/textarea.md +++ b/website/base/primitives/textarea.md @@ -92,7 +92,7 @@ notes.update(cx, |state, cx| { Tokens display as unstyled labels. Use `render_token` to supply your own single-row element and `on_token_click` to open a reference. Copy and `value()` return the real text, such as `@alice`. Save drafts with `content()` and restore them with -`set_content()` to keep their references. +`set_value(content)` to keep their references. See [Input's token examples](../../component/input.md#atomic-inline-tokens) for custom rendering, draft restoration and range units. Import the data types from diff --git a/website/component/input-group.md b/website/component/input-group.md index e7c1162c31..bd990ec75a 100644 --- a/website/component/input-group.md +++ b/website/component/input-group.md @@ -376,5 +376,5 @@ InputGroup::new("composer") ``` The JavaScript group controls also expose `render_token` and `on_token_click`. -Retain the same input state across redraws; call `set_content` explicitly to -restore a draft. See [atomic inline tokens](./input.md#atomic-inline-tokens). +Retain the same input state across redraws; call `set_value` with saved content +to restore a draft. See [atomic inline tokens](./input.md#atomic-inline-tokens). diff --git a/website/component/input.md b/website/component/input.md index fec4d7fc03..441c95b1e4 100644 --- a/website/component/input.md +++ b/website/component/input.md @@ -367,9 +367,10 @@ The ID names the referenced resource, so two mentions of the same person carry the same ID. Use `text` for the value to copy or submit and `with_label` for its displayed name. Omit `with_label` to display the text itself. -Users can move the caret to either side of a token, select it, or delete it with -Backspace/Delete. A selection that crosses part of a token includes the whole -token. Undo/Redo restores both its text and reference. Pasting inserts plain text. +Users can move the caret to either side of a token, click it to select it, or +delete it with Backspace/Delete. A selection that crosses part of a token +includes the whole token. Undo/Redo restores both its text and reference. +Pasting inserts plain text. ### Customize appearance and opening a reference @@ -400,7 +401,8 @@ the renderer; event callbacks may update it. Keep hover and selection styles the same size. Tokens are measured whenever they render, so a tag that grows once its data arrives reflows on the next frame. -Dragging or Shift-selecting a token does not open it. Read-only inputs allow +A click selects the token and then opens it; dragging or Shift-selecting a token +does not open it. Read-only inputs allow opening references; disabled inputs do not. To offer a keyboard shortcut for opening an exactly selected token, bind `ActivateToken` to a key of your choice; assistive technology reaches the same listener through the token's click action. @@ -419,29 +421,31 @@ Use `content()` to keep the text and references together when saving a draft: ```rust let draft = input.read(cx).content(); -// Restore the saved draft later. +// Restore the saved draft later: `set_value` takes plain text or content. input.update(cx, |state, cx| { - state.set_content(draft, window, cx).expect("valid draft"); + state.set_value(draft, window, cx); }); ``` -To restore data from your own storage, build an `InputContent` with matching text -and token ranges: +To restore data from your own storage, build an `InputContent` from the text and +attach each token to its byte range. `with_token` validates the range against the +text as you go, so a content value is always consistent by the time it is set: ```rust use gpui_kit::component::input::InputContent; let draft = InputContent::new("Ask @alice") - .with_token(4..10, InlineToken::new("person-1", "@alice").with_label("Alice")); + .with_token(4..10, InlineToken::new("person-1", "@alice").with_label("Alice"))?; ``` At submission time, read a fresh `content()`: `text()` is the message, and `tokens()` contains the references still present in it. Use each token's ID to look up its resource and handle missing resources before sending. -`set_content` clears undo history and does not emit `InputEvent::Change`. -Use `set_value` to reset to plain text; it removes tokens even if the text is -unchanged. Use `replace_all` for an undoable plain-text replacement. Token edits, +`set_value` clears undo history and does not emit `InputEvent::Change`. Passing +plain text removes every token, even if the text is unchanged; passing content +restores its tokens, except in modes that cannot show them. Use `replace_all` for +an undoable plain-text replacement. Token edits, including adding a reference to existing text, emit `InputEvent::Change`. Programmatic setters can update read-only or disabled inputs, so check these states in application commands that should be unavailable to users. @@ -469,7 +473,7 @@ import { Input, InputState } from "gpui-component"; // In init(): this.input = InputState(); -this.input.set_content({ +this.input.set_value({ text: "🙂 @alice", tokens: [{ range: { start: 3, end: 9 }, @@ -485,8 +489,8 @@ new Input(this.input) ``` Use `replace_with_token` or `replace_range_with_token` to insert references, -`content()` and `set_content()` to save and restore drafts, and `tokens()` to read -the current references. To remove a reference, pass its current range to +`content()` and `set_value(content)` to save and restore drafts, and `tokens()` to +read the current references. To remove a reference, pass its current range to `set_selected_range`, then call `replace("")`. Returned snapshots are independent objects; changing one does not update the input. Make edits from initialization, event or task callbacks, not from renderers. diff --git a/website/component/textarea.md b/website/component/textarea.md index 1530c7f8f5..3f6998d355 100644 --- a/website/component/textarea.md +++ b/website/component/textarea.md @@ -84,7 +84,7 @@ textarea. Use tokens to include mentions, file references or commands in a multi-line message. Insert a token with `TextareaState::replace_with_token`, or restore a -saved draft with `set_content`. The default label is ready to use; add a renderer +saved draft with `set_value`. The default label is ready to use; add a renderer when you want an icon or other custom content: ```rust diff --git a/website/zh-CN/base/primitives/input.md b/website/zh-CN/base/primitives/input.md index a3a2f8c6bb..50fbe29556 100644 --- a/website/zh-CN/base/primitives/input.md +++ b/website/zh-CN/base/primitives/input.md @@ -80,6 +80,6 @@ input.update(cx, |state, cx| { }); ``` -token 默认显示为无装饰标签。使用 `render_token` 提供自己的单行元素,通过 `on_token_click` 打开引用。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_content()` 恢复草稿,可以保留其中的引用。 +token 默认显示为无装饰标签。使用 `render_token` 提供自己的单行元素,通过 `on_token_click` 打开引用。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_value(content)` 恢复草稿,可以保留其中的引用。 自定义展示、草稿恢复和范围单位见 [Input 的 token 示例](../../component/input.md#原子行内-token)。数据类型从 `gpui_kit::base::input` 导入。JavaScript 使用 `gpui-base` 中的 `InputState.new()`,它提供相同的 token 方法。 diff --git a/website/zh-CN/base/primitives/textarea.md b/website/zh-CN/base/primitives/textarea.md index 17d792bdc3..27c21406af 100644 --- a/website/zh-CN/base/primitives/textarea.md +++ b/website/zh-CN/base/primitives/textarea.md @@ -75,6 +75,6 @@ notes.update(cx, |state, cx| { }); ``` -token 默认显示为无装饰标签。使用 `render_token` 提供自己的单行元素,通过 `on_token_click` 打开引用。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_content()` 恢复草稿,可以保留其中的引用。 +token 默认显示为无装饰标签。使用 `render_token` 提供自己的单行元素,通过 `on_token_click` 打开引用。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_value(content)` 恢复草稿,可以保留其中的引用。 自定义展示、草稿恢复和范围单位见 [Input 的 token 示例](../../component/input.md#原子行内-token)。数据类型从 `gpui_kit::base::input` 导入。JavaScript 使用 `gpui-base` 中的 `TextareaState.new()`,它提供相同的 token 方法。 diff --git a/website/zh-CN/component/input-group.md b/website/zh-CN/component/input-group.md index 7125b9758b..e1521ec02d 100644 --- a/website/zh-CN/component/input-group.md +++ b/website/zh-CN/component/input-group.md @@ -357,4 +357,4 @@ InputGroup::new("composer") .render_token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File))) ``` -JavaScript 的分组输入也提供 `render_token` 和 `on_token_click`。重绘时保留原输入状态,需要恢复草稿时显式调用 `set_content`。详见[原子行内 token](./input.md#原子行内-token)。 +JavaScript 的分组输入也提供 `render_token` 和 `on_token_click`。重绘时保留原输入状态,需要恢复草稿时用保存的 content 调用 `set_value`。详见[原子行内 token](./input.md#原子行内-token)。 diff --git a/website/zh-CN/component/input.md b/website/zh-CN/component/input.md index 193ea353ac..1692b72323 100644 --- a/website/zh-CN/component/input.md +++ b/website/zh-CN/component/input.md @@ -308,7 +308,7 @@ Input::new(&input) ID 标识被引用的资源,同一个人被提及两次时两个 token 使用同一个 ID。`text` 用于复制和提交,`with_label` 指定显示名称;省略 `with_label` 时直接显示真实文本。 -用户可以将光标移到 token 两侧,选中它,或用 Backspace/Delete 删除它。选区覆盖部分 token 时会包含整块 token。Undo/Redo 同时恢复文本和引用。粘贴得到普通文本。 +用户可以将光标移到 token 两侧,单击选中整块 token,或用 Backspace/Delete 删除它。选区覆盖部分 token 时会包含整块 token。Undo/Redo 同时恢复文本和引用。粘贴得到普通文本。 ### 自定义外观与打开引用 @@ -333,7 +333,7 @@ Input::new(&input) 也可以返回自己的单行元素。元素应保持在输入框行高内,超出可用行宽的内容会被裁切。通过 renderer 的上下文读取选中、只读和禁用状态。在事件回调中修改输入,不要在 renderer 中修改。悬停和选中样式应保持尺寸稳定。token 每次渲染时都会重新测量,因此数据到达后变宽的 tag 会在下一帧重新排版。 -拖选或 Shift 扩选不会打开引用。只读输入允许打开引用,禁用输入不允许。若要为打开选中的整块 token 提供快捷键,可以将 `ActivateToken` 绑定到自己选择的按键;辅助技术通过 token 的 click 操作触发同一个监听器。当应用能为引用给出明确名称(例如“打开文件”)时,可以通过 `context_menu` 自行添加菜单项。 +单击会先选中 token 再打开引用;拖选或 Shift 扩选不会打开引用。只读输入允许打开引用,禁用输入不允许。若要为打开选中的整块 token 提供快捷键,可以将 `ActivateToken` 绑定到自己选择的按键;辅助技术通过 token 的 click 操作触发同一个监听器。当应用能为引用给出明确名称(例如“打开文件”)时,可以通过 `context_menu` 自行添加菜单项。 如果 token 内含按钮,应消费按钮的 mouse-down 和 click 事件,避免同时打开引用。所有子操作(包括无障碍操作)都应遵守 `token.is_disabled()`;会修改内容的操作还应遵守 `token.is_readonly()`。 @@ -344,24 +344,24 @@ Input::new(&input) ```rust let draft = input.read(cx).content(); -// 稍后恢复保存的草稿。 +// 稍后恢复保存的草稿:`set_value` 既接受纯文本,也接受 content。 input.update(cx, |state, cx| { - state.set_content(draft, window, cx).expect("有效的草稿"); + state.set_value(draft, window, cx); }); ``` -从应用自己的存储中恢复数据时,可以用相匹配的文本和 token 范围构造 `InputContent`: +从应用自己的存储中恢复数据时,先用文本构造 `InputContent`,再把每个 token 附加到它的字节范围上。`with_token` 会随即按文本校验范围,因此 content 在设置之前就已经是自洽的: ```rust use gpui_kit::component::input::InputContent; let draft = InputContent::new("Ask @alice") - .with_token(4..10, InlineToken::new("person-1", "@alice").with_label("Alice")); + .with_token(4..10, InlineToken::new("person-1", "@alice").with_label("Alice"))?; ``` 提交时重新读取 `content()`:`text()` 是消息文本,`tokens()` 是其中仍然存在的引用。根据 token ID 查找资源,并在发送前处理资源已不存在的情况。 -`set_content` 清空撤销历史,不触发 `InputEvent::Change`。要重置为普通文本,使用 `set_value`;即使文字未变,它也会移除 token。需要可撤销的纯文本替换时使用 `replace_all`。token 编辑会触发 `InputEvent::Change`,包括为已有文本添加引用。程序化 setter 可以修改只读或禁用的输入,因此不应对用户开放的应用命令需要自行检查这些状态。 +`set_value` 清空撤销历史,不触发 `InputEvent::Change`。传入纯文本会移除全部 token,即使文字未变;传入 content 则恢复其中的 token(无法显示 token 的模式除外)。需要可撤销的纯文本替换时使用 `replace_all`。token 编辑会触发 `InputEvent::Change`,包括为已有文本添加引用。程序化 setter 可以修改只读或禁用的输入,因此不应对用户开放的应用命令需要自行检查这些状态。 ### 校验与适用范围 @@ -378,7 +378,7 @@ import { Input, InputState } from "gpui-component"; // 在 init() 中: this.input = InputState(); -this.input.set_content({ +this.input.set_value({ text: "🙂 @alice", tokens: [{ range: { start: 3, end: 9 }, @@ -393,6 +393,6 @@ new Input(this.input) }); ``` -用 `replace_with_token` 或 `replace_range_with_token` 插入引用,用 `content()` 和 `set_content()` 保存、恢复草稿,用 `tokens()` 读取当前引用。要删除引用,将它的当前范围传给 `set_selected_range`,再调用 `replace("")`。返回的快照是独立对象,修改快照不会更新输入。应在初始化、事件或任务回调中编辑,不要在 renderer 中编辑。 +用 `replace_with_token` 或 `replace_range_with_token` 插入引用,用 `content()` 和 `set_value(content)` 保存、恢复草稿,用 `tokens()` 读取当前引用。要删除引用,将它的当前范围传给 `set_selected_range`,再调用 `replace("")`。返回的快照是独立对象,修改快照不会更新输入。应在初始化、事件或任务回调中编辑,不要在 renderer 中编辑。 token 校验异常提供 `error.code`,例如 `InvalidBoundary` 或 `CompositionActive`;参数形状无效时也会抛出异常。Textarea 的 `TextareaState()` 提供相同方法。使用 `gpui-base` 时,改用 `InputState.new()` 或 `TextareaState.new()` 构造状态。 diff --git a/website/zh-CN/component/textarea.md b/website/zh-CN/component/textarea.md index 12c08a5abe..6b78f6023c 100644 --- a/website/zh-CN/component/textarea.md +++ b/website/zh-CN/component/textarea.md @@ -76,7 +76,7 @@ Textarea::new(¬es) ## 原子行内 token -在多行消息中加入人员提及、文件引用或命令时,可以使用 token。通过 `TextareaState::replace_with_token` 插入,或用 `set_content` 恢复已有草稿。默认标签可以直接使用,需要图标等自定义内容时再设置 renderer: +在多行消息中加入人员提及、文件引用或命令时,可以使用 token。通过 `TextareaState::replace_with_token` 插入,或用 `set_value` 恢复已有草稿。默认标签可以直接使用,需要图标等自定义内容时再设置 renderer: ```rust Textarea::new(&state) From 9f9042c8324c496eec9c14a096145b427739df27 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 18 Sep 2026 20:25:54 +0800 Subject: [PATCH 05/11] input: Let a selected token paint its own selection The text selection highlight now stops at a token's edges, so a selected token shows only the state its tag paints instead of a rectangle behind the pill. `InlineTokenTag` drops its tooltip: the label already names the reference, and a hover hint on every token is noise. Co-Authored-By: Claude Opus 5 (1M context) --- crates/base/src/input/base/element.rs | 28 +++++++++++++++++-- .../component/src/input/inline_token_tag.rs | 16 ++--------- crates/story/src/stories/input_tokens.rs | 1 - website/component/input.md | 8 ++---- website/zh-CN/component/input.md | 6 ++-- 5 files changed, 34 insertions(+), 25 deletions(-) diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index 6b8c259c0c..51d24f912e 100644 --- a/crates/base/src/input/base/element.rs +++ b/crates/base/src/input/base/element.rs @@ -936,8 +936,32 @@ impl TextElement { continue; } - if let Some(path) = Self::layout_match_range(range, last_layout, bounds) { - paths.push(path); + // A selected token shows its own selected state, so the text + // highlight stops at its edges instead of painting behind it. + let mut start = range.start; + if state.tokens_visible() { + let spans = state.token_spans(); + let first = spans.partition_point(|s| s.range().end <= range.start); + for span in spans[first..] + .iter() + .take_while(|s| s.range().start < range.end) + { + if start < span.range().start { + paths.extend(Self::layout_match_range( + start..span.range().start, + last_layout, + bounds, + )); + } + start = span.range().end.max(start); + } + } + if start < range.end { + paths.extend(Self::layout_match_range( + start..range.end, + last_layout, + bounds, + )); } } diff --git a/crates/component/src/input/inline_token_tag.rs b/crates/component/src/input/inline_token_tag.rs index 56bbb4a185..af3dfe605d 100644 --- a/crates/component/src/input/inline_token_tag.rs +++ b/crates/component/src/input/inline_token_tag.rs @@ -1,9 +1,8 @@ use super::InlineTokenContext; -use crate::{ActiveTheme as _, Icon, StyledExt as _, tooltip::Tooltip}; +use crate::{ActiveTheme as _, Icon, StyledExt as _}; use gpui::{ - App, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, SharedString, - StatefulInteractiveElement as _, StyleRefinement, Styled, Window, div, - prelude::FluentBuilder as _, + App, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, StyleRefinement, + Styled, Window, div, prelude::FluentBuilder as _, }; /// The default tag an [`InlineToken`](super::InlineToken) renders as. Editing @@ -12,7 +11,6 @@ use gpui::{ pub struct InlineTokenTag { context: InlineTokenContext, icon: Option, - tooltip: Option, style: StyleRefinement, } impl InlineTokenTag { @@ -20,7 +18,6 @@ impl InlineTokenTag { Self { context: context.clone(), icon: None, - tooltip: None, style: Default::default(), } } @@ -28,10 +25,6 @@ impl InlineTokenTag { self.icon = Some(icon.into()); self } - pub fn with_tooltip(mut self, tooltip: impl Into) -> Self { - self.tooltip = Some(tooltip.into()); - self - } } impl Styled for InlineTokenTag { fn style(&mut self) -> &mut StyleRefinement { @@ -65,9 +58,6 @@ impl RenderOnce for InlineTokenTag { .text_ellipsis() .child(self.context.token().label().clone()), ) - .when_some(self.tooltip, |this, tooltip| { - this.tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) - }) .refine_style(&self.style) } } diff --git a/crates/story/src/stories/input_tokens.rs b/crates/story/src/stories/input_tokens.rs index 136dced933..47b3a8cd27 100644 --- a/crates/story/src/stories/input_tokens.rs +++ b/crates/story/src/stories/input_tokens.rs @@ -219,7 +219,6 @@ impl TokenExample { let reference = Reference::of(token.token()); InlineTokenTag::new(token) .when_some(reference, |tag, reference| tag.with_icon(reference.icon())) - .with_tooltip(token.token().text().clone()) .border_1() .border_color(cx.theme().border) } diff --git a/website/component/input.md b/website/component/input.md index 441c95b1e4..ae31b368ef 100644 --- a/website/component/input.md +++ b/website/component/input.md @@ -374,8 +374,8 @@ Pasting inserts plain text. ### Customize appearance and opening a reference -Tokens render as an `InlineTokenTag` by default. To add an icon or tooltip, -return one from `render_token`, and use `on_token_click` to open the reference: +Tokens render as an `InlineTokenTag` by default. To add an icon, return one from +`render_token`, and use `on_token_click` to open the reference: ```rust use gpui_kit::component::{ @@ -385,9 +385,7 @@ use gpui_kit::component::{ Input::new(&input) .render_token(|token, _, _| { - InlineTokenTag::new(token) - .with_icon(IconName::File) - .with_tooltip("Open reference") + InlineTokenTag::new(token).with_icon(IconName::File) }) .on_token_click(|event: &InlineTokenClickEvent, _, _| { // Look up event.token().id() and open its resource. diff --git a/website/zh-CN/component/input.md b/website/zh-CN/component/input.md index 1692b72323..15b9c59e36 100644 --- a/website/zh-CN/component/input.md +++ b/website/zh-CN/component/input.md @@ -312,7 +312,7 @@ ID 标识被引用的资源,同一个人被提及两次时两个 token 使用 ### 自定义外观与打开引用 -token 默认渲染为 `InlineTokenTag`。需要图标或 tooltip 时,让 `render_token` 返回自定义的 tag,并通过 `on_token_click` 打开引用: +token 默认渲染为 `InlineTokenTag`。需要图标时,让 `render_token` 返回自定义的 tag,并通过 `on_token_click` 打开引用: ```rust use gpui_kit::component::{ @@ -322,9 +322,7 @@ use gpui_kit::component::{ Input::new(&input) .render_token(|token, _, _| { - InlineTokenTag::new(token) - .with_icon(IconName::File) - .with_tooltip("打开引用") + InlineTokenTag::new(token).with_icon(IconName::File) }) .on_token_click(|event: &InlineTokenClickEvent, _, _| { // 根据 event.token().id() 查找并打开资源。 From dbd4f35ba1658148d11705d719f00b07c2a6faba Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 18 Sep 2026 20:36:43 +0800 Subject: [PATCH 06/11] input: Give the token tag a border that follows its selected state The default tag draws a hairline border at rest and switches both fill and border to the selection color when selected, so the Story no longer adds a border of its own. Co-Authored-By: Claude Opus 5 (1M context) --- crates/component/src/input/inline_token_tag.rs | 14 ++++++++++---- crates/story/src/stories/input_tokens.rs | 8 +++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/component/src/input/inline_token_tag.rs b/crates/component/src/input/inline_token_tag.rs index af3dfe605d..9d99a8295c 100644 --- a/crates/component/src/input/inline_token_tag.rs +++ b/crates/component/src/input/inline_token_tag.rs @@ -42,10 +42,16 @@ impl RenderOnce for InlineTokenTag { .h(self.context.line_height()) .max_w(self.context.available_width()) .rounded(cx.theme().radius) - .bg(if self.context.is_selected() { - cx.theme().selection - } else { - cx.theme().muted + .border_1() + .map(|this| { + // The selection color is translucent as a fill; its opaque form + // is the matching border. + if self.context.is_selected() { + this.bg(cx.theme().selection) + .border_color(cx.theme().selection.alpha(1.)) + } else { + this.bg(cx.theme().muted).border_color(cx.theme().border) + } }) .text_color(cx.theme().foreground) .when(self.context.is_disabled(), |this| this.opacity(0.5)) diff --git a/crates/story/src/stories/input_tokens.rs b/crates/story/src/stories/input_tokens.rs index 47b3a8cd27..94fb229763 100644 --- a/crates/story/src/stories/input_tokens.rs +++ b/crates/story/src/stories/input_tokens.rs @@ -215,12 +215,10 @@ impl TokenExample { cx.notify(); } - fn render_token(token: &InlineTokenContext, cx: &App) -> InlineTokenTag { + fn render_token(token: &InlineTokenContext) -> InlineTokenTag { let reference = Reference::of(token.token()); InlineTokenTag::new(token) .when_some(reference, |tag, reference| tag.with_icon(reference.icon())) - .border_1() - .border_color(cx.theme().border) } fn insert_button(&self, reference: Reference, cx: &Context) -> InputGroupButton { @@ -250,7 +248,7 @@ impl TokenExample { .input( InputGroupInput::new(input) .aria_label("Message") - .render_token(|token, _, cx| Self::render_token(token, cx)) + .render_token(|token, _, _| Self::render_token(token)) .on_token_click(open), ) .addon( @@ -263,7 +261,7 @@ impl TokenExample { .input( InputGroupTextarea::new(input) .aria_label("Message") - .render_token(|token, _, cx| Self::render_token(token, cx)) + .render_token(|token, _, _| Self::render_token(token)) .on_token_click(open), ) .addon( From 2660ef10ab8f1d761ca698e7b4648f0d1e616e19 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 18 Sep 2026 20:53:16 +0800 Subject: [PATCH 07/11] input: Name the token element slot `token` Fluent builders in this library name the slot they fill (`trigger`, `content`, `empty`, `footer`) rather than describing the callback, so `render_token` becomes `token` on the Base and Component Input and Textarea, in the JavaScript elements and in the docs. Co-Authored-By: Claude Opus 5 (1M context) --- crates/base/src/input/base/state.rs | 4 ++-- crates/base/src/input/base/token_presentation.rs | 2 +- crates/base/src/input/input/mod.rs | 6 +++--- crates/base/src/input/textarea/mod.rs | 6 +++--- crates/component-shell/src/shell/input_tokens.rs | 8 ++++---- crates/component-shell/tests/inline_tokens_host.rs | 4 ++-- crates/component/src/input/input.rs | 4 ++-- crates/component/src/input/textarea.rs | 6 +++--- crates/shell/src/engine/quickjs/mod.rs | 6 +++--- crates/shell/src/input_tokens.rs | 6 +++--- crates/shell/src/materialize.rs | 10 ++++------ crates/shell/src/materialize/components/textarea.rs | 4 ++-- crates/shell/src/typings.rs | 4 ++-- crates/story/src/stories/input_tokens.rs | 6 +++--- examples/js_story/stories/registered.js | 2 +- website/base/primitives/input.md | 2 +- website/base/primitives/textarea.md | 2 +- website/component/input-group.md | 4 ++-- website/component/input.md | 7 ++++--- website/component/textarea.md | 2 +- website/zh-CN/base/primitives/input.md | 2 +- website/zh-CN/base/primitives/textarea.md | 2 +- website/zh-CN/component/input-group.md | 4 ++-- website/zh-CN/component/input.md | 4 ++-- website/zh-CN/component/textarea.md | 2 +- 25 files changed, 54 insertions(+), 55 deletions(-) diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index 6f3a427aa2..06638af857 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -4422,7 +4422,7 @@ mod tests { .unwrap(); state.set_token_presentation( InlineTokenPresentation::default() - .render_token(move |_, _, _| div().w(px(render_width.get()))), + .token(move |_, _, _| div().w(px(render_width.get()))), ); }); }) @@ -4465,7 +4465,7 @@ mod tests { .unwrap(); state.set_token_presentation( InlineTokenPresentation::default() - .render_token(|_, _, _| div().w(px(100.)).h(px(20.))) + .token(|_, _, _| div().w(px(100.)).h(px(20.))) .on_token_click(move |_, window, cx| { target.update(cx, |state, cx| { state.set_value("activated", window, cx) diff --git a/crates/base/src/input/base/token_presentation.rs b/crates/base/src/input/base/token_presentation.rs index 41fe1b9736..8ee215be41 100644 --- a/crates/base/src/input/base/token_presentation.rs +++ b/crates/base/src/input/base/token_presentation.rs @@ -74,7 +74,7 @@ pub(crate) struct InlineTokenPresentation { secret: bool, } impl InlineTokenPresentation { - pub(crate) fn render_token( + pub(crate) fn token( mut self, render: impl Fn(&InlineTokenContext, &mut Window, &mut App) -> R + 'static, ) -> Self { diff --git a/crates/base/src/input/input/mod.rs b/crates/base/src/input/input/mod.rs index 0dbeba6938..aca5d2e21f 100644 --- a/crates/base/src/input/input/mod.rs +++ b/crates/base/src/input/input/mod.rs @@ -26,12 +26,12 @@ impl Input { presentation: Default::default(), } } - /// Render each atomic token; the input keeps editing and history. - pub fn render_token( + /// The element each atomic token renders as; the input keeps editing and history. + pub fn token( mut self, render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, ) -> Self { - self.presentation = self.presentation.render_token(render); + self.presentation = self.presentation.token(render); self } /// Open a reference after a completed, unconsumed token click. diff --git a/crates/base/src/input/textarea/mod.rs b/crates/base/src/input/textarea/mod.rs index d0fbe22aff..cf0e5cd76e 100644 --- a/crates/base/src/input/textarea/mod.rs +++ b/crates/base/src/input/textarea/mod.rs @@ -23,12 +23,12 @@ impl Textarea { presentation: Default::default(), } } - /// Render each atomic token; the input keeps editing and history. - pub fn render_token( + /// The element each atomic token renders as; the input keeps editing and history. + pub fn token( mut self, render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, ) -> Self { - self.presentation = self.presentation.render_token(render); + self.presentation = self.presentation.token(render); self } /// Open a reference after a completed, unconsumed token click. diff --git a/crates/component-shell/src/shell/input_tokens.rs b/crates/component-shell/src/shell/input_tokens.rs index 96b4ada570..3da491b57f 100644 --- a/crates/component-shell/src/shell/input_tokens.rs +++ b/crates/component-shell/src/shell/input_tokens.rs @@ -30,7 +30,7 @@ macro_rules! dispatch { pub(super) fn methods(include_change: bool) -> Vec { let mut methods = vec![ MethodDescriptor::new( - "render_token", + "token", vec![ArgumentDescriptor::new( "render", ArgumentSchema::Callback( @@ -41,7 +41,7 @@ pub(super) fn methods(include_change: bool) -> Vec { [arg @ ComponentArgument::Callback(_)] => { Ok(ComponentPayload::new(Op::Render(arg.clone()))) } - _ => Err("render_token expects a renderer".into()), + _ => Err("token expects a renderer".into()), }, ) .with_documentation( @@ -104,7 +104,7 @@ impl Binding { pub(super) fn input(&self, input: Input) -> Input { self.callbacks.apply( input, - |input, render| input.render_token(move |token, window, cx| render(token, window, cx)), + |input, render| input.token(move |token, window, cx| render(token, window, cx)), |input, listen| { input.on_token_click(move |event, window, cx| listen(event, window, cx)) }, @@ -113,7 +113,7 @@ impl Binding { pub(super) fn textarea(&self, input: Textarea) -> Textarea { self.callbacks.apply( input, - |input, render| input.render_token(move |token, window, cx| render(token, window, cx)), + |input, render| input.token(move |token, window, cx| render(token, window, cx)), |input, listen| { input.on_token_click(move |event, window, cx| listen(event, window, cx)) }, diff --git a/crates/component-shell/tests/inline_tokens_host.rs b/crates/component-shell/tests/inline_tokens_host.rs index 381f992480..40a77b80e9 100644 --- a/crates/component-shell/tests/inline_tokens_host.rs +++ b/crates/component-shell/tests/inline_tokens_host.rs @@ -64,14 +64,14 @@ export default class TokenHost extends View { render() { return div().relative().w(400).h(260) .child(new Input(this.input).w(350).aria_label("Token input") - .render_token(token => div().w(80).h(20).child(token.token.label)) + .token(token => div().w(80).h(20).child(token.token.label)) .on_token_click((event, cx) => { assert(event.token.id === "a", "current identity"); this.input.set_value("opened"); this.status = "clicked"; cx.notify(); })) .child(new Textarea(this.textarea).w(350).h(60)) .child(new Input(this.child).absolute().top(140).left(0).w(350) - .render_token(token => div().flex().w(100).h(20).child(div().w(70).child(token.token.label)) + .token(token => div().flex().w(100).h(20).child(div().w(70).child(token.token.label)) .child(BaseButton.new("remove-token-child").w(30).h(20).child("×") .on_mouse_down("left", (_event, cx) => cx.stop_propagation()) .on_click((_event, cx) => { this.child.set_value("removed"); this.status = "child"; cx.stop_propagation(); cx.notify(); }))) diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index ca45dfa6e8..f6cf208ef5 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -172,10 +172,10 @@ impl crate::FocusableExt for Input { } impl Input { - /// Render each atomic inline token in place of the default + /// The element each atomic inline token renders as, in place of the default /// [`InlineTokenTag`](super::InlineTokenTag); editing and history stay /// with the input. - pub fn render_token( + pub fn token( mut self, render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, ) -> Self { diff --git a/crates/component/src/input/textarea.rs b/crates/component/src/input/textarea.rs index 1cf9f28cce..1fbdc67e6b 100644 --- a/crates/component/src/input/textarea.rs +++ b/crates/component/src/input/textarea.rs @@ -35,10 +35,10 @@ pub struct Textarea { } impl Textarea { - /// Render each atomic inline token in place of the default + /// The element each atomic inline token renders as, in place of the default /// [`InlineTokenTag`](super::InlineTokenTag); editing and history stay /// with the input. - pub fn render_token( + pub fn token( mut self, render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, ) -> Self { @@ -166,7 +166,7 @@ impl Textarea { pub(crate) fn into_input(self) -> Input { Input::from_state(self.state.clone()) .when_some(self.token_renderer, |this, render| { - this.render_token(move |token, window, cx| render(token, window, cx)) + this.token(move |token, window, cx| render(token, window, cx)) }) .when_some(self.token_click_listener, |this, listener| { this.on_token_click(move |event, window, cx| listener(event, window, cx)) diff --git a/crates/shell/src/engine/quickjs/mod.rs b/crates/shell/src/engine/quickjs/mod.rs index 78d4473d94..0cd58d0b48 100644 --- a/crates/shell/src/engine/quickjs/mod.rs +++ b/crates/shell/src/engine/quickjs/mod.rs @@ -7046,7 +7046,7 @@ impl ShellRuntime { "on_item_click", "on_item_secondary_click", "on_change", - "render_token", + "token", "on_token_click", "on_open_change", "on_confirm", @@ -8021,7 +8021,7 @@ impl ShellRuntime { | "on_link_click" | "on_resize" | "on_change" - | "render_token" + | "token" | "on_token_click" | "on_open_change" | "on_confirm" @@ -9969,7 +9969,7 @@ fn callback_op_name(method: &str) -> Option<&'static str> { "drop_indicator" => "drop_indicator", "dock" => "dock", "on_change" => "on_change", - "render_token" => "render_token", + "token" => "token", "on_token_click" => "on_token_click", "on_confirm" => "on_confirm", "on_dismiss" => "on_dismiss", diff --git a/crates/shell/src/input_tokens.rs b/crates/shell/src/input_tokens.rs index 75ff85c240..3d1b405300 100644 --- a/crates/shell/src/input_tokens.rs +++ b/crates/shell/src/input_tokens.rs @@ -278,7 +278,7 @@ state_binding!(InputState, invoke_input, input_token_state_methods); state_binding!(TextareaState, invoke_textarea, textarea_token_state_methods); /// Script callbacks for one input's tokens, adapted to the native -/// `render_token` / `on_token_click` builders of any Input or Textarea element. +/// `token` / `on_token_click` builders of any Input or Textarea element. pub struct InlineTokenCallbacks { renderer: Option, listener: Option, @@ -328,11 +328,11 @@ impl InlineTokenCallbacks { pub fn apply( &self, element: E, - render_token: impl FnOnce(E, gpui_base::input::InlineTokenRenderer) -> E, + token: impl FnOnce(E, gpui_base::input::InlineTokenRenderer) -> E, on_token_click: impl FnOnce(E, gpui_base::input::InlineTokenClickListener) -> E, ) -> E { let element = match &self.renderer { - Some(renderer) => render_token(element, renderer.clone()), + Some(renderer) => token(element, renderer.clone()), None => element, }; match &self.listener { diff --git a/crates/shell/src/materialize.rs b/crates/shell/src/materialize.rs index 6345776ef9..9c6b2fd5f9 100644 --- a/crates/shell/src/materialize.rs +++ b/crates/shell/src/materialize.rs @@ -334,7 +334,7 @@ struct Behavior { href: Option, on_click: Option, on_change: Option, - render_token: Option, + token: Option, on_token_click: Option, on_mouse_move: Option, on_hover: Option, @@ -1504,7 +1504,7 @@ fn materialize_component( let callbacks = crate::InlineTokenCallbacks::new( &state, behavior - .render_token + .token .map(|id| crate::ComponentElementCallback::from_runtime(runtime, id)), behavior .on_token_click @@ -1512,9 +1512,7 @@ fn materialize_component( ); let input = callbacks.apply( Input::new(&state), - |input, render| { - input.render_token(move |token, window, cx| render(token, window, cx)) - }, + |input, render| input.token(move |token, window, cx| render(token, window, cx)), |input, listen| { input.on_token_click(move |event, window, cx| listen(event, window, cx)) }, @@ -2514,7 +2512,7 @@ pub(in crate::materialize) fn resolve_ops( "on_scroll_wheel" => behavior.on_scroll_wheel = Some(*id), "on_resize" => behavior.on_resize = Some(*id), "on_change" => behavior.on_change = Some(*id), - "render_token" => behavior.render_token = Some(*id), + "token" => behavior.token = Some(*id), "on_token_click" => behavior.on_token_click = Some(*id), "on_step" => behavior.on_step = Some(*id), "on_open_change" => behavior.on_open_change = Some(*id), diff --git a/crates/shell/src/materialize/components/textarea.rs b/crates/shell/src/materialize/components/textarea.rs index d9939a7a73..85a645a51b 100644 --- a/crates/shell/src/materialize/components/textarea.rs +++ b/crates/shell/src/materialize/components/textarea.rs @@ -61,7 +61,7 @@ pub(in crate::materialize) fn textarea( let callbacks = crate::InlineTokenCallbacks::new( &state, behavior - .render_token + .token .map(|id| crate::ComponentElementCallback::from_runtime(runtime, id)), behavior .on_token_click @@ -69,7 +69,7 @@ pub(in crate::materialize) fn textarea( ); let textarea = callbacks.apply( Textarea::new(&state), - |input, render| input.render_token(move |token, window, cx| render(token, window, cx)), + |input, render| input.token(move |token, window, cx| render(token, window, cx)), |input, listen| input.on_token_click(move |event, window, cx| listen(event, window, cx)), ); frame.child(textarea).into_any_element() diff --git a/crates/shell/src/typings.rs b/crates/shell/src/typings.rs index 68c3e5d6ec..4192dc1360 100644 --- a/crates/shell/src/typings.rs +++ b/crates/shell/src/typings.rs @@ -215,7 +215,7 @@ pub(crate) fn declarations_with_components(components: &crate::FrozenComponentRe out.push_str(" /** The fluent builder returned by native and Base element factories. */\n"); out.push_str(" export interface NativeElement {\n"); out.push_str(ELEMENT_METHODS); - out.push_str(" render_token(render: (token: import(\"gpui-base\").InlineTokenContext, cx: Context) => Element | null): this;\n on_token_click(listener: (event: import(\"gpui-base\").InlineTokenClickEvent, cx: Context) => void): this;\n"); + out.push_str(" token(render: (token: import(\"gpui-base\").InlineTokenContext, cx: Context) => Element | null): this;\n on_token_click(listener: (event: import(\"gpui-base\").InlineTokenClickEvent, cx: Context) => void): this;\n"); out.push_str(¶metric_styles(¶metric)); out.push_str(&nullary_styles(&nullary)); out.push_str(" }\n"); @@ -3893,7 +3893,7 @@ mod tests { "controls_right", "when", "on_click", - "render_token", + "token", "on_token_click", "on_mouse_move", "on_hover", diff --git a/crates/story/src/stories/input_tokens.rs b/crates/story/src/stories/input_tokens.rs index 94fb229763..5350f7a254 100644 --- a/crates/story/src/stories/input_tokens.rs +++ b/crates/story/src/stories/input_tokens.rs @@ -215,7 +215,7 @@ impl TokenExample { cx.notify(); } - fn render_token(token: &InlineTokenContext) -> InlineTokenTag { + fn tag(token: &InlineTokenContext) -> InlineTokenTag { let reference = Reference::of(token.token()); InlineTokenTag::new(token) .when_some(reference, |tag, reference| tag.with_icon(reference.icon())) @@ -248,7 +248,7 @@ impl TokenExample { .input( InputGroupInput::new(input) .aria_label("Message") - .render_token(|token, _, _| Self::render_token(token)) + .token(|token, _, _| Self::tag(token)) .on_token_click(open), ) .addon( @@ -261,7 +261,7 @@ impl TokenExample { .input( InputGroupTextarea::new(input) .aria_label("Message") - .render_token(|token, _, _| Self::render_token(token)) + .token(|token, _, _| Self::tag(token)) .on_token_click(open), ) .addon( diff --git a/examples/js_story/stories/registered.js b/examples/js_story/stories/registered.js index e709011534..aad2c642f0 100644 --- a/examples/js_story/stories/registered.js +++ b/examples/js_story/stories/registered.js @@ -509,7 +509,7 @@ function tokenExample(multiline, cx) { const content = input.content(); const control = multiline ? new Textarea(/** @type {import("gpui-component").TextareaState} */ (input)).w_full().h(100) - .render_token(token => h_flex().gap(4).px(4).h(token.line_height) + .token(token => h_flex().gap(4).px(4).h(token.line_height) .child("◆").child(token.token.label ?? token.token.text)) : new Input(/** @type {import("gpui-component").InputState} */ (input)).w_full(); return { diff --git a/website/base/primitives/input.md b/website/base/primitives/input.md index da5a1896d3..e1b66d2609 100644 --- a/website/base/primitives/input.md +++ b/website/base/primitives/input.md @@ -104,7 +104,7 @@ input.update(cx, |state, cx| { }); ``` -Tokens display as unstyled labels. Use `render_token` to supply your own single-row +Tokens display as unstyled labels. Use the `token` slot to supply your own single-row element and `on_token_click` to open a reference. Copy and `value()` return the real text, such as `@alice`. Save drafts with `content()` and restore them with `set_value(content)` to keep their references. diff --git a/website/base/primitives/textarea.md b/website/base/primitives/textarea.md index 886b5f8d39..ebbe3b720d 100644 --- a/website/base/primitives/textarea.md +++ b/website/base/primitives/textarea.md @@ -89,7 +89,7 @@ notes.update(cx, |state, cx| { }); ``` -Tokens display as unstyled labels. Use `render_token` to supply your own single-row +Tokens display as unstyled labels. Use the `token` slot to supply your own single-row element and `on_token_click` to open a reference. Copy and `value()` return the real text, such as `@alice`. Save drafts with `content()` and restore them with `set_value(content)` to keep their references. diff --git a/website/component/input-group.md b/website/component/input-group.md index bd990ec75a..e15b846b2d 100644 --- a/website/component/input-group.md +++ b/website/component/input-group.md @@ -372,9 +372,9 @@ use gpui_kit::component::{ InputGroup::new("composer") .input(Textarea::new(&state) - .render_token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File))) + .token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File))) ``` -The JavaScript group controls also expose `render_token` and `on_token_click`. +The JavaScript group controls also expose `token` and `on_token_click`. Retain the same input state across redraws; call `set_value` with saved content to restore a draft. See [atomic inline tokens](./input.md#atomic-inline-tokens). diff --git a/website/component/input.md b/website/component/input.md index ae31b368ef..c567b1bf9e 100644 --- a/website/component/input.md +++ b/website/component/input.md @@ -374,8 +374,9 @@ Pasting inserts plain text. ### Customize appearance and opening a reference -Tokens render as an `InlineTokenTag` by default. To add an icon, return one from -`render_token`, and use `on_token_click` to open the reference: +Tokens render as an `InlineTokenTag` by default. The `token` slot supplies the +element for each token; return a tag with an icon from it, and use +`on_token_click` to open the reference: ```rust use gpui_kit::component::{ @@ -384,7 +385,7 @@ use gpui_kit::component::{ }; Input::new(&input) - .render_token(|token, _, _| { + .token(|token, _, _| { InlineTokenTag::new(token).with_icon(IconName::File) }) .on_token_click(|event: &InlineTokenClickEvent, _, _| { diff --git a/website/component/textarea.md b/website/component/textarea.md index 3f6998d355..e1d2a2f284 100644 --- a/website/component/textarea.md +++ b/website/component/textarea.md @@ -89,7 +89,7 @@ when you want an icon or other custom content: ```rust Textarea::new(&state) - .render_token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File)) + .token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File)) ``` Import `InlineTokenTag` from `gpui_kit::component::input` and `IconName` from diff --git a/website/zh-CN/base/primitives/input.md b/website/zh-CN/base/primitives/input.md index 50fbe29556..b328e7b65e 100644 --- a/website/zh-CN/base/primitives/input.md +++ b/website/zh-CN/base/primitives/input.md @@ -80,6 +80,6 @@ input.update(cx, |state, cx| { }); ``` -token 默认显示为无装饰标签。使用 `render_token` 提供自己的单行元素,通过 `on_token_click` 打开引用。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_value(content)` 恢复草稿,可以保留其中的引用。 +token 默认显示为无装饰标签。通过 `token` 槽位提供自己的单行元素,通过 `on_token_click` 打开引用。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_value(content)` 恢复草稿,可以保留其中的引用。 自定义展示、草稿恢复和范围单位见 [Input 的 token 示例](../../component/input.md#原子行内-token)。数据类型从 `gpui_kit::base::input` 导入。JavaScript 使用 `gpui-base` 中的 `InputState.new()`,它提供相同的 token 方法。 diff --git a/website/zh-CN/base/primitives/textarea.md b/website/zh-CN/base/primitives/textarea.md index 27c21406af..c795327443 100644 --- a/website/zh-CN/base/primitives/textarea.md +++ b/website/zh-CN/base/primitives/textarea.md @@ -75,6 +75,6 @@ notes.update(cx, |state, cx| { }); ``` -token 默认显示为无装饰标签。使用 `render_token` 提供自己的单行元素,通过 `on_token_click` 打开引用。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_value(content)` 恢复草稿,可以保留其中的引用。 +token 默认显示为无装饰标签。通过 `token` 槽位提供自己的单行元素,通过 `on_token_click` 打开引用。复制和 `value()` 返回 `@alice` 这样的真实文本。用 `content()` 保存草稿、`set_value(content)` 恢复草稿,可以保留其中的引用。 自定义展示、草稿恢复和范围单位见 [Input 的 token 示例](../../component/input.md#原子行内-token)。数据类型从 `gpui_kit::base::input` 导入。JavaScript 使用 `gpui-base` 中的 `TextareaState.new()`,它提供相同的 token 方法。 diff --git a/website/zh-CN/component/input-group.md b/website/zh-CN/component/input-group.md index e1521ec02d..394814086e 100644 --- a/website/zh-CN/component/input-group.md +++ b/website/zh-CN/component/input-group.md @@ -354,7 +354,7 @@ use gpui_kit::component::{ InputGroup::new("composer") .input(Textarea::new(&state) - .render_token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File))) + .token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File))) ``` -JavaScript 的分组输入也提供 `render_token` 和 `on_token_click`。重绘时保留原输入状态,需要恢复草稿时用保存的 content 调用 `set_value`。详见[原子行内 token](./input.md#原子行内-token)。 +JavaScript 的分组输入也提供 `token` 和 `on_token_click`。重绘时保留原输入状态,需要恢复草稿时用保存的 content 调用 `set_value`。详见[原子行内 token](./input.md#原子行内-token)。 diff --git a/website/zh-CN/component/input.md b/website/zh-CN/component/input.md index 15b9c59e36..06d6817202 100644 --- a/website/zh-CN/component/input.md +++ b/website/zh-CN/component/input.md @@ -312,7 +312,7 @@ ID 标识被引用的资源,同一个人被提及两次时两个 token 使用 ### 自定义外观与打开引用 -token 默认渲染为 `InlineTokenTag`。需要图标时,让 `render_token` 返回自定义的 tag,并通过 `on_token_click` 打开引用: +token 默认渲染为 `InlineTokenTag`。`token` 槽位提供每个 token 的元素;需要图标时在其中返回带图标的 tag,并通过 `on_token_click` 打开引用: ```rust use gpui_kit::component::{ @@ -321,7 +321,7 @@ use gpui_kit::component::{ }; Input::new(&input) - .render_token(|token, _, _| { + .token(|token, _, _| { InlineTokenTag::new(token).with_icon(IconName::File) }) .on_token_click(|event: &InlineTokenClickEvent, _, _| { diff --git a/website/zh-CN/component/textarea.md b/website/zh-CN/component/textarea.md index 6b78f6023c..e3274691bc 100644 --- a/website/zh-CN/component/textarea.md +++ b/website/zh-CN/component/textarea.md @@ -80,7 +80,7 @@ Textarea::new(¬es) ```rust Textarea::new(&state) - .render_token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File)) + .token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File)) ``` 从 `gpui_kit::component::input` 导入 `InlineTokenTag`,从 `gpui_kit::component` 导入 `IconName`。token 会整块换到下一行,自动增高会相应调整输入框高度。换行符应放在 token 之间的普通文本中。编辑、激活、草稿保存、模式限制和 JavaScript API 见 [Input:原子行内 token](./input.md#原子行内-token)。 From 54181dd2fe40aabd2b5b402ea0f69a785b6a05c1 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 18 Sep 2026 21:19:34 +0800 Subject: [PATCH 08/11] input: Name the default token element InputToken Component parts are named after the control they belong to (InputGroupAddon, DialogFooter, ListItem), so the element a token renders as is `InputToken` rather than a web noun. Co-Authored-By: Claude Opus 5 (1M context) --- crates/component/src/input/input.rs | 4 ++-- crates/component/src/input/mod.rs | 4 ++-- crates/component/src/input/textarea.rs | 2 +- .../input/{inline_token_tag.rs => token.rs} | 12 ++++++------ crates/story/src/stories/input_tokens.rs | 18 ++++++++++-------- website/component/input-group.md | 4 ++-- website/component/input.md | 10 +++++----- website/component/textarea.md | 4 ++-- website/zh-CN/component/input-group.md | 4 ++-- website/zh-CN/component/input.md | 8 ++++---- website/zh-CN/component/textarea.md | 4 ++-- 11 files changed, 38 insertions(+), 36 deletions(-) rename crates/component/src/input/{inline_token_tag.rs => token.rs} (89%) diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index f6cf208ef5..6e6ba55673 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -173,7 +173,7 @@ impl crate::FocusableExt for Input { impl Input { /// The element each atomic inline token renders as, in place of the default - /// [`InlineTokenTag`](super::InlineTokenTag); editing and history stay + /// [`InputToken`](super::InputToken); editing and history stay /// with the input. pub fn token( mut self, @@ -516,7 +516,7 @@ impl RenderOnce for Input { let state = self.state.clone(); state.install_token_presentation( Some(self.token_renderer.unwrap_or_else(|| { - Rc::new(|token, _, _| super::InlineTokenTag::new(token).into_any_element()) + Rc::new(|token, _, _| super::InputToken::new(token).into_any_element()) })), self.token_click_listener, matches!( diff --git a/crates/component/src/input/mod.rs b/crates/component/src/input/mod.rs index 6ca6709be9..8cbf95b19b 100644 --- a/crates/component/src/input/mod.rs +++ b/crates/component/src/input/mod.rs @@ -1,12 +1,12 @@ mod clear_button; mod content_type; -mod inline_token_tag; mod input; +mod token; pub use gpui_base::input::{ ActivateToken, InlineToken, InlineTokenClickEvent, InlineTokenContext, InlineTokenError, InlineTokenSpan, InputContent, }; -pub use inline_token_tag::InlineTokenTag; +pub use token::InputToken; pub mod language_config; mod number_input; mod otp_input; diff --git a/crates/component/src/input/textarea.rs b/crates/component/src/input/textarea.rs index 1fbdc67e6b..77b3cb885a 100644 --- a/crates/component/src/input/textarea.rs +++ b/crates/component/src/input/textarea.rs @@ -36,7 +36,7 @@ pub struct Textarea { impl Textarea { /// The element each atomic inline token renders as, in place of the default - /// [`InlineTokenTag`](super::InlineTokenTag); editing and history stay + /// [`InputToken`](super::InputToken); editing and history stay /// with the input. pub fn token( mut self, diff --git a/crates/component/src/input/inline_token_tag.rs b/crates/component/src/input/token.rs similarity index 89% rename from crates/component/src/input/inline_token_tag.rs rename to crates/component/src/input/token.rs index 9d99a8295c..2650cb5368 100644 --- a/crates/component/src/input/inline_token_tag.rs +++ b/crates/component/src/input/token.rs @@ -5,15 +5,15 @@ use gpui::{ Styled, Window, div, prelude::FluentBuilder as _, }; -/// The default tag an [`InlineToken`](super::InlineToken) renders as. Editing +/// The element an [`InlineToken`](super::InlineToken) renders as by default. Editing /// and activation belong to the input. #[derive(IntoElement)] -pub struct InlineTokenTag { +pub struct InputToken { context: InlineTokenContext, icon: Option, style: StyleRefinement, } -impl InlineTokenTag { +impl InputToken { pub fn new(context: &InlineTokenContext) -> Self { Self { context: context.clone(), @@ -26,15 +26,15 @@ impl InlineTokenTag { self } } -impl Styled for InlineTokenTag { +impl Styled for InputToken { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } -impl RenderOnce for InlineTokenTag { +impl RenderOnce for InputToken { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { div() - .id("inline-token-tag") + .id("input-token") .flex() .items_center() .gap_1() diff --git a/crates/story/src/stories/input_tokens.rs b/crates/story/src/stories/input_tokens.rs index 5350f7a254..8e35be1034 100644 --- a/crates/story/src/stories/input_tokens.rs +++ b/crates/story/src/stories/input_tokens.rs @@ -8,9 +8,10 @@ use gpui_kit::component::{ checkbox::Checkbox, h_flex, input::{ - InlineToken, InlineTokenClickEvent, InlineTokenContext, InlineTokenError, InlineTokenTag, - InputContent, InputEvent, InputGroup, InputGroupAddon, InputGroupAddonAlignment as Align, - InputGroupButton, InputGroupInput, InputGroupTextarea, InputState, TextareaState, + InlineToken, InlineTokenClickEvent, InlineTokenContext, InlineTokenError, InputContent, + InputEvent, InputGroup, InputGroupAddon, InputGroupAddonAlignment as Align, + InputGroupButton, InputGroupInput, InputGroupTextarea, InputState, InputToken, + TextareaState, }, v_flex, }; @@ -215,10 +216,11 @@ impl TokenExample { cx.notify(); } - fn tag(token: &InlineTokenContext) -> InlineTokenTag { + fn token(token: &InlineTokenContext) -> InputToken { let reference = Reference::of(token.token()); - InlineTokenTag::new(token) - .when_some(reference, |tag, reference| tag.with_icon(reference.icon())) + InputToken::new(token).when_some(reference, |element, reference| { + element.with_icon(reference.icon()) + }) } fn insert_button(&self, reference: Reference, cx: &Context) -> InputGroupButton { @@ -248,7 +250,7 @@ impl TokenExample { .input( InputGroupInput::new(input) .aria_label("Message") - .token(|token, _, _| Self::tag(token)) + .token(|token, _, _| Self::token(token)) .on_token_click(open), ) .addon( @@ -261,7 +263,7 @@ impl TokenExample { .input( InputGroupTextarea::new(input) .aria_label("Message") - .token(|token, _, _| Self::tag(token)) + .token(|token, _, _| Self::token(token)) .on_token_click(open), ) .addon( diff --git a/website/component/input-group.md b/website/component/input-group.md index e15b846b2d..4760ad6e2e 100644 --- a/website/component/input-group.md +++ b/website/component/input-group.md @@ -367,12 +367,12 @@ Textarea containing tokens to `InputGroup`. You can customize its labels as usua ```rust use gpui_kit::component::{ IconName, - input::{InlineTokenTag, InputGroup, Textarea}, + input::{InputToken, InputGroup, Textarea}, }; InputGroup::new("composer") .input(Textarea::new(&state) - .token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File))) + .token(|token, _, _| InputToken::new(token).with_icon(IconName::File))) ``` The JavaScript group controls also expose `token` and `on_token_click`. diff --git a/website/component/input.md b/website/component/input.md index c567b1bf9e..922e337a2a 100644 --- a/website/component/input.md +++ b/website/component/input.md @@ -374,19 +374,19 @@ Pasting inserts plain text. ### Customize appearance and opening a reference -Tokens render as an `InlineTokenTag` by default. The `token` slot supplies the -element for each token; return a tag with an icon from it, and use +Tokens render as an `InputToken` by default. The `token` slot supplies the +element for each token; return one with an icon from it, and use `on_token_click` to open the reference: ```rust use gpui_kit::component::{ IconName, - input::{InlineTokenTag, InlineTokenClickEvent}, + input::{InputToken, InlineTokenClickEvent}, }; Input::new(&input) .token(|token, _, _| { - InlineTokenTag::new(token).with_icon(IconName::File) + InputToken::new(token).with_icon(IconName::File) }) .on_token_click(|event: &InlineTokenClickEvent, _, _| { // Look up event.token().id() and open its resource. @@ -397,7 +397,7 @@ You can also return your own single-row element. Keep it within the input's line height; content wider than the available row is clipped. Read selection and read-only/disabled state from the renderer's context. Do not edit the input from the renderer; event callbacks may update it. Keep hover and selection styles the -same size. Tokens are measured whenever they render, so a tag that grows once +same size. Tokens are measured whenever they render, so an element that grows once its data arrives reflows on the next frame. A click selects the token and then opens it; dragging or Shift-selecting a token diff --git a/website/component/textarea.md b/website/component/textarea.md index e1d2a2f284..a23d7a75e5 100644 --- a/website/component/textarea.md +++ b/website/component/textarea.md @@ -89,10 +89,10 @@ when you want an icon or other custom content: ```rust Textarea::new(&state) - .token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File)) + .token(|token, _, _| InputToken::new(token).with_icon(IconName::File)) ``` -Import `InlineTokenTag` from `gpui_kit::component::input` and `IconName` from +Import `InputToken` from `gpui_kit::component::input` and `IconName` from `gpui_kit::component`. A token wraps onto the next line as a whole, and auto-grow adjusts the textarea height to fit. Put line breaks in the text between tokens. See [Input: atomic inline tokens](./input.md#atomic-inline-tokens) for editing, diff --git a/website/zh-CN/component/input-group.md b/website/zh-CN/component/input-group.md index 394814086e..e5b3aa52fd 100644 --- a/website/zh-CN/component/input-group.md +++ b/website/zh-CN/component/input-group.md @@ -349,12 +349,12 @@ new InputGroupButton("clear").label("清空").icon("icons/x.svg").font_semibold( ```rust use gpui_kit::component::{ IconName, - input::{InlineTokenTag, InputGroup, Textarea}, + input::{InputToken, InputGroup, Textarea}, }; InputGroup::new("composer") .input(Textarea::new(&state) - .token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File))) + .token(|token, _, _| InputToken::new(token).with_icon(IconName::File))) ``` JavaScript 的分组输入也提供 `token` 和 `on_token_click`。重绘时保留原输入状态,需要恢复草稿时用保存的 content 调用 `set_value`。详见[原子行内 token](./input.md#原子行内-token)。 diff --git a/website/zh-CN/component/input.md b/website/zh-CN/component/input.md index 06d6817202..1459bd6bcb 100644 --- a/website/zh-CN/component/input.md +++ b/website/zh-CN/component/input.md @@ -312,24 +312,24 @@ ID 标识被引用的资源,同一个人被提及两次时两个 token 使用 ### 自定义外观与打开引用 -token 默认渲染为 `InlineTokenTag`。`token` 槽位提供每个 token 的元素;需要图标时在其中返回带图标的 tag,并通过 `on_token_click` 打开引用: +token 默认渲染为 `InputToken`。`token` 槽位提供每个 token 的元素;需要图标时在其中返回带图标的 `InputToken`,并通过 `on_token_click` 打开引用: ```rust use gpui_kit::component::{ IconName, - input::{InlineTokenTag, InlineTokenClickEvent}, + input::{InputToken, InlineTokenClickEvent}, }; Input::new(&input) .token(|token, _, _| { - InlineTokenTag::new(token).with_icon(IconName::File) + InputToken::new(token).with_icon(IconName::File) }) .on_token_click(|event: &InlineTokenClickEvent, _, _| { // 根据 event.token().id() 查找并打开资源。 }); ``` -也可以返回自己的单行元素。元素应保持在输入框行高内,超出可用行宽的内容会被裁切。通过 renderer 的上下文读取选中、只读和禁用状态。在事件回调中修改输入,不要在 renderer 中修改。悬停和选中样式应保持尺寸稳定。token 每次渲染时都会重新测量,因此数据到达后变宽的 tag 会在下一帧重新排版。 +也可以返回自己的单行元素。元素应保持在输入框行高内,超出可用行宽的内容会被裁切。通过 renderer 的上下文读取选中、只读和禁用状态。在事件回调中修改输入,不要在 renderer 中修改。悬停和选中样式应保持尺寸稳定。token 每次渲染时都会重新测量,因此数据到达后变宽的元素会在下一帧重新排版。 单击会先选中 token 再打开引用;拖选或 Shift 扩选不会打开引用。只读输入允许打开引用,禁用输入不允许。若要为打开选中的整块 token 提供快捷键,可以将 `ActivateToken` 绑定到自己选择的按键;辅助技术通过 token 的 click 操作触发同一个监听器。当应用能为引用给出明确名称(例如“打开文件”)时,可以通过 `context_menu` 自行添加菜单项。 diff --git a/website/zh-CN/component/textarea.md b/website/zh-CN/component/textarea.md index e3274691bc..76fc1c8168 100644 --- a/website/zh-CN/component/textarea.md +++ b/website/zh-CN/component/textarea.md @@ -80,7 +80,7 @@ Textarea::new(¬es) ```rust Textarea::new(&state) - .token(|token, _, _| InlineTokenTag::new(token).with_icon(IconName::File)) + .token(|token, _, _| InputToken::new(token).with_icon(IconName::File)) ``` -从 `gpui_kit::component::input` 导入 `InlineTokenTag`,从 `gpui_kit::component` 导入 `IconName`。token 会整块换到下一行,自动增高会相应调整输入框高度。换行符应放在 token 之间的普通文本中。编辑、激活、草稿保存、模式限制和 JavaScript API 见 [Input:原子行内 token](./input.md#原子行内-token)。 +从 `gpui_kit::component::input` 导入 `InputToken`,从 `gpui_kit::component` 导入 `IconName`。token 会整块换到下一行,自动增高会相应调整输入框高度。换行符应放在 token 之间的普通文本中。编辑、激活、草稿保存、模式限制和 JavaScript API 见 [Input:原子行内 token](./input.md#原子行内-token)。 From 7c04b4b50d06293fcf1a6b6249170b05449b7d2d Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 18 Sep 2026 21:31:09 +0800 Subject: [PATCH 09/11] input: Align the token element and click event with library naming `InputToken::icon` follows every other element's icon builder instead of `with_icon`, `InlineTokenClickEvent::click` names the click it carries, and the JavaScript token states drop the undocumented `refresh()` now that tokens re-measure on their own. Co-Authored-By: Claude Opus 5 (1M context) --- crates/base/src/input/base/token_presentation.rs | 4 +++- crates/component/src/input/token.rs | 2 +- crates/shell/src/engine/quickjs/mod.rs | 2 +- crates/shell/src/input_tokens.rs | 7 +------ crates/shell/src/typings.rs | 2 -- crates/story/src/stories/input_tokens.rs | 2 +- website/component/input-group.md | 2 +- website/component/input.md | 2 +- website/component/textarea.md | 2 +- website/zh-CN/component/input-group.md | 2 +- website/zh-CN/component/input.md | 2 +- website/zh-CN/component/textarea.md | 2 +- 12 files changed, 13 insertions(+), 18 deletions(-) diff --git a/crates/base/src/input/base/token_presentation.rs b/crates/base/src/input/base/token_presentation.rs index 8ee215be41..58969eee5e 100644 --- a/crates/base/src/input/base/token_presentation.rs +++ b/crates/base/src/input/base/token_presentation.rs @@ -54,7 +54,9 @@ impl InlineTokenClickEvent { pub fn bounds(&self) -> Bounds { self.bounds } - pub fn event(&self) -> &ClickEvent { + /// The click that opened the token; keyboard activation reports a + /// keyboard click. + pub fn click(&self) -> &ClickEvent { &self.event } } diff --git a/crates/component/src/input/token.rs b/crates/component/src/input/token.rs index 2650cb5368..0cec719634 100644 --- a/crates/component/src/input/token.rs +++ b/crates/component/src/input/token.rs @@ -21,7 +21,7 @@ impl InputToken { style: Default::default(), } } - pub fn with_icon(mut self, icon: impl Into) -> Self { + pub fn icon(mut self, icon: impl Into) -> Self { self.icon = Some(icon.into()); self } diff --git a/crates/shell/src/engine/quickjs/mod.rs b/crates/shell/src/engine/quickjs/mod.rs index 0cd58d0b48..8f1162ece4 100644 --- a/crates/shell/src/engine/quickjs/mod.rs +++ b/crates/shell/src/engine/quickjs/mod.rs @@ -6015,7 +6015,7 @@ globalThis.__gpui = (() => { // to read it back off `this`. const tokenStateMethods = (handle, invoke) => Object.fromEntries([ "content", "tokens", "replace_with_token", "replace_range_with_token", - "refresh", "set_selected_range", "replace" + "set_selected_range", "replace" ].map(name => [name, (...args) => invoke(handle, name, args)])); // A value is plain text or a content snapshot with tokens. const setValue = (handle, setText, invoke) => (next) => diff --git a/crates/shell/src/input_tokens.rs b/crates/shell/src/input_tokens.rs index 3d1b405300..241c7ee9fc 100644 --- a/crates/shell/src/input_tokens.rs +++ b/crates/shell/src/input_tokens.rs @@ -163,7 +163,7 @@ pub fn inline_token_context_data(token: &InlineTokenContext, text: &Rope) -> Dat } /// Plain JS activation event, with current token identity and pointer modifiers. pub fn inline_token_click_data(event: &InlineTokenClickEvent, text: &Rope) -> Data { - let modifiers = event.event().modifiers(); + let modifiers = event.click().modifiers(); let bounds = event.bounds(); object([ ("token", token_data(event.token())), @@ -200,7 +200,6 @@ pub(crate) const METHODS: &[(&str, &str, bool)] = &[ "(range: InputRange, token: InlineToken): void", false, ), - ("refresh", "(): void", false), ("set_selected_range", "(range: InputRange): void", false), ("replace", "(text: string): void", false), ]; @@ -241,10 +240,6 @@ macro_rules! state_binding { })?; Ok(Data::Null) } - ("refresh", []) => { - entity.update(cx, |state, cx| state.refresh(cx)); - Ok(Data::Null) - } ("set_selected_range", [value]) => { let range = range(&entity.read(cx).value(), value)?; entity.update(cx, |state, cx| state.set_selected_range(range, cx)); diff --git a/crates/shell/src/typings.rs b/crates/shell/src/typings.rs index 4192dc1360..f94af8a304 100644 --- a/crates/shell/src/typings.rs +++ b/crates/shell/src/typings.rs @@ -3001,7 +3001,6 @@ const BASE: &str = r#" /** A row. */ tokens(): InlineTokenSpan[]; replace_with_token(token: InlineToken): void; replace_range_with_token(range: InputRange, token: InlineToken): void; - refresh(): void; set_selected_range(range: InputRange): void; replace(text: string): void; value(): string; @@ -3069,7 +3068,6 @@ const BASE: &str = r#" /** A row. */ tokens(): InlineTokenSpan[]; replace_with_token(token: InlineToken): void; replace_range_with_token(range: InputRange, token: InlineToken): void; - refresh(): void; set_selected_range(range: InputRange): void; replace(text: string): void; value(): string; diff --git a/crates/story/src/stories/input_tokens.rs b/crates/story/src/stories/input_tokens.rs index 8e35be1034..ee2a08f490 100644 --- a/crates/story/src/stories/input_tokens.rs +++ b/crates/story/src/stories/input_tokens.rs @@ -219,7 +219,7 @@ impl TokenExample { fn token(token: &InlineTokenContext) -> InputToken { let reference = Reference::of(token.token()); InputToken::new(token).when_some(reference, |element, reference| { - element.with_icon(reference.icon()) + element.icon(reference.icon()) }) } diff --git a/website/component/input-group.md b/website/component/input-group.md index 4760ad6e2e..039f770fa9 100644 --- a/website/component/input-group.md +++ b/website/component/input-group.md @@ -372,7 +372,7 @@ use gpui_kit::component::{ InputGroup::new("composer") .input(Textarea::new(&state) - .token(|token, _, _| InputToken::new(token).with_icon(IconName::File))) + .token(|token, _, _| InputToken::new(token).icon(IconName::File))) ``` The JavaScript group controls also expose `token` and `on_token_click`. diff --git a/website/component/input.md b/website/component/input.md index 922e337a2a..58a206d514 100644 --- a/website/component/input.md +++ b/website/component/input.md @@ -386,7 +386,7 @@ use gpui_kit::component::{ Input::new(&input) .token(|token, _, _| { - InputToken::new(token).with_icon(IconName::File) + InputToken::new(token).icon(IconName::File) }) .on_token_click(|event: &InlineTokenClickEvent, _, _| { // Look up event.token().id() and open its resource. diff --git a/website/component/textarea.md b/website/component/textarea.md index a23d7a75e5..145fa2c96e 100644 --- a/website/component/textarea.md +++ b/website/component/textarea.md @@ -89,7 +89,7 @@ when you want an icon or other custom content: ```rust Textarea::new(&state) - .token(|token, _, _| InputToken::new(token).with_icon(IconName::File)) + .token(|token, _, _| InputToken::new(token).icon(IconName::File)) ``` Import `InputToken` from `gpui_kit::component::input` and `IconName` from diff --git a/website/zh-CN/component/input-group.md b/website/zh-CN/component/input-group.md index e5b3aa52fd..947ee7fb3f 100644 --- a/website/zh-CN/component/input-group.md +++ b/website/zh-CN/component/input-group.md @@ -354,7 +354,7 @@ use gpui_kit::component::{ InputGroup::new("composer") .input(Textarea::new(&state) - .token(|token, _, _| InputToken::new(token).with_icon(IconName::File))) + .token(|token, _, _| InputToken::new(token).icon(IconName::File))) ``` JavaScript 的分组输入也提供 `token` 和 `on_token_click`。重绘时保留原输入状态,需要恢复草稿时用保存的 content 调用 `set_value`。详见[原子行内 token](./input.md#原子行内-token)。 diff --git a/website/zh-CN/component/input.md b/website/zh-CN/component/input.md index 1459bd6bcb..862130fb79 100644 --- a/website/zh-CN/component/input.md +++ b/website/zh-CN/component/input.md @@ -322,7 +322,7 @@ use gpui_kit::component::{ Input::new(&input) .token(|token, _, _| { - InputToken::new(token).with_icon(IconName::File) + InputToken::new(token).icon(IconName::File) }) .on_token_click(|event: &InlineTokenClickEvent, _, _| { // 根据 event.token().id() 查找并打开资源。 diff --git a/website/zh-CN/component/textarea.md b/website/zh-CN/component/textarea.md index 76fc1c8168..a467c87190 100644 --- a/website/zh-CN/component/textarea.md +++ b/website/zh-CN/component/textarea.md @@ -80,7 +80,7 @@ Textarea::new(¬es) ```rust Textarea::new(&state) - .token(|token, _, _| InputToken::new(token).with_icon(IconName::File)) + .token(|token, _, _| InputToken::new(token).icon(IconName::File)) ``` 从 `gpui_kit::component::input` 导入 `InputToken`,从 `gpui_kit::component` 导入 `IconName`。token 会整块换到下一行,自动增高会相应调整输入框高度。换行符应放在 token 之间的普通文本中。编辑、激活、草稿保存、模式限制和 JavaScript API 见 [Input:原子行内 token](./input.md#原子行内-token)。 From 2ea60fe9ecbe475c0c2a7bffac9c081af407eeba Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 18 Sep 2026 21:35:19 +0800 Subject: [PATCH 10/11] docs: Spell the readonly state as one word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Coding Guides now say the state is `readonly` everywhere — identifiers, labels and prose — matching the builder and reader, not `read-only` or `read only`. The token Story and docs, the Editor story's menu item and a Shell test name follow. Co-Authored-By: Claude Opus 5 (1M context) --- crates/shell/src/tests/render.rs | 2 +- crates/story/src/stories/editor_story.rs | 2 +- crates/story/src/stories/input_tokens.rs | 2 +- skills/gpui-kit/references/coding-guides.md | 6 ++++-- website/component/input.md | 6 +++--- website/docs/coding-guides.md | 6 ++++-- website/zh-CN/docs/coding-guides.md | 2 +- 7 files changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/shell/src/tests/render.rs b/crates/shell/src/tests/render.rs index 3bb9bca423..3c6d682ed7 100644 --- a/crates/shell/src/tests/render.rs +++ b/crates/shell/src/tests/render.rs @@ -2539,7 +2539,7 @@ export default class Themed extends View { } #[gpui::test] -fn render_context_theme_snapshot_is_deeply_read_only(cx: &mut TestAppContext) { +fn render_context_theme_snapshot_is_deeply_readonly(cx: &mut TestAppContext) { cx.update(crate::init); let runtime = ShellRuntime::new_isolated().expect("runtime"); cx.update(|cx| runtime.set_global(cx)); diff --git a/crates/story/src/stories/editor_story.rs b/crates/story/src/stories/editor_story.rs index 75dc6b2136..fa1763a9ef 100644 --- a/crates/story/src/stories/editor_story.rs +++ b/crates/story/src/stories/editor_story.rs @@ -159,7 +159,7 @@ impl EditorStory { story_toolbar_group().dropdown_child( Button::new("editor-options").label("Options"), move |menu, window, _| { - let menu = menu.item(PopupMenuItem::new("Read only").checked(readonly).on_click( + let menu = menu.item(PopupMenuItem::new("Readonly").checked(readonly).on_click( window.listener_for(&story, |this, _, _, cx| { this.readonly = !this.readonly; cx.notify(); diff --git a/crates/story/src/stories/input_tokens.rs b/crates/story/src/stories/input_tokens.rs index ee2a08f490..4ac89cea25 100644 --- a/crates/story/src/stories/input_tokens.rs +++ b/crates/story/src/stories/input_tokens.rs @@ -349,7 +349,7 @@ impl Render for TokenExample { .child( Checkbox::new("tokens-readonly") .small() - .label("Read-only") + .label("Readonly") .checked(self.readonly) .on_click(cx.listener(|this, value, _, cx| { this.readonly = *value; diff --git a/skills/gpui-kit/references/coding-guides.md b/skills/gpui-kit/references/coding-guides.md index 7f67f4ffd0..e3b89fae69 100644 --- a/skills/gpui-kit/references/coding-guides.md +++ b/skills/gpui-kit/references/coding-guides.md @@ -726,9 +726,11 @@ could only hint at. activation result. Never use them interchangeably. - **open/close** describes an overlay or disclosure state; **show/hide** is for transient presentation requests; **expand/collapse** describes structure. -- **disabled** prevents interaction; **read-only** permits navigation and +- **disabled** prevents interaction; **readonly** permits navigation and selection but prevents editing; **loading** prevents duplicate work while an - operation is pending. + operation is pending. Spell the state `readonly` — one word, as the + `readonly(bool)` builder and `is_readonly()` reader do — in identifiers, + interface labels, and documentation alike; never `read-only` or `read only`. - **index** is a current positional coordinate; **id** is stable identity; `IndexPath` represents hierarchical position. Do not persist or key reorderable data by index. diff --git a/website/component/input.md b/website/component/input.md index 58a206d514..02f12b3393 100644 --- a/website/component/input.md +++ b/website/component/input.md @@ -395,13 +395,13 @@ Input::new(&input) You can also return your own single-row element. Keep it within the input's line height; content wider than the available row is clipped. Read selection and -read-only/disabled state from the renderer's context. Do not edit the input from +readonly/disabled state from the renderer's context. Do not edit the input from the renderer; event callbacks may update it. Keep hover and selection styles the same size. Tokens are measured whenever they render, so an element that grows once its data arrives reflows on the next frame. A click selects the token and then opens it; dragging or Shift-selecting a token -does not open it. Read-only inputs allow +does not open it. Readonly inputs allow opening references; disabled inputs do not. To offer a keyboard shortcut for opening an exactly selected token, bind `ActivateToken` to a key of your choice; assistive technology reaches the same listener through the token's click action. @@ -446,7 +446,7 @@ plain text removes every token, even if the text is unchanged; passing content restores its tokens, except in modes that cannot show them. Use `replace_all` for an undoable plain-text replacement. Token edits, including adding a reference to existing text, emit `InputEvent::Change`. -Programmatic setters can update read-only or disabled inputs, so check these +Programmatic setters can update readonly or disabled inputs, so check these states in application commands that should be unavailable to users. ### Validation and supported inputs diff --git a/website/docs/coding-guides.md b/website/docs/coding-guides.md index 7f67f4ffd0..e3b89fae69 100644 --- a/website/docs/coding-guides.md +++ b/website/docs/coding-guides.md @@ -726,9 +726,11 @@ could only hint at. activation result. Never use them interchangeably. - **open/close** describes an overlay or disclosure state; **show/hide** is for transient presentation requests; **expand/collapse** describes structure. -- **disabled** prevents interaction; **read-only** permits navigation and +- **disabled** prevents interaction; **readonly** permits navigation and selection but prevents editing; **loading** prevents duplicate work while an - operation is pending. + operation is pending. Spell the state `readonly` — one word, as the + `readonly(bool)` builder and `is_readonly()` reader do — in identifiers, + interface labels, and documentation alike; never `read-only` or `read only`. - **index** is a current positional coordinate; **id** is stable identity; `IndexPath` represents hierarchical position. Do not persist or key reorderable data by index. diff --git a/website/zh-CN/docs/coding-guides.md b/website/zh-CN/docs/coding-guides.md index 045ccbe38d..06d352c3c5 100644 --- a/website/zh-CN/docs/coding-guides.md +++ b/website/zh-CN/docs/coding-guides.md @@ -449,7 +449,7 @@ Boolean builder 可叫 `disabled(bool)`,reader 叫 `is_disabled()`。含 non-b - **selected** 是持久 membership/active item;**focused** 是 keyboard target;**hovered** 是 pointer presence;**confirmed** 是 activation result,不能混用。 - **open/close** 描述 overlay/disclosure state;**show/hide** 表示 transient presentation request;**expand/collapse** 描述结构。 -- **disabled** 禁止交互;**read-only** 允许导航/选择但禁止编辑;**loading** 表示操作中并应防止重复提交。 +- **disabled** 禁止交互;**readonly** 允许导航/选择但禁止编辑;**loading** 表示操作中并应防止重复提交。这个状态一律拼作 `readonly`——一个词,与 `readonly(bool)` builder 和 `is_readonly()` reader 一致——标识符、界面标签和文档中都如此,不写 `read-only` 或 `read only`。 - **index** 是当前位置;**id** 是稳定 identity;`IndexPath` 是层级位置。重排数据不能用 index 持久化或作为 key。 - **value** 是 controlled domain data;**presentation** 是 render 用 read-only snapshot;**state** 是 retained behavior。 - **placement** 是 side/anchor policy;**position** 是 resolved geometry。 From 6d41dbba6aae095413c2709350f8a7e4de9de1cf Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 18 Sep 2026 21:46:07 +0800 Subject: [PATCH 11/11] contributing: Require pull requests to list their public API A pull request that adds, changes or removes anything public now lists it under a `## Public API` section, grouped by crate, with signatures and a line on each item's purpose; changes to existing items still go under `## Breaking Changes` with a diff. The PR template and CLAUDE.md say the same. Co-Authored-By: Claude Opus 5 (1M context) --- .github/pull_request_template.md | 8 +++++++- CLAUDE.md | 9 ++++++--- CONTRIBUTING.md | 14 ++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8ad39c2e09..2591a7f6be 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,7 +11,13 @@ Please keep **1 PR to solve 1 problem**, and keep **Small improvements should be | ---------------------------- | --------------------------- | | [Put Before Screenshot here] | [Put After Screenshot here] | -## Break Changes +## Public API + +List every public item this pull request adds, changes or removes, grouped by crate, with its signature and one line on what it is for. Include JavaScript methods and TypeScript declarations. If none, remove this section. + +- `gpui_component::input::Example::builder(value: bool) -> Self` — what it does. + +## Breaking Changes Describe any breaking changes introduced by this pull request. If none, remove this section. diff --git a/CLAUDE.md b/CLAUDE.md index 326820a204..76fffb820a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -244,9 +244,12 @@ Text input system based on Rope data structure: - When creating a PR, inspect previous PR titles in the repository and match that style. Do not blindly use conventional prefixes like `fix:` or `feat:` unless the existing PR title style uses them. -- When a PR changes the public API of `crates/component`, add a `## Breaking Changes` - section with `diff` blocks showing the old and new usage. See PR #2691 and - `.claude/skills/gpui-component-dev/references/pr-description.md`. +- When a PR adds, changes or removes public API in any crate, list every item + under a `## Public API` section of the description, grouped by crate, with its + signature and one line on its purpose (JavaScript methods and TypeScript + declarations included). Changes to existing items also go under + `## Breaking Changes` with `diff` blocks showing the old and new usage. See + PR #2691 and the "Describe public API changes" section of `CONTRIBUTING.md`. - Avoid `Kind` as a type-name suffix. It says an enum classifies something without saying what it classifies, and carries no meaning a reader could not already infer from `enum`. Name the type after what its variants _are_ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9ded59d77..0c16699d09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,6 +47,20 @@ Before opening a pull request, please make sure that: When using AI, ask it to avoid unrelated refactors, cleanup, or formatting. **Less is better.** +### Describe public API changes + +A pull request that adds, changes or removes anything public — a type, a +function, a builder method, an action, a re-export, a JavaScript method or a +TypeScript declaration — lists it in the description under a `## Public API` +section, grouped by crate, with the signature as a reviewer would read it in +the docs. Say what each item is for in one line; a name alone is not enough. +The reviewer should be able to judge the API from the description without +reading the diff. + +Changes to existing public items go under `## Breaking Changes` with a `diff` +block showing the old and the new usage, even when the old form still +compiles. Additive changes are not breaking, but they are still listed. + Well-prepared pull requests are easier for us to review and may be merged very quickly. If a contribution is already in good shape, maintainers may directly help polish small details and move it forward.