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. 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..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, + )); } } @@ -1374,6 +1398,464 @@ 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); + // 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(); + gpui::div() + .id(("inline-token", start)) + .flex() + .items_center() + .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()) + .on_a11y_action(gpui::AccessibleAction::Click, move |_, window, cx| { + let state = accessible_state.read(cx); + let activation = state + .token_spans() + .iter() + .find(|s| s.range().start == start) + .and_then(|span| { + state.range_to_bounds(&span.range()).and_then(|bounds| { + state.token_activation( + start, + 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.range().start == start) + { + 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((start, 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, cx| { + let (pressed, revision, _) = state.pressed_token.take()?; + 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.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()) + }); + 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 + || 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); + 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.contains_key(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.range().start, 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) != Some(&width); + cache.widths.insert(token, width); + } + cache.key = Some(key); + if changed { + let spans = state.token_spans(); + 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()) + .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()).copied().unwrap_or_default(); + 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()).copied().unwrap_or_default(); + 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.range().start).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 +1868,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 +2142,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 +2350,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 +2511,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 +2679,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 +2921,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..a3d3f71232 --- /dev/null +++ b/crates/base/src/input/base/inline_tokens.rs @@ -0,0 +1,426 @@ +//! Document annotations for atomic inline objects. Coordinates are always source bytes. +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 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, + 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: 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, + tokens: Vec, +} +impl InputContent { + pub fn new(text: impl Into) -> Self { + Self { + text: text.into(), + tokens: vec![], + } + } + /// 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 + } + pub fn tokens(&self) -> &[InlineTokenSpan] { + &self.tokens + } +} + +macro_rules! content_from_text { + ($($text:ty),* $(,)?) => { + $(impl From<$text> for InputContent { + fn from(text: $text) -> Self { + Self::new(text) + } + })* + }; +} +// 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)] +#[non_exhaustive] +pub enum InlineTokenError { + InvalidRange, + InvalidBoundary, + InvalidToken, + OverlappingTokens, + 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::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); + 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(()) + } + /// 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, + }) + }); + } +} + +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() + } + /// 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(), + } + } + } + }; +} +token_api!(super::InputMode); +token_api!(super::TextareaMode); diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index c7bc92e074..06638af857 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,16 @@ 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>, + /// 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`]. @@ -692,6 +703,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 +825,7 @@ impl InputBaseState { ) { self.mode.set_highlighter_factory(factory); self._pending_update = true; + self.token_layout_cache = None; cx.notify(); } @@ -888,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 @@ -896,13 +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; @@ -941,7 +966,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 +1439,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 +1456,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 +2253,11 @@ impl InputBaseState { window: &mut Window, cx: &mut Context, ) { + // 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(); // 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,61 @@ 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()); + 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; + if token_aware && emit_events { + cx.emit(InputEvent::Change); + } + cx.notify(); } /// Restore a set of selections captured in a transaction, clamping offsets @@ -2784,6 +2846,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 +2856,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 +2909,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 +3100,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 +3127,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 +3204,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 +3366,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 +3539,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 +3814,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 +3829,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 +3933,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 +4057,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 +4072,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 +4082,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 +4325,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.range().start, + 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 +4405,253 @@ 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::default() + .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, |_, 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) + }); + } + + #[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::default() + .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_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!")); + 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(); + 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); + 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")) + .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); + 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| { + assert_eq!( + 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) + .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..58969eee5e --- /dev/null +++ b/crates/base/src/input/base/token_presentation.rs @@ -0,0 +1,194 @@ +//! 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 + } + /// The click that opened the token; keyboard activation reports a + /// keyboard click. + pub fn click(&self) -> &ClickEvent { + &self.event + } +} + +/// 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 shared by Base and styled controls. It owns no content. +#[derive(Clone, Default)] +pub(crate) struct InlineTokenPresentation { + renderer: Option, + listener: Option, + secret: bool, +} +impl InlineTokenPresentation { + pub(crate) fn 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(crate) fn on_token_click( + mut self, + listener: impl Fn(&InlineTokenClickEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.listener = Some(Rc::new(listener)); + 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 { + 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. + 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 + && 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, + } + } + /// The token starting at `start`, paired with the listener that opens it. + pub(super) fn token_activation( + &self, + start: usize, + bounds: Bounds, + event: ClickEvent, + ) -> Option<(InlineTokenClickListener, InlineTokenClickEvent)> { + if self.disabled || !self.tokens_visible() { + return None; + } + let span = self + .token_spans() + .iter() + .find(|span| span.range().start == start)? + .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..aca5d2e21f 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,32 @@ impl Input { pub fn new(state: &Entity) -> Self { Self { state: state.clone(), + presentation: Default::default(), } } + /// 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.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, + ) -> 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..e0cadb6c48 100644 --- a/crates/base/src/input/mod.rs +++ b/crates/base/src/input/mod.rs @@ -32,7 +32,16 @@ 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(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 341d108e06..cf0e5cd76e 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,32 @@ impl Textarea { pub fn new(state: &Entity) -> Self { Self { state: state.clone(), + presentation: Default::default(), } } + /// 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.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, + ) -> 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..3da491b57f --- /dev/null +++ b/crates/component-shell/src/shell/input_tokens.rs @@ -0,0 +1,174 @@ +//! Inline presentation and change subscriptions shared by Input and Textarea. +use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState}; +use gpui_shell::{ + ArgumentDescriptor, ArgumentSchema, ComponentArgument, ComponentCallback, ComponentPayload, + InlineTokenCallbacks, 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( + "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("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, + callbacks: InlineTokenCallbacks, + 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 callbacks = dispatch!(&state, |state| InlineTokenCallbacks::new( + state, renderer, listener + )); + Ok(Binding { + state, + callbacks, + change, + }) +} +impl Binding { + pub(super) fn input(&self, input: Input) -> Input { + self.callbacks.apply( + input, + |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)) + }, + ) + } + pub(super) fn textarea(&self, input: Textarea) -> Textarea { + self.callbacks.apply( + input, + |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)) + }, + ) + } + 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..40a77b80e9 --- /dev/null +++ b/crates/component-shell/tests/inline_tokens_host.rs @@ -0,0 +1,120 @@ +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_value(saved); + state.set_value(state.value()); + assert(state.tokens().length === 0, "explicit same value clears identity"); + state.set_value(saved); + 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") + .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) + .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/src/input/input.rs b/crates/component/src/input/input.rs index 40cd0d8ae3..6e6ba55673 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -109,6 +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_renderer: Option, + token_click_listener: Option, id: Option, state: TextInputState, style: StyleRefinement, @@ -170,6 +172,27 @@ impl crate::FocusableExt for Input { } impl Input { + /// The element each atomic inline token renders as, in place of the default + /// [`InputToken`](super::InputToken); editing and history stay + /// with the input. + pub fn token( + mut self, + render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, + ) -> Self { + self.token_renderer = Some(Rc::new(move |token, window, cx| { + render(token, window, cx).into_any_element() + })); + 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, + ) -> Self { + self.token_click_listener = Some(Rc::new(listener)); + 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_renderer: None, + token_click_listener: None, } } @@ -489,6 +514,17 @@ 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.install_token_presentation( + Some(self.token_renderer.unwrap_or_else(|| { + Rc::new(|token, _, _| super::InputToken::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. sync_focused_input_registry(&state, window, cx); diff --git a/crates/component/src/input/mod.rs b/crates/component/src/input/mod.rs index 7289726e5f..8cbf95b19b 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, + 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..5d6cab5ca3 100644 --- a/crates/component/src/input/state.rs +++ b/crates/component/src/input/state.rs @@ -52,6 +52,17 @@ macro_rules! dispatch { } impl TextInputState { + pub(crate) fn install_token_presentation( + &self, + renderer: Option, + listener: Option, + secret: bool, + cx: &mut App, + ) { + dispatch!(self, |state| state.update(cx, |state, _| state + .install_token_presentation(renderer, listener, secret))) + } + 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..77b3cb885a 100644 --- a/crates/component/src/input/textarea.rs +++ b/crates/component/src/input/textarea.rs @@ -12,6 +12,8 @@ use crate::{RoleOverride, StyledExt as _}; /// A styled ordinary multi-line text field. #[derive(IntoElement)] pub struct Textarea { + token_renderer: Option, + token_click_listener: Option, state: Entity, style: StyleRefinement, height: Option, @@ -33,6 +35,27 @@ pub struct Textarea { } impl Textarea { + /// The element each atomic inline token renders as, in place of the default + /// [`InputToken`](super::InputToken); editing and history stay + /// with the input. + pub fn token( + mut self, + render: impl Fn(&super::InlineTokenContext, &mut Window, &mut App) -> R + 'static, + ) -> Self { + self.token_renderer = Some(Rc::new(move |token, window, cx| { + render(token, window, cx).into_any_element() + })); + 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, + ) -> Self { + self.token_click_listener = Some(Rc::new(listener)); + 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_renderer: None, + token_click_listener: None, } } @@ -140,6 +165,12 @@ impl Textarea { /// it. pub(crate) fn into_input(self) -> Input { Input::from_state(self.state.clone()) + .when_some(self.token_renderer, |this, render| { + 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)) + }) .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..0cec719634 --- /dev/null +++ b/crates/component/src/input/token.rs @@ -0,0 +1,69 @@ +use super::InlineTokenContext; +use crate::{ActiveTheme as _, Icon, StyledExt as _}; +use gpui::{ + App, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, StyleRefinement, + Styled, Window, div, prelude::FluentBuilder as _, +}; + +/// The element an [`InlineToken`](super::InlineToken) renders as by default. Editing +/// and activation belong to the input. +#[derive(IntoElement)] +pub struct InputToken { + context: InlineTokenContext, + icon: Option, + style: StyleRefinement, +} +impl InputToken { + pub fn new(context: &InlineTokenContext) -> Self { + Self { + context: context.clone(), + icon: None, + style: Default::default(), + } + } + pub fn icon(mut self, icon: impl Into) -> Self { + self.icon = Some(icon.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") + .flex() + .items_center() + .gap_1() + .px_1() + .h(self.context.line_height()) + .max_w(self.context.available_width()) + .rounded(cx.theme().radius) + .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)) + .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()), + ) + .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..8f1162ece4 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,10 +6013,20 @@ 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", "replace_with_token", "replace_range_with_token", + "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 @@ -5863,9 +6042,10 @@ 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 ?? "")), + 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) => @@ -6866,6 +7046,8 @@ impl ShellRuntime { "on_item_click", "on_item_secondary_click", "on_change", + "token", + "on_token_click", "on_open_change", "on_confirm", "on_dismiss", @@ -7459,6 +7641,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 +7919,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 +7962,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 +8021,8 @@ impl ShellRuntime { | "on_link_click" | "on_resize" | "on_change" + | "token" + | "on_token_click" | "on_open_change" | "on_confirm" | "on_dismiss" @@ -7842,7 +8050,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 +9615,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 +9969,8 @@ fn callback_op_name(method: &str) -> Option<&'static str> { "drop_indicator" => "drop_indicator", "dock" => "dock", "on_change" => "on_change", + "token" => "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..241c7ee9fc --- /dev/null +++ b/crates/shell/src/input_tokens.rs @@ -0,0 +1,338 @@ +//! 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, +}; +use std::rc::Rc; + +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.click().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", "(value: string | InputContent): void", false), + ("content", "(): InputContent", true), + ("tokens", "(): InlineTokenSpan[]", true), + ("replace_with_token", "(token: InlineToken): void", false), + ( + "replace_range_with_token", + "(range: InputRange, token: InlineToken): 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", [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]) => { + 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) + } + ("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); + +/// Script callbacks for one input's tokens, adapted to the native +/// `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, + result => { + if let Err(error) = result { + tracing::error!("inline token renderer failed: {error:#}"); + } + gpui::div() + .child(token.token().label().clone()) + .into_any_element() + } + } + }); + 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:#}"); + } + }); + listen + }); + Self { renderer, listener } + } + /// Install the callbacks on an element through its own builders. + pub fn apply( + &self, + element: 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) => token(element, renderer.clone()), + None => element, + }; + match &self.listener { + Some(listener) => on_token_click(element, listener.clone()), + None => element, + } + } +} diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index ee46429789..1fbc249126 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::{ + InlineTokenCallbacks, inline_token_click_data, inline_token_context_data, + input_token_state_methods, 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..9c6b2fd5f9 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, + token: Option, + on_token_click: Option, on_mouse_move: Option, on_hover: Option, /// Reports a key press that reached this element. @@ -1499,7 +1501,23 @@ 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 callbacks = crate::InlineTokenCallbacks::new( + &state, + behavior + .token + .map(|id| crate::ComponentElementCallback::from_runtime(runtime, id)), + behavior + .on_token_click + .map(|id| crate::ComponentCallback::from_runtime(runtime, id)), + ); + let input = callbacks.apply( + Input::new(&state), + |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(input).into_any_element() } Component::OtpInput(handle) => components::otp_input::otp_input( runtime, handle, refinement, behavior, states, children, window, cx, @@ -2494,6 +2512,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), + "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), "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..85a645a51b 100644 --- a/crates/shell/src/materialize/components/textarea.rs +++ b/crates/shell/src/materialize/components/textarea.rs @@ -58,5 +58,19 @@ 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 callbacks = crate::InlineTokenCallbacks::new( + &state, + behavior + .token + .map(|id| crate::ComponentElementCallback::from_runtime(runtime, id)), + behavior + .on_token_click + .map(|id| crate::ComponentCallback::from_runtime(runtime, id)), + ); + let textarea = callbacks.apply( + Textarea::new(&state), + |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/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/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/shell/src/typings.rs b/crates/shell/src/typings.rs index d864352cfd..f94af8a304 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(" 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,8 +2997,15 @@ const BASE: &str = r#" /** A row. */ * an event handler — never in `render`. */ export interface InputState { + content(): InputContent; + tokens(): InlineTokenSpan[]; + replace_with_token(token: InlineToken): void; + replace_range_with_token(range: InputRange, token: InlineToken): 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; /** @@ -3032,8 +3064,15 @@ const BASE: &str = r#" /** A row. */ * call `set_auto_grow(...)`, or size the element with `.h(...)`. */ export interface TextareaState { + content(): InputContent; + tokens(): InlineTokenSpan[]; + replace_with_token(token: InlineToken): void; + replace_range_with_token(range: InputRange, token: InlineToken): 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. */ @@ -3852,6 +3891,8 @@ mod tests { "controls_right", "when", "on_click", + "token", + "on_token_click", "on_mouse_move", "on_hover", "on_key_down", 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_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..4ac89cea25 --- /dev/null +++ b/crates/story/src/stories/input_tokens.rs @@ -0,0 +1,481 @@ +//! 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 _, Sizable as _, + button::{Button, ButtonVariants as _}, + checkbox::Checkbox, + h_flex, + input::{ + InlineToken, InlineTokenClickEvent, InlineTokenContext, InlineTokenError, InputContent, + InputEvent, InputGroup, InputGroupAddon, InputGroupAddonAlignment as Align, + InputGroupButton, InputGroupInput, InputGroupTextarea, InputState, InputToken, + TextareaState, + }, + v_flex, +}; +use gpui_kit::{ + 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, + 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 { + 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, 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 { + 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_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, + } + }) + } + + 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 token(token: &InlineTokenContext) -> InputToken { + let reference = Reference::of(token.token()); + InputToken::new(token).when_some(reference, |element, reference| { + element.icon(reference.icon()) + }) + } + + 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") + .token(|token, _, _| Self::token(token)) + .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( + InputGroupTextarea::new(input) + .aria_label("Message") + .token(|token, _, _| Self::token(token)) + .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), + ) + .child(div().min_w_0().flex_1().child(value)) + }; + v_flex() + .w_full() + .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_6() + .child( + 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( + h_flex() + .gap_4() + .child( + Checkbox::new("tokens-readonly") + .small() + .label("Readonly") + .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(self.readout(cx)) + } +} + +#[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| story.content(cx)) + } + + #[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); + 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); + 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() == "command:commit-pr") + .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-person", &mut visual); + click("restore-draft", &mut visual); + assert_eq!(content(&story, &mut visual), inserted); + click("send-message", &mut visual); + story.read_with(&visual, |story, _| { + assert_eq!( + story.status, + format!( + "Sent {:?} with {} references", + inserted.text().trim(), + inserted.tokens().len() + ) + ); + }); + click("tokens-readonly", &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-draft", &mut visual); + click("insert-command", &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..aad2c642f0 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_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)); } @@ -491,6 +502,45 @@ 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) + .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) => { + // 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) => { + setState(`${key}-saved`, input.content(), cx); + })) + .child(new Button(`${key}-restore`).label("Restore draft").on_click((_event, cx) => { + 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) => { + 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 +1291,7 @@ export function registeredExamples(surface, cx) { ), ), }, + tokenExample(false, cx), ]; case "NumberInput": return [ @@ -1283,6 +1334,7 @@ export function registeredExamples(surface, cx) { .h(120), ), }, + tokenExample(true, cx), ]; case "Checkbox": return [ 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/base/primitives/input.md b/website/base/primitives/input.md index 26da72d632..e1b66d2609 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 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. + +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..ebbe3b720d 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 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. + +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..039f770fa9 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::{InputToken, InputGroup, Textarea}, +}; + +InputGroup::new("composer") + .input(Textarea::new(&state) + .token(|token, _, _| InputToken::new(token).icon(IconName::File))) +``` + +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 e15c15d12e..02f12b3393 100644 --- a/website/component/input.md +++ b/website/component/input.md @@ -331,3 +331,170 @@ 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`. + +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, 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 + +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::{InputToken, InlineTokenClickEvent}, +}; + +Input::new(&input) + .token(|token, _, _| { + InputToken::new(token).icon(IconName::File) + }) + .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 +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. 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. +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 +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: `set_value` takes plain text or content. +input.update(cx, |state, cx| { + state.set_value(draft, window, cx); +}); +``` + +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"))?; +``` + +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_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 readonly 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_value({ + 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_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. + +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 9bc6bcb716..145fa2c96e 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_value`. The default label is ready to use; add a renderer +when you want an icon or other custom content: + +```rust +Textarea::new(&state) + .token(|token, _, _| InputToken::new(token).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/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/base/primitives/input.md b/website/zh-CN/base/primitives/input.md index 5d4857ffaf..b328e7b65e 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 默认显示为无装饰标签。通过 `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 4d04b923d9..c795327443 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 默认显示为无装饰标签。通过 `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 b6ae3e58fb..947ee7fb3f 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::{InputToken, InputGroup, Textarea}, +}; + +InputGroup::new("composer") + .input(Textarea::new(&state) + .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 e374ee1295..862130fb79 100644 --- a/website/zh-CN/component/input.md +++ b/website/zh-CN/component/input.md @@ -279,3 +279,118 @@ 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` 等方法返回的字节偏移。 + +ID 标识被引用的资源,同一个人被提及两次时两个 token 使用同一个 ID。`text` 用于复制和提交,`with_label` 指定显示名称;省略 `with_label` 时直接显示真实文本。 + +用户可以将光标移到 token 两侧,单击选中整块 token,或用 Backspace/Delete 删除它。选区覆盖部分 token 时会包含整块 token。Undo/Redo 同时恢复文本和引用。粘贴得到普通文本。 + +### 自定义外观与打开引用 + +token 默认渲染为 `InputToken`。`token` 槽位提供每个 token 的元素;需要图标时在其中返回带图标的 `InputToken`,并通过 `on_token_click` 打开引用: + +```rust +use gpui_kit::component::{ + IconName, + input::{InputToken, InlineTokenClickEvent}, +}; + +Input::new(&input) + .token(|token, _, _| { + InputToken::new(token).icon(IconName::File) + }) + .on_token_click(|event: &InlineTokenClickEvent, _, _| { + // 根据 event.token().id() 查找并打开资源。 + }); +``` + +也可以返回自己的单行元素。元素应保持在输入框行高内,超出可用行宽的内容会被裁切。通过 renderer 的上下文读取选中、只读和禁用状态。在事件回调中修改输入,不要在 renderer 中修改。悬停和选中样式应保持尺寸稳定。token 每次渲染时都会重新测量,因此数据到达后变宽的元素会在下一帧重新排版。 + +单击会先选中 token 再打开引用;拖选或 Shift 扩选不会打开引用。只读输入允许打开引用,禁用输入不允许。若要为打开选中的整块 token 提供快捷键,可以将 `ActivateToken` 绑定到自己选择的按键;辅助技术通过 token 的 click 操作触发同一个监听器。当应用能为引用给出明确名称(例如“打开文件”)时,可以通过 `context_menu` 自行添加菜单项。 + +如果 token 内含按钮,应消费按钮的 mouse-down 和 click 事件,避免同时打开引用。所有子操作(包括无障碍操作)都应遵守 `token.is_disabled()`;会修改内容的操作还应遵守 `token.is_readonly()`。 + +### 保存、恢复与提交 + +保存草稿时,使用 `content()` 一起保留文本和引用: + +```rust +let draft = input.read(cx).content(); + +// 稍后恢复保存的草稿:`set_value` 既接受纯文本,也接受 content。 +input.update(cx, |state, cx| { + state.set_value(draft, window, cx); +}); +``` + +从应用自己的存储中恢复数据时,先用文本构造 `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"))?; +``` + +提交时重新读取 `content()`:`text()` 是消息文本,`tokens()` 是其中仍然存在的引用。根据 token ID 查找资源,并在发送前处理资源已不存在的情况。 + +`set_value` 清空撤销历史,不触发 `InputEvent::Change`。传入纯文本会移除全部 token,即使文字未变;传入 content 则恢复其中的 token(无法显示 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_value({ + 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_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 368c07f03e..a467c87190 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_value` 恢复已有草稿。默认标签可以直接使用,需要图标等自定义内容时再设置 renderer: + +```rust +Textarea::new(&state) + .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)。 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。