From 0fae175d373dbb4e2f76a7ae90dcbba2f80d98ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Wed, 9 Sep 2026 21:26:48 +0800 Subject: [PATCH 1/2] input: gutter markers, range decorations, and inline widgets Editors can now show feature-owned markers in a reserved gutter lane, geometric Fill/Frame decorations over UTF-8 ranges, and non-document inline widgets at an offset. All three follow text edits and are exposed through InputBaseState setters plus an Editor builder for the marker renderer; markers report clicks through InputEvent::GutterMarkerMouseDown. Co-Authored-By: Claude --- crates/base/src/input/base/element.rs | 399 +++++++++++++++++- crates/base/src/input/base/kind.rs | 40 +- crates/base/src/input/base/state.rs | 9 +- crates/base/src/input/editor/decorations.rs | 319 +++++++++++++- crates/base/src/input/editor/mod.rs | 33 ++ crates/base/src/input/mod.rs | 5 +- crates/component/src/input/editor.rs | 41 +- crates/component/src/input/mod.rs | 19 +- crates/shell/src/engine/quickjs/mod.rs | 5 +- crates/story/src/stories/input_story.rs | 1 + .../story/src/stories/number_input_story.rs | 1 + 11 files changed, 848 insertions(+), 24 deletions(-) diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index 5f82b2cf65..89e94a0f58 100644 --- a/crates/base/src/input/base/element.rs +++ b/crates/base/src/input/base/element.rs @@ -21,7 +21,7 @@ use crate::{ }; use super::{ - InputBaseState, TextDecoration, + GutterMarker, InputBaseState, InputEvent, RangeDecorationStyle, TextDecoration, layout::{LastLayout, WhitespaceIndicators}, mode::LayoutMode, }; @@ -51,6 +51,7 @@ pub(super) const RIGHT_MARGIN: Pixels = px(10.); pub(super) const LINE_NUMBER_RIGHT_MARGIN: Pixels = px(10.); const FOLD_ICON_WIDTH: Pixels = px(14.); const FOLD_ICON_HITBOX_WIDTH: Pixels = px(18.); +pub(super) const GUTTER_MARKER_HITBOX_WIDTH: Pixels = px(22.); const MAX_HIGHLIGHT_LINE_LENGTH: usize = 10_000; const FOLD_CHEVRON_RIGHT_SVG: &[u8] = br#""#; const FOLD_CHEVRON_DOWN_SVG: &[u8] = br#""#; @@ -397,6 +398,10 @@ struct FoldIconLayout { icons: Vec<(usize, bool, gpui::AnyElement)>, } +struct GutterMarkerLayout { + icons: Vec, +} + pub(super) struct TextElement { pub(crate) state: Entity>, placeholder: SharedString, @@ -794,6 +799,137 @@ impl TextElement { builder.build().ok() } + fn layout_range_corners( + range: &Range, + last_layout: &LastLayout, + ) -> Option>>> { + if range.is_empty() + || range.start < last_layout.visible_range_offset.start + || range.end > last_layout.visible_range_offset.end + { + return None; + } + + let mut offset_y = last_layout.visible_top; + let mut corners = Vec::new(); + for (line_offset, line) in last_layout + .visible_line_byte_offsets + .iter() + .zip(last_layout.lines.iter()) + { + let line_size = line.size(last_layout.line_height); + let start = line.position_for_index( + range.start.saturating_sub(*line_offset), + last_layout, + false, + ); + let end = + line.position_for_index(range.end.saturating_sub(*line_offset), last_layout, true); + + if start.is_some() || end.is_some() { + let start = start + .unwrap_or_else(|| line.position_for_index(0, last_layout, false).unwrap()); + let end = end.unwrap_or_else(|| { + line.position_for_index(line.len(), last_layout, false) + .unwrap() + }); + let wrapped_lines = (end.y / last_layout.line_height).ceil() as usize + - (start.y / last_layout.line_height).ceil() as usize; + let mut end_x = end.x; + if wrapped_lines > 0 { + end_x = line_size.width; + } + end_x = end_x.max(start.x + px(6.)); + let line_origin = point(px(0.), offset_y); + corners.push(Corners { + top_left: line_origin + point(start.x, start.y), + top_right: line_origin + point(end_x, start.y), + bottom_left: line_origin + point(start.x, start.y + last_layout.line_height), + bottom_right: line_origin + point(end_x, start.y + last_layout.line_height), + }); + + for index in 1..=wrapped_lines { + let start = point( + line.wrap_indent, + start.y + index as f32 * last_layout.line_height, + ); + let mut wrapped_end = point(end.x, start.y); + if index < wrapped_lines { + wrapped_end.x = line_size.width; + } + corners.push(Corners { + top_left: line_origin + start, + top_right: line_origin + wrapped_end, + bottom_left: line_origin + + point(start.x, start.y + last_layout.line_height), + bottom_right: line_origin + + point(wrapped_end.x, wrapped_end.y + last_layout.line_height), + }); + } + } + + if start.is_some() && end.is_some() { + break; + } + offset_y += line_size.height; + } + + for corners in &mut corners { + if corners.top_left.x > corners.top_right.x { + std::mem::swap(&mut corners.top_left, &mut corners.top_right); + std::mem::swap(&mut corners.bottom_left, &mut corners.bottom_right); + } + } + (!corners.is_empty()).then_some(corners) + } + + fn layout_range_decorations( + &self, + last_layout: &LastLayout, + bounds: &Bounds, + cx: &App, + ) -> (Vec<(Path, Hsla)>, Vec<(Path, Hsla)>) { + let state = self.state.read(cx); + let mut fills = Vec::new(); + let mut frames = Vec::new(); + for decoration in state.extras.range_decorations() { + match decoration.style() { + RangeDecorationStyle::Fill => { + let color = decoration + .color() + .unwrap_or(state.editor_style.foreground.opacity(0.12)); + if let Some(path) = + Self::layout_match_range(decoration.range().clone(), last_layout, bounds) + { + fills.push((path, color)); + } + } + RangeDecorationStyle::Frame => { + let color = decoration.color().unwrap_or(state.editor_style.foreground); + let Some(corners) = Self::layout_range_corners(decoration.range(), last_layout) + else { + continue; + }; + let points = frame_outline_points(&corners); + let Some(first) = points.first().copied() else { + continue; + }; + let origin = bounds.origin + point(last_layout.line_number_width, px(0.)); + let mut builder = gpui::PathBuilder::stroke(px(1.)); + builder.move_to(origin + first); + for point in points.iter().skip(1) { + builder.line_to(origin + *point); + } + builder.close(); + if let Ok(path) = builder.build() { + frames.push((path, color)); + } + } + } + } + (fills, frames) + } + fn layout_search_matches( &self, last_layout: &LastLayout, @@ -1018,6 +1154,9 @@ impl TextElement { // Add extra space for fold icons line_number_width += FOLD_ICON_HITBOX_WIDTH } + if state.extras.gutter_lane_reserved() || !state.extras.gutter_markers().is_empty() { + line_number_width += GUTTER_MARKER_HITBOX_WIDTH; + } (line_number_width, line_number_len) } @@ -1310,6 +1449,92 @@ impl TextElement { icon_layout } + fn layout_gutter_markers( + &self, + origin_x: Pixels, + bounds: &Bounds, + last_layout: &LastLayout, + window: &mut Window, + cx: &mut App, + ) -> GutterMarkerLayout { + let (markers, renderer, marker_bounds_by_id) = { + let state = self.state.read(cx); + let Some(marker_bounds_by_id) = state.extras.gutter_marker_bounds() else { + return GutterMarkerLayout { icons: Vec::new() }; + }; + marker_bounds_by_id.borrow_mut().clear(); + let Some(renderer) = state.extras.gutter_marker_renderer() else { + return GutterMarkerLayout { icons: Vec::new() }; + }; + let mut markers = state + .extras + .gutter_markers() + .iter() + .filter(|marker| { + last_layout + .visible_buffer_lines + .binary_search(&marker.logical_row()) + .is_ok() + }) + .cloned() + .collect::>(); + markers.sort_by_key(GutterMarker::logical_row); + (markers, renderer, marker_bounds_by_id) + }; + + let mut icons = Vec::with_capacity(markers.len()); + for marker in markers { + let Some(line_index) = last_layout + .visible_buffer_lines + .iter() + .position(|row| *row == marker.logical_row()) + else { + continue; + }; + let offset_y = last_layout.visible_top + + last_layout.lines[..line_index] + .iter() + .map(|line| line.size(last_layout.line_height).height) + .fold(px(0.), |height, line_height| height + line_height); + let marker_bounds = Bounds::new( + point( + origin_x + last_layout.line_number_width - GUTTER_MARKER_HITBOX_WIDTH, + bounds.origin.y + offset_y, + ), + size(GUTTER_MARKER_HITBOX_WIDTH, last_layout.line_height), + ); + let child = renderer(&marker); + let marker_id = marker.id().clone(); + let element_id = marker_id.clone(); + marker_bounds_by_id + .borrow_mut() + .insert(marker_id.clone(), marker_bounds); + let logical_row = marker.logical_row(); + let enabled = marker.is_enabled(); + let state = self.state.clone(); + let mut icon = gpui::div() + .id(element_id) + .size_full() + .child(child) + .on_mouse_down(MouseButton::Left, move |_, _, cx| { + cx.stop_propagation(); + if enabled { + state.update(cx, |_, cx| { + cx.emit(InputEvent::GutterMarkerMouseDown { + marker_id: marker_id.clone(), + logical_row, + }); + }); + } + }) + .into_any_element(); + icon.prepaint_as_root(marker_bounds.origin, marker_bounds.size.into(), window, cx); + icons.push(icon); + } + + GutterMarkerLayout { icons } + } + /// Paint fold icons using prepaint hitboxes. /// /// This handles: @@ -1336,6 +1561,12 @@ impl TextElement { } } + fn paint_gutter_markers(layout: &mut GutterMarkerLayout, window: &mut Window, cx: &mut App) { + for icon in &mut layout.icons { + icon.paint(window, cx); + } + } + #[allow(clippy::too_many_arguments)] fn layout_lines( state: &InputBaseState, @@ -1634,11 +1865,14 @@ pub(super) struct PrepaintState { hover_highlight_path: Option>, search_match_paths: Vec<(Path, bool)>, document_color_paths: Vec<(Path, Hsla)>, + range_decoration_fills: Vec<(Path, Hsla)>, + range_decoration_frames: Vec<(Path, Hsla)>, hover_definition_hitbox: Option, indent_guides_path: Option>, bounds: Bounds, /// Fold icon layout data fold_icon_layout: FoldIconLayout, + gutter_marker_layout: GutterMarkerLayout, // Inline completion rendering data /// Shaped ghost lines to paint after cursor row (completion lines 2+) ghost_lines: Vec, @@ -1703,6 +1937,38 @@ fn print_points_as_svg_path( } } } + +fn frame_outline_points(corners: &[Corners>]) -> Vec> { + let rects = corners + .iter() + .map(|corners| (corners.top_left, corners.bottom_right)) + .collect::>(); + let mut points = Vec::with_capacity(rects.len() * 4); + let first = rects[0]; + let last = rects[rects.len() - 1]; + points.push(first.0); + points.push(point(first.1.x, first.0.y)); + for pair in rects.windows(2) { + let current = pair[0]; + let next = pair[1]; + points.push(current.1); + points.push(point(next.1.x, current.1.y)); + points.push(point(next.1.x, next.1.y)); + } + if points.last() != Some(&last.1) { + points.push(last.1); + } + points.push(point(last.0.x, last.1.y)); + for pair in rects.windows(2).rev() { + let current = pair[1]; + let next = pair[0]; + points.push(point(current.0.x, current.0.y)); + points.push(point(next.0.x, current.0.y)); + points.push(point(next.0.x, next.0.y)); + } + points +} + impl Element for TextElement { type RequestLayoutState = (); type PrepaintState = PrepaintState; @@ -2068,6 +2334,8 @@ impl Element for TextElement { let hover_highlight_path = self.layout_hover_highlight(&last_layout, &mut bounds, cx); let document_color_paths = self.layout_document_colors(&document_colors, &last_layout, &bounds, cx); + let (range_decoration_fills, range_decoration_frames) = + self.layout_range_decorations(&last_layout, &bounds, cx); let state = self.state.read(cx); let line_numbers = if state.mode.line_number() { @@ -2134,6 +2402,8 @@ impl Element for TextElement { ))); let fold_icon_layout = self.layout_fold_icons(original_x, &bounds, &last_layout, window, cx); + let gutter_marker_layout = + self.layout_gutter_markers(original_x, &bounds, &last_layout, window, cx); PrepaintState { bounds, @@ -2148,8 +2418,11 @@ impl Element for TextElement { hover_highlight_path, hover_definition_hitbox, document_color_paths, + range_decoration_fills, + range_decoration_frames, indent_guides_path, fold_icon_layout, + gutter_marker_layout, ghost_first_line, ghost_lines, ghost_lines_height, @@ -2277,6 +2550,14 @@ impl Element for TextElement { window.paint_path(path, editor_style.border.opacity(0.85)); } + // Application decorations sit below the user's selection. + for (path, color) in &prepaint.range_decoration_fills { + window.paint_path(path.clone(), *color); + } + for (path, color) in &prepaint.range_decoration_frames { + window.paint_path(path.clone(), *color); + } + // Paint selections if window.is_window_active() { let secondary_selection = Hsla { @@ -2374,14 +2655,14 @@ impl Element for TextElement { } } + self.paint_inline_widgets(prepaint, origin, scroll_offset, text_align, window, cx); + // Paint blinking cursors (shared blink state for all carets) if focused && show_cursor { for cursor_info in prepaint.cursor_infos_with_scroll() { window.paint_quad(fill(cursor_info.bounds, editor_style.caret)); } - } - - // Paint line numbers + } // Paint line numbers let mut offset_y = px(0.); if let Some(line_numbers) = prepaint.line_numbers.as_ref() { offset_y += invisible_top_padding; @@ -2442,6 +2723,7 @@ impl Element for TextElement { window, cx, ); + Self::paint_gutter_markers(&mut prepaint.gutter_marker_layout, window, cx); self.state.update(cx, |state, cx| { let geometry_changed = state.last_bounds != Some(bounds) @@ -2499,6 +2781,82 @@ impl Element for TextElement { } } +impl TextElement { + fn paint_inline_widgets( + &self, + prepaint: &PrepaintState, + origin: Point, + scroll_offset: Pixels, + text_align: TextAlign, + window: &mut Window, + cx: &mut App, + ) { + let (widgets, color) = { + let state = self.state.read(cx); + ( + state + .extras + .inline_widgets() + .iter() + .map(|widget| { + ( + widget.clone(), + state.text.offset_to_point(widget.offset()).row, + ) + }) + .collect::>(), + state.editor_style.muted_foreground, + ) + }; + if widgets.is_empty() { + return; + } + let text_style = window.text_style(); + let text_size = text_style.font_size.to_pixels(window.rem_size()); + for (widget, row) in &widgets { + let Ok(line_index) = prepaint.last_layout.visible_buffer_lines.binary_search(row) + else { + continue; + }; + let line = &prepaint.last_layout.lines[line_index]; + let line_offset = prepaint.last_layout.visible_line_byte_offsets[line_index]; + let Some(widget_point) = line.position_for_index( + widget.offset().saturating_sub(line_offset), + &prepaint.last_layout, + false, + ) else { + continue; + }; + let y = prepaint.last_layout.visible_top + + prepaint.last_layout.lines[..line_index] + .iter() + .map(|line| line.size(prepaint.last_layout.line_height).height) + .fold(px(0.), |height, line_height| height + line_height); + let position = widget_point + gpui::point(prepaint.last_layout.line_number_width, y); + let text = widget.text().clone(); + let run = TextRun { + len: text.len(), + font: text_style.font(), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + let shaped = window + .text_system() + .shape_line(text, text_size, &[run], None); + let _ = shaped.paint( + origin + position + point(scroll_offset, px(0.)), + prepaint.last_layout.line_height, + text_align, + None, + window, + cx, + ); + } + } +} + /// Split placeholder text into display lines and trim runs to each line. fn placeholder_line_runs<'a>( display_text: &'a str, @@ -2737,6 +3095,39 @@ fn split_runs_by_bg_segments( mod tests { use super::*; + #[test] + fn frame_outline_is_continuous_across_different_line_widths() { + let corners = [ + Corners { + top_left: point(px(2.), px(0.)), + top_right: point(px(20.), px(0.)), + bottom_left: point(px(2.), px(10.)), + bottom_right: point(px(20.), px(10.)), + }, + Corners { + top_left: point(px(0.), px(10.)), + top_right: point(px(12.), px(10.)), + bottom_left: point(px(0.), px(20.)), + bottom_right: point(px(12.), px(20.)), + }, + ]; + + assert_eq!( + frame_outline_points(&corners), + vec![ + point(px(2.), px(0.)), + point(px(20.), px(0.)), + point(px(20.), px(10.)), + point(px(12.), px(10.)), + point(px(12.), px(20.)), + point(px(0.), px(20.)), + point(px(0.), px(10.)), + point(px(2.), px(10.)), + point(px(2.), px(0.)), + ] + ); + } + #[test] fn test_plain_text_decorations_include_unstyled_gaps() { let decoration = HighlightStyle { diff --git a/crates/base/src/input/base/kind.rs b/crates/base/src/input/base/kind.rs index 6308a7af00..567bf3a4e0 100644 --- a/crates/base/src/input/base/kind.rs +++ b/crates/base/src/input/base/kind.rs @@ -21,15 +21,17 @@ //! reachable. use std::cell::RefCell; +use std::collections::HashMap; use std::rc::Rc; -use gpui::{Div, Entity, Stateful, Window}; +use gpui::{Bounds, Div, Entity, Pixels, SharedString, Stateful, Window}; use ropey::Rope; -use super::decorations::DecorationCollections; +use super::decorations::{DecorationCollections, EditorAnnotations}; use super::lsp::{ContextMenuContent, HoverDefinition, InlineCompletion}; use crate::input::{ - HighlightStyleResolver, InputEdit, InputHighlighter, SyntaxContext, TextDecoration, + GutterMarker, HighlightStyleResolver, InlineWidget, InputEdit, InputHighlighter, + RangeDecoration, SyntaxContext, TextDecoration, }; use crate::input::{HoverPopoverState, Lsp}; use gpui::Task; @@ -115,6 +117,36 @@ pub trait InputExtras: Default + 'static { fn context_menu_capabilities(&self) -> (bool, bool) { (false, false) } + + /// Feature-owned gutter markers anchored to logical rows. + fn gutter_markers(&self) -> &[GutterMarker] { + &[] + } + + /// Whether the gutter marker lane has ever been used and stays reserved. + fn gutter_lane_reserved(&self) -> bool { + false + } + + /// The application-owned presentation for gutter markers, if any. + fn gutter_marker_renderer(&self) -> Option { + None + } + + /// Last-paint bounds per gutter marker, keyed by marker id. + fn gutter_marker_bounds(&self) -> Option>>>> { + None + } + + /// Geometric range decorations to paint. + fn range_decorations(&self) -> &[RangeDecoration] { + &[] + } + + /// Non-document inline widgets to paint at their offsets. + fn inline_widgets(&self) -> &[InlineWidget] { + &[] + } } /// A mode with nothing extra to render. @@ -341,6 +373,7 @@ pub struct EditorExtras { pub(crate) hover_popover: Option, pub(crate) hover_definition: HoverDefinition, pub(crate) context_menu_task: Task>, + pub(crate) annotations: EditorAnnotations, } impl Default for EditorExtras { @@ -353,6 +386,7 @@ impl Default for EditorExtras { hover_popover: None, hover_definition: HoverDefinition::default(), context_menu_task: Task::ready(Ok(())), + annotations: EditorAnnotations::default(), } } } diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index 56603df8b0..e6e768827b 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -121,9 +121,16 @@ actions!( #[derive(Clone)] pub enum InputEvent { Change, - PressEnter { secondary: bool, shift: bool }, + PressEnter { + secondary: bool, + shift: bool, + }, Focus, Blur, + GutterMarkerMouseDown { + marker_id: SharedString, + logical_row: usize, + }, } pub(super) const CONTEXT: &str = "Input"; diff --git a/crates/base/src/input/editor/decorations.rs b/crates/base/src/input/editor/decorations.rs index dc4b393cce..aa1ffb6849 100644 --- a/crates/base/src/input/editor/decorations.rs +++ b/crates/base/src/input/editor/decorations.rs @@ -1,12 +1,204 @@ use crate::input::EditorMode; +use std::cell::RefCell; +use std::collections::HashMap; use std::ops::Range; +use std::rc::Rc; -use gpui::{App, Context, HighlightStyle, WeakEntity}; +use gpui::{ + AnyElement, App, Bounds, Context, HighlightStyle, Hsla, Pixels, SharedString, WeakEntity, +}; use ropey::Rope; use sum_tree::Bias; use super::{InputBaseState, RopeExt as _}; +/// A feature-owned marker anchored to a logical editor row. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GutterMarker { + id: SharedString, + logical_row: usize, + icon: SharedString, + tooltip: Option, + enabled: bool, +} + +impl GutterMarker { + pub fn new( + id: impl Into, + logical_row: usize, + icon: impl Into, + ) -> Self { + Self { + id: id.into(), + logical_row, + icon: icon.into(), + tooltip: None, + enabled: true, + } + } + + pub fn id(&self) -> &SharedString { + &self.id + } + + pub fn logical_row(&self) -> usize { + self.logical_row + } + + pub fn icon(&self) -> &SharedString { + &self.icon + } + + pub fn tooltip(&self) -> Option<&SharedString> { + self.tooltip.as_ref() + } + + pub fn is_enabled(&self) -> bool { + self.enabled + } + + pub fn with_tooltip(mut self, tooltip: impl Into) -> Self { + self.tooltip = Some(tooltip.into()); + self + } + + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } +} + +/// Application-owned presentation for a gutter marker. +pub type GutterMarkerRenderer = std::rc::Rc AnyElement>; + +/// Geometric presentation for an editor range decoration. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RangeDecorationStyle { + /// Fill the continuous visual range. + Fill, + /// Draw a continuous one-pixel frame around the visual range. + #[default] + Frame, +} + +/// A geometric decoration over a UTF-8 byte range. +#[derive(Clone, Debug, PartialEq)] +pub struct RangeDecoration { + id: SharedString, + range: Range, + style: RangeDecorationStyle, + color: Option, +} + +impl RangeDecoration { + pub fn new(id: impl Into, range: Range) -> Self { + Self { + id: id.into(), + range, + style: RangeDecorationStyle::default(), + color: None, + } + } + + pub fn id(&self) -> &SharedString { + &self.id + } + + pub fn range(&self) -> &Range { + &self.range + } + + pub fn style(&self) -> RangeDecorationStyle { + self.style + } + + pub fn color(&self) -> Option { + self.color + } + + pub fn with_style(mut self, style: RangeDecorationStyle) -> Self { + self.style = style; + self + } + + pub fn with_color(mut self, color: Hsla) -> Self { + self.color = Some(color); + self + } +} + +/// Non-document text painted at a UTF-8 byte offset. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InlineWidget { + id: SharedString, + offset: usize, + text: SharedString, +} + +impl InlineWidget { + pub fn new(id: impl Into, offset: usize, text: impl Into) -> Self { + Self { + id: id.into(), + offset, + text: text.into(), + } + } + + pub fn id(&self) -> &SharedString { + &self.id + } + + pub fn offset(&self) -> usize { + self.offset + } + + pub fn text(&self) -> &SharedString { + &self.text + } +} + +#[derive(Default)] +pub(crate) struct EditorAnnotations { + pub(crate) gutter_markers: Vec, + pub(crate) gutter_lane_reserved: bool, + pub(crate) gutter_marker_bounds: Rc>>>, + pub(crate) range_decorations: Vec, + pub(crate) inline_widgets: Vec, + pub(crate) gutter_marker_renderer: Option, +} + +impl EditorAnnotations { + pub(crate) fn adjust_for_edit(&mut self, edited_range: &Range, inserted_len: usize) { + for decoration in &mut self.range_decorations { + decoration.range = adjust_range_for_edit(&decoration.range, edited_range, inserted_len); + } + self.range_decorations + .retain(|decoration| !decoration.range.is_empty()); + for widget in &mut self.inline_widgets { + widget.offset = adjust_offset_for_edit(widget.offset, edited_range, inserted_len); + } + } +} + +fn adjust_offset_for_edit( + offset: usize, + edited_range: &Range, + inserted_len: usize, +) -> usize { + if offset <= edited_range.start { + return offset; + } + if offset < edited_range.end { + return edited_range.start.saturating_add(inserted_len); + } + let removed_len = edited_range.end.saturating_sub(edited_range.start); + if inserted_len >= removed_len { + offset.saturating_add(inserted_len - removed_len) + } else { + offset.saturating_sub(removed_len - inserted_len) + } +} + /// A presentation style applied to a UTF-8 byte range in an input. /// /// This is the GPUI [`HighlightStyle`] counterpart of Monaco's @@ -248,6 +440,104 @@ impl InputBaseState { id, } } + + /// Replace all gutter markers. The marker lane remains reserved after first use. + pub fn set_gutter_markers(&mut self, markers: Vec, cx: &mut Context) { + self.extras.annotations.gutter_markers = markers; + self.extras.annotations.gutter_lane_reserved = true; + cx.notify(); + } + + pub fn clear_gutter_markers(&mut self, cx: &mut Context) { + if !self.extras.annotations.gutter_markers.is_empty() { + self.extras.annotations.gutter_markers.clear(); + cx.notify(); + } + } + + pub fn gutter_markers(&self) -> &[GutterMarker] { + &self.extras.annotations.gutter_markers + } + + pub fn gutter_marker_bounds(&self, id: &str) -> Option> { + self.extras + .annotations + .gutter_marker_bounds + .borrow() + .get(id) + .copied() + } + + pub fn set_gutter_marker_renderer( + &mut self, + renderer: GutterMarkerRenderer, + cx: &mut Context, + ) { + self.extras.annotations.gutter_marker_renderer = Some(renderer); + cx.notify(); + } + + #[doc(hidden)] + pub fn project_gutter_marker_renderer(&mut self, renderer: GutterMarkerRenderer) { + self.extras.annotations.gutter_marker_renderer = Some(renderer); + } + + pub fn set_range_decorations( + &mut self, + decorations: Vec, + cx: &mut Context, + ) { + self.extras.annotations.range_decorations = normalize_ranges(&self.text, decorations); + cx.notify(); + } + + pub fn clear_range_decorations(&mut self, cx: &mut Context) { + if !self.extras.annotations.range_decorations.is_empty() { + self.extras.annotations.range_decorations.clear(); + cx.notify(); + } + } + + pub fn range_decorations(&self) -> &[RangeDecoration] { + &self.extras.annotations.range_decorations + } + + pub fn set_inline_widgets(&mut self, widgets: Vec, cx: &mut Context) { + self.extras.annotations.inline_widgets = normalize_widgets(&self.text, widgets); + cx.notify(); + } + + pub fn clear_inline_widgets(&mut self, cx: &mut Context) { + if !self.extras.annotations.inline_widgets.is_empty() { + self.extras.annotations.inline_widgets.clear(); + cx.notify(); + } + } + + pub fn inline_widgets(&self) -> &[InlineWidget] { + &self.extras.annotations.inline_widgets + } +} + +fn normalize_ranges(text: &Rope, decorations: Vec) -> Vec { + decorations + .into_iter() + .filter_map(|mut decoration| { + decoration.range = text.clip_offset(decoration.range.start, Bias::Left) + ..text.clip_offset(decoration.range.end, Bias::Right); + (!decoration.range.is_empty()).then_some(decoration) + }) + .collect() +} + +fn normalize_widgets(text: &Rope, widgets: Vec) -> Vec { + widgets + .into_iter() + .map(|mut widget| { + widget.offset = text.clip_offset(widget.offset, Bias::Left); + widget + }) + .collect() } #[cfg(test)] @@ -333,4 +623,31 @@ mod tests { assert_eq!(adjust_range_for_edit(&(2..6), &(6..6), 2), 2..6); assert_eq!(adjust_range_for_edit(&(2..6), &(2..6), 3), 2..5); } + + #[test] + fn geometric_decorations_and_widgets_follow_utf8_edits() { + let mut annotations = EditorAnnotations { + range_decorations: vec![RangeDecoration::new("range", 2..6)], + inline_widgets: vec![InlineWidget::new("hint", 6, "hint")], + ..Default::default() + }; + + annotations.adjust_for_edit(&(0..0), "é".len()); + assert_eq!(annotations.range_decorations[0].range(), &(4..8)); + assert_eq!(annotations.inline_widgets[0].offset(), 8); + + annotations.adjust_for_edit(&(5..7), 1); + assert_eq!(annotations.range_decorations[0].range(), &(4..7)); + assert_eq!(annotations.inline_widgets[0].offset(), 7); + } + + #[test] + fn extension_ranges_and_offsets_clip_to_utf8_boundaries() { + let text = Rope::from("éx"); + let decorations = normalize_ranges(&text, vec![RangeDecoration::new("range", 1..3)]); + let widgets = normalize_widgets(&text, vec![InlineWidget::new("hint", 1, "hint")]); + + assert_eq!(decorations[0].range(), &(0..3)); + assert_eq!(widgets[0].offset(), 0); + } } diff --git a/crates/base/src/input/editor/mod.rs b/crates/base/src/input/editor/mod.rs index 27d0b77928..fa9cd08028 100644 --- a/crates/base/src/input/editor/mod.rs +++ b/crates/base/src/input/editor/mod.rs @@ -50,6 +50,7 @@ impl InputModeKind for EditorMode { new_len: usize, ) { state.extras.decorations.adjust_for_edit(range, new_len); + state.extras.annotations.adjust_for_edit(range, new_len); } fn refresh_language_features( @@ -251,4 +252,36 @@ impl crate::input::InputExtras for super::EditorExtras { !self.lsp.code_action_providers.is_empty(), ) } + + fn gutter_markers(&self) -> &[super::GutterMarker] { + &self.annotations.gutter_markers + } + + fn gutter_lane_reserved(&self) -> bool { + self.annotations.gutter_lane_reserved + } + + fn gutter_marker_renderer(&self) -> Option { + self.annotations.gutter_marker_renderer.clone() + } + + fn gutter_marker_bounds( + &self, + ) -> Option< + std::rc::Rc< + std::cell::RefCell< + std::collections::HashMap>, + >, + >, + > { + Some(self.annotations.gutter_marker_bounds.clone()) + } + + fn range_decorations(&self) -> &[super::RangeDecoration] { + &self.annotations.range_decorations + } + + fn inline_widgets(&self) -> &[super::InlineWidget] { + &self.annotations.inline_widgets + } } diff --git a/crates/base/src/input/mod.rs b/crates/base/src/input/mod.rs index 11a47b3693..0e973d53d3 100644 --- a/crates/base/src/input/mod.rs +++ b/crates/base/src/input/mod.rs @@ -70,7 +70,10 @@ pub(crate) fn init(cx: &mut App) { pub use crate::number_input::{NumberInputEvent, NumberStep}; pub use base::{InputBase, InputContextMenuCapabilities, InputStyles}; pub use cursor::Selection; -pub use decorations::{TextDecoration, TextDecorationCollection}; +pub use decorations::{ + GutterMarker, GutterMarkerRenderer, InlineWidget, RangeDecoration, RangeDecorationStyle, + TextDecoration, TextDecorationCollection, +}; pub use diagnostics::{ Diagnostic, DiagnosticEntry, DiagnosticRelatedInformation, DiagnosticSet, DiagnosticSeverity, DiagnosticSummary, DiagnosticTag, RelatedInformation, diff --git a/crates/component/src/input/editor.rs b/crates/component/src/input/editor.rs index 3f279d3a61..ff8688faa4 100644 --- a/crates/component/src/input/editor.rs +++ b/crates/component/src/input/editor.rs @@ -1,12 +1,14 @@ use std::rc::Rc; use gpui::{ - App, DefiniteLength, Entity, IntoElement, RenderOnce, SharedString, StyleRefinement, Styled, - Window, prelude::FluentBuilder as _, relative, + App, DefiniteLength, Entity, InteractiveElement as _, IntoElement, ParentElement as _, + RenderOnce, SharedString, StyleRefinement, Styled, Window, div, prelude::FluentBuilder as _, + relative, }; use super::{EditorState, Input}; use crate::native_menu::NativeMenu; +use crate::tooltip::{ManagedTooltipExt as _, Tooltip}; use crate::{ActiveTheme as _, RoleOverride, StyledExt as _}; /// A code editor takes its rows from the font, so that a smaller or larger @@ -26,6 +28,7 @@ pub struct Editor { tab_index: isize, role: RoleOverride, aria_label: Option, + gutter_marker_renderer: Option, /// An optional context menu builder to allow a custom context menu. /// @@ -46,6 +49,23 @@ impl Editor { tab_index: 0, role: RoleOverride::default(), aria_label: None, + gutter_marker_renderer: Some(Rc::new(|marker| { + div() + .id(marker.id().clone()) + .size_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .when(!marker.is_enabled(), |this| this.opacity(0.5)) + .child(marker.icon().clone()) + .when_some(marker.tooltip().cloned(), |this, tooltip| { + this.managed_tooltip(move |window, cx| { + Tooltip::new(tooltip.clone()).build(window, cx) + }) + }) + .into_any_element() + })), context_menu_builder: None, } } @@ -106,6 +126,15 @@ impl Editor { self.context_menu_builder = Some(Rc::new(f)); self } + + /// Render application-defined gutter marker icon tokens. + pub fn gutter_marker_renderer( + mut self, + renderer: impl Fn(&crate::input::GutterMarker) -> gpui::AnyElement + 'static, + ) -> Self { + self.gutter_marker_renderer = Some(Rc::new(renderer)); + self + } } impl Styled for Editor { @@ -116,6 +145,11 @@ impl Styled for Editor { impl RenderOnce for Editor { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + if let Some(renderer) = self.gutter_marker_renderer.clone() { + self.state.update(cx, |state, _| { + state.project_gutter_marker_renderer(renderer); + }); + } Input::from_state(self.state.clone()) // Source code wants a monospace font at a code size, and rows that // follow that size. These come first so that a text style set on @@ -145,8 +179,7 @@ mod tests { use super::*; use crate::input::EditorState; use gpui::{ - AppContext as _, Context, ParentElement as _, Pixels, Render, TestAppContext, - VisualTestContext, div, px, + AppContext as _, Context, Pixels, Render, TestAppContext, VisualTestContext, div, px, }; struct Harness { diff --git a/crates/component/src/input/mod.rs b/crates/component/src/input/mod.rs index becfdac527..fdf028e664 100644 --- a/crates/component/src/input/mod.rs +++ b/crates/component/src/input/mod.rs @@ -21,15 +21,16 @@ pub use gpui_base::input::{ CompletionProvider, Copy, Cut, DefinitionProvider, Delete, DeleteToBeginningOfLine, DeleteToEndOfLine, DeleteToNextWordEnd, DeleteToPreviousWordStart, DisplayMap, DisplayPoint, DocumentColorProvider, DocumentRangeSemanticTokensProvider, EditorState, Enter, Escape, - FoldRange, GoToDefinition, HighlightStyleResolver, HoverPopoverState, HoverProvider, Indent, - IndentInline, InputEdit, InputEvent, InputHighlighter, InputHighlighterFactory, InputState, - Lsp, MaskPattern, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight, - MoveToEnd, MoveToEndOfLine, MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveToStartOfLine, - MoveUp, Outdent, OutdentInline, Paste, Point, Redo, Replace, Rope, RopeExt, RopeLines, Search, - SelectAll, SelectToEnd, SelectToEndOfLine, SelectToNextWordEnd, SelectToPreviousWordStart, - SelectToStart, SelectToStartOfLine, Selection, ShowCharacterPalette, ShowDocumentHandler, - TabSize, TextDecoration, TextDecorationCollection, TextareaState, ToggleCodeActions, Undo, - WrappingIndent, + FoldRange, GoToDefinition, GutterMarker, GutterMarkerRenderer, HighlightStyleResolver, + HoverPopoverState, HoverProvider, Indent, IndentInline, InlineWidget, InputEdit, InputEvent, + InputHighlighter, InputHighlighterFactory, InputState, Lsp, MaskPattern, MoveDown, MoveEnd, + MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight, MoveToEnd, MoveToEndOfLine, + MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveToStartOfLine, MoveUp, Outdent, + OutdentInline, Paste, Point, RangeDecoration, RangeDecorationStyle, Redo, Replace, Rope, + RopeExt, RopeLines, Search, SelectAll, SelectToEnd, SelectToEndOfLine, SelectToNextWordEnd, + SelectToPreviousWordStart, SelectToStart, SelectToStartOfLine, Selection, ShowCharacterPalette, + ShowDocumentHandler, TabSize, TextDecoration, TextDecorationCollection, TextareaState, + ToggleCodeActions, Undo, WrappingIndent, }; pub use gpui_base::input::{EditorMode, InputMode, InputModeKind, TextareaMode}; #[doc(hidden)] diff --git a/crates/shell/src/engine/quickjs/mod.rs b/crates/shell/src/engine/quickjs/mod.rs index 954e139a31..85f632fe2d 100644 --- a/crates/shell/src/engine/quickjs/mod.rs +++ b/crates/shell/src/engine/quickjs/mod.rs @@ -3776,7 +3776,10 @@ impl ShellRuntime { payload.set("secondary", *secondary)?; payload.set("shift", *shift)?; } - InputEvent::Change | InputEvent::Focus | InputEvent::Blur => {} + InputEvent::Change + | InputEvent::Focus + | InputEvent::Blur + | InputEvent::GutterMarkerMouseDown { .. } => {} } handler.call::<_, ()>(( payload, diff --git a/crates/story/src/stories/input_story.rs b/crates/story/src/stories/input_story.rs index 1c89b48787..2dc117c5bd 100644 --- a/crates/story/src/stories/input_story.rs +++ b/crates/story/src/stories/input_story.rs @@ -364,6 +364,7 @@ impl InputStory { } InputEvent::Focus => println!("Focus"), InputEvent::Blur => println!("Blur"), + InputEvent::GutterMarkerMouseDown { .. } => {} }; } } diff --git a/crates/story/src/stories/number_input_story.rs b/crates/story/src/stories/number_input_story.rs index af70040566..04fcfa4954 100644 --- a/crates/story/src/stories/number_input_story.rs +++ b/crates/story/src/stories/number_input_story.rs @@ -155,6 +155,7 @@ impl NumberInputStory { } InputEvent::Focus => println!("Focus"), InputEvent::Blur => println!("Blur"), + InputEvent::GutterMarkerMouseDown { .. } => {} } } From 8aaf1850305e796f92d98d9cd50978d6179efdcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=83=A1=E9=A3=9E?= <1835698775@qq.com> Date: Wed, 9 Sep 2026 21:42:00 +0800 Subject: [PATCH 2/2] input: track completion staleness and allow a popup refresh Editor documents now carry a monotonic revision and completion epoch, bumped on every content mutation through the model hook. Completion responses check both plus cursor, trigger offset and query when they land so a stale response can no longer overwrite a newer request. A public refresh_completion_popup re-requests popup completion without editing, for asynchronous metadata sources that become ready late. Co-Authored-By: Claude --- crates/base/src/input/base/kind.rs | 3 + crates/base/src/input/base/state.rs | 22 ++- crates/base/src/input/editor/decorations.rs | 7 + .../base/src/input/editor/lsp/completions.rs | 154 +++++++++++++++++- crates/base/src/input/editor/mod.rs | 8 + 5 files changed, 180 insertions(+), 14 deletions(-) diff --git a/crates/base/src/input/base/kind.rs b/crates/base/src/input/base/kind.rs index 567bf3a4e0..5791b711d0 100644 --- a/crates/base/src/input/base/kind.rs +++ b/crates/base/src/input/base/kind.rs @@ -235,6 +235,9 @@ pub trait InputModeKind: sealed::Sealed + Sized + 'static { ) { } + /// Records a successful document mutation after validation and normalization. + fn document_did_change(_state: &mut InputBaseState) {} + /// Refreshes language-server state after the text changed. fn refresh_language_features( _state: &mut InputBaseState, diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index e6e768827b..145fd89cc9 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -3492,6 +3492,7 @@ impl InputBaseState { self.text.replace(range.clone(), new_text); M::adjust_annotations(self, range, new_text.len()); + M::document_did_change(self); recorded |= self.push_history( &old_text, range, @@ -3837,12 +3838,16 @@ impl EntityInputHandler for InputBaseState { } } - if mask_changed { - // Masking rewrites the whole document, so ranges recorded against - // the old text no longer point at anything. - M::reset_annotations(self); - } else { - M::adjust_annotations(self, &range, new_text.len()); + let document_changed = old_text != self.text; + if document_changed { + if mask_changed { + // Masking rewrites the whole document, so ranges recorded against + // the old text no longer point at anything. + M::reset_annotations(self); + } else { + M::adjust_annotations(self, &range, new_text.len()); + } + M::document_did_change(self); } if mask_changed { // A segment-based history entry no longer matches the masked @@ -3972,7 +3977,10 @@ impl EntityInputHandler for InputBaseState { } } - M::adjust_annotations(self, &range, new_text.len()); + if old_text != self.text { + M::adjust_annotations(self, &range, new_text.len()); + M::document_did_change(self); + } if let Some(diagnostics) = self.mode.diagnostics_mut() { diagnostics.reset(&self.text) } diff --git a/crates/base/src/input/editor/decorations.rs b/crates/base/src/input/editor/decorations.rs index aa1ffb6849..4f612e1ec1 100644 --- a/crates/base/src/input/editor/decorations.rs +++ b/crates/base/src/input/editor/decorations.rs @@ -159,6 +159,8 @@ impl InlineWidget { #[derive(Default)] pub(crate) struct EditorAnnotations { + pub(crate) document_revision: u64, + pub(crate) completion_epoch: u64, pub(crate) gutter_markers: Vec, pub(crate) gutter_lane_reserved: bool, pub(crate) gutter_marker_bounds: Rc>>>, @@ -441,6 +443,11 @@ impl InputBaseState { } } + /// Monotonic content revision. Selection, focus and scrolling do not change it. + pub fn document_revision(&self) -> u64 { + self.extras.annotations.document_revision + } + /// Replace all gutter markers. The marker lane remains reserved after first use. pub fn set_gutter_markers(&mut self, markers: Vec, cx: &mut Context) { self.extras.annotations.gutter_markers = markers; diff --git a/crates/base/src/input/editor/lsp/completions.rs b/crates/base/src/input/editor/lsp/completions.rs index 5e3113129c..ce70e5783c 100644 --- a/crates/base/src/input/editor/lsp/completions.rs +++ b/crates/base/src/input/editor/lsp/completions.rs @@ -8,12 +8,33 @@ use lsp_types::{ }; use ropey::Rope; use std::{cell::RefCell, ops::Range, rc::Rc, time::Duration}; +use sum_tree::Bias; -use crate::input::InputBaseState; +use crate::input::{InputBaseState, RopeExt as _}; /// Default debounce duration for inline completions. const DEFAULT_INLINE_COMPLETION_DEBOUNCE: Duration = Duration::from_millis(300); +#[allow(clippy::too_many_arguments)] +fn completion_context_is_current( + current_epoch: u64, + request_epoch: u64, + current_revision: u64, + request_revision: u64, + current_cursor: usize, + request_cursor: usize, + current_start: Option, + request_start: usize, + current_query: &str, + request_query: &str, +) -> bool { + current_epoch == request_epoch + && current_revision == request_revision + && current_cursor == request_cursor + && current_start == Some(request_start) + && current_query == request_query +} + /// Display options for the LSP completion popover. /// /// Accessed through [`super::Lsp::completion_menu`] so embedders can tweak the @@ -125,6 +146,39 @@ impl InputBaseState { new_text: &str, window: &mut Window, cx: &mut Context, + ) { + self.request_completion(range, new_text, false, window, cx); + } + + /// Re-run popup completion at the current cursor without changing the document. + /// + /// This is useful when an asynchronous metadata source becomes ready after + /// the original request. Newline, tab, and line-start positions are ignored. + pub fn refresh_completion_popup(&mut self, window: &mut Window, cx: &mut Context) { + if self.completion_inserting { + return; + } + let cursor = self.cursor(); + let start = self.text.clip_offset(cursor.saturating_sub(1), Bias::Left); + let Some(last_char) = self.text.char_at(start) else { + return; + }; + if !(last_char.is_ascii_alphanumeric() + || matches!(last_char, '_' | '.' | ' ' | ')' | ']' | '"' | '\'')) + { + return; + } + let text = self.text.slice(start..cursor).to_string(); + self.request_completion(&(start..start), &text, true, window, cx); + } + + fn request_completion( + &mut self, + range: &Range, + new_text: &str, + force: bool, + window: &mut Window, + cx: &mut Context, ) { if self.completion_inserting { return; @@ -141,7 +195,7 @@ impl InputBaseState { let start = range.end; let new_offset = self.cursor(); - if !provider.is_completion_trigger(start, new_text, cx) { + if !force && !provider.is_completion_trigger(start, new_text, cx) { return; } @@ -175,10 +229,17 @@ impl InputBaseState { .clone_from(&query); let completion_context = CompletionContext { - trigger_kind: lsp_types::CompletionTriggerKind::TRIGGER_CHARACTER, + trigger_kind: if force { + lsp_types::CompletionTriggerKind::INVOKED + } else { + lsp_types::CompletionTriggerKind::TRIGGER_CHARACTER + }, trigger_character: Some(query), }; + let request_id = self.next_completion_request_id(); + let document_revision = self.document_revision(); + let query = self.extras.context_menu_content.completion.query.clone(); let provider_responses = provider.completions(&self.text, new_offset, completion_context, window, cx); self.extras.context_menu_task = cx.spawn_in(window, async move |editor, cx| { @@ -191,7 +252,17 @@ impl InputBaseState { } if completions.is_empty() { - editor.update(cx, |editor, cx| { + editor.update_in(cx, |editor, window, cx| { + if !editor.completion_request_is_current( + request_id, + document_revision, + new_offset, + start_offset, + &query, + window, + ) { + return; + } editor.extras.context_menu_content.completion.open = false; editor.extras.context_menu_content.completion.items.clear(); editor.extras.context_menu_content.completion.bump(); @@ -202,7 +273,14 @@ impl InputBaseState { editor .update_in(cx, |editor, window, cx| { - if !editor.focus_handle.is_focused(window) { + if !editor.completion_request_is_current( + request_id, + document_revision, + new_offset, + start_offset, + &query, + window, + ) { return; } @@ -223,6 +301,39 @@ impl InputBaseState { }); } + fn next_completion_request_id(&mut self) -> u64 { + self.extras.annotations.completion_epoch = + self.extras.annotations.completion_epoch.saturating_add(1); + self.extras.annotations.completion_epoch + } + + fn completion_request_is_current( + &self, + request_id: u64, + document_revision: u64, + cursor: usize, + start_offset: usize, + query: &str, + window: &Window, + ) -> bool { + self.focus_handle.is_focused(window) + && completion_context_is_current( + self.extras.annotations.completion_epoch, + request_id, + self.document_revision(), + document_revision, + self.cursor(), + cursor, + self.extras + .context_menu_content + .completion + .trigger_start_offset, + start_offset, + &self.extras.context_menu_content.completion.query, + query, + ) + } + pub(crate) fn hide_context_menu(&mut self, cx: &mut Context) { self.extras.context_menu_content.completion.open = false; self.extras.context_menu_content.code_action.open = false; @@ -283,6 +394,8 @@ impl InputBaseState { let offset = self.cursor(); let text = self.text.clone(); + let request_id = self.next_completion_request_id(); + let document_revision = self.document_revision(); let debounce = provider.inline_completion_debounce(); let background_executor = cx.background_executor().clone(); @@ -293,7 +406,11 @@ impl InputBaseState { // Now fetch the inline completion after the debounce period let task = editor.update_in(cx, |editor, window, cx| { // Check if cursor has moved during debounce - if editor.cursor() != offset { + if editor.extras.annotations.completion_epoch != request_id + || editor.document_revision() != document_revision + || editor.cursor() != offset + || editor.text != text + { return None; } @@ -318,7 +435,11 @@ impl InputBaseState { editor.update_in(cx, |editor, _window, cx| { // Only apply if cursor still hasn't moved - if editor.cursor() != offset { + if editor.extras.annotations.completion_epoch != request_id + || editor.document_revision() != document_revision + || editor.cursor() != offset + || editor.text != text + { return; } @@ -370,3 +491,22 @@ impl InputBaseState { true } } + +#[cfg(test)] +mod tests { + use super::completion_context_is_current; + + #[test] + fn completion_context_rejects_every_stale_dimension() { + let current = |epoch, revision, cursor, start, query: &str| { + completion_context_is_current(epoch, 7, revision, 11, cursor, 5, start, 2, query, "abc") + }; + + assert!(current(7, 11, 5, Some(2), "abc")); + assert!(!current(8, 11, 5, Some(2), "abc")); + assert!(!current(7, 12, 5, Some(2), "abc")); + assert!(!current(7, 11, 6, Some(2), "abc")); + assert!(!current(7, 11, 5, Some(1), "abc")); + assert!(!current(7, 11, 5, Some(2), "abcd")); + } +} diff --git a/crates/base/src/input/editor/mod.rs b/crates/base/src/input/editor/mod.rs index fa9cd08028..ed173bf466 100644 --- a/crates/base/src/input/editor/mod.rs +++ b/crates/base/src/input/editor/mod.rs @@ -53,6 +53,14 @@ impl InputModeKind for EditorMode { state.extras.annotations.adjust_for_edit(range, new_len); } + fn document_did_change(state: &mut InputBaseState) { + state.extras.annotations.document_revision = + state.extras.annotations.document_revision.saturating_add(1); + state.extras.annotations.completion_epoch = + state.extras.annotations.completion_epoch.saturating_add(1); + state.extras.inline_completion.item = None; + } + fn refresh_language_features( state: &mut InputBaseState, window: &mut Window,