From f272f80d099e0cc101b7c04dc57977b21af40492 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 5 Nov 2026 16:42:15 +0700 Subject: [PATCH] feat: add selection, active line, and incremental highlighting to code view --- crates/editor/src/code_view.rs | 487 +++++++++++++++++++++++-- crates/editor/src/highlight.rs | 93 +++-- crates/editor/tests/code_view.rs | 119 +++++- crates/editor/tests/code_view_bench.rs | 13 +- docs/CHECKLIST.md | 16 +- examples/gallery/src/main.rs | 4 +- 6 files changed, 640 insertions(+), 92 deletions(-) diff --git a/crates/editor/src/code_view.rs b/crates/editor/src/code_view.rs index 38dc4f1..9b99242 100644 --- a/crates/editor/src/code_view.rs +++ b/crates/editor/src/code_view.rs @@ -4,15 +4,47 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - Context, ElementId, HighlightStyle, Hsla, InteractiveElement as _, IntoElement, - ParentElement as _, Pixels, Render, ScrollStrategy, SharedString, - StatefulInteractiveElement as _, Styled as _, TextStyle, UniformListScrollHandle, Window, div, - uniform_list, + App, Bounds, ClipboardItem, Context, ElementId, HighlightStyle, Hsla, InteractiveElement as _, + IntoElement, KeyBinding, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + ParentElement as _, Pixels, Point, Render, ScrollStrategy, SharedString, + StatefulInteractiveElement as _, Styled as _, TextStyle, UniformListScrollHandle, Window, + actions, canvas, div, uniform_list, }; -use gpui_native_core::scrollbar::{Scrollbar, ScrollbarVisibility}; +use gpui_native_core::scrollbar::{Scrollbar, ScrollbarHandle, ScrollbarVisibility}; use gpui_native_theme::{ActiveTheme as _, ColorTokens, Ramps}; -use crate::highlight::{HighlightedCode, Language, Role, highlight}; +use crate::highlight::{HighlightedCode, Language, Role, highlight_with_tree}; + +actions!(code_view, [Copy]); + +/// The key context the code view declares while it holds focus. +pub const CODE_VIEW_CONTEXT: &str = "CodeView"; + +/// Binds the code view's copy chord: cmd-c on macOS, ctrl-c elsewhere. +/// +/// Call once at startup. Without it, selection copy has no key. +pub fn bind_code_view_keys(cx: &mut App) { + let context = Some(CODE_VIEW_CONTEXT); + #[cfg(target_os = "macos")] + let copy = "cmd-c"; + #[cfg(not(target_os = "macos"))] + let copy = "ctrl-c"; + cx.bind_keys([KeyBinding::new(copy, Copy, context)]); +} + +/// A point in the source, in lines and characters (not bytes). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SelectionPoint { + pub row: usize, + pub col: usize, +} + +impl SelectionPoint { + /// The earlier of two points, in document order. + fn earlier_than(&self, other: &Self) -> bool { + (self.row, self.col) <= (other.row, other.col) + } +} /// Retained state for a code view. pub struct CodeViewState { @@ -20,26 +52,40 @@ pub struct CodeViewState { source: SharedString, language: Language, code: HighlightedCode, + /// The parse tree of `source`, so updates reparse incrementally instead of from scratch. + tree: Option, handle: UniformListScrollHandle, + /// The hovered line, driving the active-line wash and number emphasis. + active_line: Option, + /// `(anchor, head)` in the order they were set. + selection: Option<(SelectionPoint, SelectionPoint)>, + /// Whether a drag that started on a row is still extending the selection. + selecting: bool, + focus_handle: gpui::FocusHandle, a11y_label: Option, } impl CodeViewState { /// Builds the state, highlighting `source` with `language`. - pub fn new(id: I, source: S, language: Language) -> Self + pub fn new(id: I, source: S, language: Language, cx: &mut App) -> Self where I: Into, S: Into, { let source = source.into(); - let code = highlight(&source, language); + let (code, tree) = highlight_with_tree(&source, language, None); Self { id: id.into(), source, language, code, + tree, handle: UniformListScrollHandle::new(), + active_line: None, + selection: None, + selecting: false, + focus_handle: cx.focus_handle(), a11y_label: None, } } @@ -49,10 +95,48 @@ impl CodeViewState { where S: Into, { - self.source = source.into(); + let source = source.into(); + + // The old tree is only reusable when the grammar stays the same. + let old_tree = if language == self.language { + self.tree.as_ref() + } else { + None + }; + + let (code, tree) = highlight_with_tree(&source, language, old_tree); + self.source = source; self.language = language; - self.code = highlight(&self.source, self.language); + self.code = code; + self.tree = tree; + self.selection = None; self.handle.scroll_to_item(0, ScrollStrategy::Top); + + cx.notify(); + } + + /// Replaces one line's text and re-highlights the file. + pub fn update_line(&mut self, line_ix: usize, text: T, cx: &mut Context) + where + T: AsRef, + { + let trailing_newline = self.source.ends_with('\n'); + let mut lines: Vec = self.source.lines().map(str::to_string).collect(); + let line_ix = line_ix.min(lines.len().saturating_sub(1)); + + let replacement: Vec = if text.as_ref().is_empty() { + vec![String::new()] + } else { + text.as_ref().lines().map(str::to_string).collect() + }; + + lines.splice(line_ix..line_ix + 1, replacement); + self.source = (lines.join("\n") + if trailing_newline { "\n" } else { "" }).into(); + + let (code, tree) = highlight_with_tree(&self.source, self.language, self.tree.as_ref()); + self.code = code; + self.tree = tree; + self.selection = None; cx.notify(); } @@ -79,6 +163,71 @@ impl CodeViewState { ); } + /// The line the pointer last hovered, if any. + pub fn active_line(&self) -> Option { + self.active_line + } + + /// Selects a range, endpoints in lines and characters. + pub fn select_range( + &mut self, + start: SelectionPoint, + end: SelectionPoint, + cx: &mut Context, + ) { + self.selection = if start.earlier_than(&end) { + Some((start, end)) + } else { + Some((end, start)) + }; + self.selecting = false; + cx.notify(); + } + + /// The selected range, endpoints in document order, when there is one. + pub fn selection(&self) -> Option<(SelectionPoint, SelectionPoint)> { + self.selection.map(|(anchor, head)| { + if anchor.earlier_than(&head) { + (anchor, head) + } else { + (head, anchor) + } + }) + } + + /// The selected text, or `None` when nothing is selected. + pub fn selected_text(&self) -> Option { + let (start, end) = self.selection()?; + + let lines: Vec<&str> = self.source.split('\n').collect(); + let mut selected = Vec::new(); + + for row in start.row..=end.row.min(lines.len().saturating_sub(1)) { + let line_chars: Vec = lines.get(row)?.chars().collect(); + let start_col = if row == start.row { + start.col.min(line_chars.len()) + } else { + 0 + }; + + let end_col = if row == end.row { + end.col.min(line_chars.len()) + } else { + line_chars.len() + }; + + selected.push(line_chars[start_col..end_col].iter().collect::()); + } + + (!selected.is_empty()).then(|| selected.join("\n")) + } + + /// Drops the selection. + pub fn clear_selection(&mut self) { + self.selection = None; + self.selecting = false; + } + /// Overrides the accessibility label of the view. pub fn accessibility_label(mut self, label: L) -> Self where @@ -87,6 +236,39 @@ impl CodeViewState { self.a11y_label = Some(label.into()); self } + + /// Maps a window position to a point in the source. + fn position_at( + &self, + bounds: Bounds, + position: Point, + scroll_y: Pixels, + line_height: Pixels, + char_width: Pixels, + text_x: Pixels, + ) -> SelectionPoint { + let row = (((position.y - bounds.origin.y - scroll_y) / line_height).floor() as isize) + .clamp(0, self.code.lines.len() as isize - 1) + .max(0) as usize; + + let chars = self.code.lines[row] + .spans + .iter() + .map(|span| span.text.chars().count()) + .sum::(); + + let col = (((position.x - text_x) / char_width).round() as isize) + .clamp(0, chars as isize) + .max(0) as usize; + + SelectionPoint { row, col } + } +} + +impl gpui::Focusable for CodeViewState { + fn focus_handle(&self, _: &App) -> gpui::FocusHandle { + self.focus_handle.clone() + } } impl Render for CodeViewState { @@ -96,24 +278,36 @@ impl Render for CodeViewState { let mono_font = theme.mono_font; let text_size = theme.sizes.small.text_size; let line_height = text_size * 1.5; + let char_width = text_size * 0.62; + let root_font_size = theme.root_font_size; let id = self.id.clone(); let state = cx.entity(); let line_count = self.code.lines.len(); + let selection = self.selection(); + let gutter_chars = line_count.max(1).to_string().len().max(2); let gutter_width = text_size * 0.62 * gutter_chars as f32 + text_size; + let bounds_cell: std::rc::Rc>>> = + std::rc::Rc::new(std::cell::RefCell::new(None)); + let rows = uniform_list((id.clone(), "rows"), line_count, { let id = id.clone(); + let state = state.clone(); move |range, _window, cx| { let code = state.read(cx); + let active_line = code.active_line; let theme = cx.theme(); + range .map(|line_ix| { render_row( code, &id, line_ix, + active_line == Some(line_ix), + selection, theme.mono_font, theme.colors, theme.ramps, @@ -136,16 +330,135 @@ impl Render for CodeViewState { .text_size(text_size) .font_family(mono_font) .text_color(colors.foreground) - .when_some(self.a11y_label.clone(), |view, label| { - view.aria_label(label) + .key_context(CODE_VIEW_CONTEXT) + .track_focus(&self.focus_handle) + .on_mouse_down(MouseButton::Left, { + let state = state.clone(); + let bounds_cell = bounds_cell.clone(); + move |event: &MouseDownEvent, window: &mut Window, cx: &mut App| { + window.focus(&state.read(cx).focus_handle.clone(), cx); + + let bounds = bounds_cell.borrow(); + let Some(bounds) = bounds.as_ref() else { + return; + }; + + let text_x = bounds.origin.x + gutter_width + root_font_size; + let point = { + let code = state.read(cx); + code.position_at( + *bounds, + event.position, + Pixels::ZERO, + line_height, + char_width, + text_x, + ) + }; + + state.update(cx, |state, cx| { + if event.modifiers.shift { + if let Some((anchor, _)) = state.selection { + state.selection = Some((anchor, point)); + } else { + state.selection = Some((point, point)); + } + } else { + state.selection = Some((point, point)); + } + state.selecting = true; + cx.notify(); + }); + } }) - .when_some(self.a11y_label.clone(), |view, label| { - view.aria_label(label) + .on_mouse_move({ + let state = state.clone(); + let bounds_cell = bounds_cell.clone(); + move |event: &MouseMoveEvent, _window: &mut Window, cx: &mut App| { + let bounds = bounds_cell.borrow(); + let Some(bounds) = bounds.as_ref() else { + return; + }; + + let text_x = bounds.origin.x + gutter_width + root_font_size; + let point = { + let code = state.read(cx); + code.position_at( + *bounds, + event.position, + ScrollbarHandle::offset(&code.handle).y, + line_height, + char_width, + text_x, + ) + }; + + let selecting = state.read(cx).selecting; + if selecting { + let changed = state + .read(cx) + .selection + .map(|(_, head)| head != point) + .unwrap_or(true); + if changed { + state.update(cx, |state, cx| { + if let Some((anchor, _)) = state.selection { + state.selection = Some((anchor, point)); + } + cx.notify(); + }); + } + return; + } + + let changed = state.read(cx).active_line != Some(point.row); + if changed { + state.update(cx, |state, cx| { + state.active_line = Some(point.row); + cx.notify(); + }); + } + } }) + .on_mouse_up(MouseButton::Left, { + let state = state.clone(); + move |_: &MouseUpEvent, _window: &mut Window, cx: &mut App| { + if state.read(cx).selecting { + state.update(cx, |state, cx| { + state.selecting = false; + cx.notify(); + }); + } + } + }) + .on_action({ + let state = state.clone(); + move |_: &Copy, _window, cx| { + if let Some(text) = state.read(cx).selected_text() { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + } + }) + .child( + canvas( + { + let bounds_cell = bounds_cell.clone(); + move |bounds, _window, _cx| { + *bounds_cell.borrow_mut() = Some(bounds); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) .child(rows) .child( Scrollbar::vertical(self.handle.clone()).visibility(ScrollbarVisibility::Platform), ) + .when_some(self.a11y_label.clone(), |view, label| { + view.aria_label(label) + }) } } @@ -154,6 +467,8 @@ fn render_row( code: &CodeViewState, id: &ElementId, line_ix: usize, + active: bool, + selection: Option<(SelectionPoint, SelectionPoint)>, mono_font: &'static str, colors: ColorTokens, ramps: Ramps, @@ -162,22 +477,74 @@ fn render_row( gutter_width: Pixels, ) -> gpui::AnyElement { let line = code.code().lines.get(line_ix).cloned().unwrap_or_default(); - // One text element per line, colored by byte-range highlights + let mut text = String::new(); - let mut highlights: Vec<(std::ops::Range, HighlightStyle)> = Vec::new(); + for span in &line.spans { + text.push_str(&span.text); + } + + let char_count = text.chars().count(); + let mut roles = vec![Role::Text; char_count]; + let mut offset = 0usize; for span in &line.spans { - let start = text.len(); - text.push_str(&span.text); - if span.role != Role::Text { - highlights.push(( - start..text.len(), - HighlightStyle { - color: Some(role_color(span.role, colors, ramps)), - ..Default::default() - }, - )); + let chars = span.text.chars().count(); + for slot in &mut roles[offset..offset + chars] { + *slot = span.role; } + offset += chars; + } + + let mut selected = vec![false; char_count]; + + if let Some((start, end)) = selection + && line_ix >= start.row + && line_ix <= end.row + { + let start_col = if line_ix == start.row { + start.col.min(char_count) + } else { + 0 + }; + + let end_col = if line_ix == end.row { + end.col.min(char_count) + } else { + char_count + }; + + for slot in &mut selected[start_col..end_col] { + *slot = true; + } + } + + let mut highlights: Vec<(std::ops::Range, HighlightStyle)> = Vec::new(); + let mut run_start = 0usize; + let mut run_end = 0usize; + let mut run_role = Role::Text; + let mut run_selected = false; + + for (char_ix, (byte_ix, character)) in text.char_indices().enumerate() { + let fresh = char_ix == 0 || roles[char_ix] != run_role || selected[char_ix] != run_selected; + if fresh { + if char_ix > 0 { + highlights.push(( + run_start..run_end, + run_style(run_role, run_selected, colors, ramps), + )); + } + run_start = byte_ix; + run_role = roles[char_ix]; + run_selected = selected[char_ix]; + } + run_end = byte_ix + character.len_utf8(); + } + + if run_end > run_start || !text.is_empty() { + highlights.push(( + run_start..run_end, + run_style(run_role, run_selected, colors, ramps), + )); } let style = TextStyle { @@ -185,6 +552,7 @@ fn render_row( font_family: SharedString::from(mono_font), font_size: text_size.into(), line_height: line_height.into(), + white_space: gpui::WhiteSpace::Nowrap, ..Default::default() }; @@ -194,31 +562,80 @@ fn render_row( move || name }) .id(("code-row", line_ix)) + .w_full() .h(line_height) .flex() .items_center() .overflow_hidden() + .when(active, |row| { + row.bg(Hsla { + a: colors.muted.a * 0.45, + ..colors.muted + }) + }) .child( - div() - .w(gutter_width) - .flex_none() - .pr_2() - .text_right() - .font_family(mono_font) - .text_size(text_size) - .text_color(colors.muted_foreground) - .child(format!("{}", line_ix + 1)), + gutter_cell( + gutter_width, + line_height, + mono_font, + text_size, + if active { + colors.foreground + } else { + colors.muted_foreground + }, + format!("{}", line_ix + 1), + ) + .justify_end() + .pr_2() + .whitespace_nowrap(), ) .child( div() .flex_1() .min_w_0() + .ml_4() .overflow_hidden() + .whitespace_nowrap() .child(gpui::StyledText::new(text).with_default_highlights(&style, highlights)), ) .into_any_element() } +fn run_style(role: Role, selected: bool, colors: ColorTokens, ramps: Ramps) -> HighlightStyle { + HighlightStyle { + color: Some(role_color(role, colors, ramps)), + background_color: selected.then_some(colors.selection), + ..Default::default() + } +} + +/// A gutter cell sized to the whole row. +fn gutter_cell( + width: Pixels, + height: Pixels, + mono_font: &'static str, + text_size: Pixels, + color: Hsla, + content: E, +) -> gpui::Div +where + E: IntoElement, +{ + div() + .w(width) + .h(height) + .flex() + .items_center() + .justify_center() + .flex_none() + .font_family(mono_font) + .text_size(text_size) + .line_height(gpui::relative(1.0)) + .text_color(color) + .child(content) +} + /// Maps a highlight role to the theme's semantic palette. fn role_color(role: Role, colors: ColorTokens, ramps: Ramps) -> Hsla { match role { diff --git a/crates/editor/src/highlight.rs b/crates/editor/src/highlight.rs index e4fd98b..a214957 100644 --- a/crates/editor/src/highlight.rs +++ b/crates/editor/src/highlight.rs @@ -54,10 +54,62 @@ use tree_sitter::StreamingIterator as _; /// Highlights `source` with `language`'s tree-sitter grammar. pub fn highlight(source: &str, language: Language) -> HighlightedCode { + highlight_with_tree(source, language, None).0 +} + +/// Highlights `source`, reusing `old_tree` so only the changed region is re-lexed. +pub fn highlight_with_tree( + source: &str, + language: Language, + old_tree: Option<&tree_sitter::Tree>, +) -> (HighlightedCode, Option) { let Some((grammar, query_source)) = grammar_for(language) else { - return build_lines(source, &[]); + return (plain(source), None); }; - highlight_with(grammar, query_source, source).unwrap_or_else(|| build_lines(source, &[])) + + let mut parser = tree_sitter::Parser::new(); + if parser.set_language(&grammar).is_err() { + return (plain(source), None); + } + + let Some(tree) = parser.parse(source, old_tree) else { + return (plain(source), None); + }; + + let Ok(query) = tree_sitter::Query::new(&grammar, query_source) else { + return (plain(source), None); + }; + + let roles: Vec> = query + .capture_names() + .iter() + .map(|name| role_for_capture(name)) + .collect(); + + // One role per byte; matches arrive in query order + let mut role_at = vec![None; source.len()]; + let mut cursor = tree_sitter::QueryCursor::new(); + let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); + + while let Some(matched) = matches.next() { + for capture in matched.captures { + let Some(role) = roles.get(capture.index as usize).copied().flatten() else { + continue; + }; + for byte in capture.node.byte_range() { + if let Some(slot) = role_at.get_mut(byte) { + *slot = Some(role); + } + } + } + } + + (build_lines(source, &role_at), Some(tree)) +} + +/// Source rendered without any grammar: plain text roles. +pub(crate) fn plain(source: &str) -> HighlightedCode { + build_lines(source, &[]) } /// The grammar and its highlight query for a language, when compiled in. @@ -90,43 +142,6 @@ fn grammar_for(language: Language) -> Option<(tree_sitter::Language, &'static st } } -fn highlight_with( - grammar: tree_sitter::Language, - query_source: &str, - source: &str, -) -> Option { - let mut parser = tree_sitter::Parser::new(); - parser.set_language(&grammar).ok()?; - - let tree = parser.parse(source, None)?; - let query = tree_sitter::Query::new(&grammar, query_source).ok()?; - - let roles: Vec> = query - .capture_names() - .iter() - .map(|name| role_for_capture(name)) - .collect(); - - let mut role_at = vec![None; source.len()]; - let mut cursor = tree_sitter::QueryCursor::new(); - let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); - - while let Some(matched) = matches.next() { - for capture in matched.captures { - let Some(role) = roles.get(capture.index as usize).copied().flatten() else { - continue; - }; - for byte in capture.node.byte_range() { - if let Some(slot) = role_at.get_mut(byte) { - *slot = Some(role); - } - } - } - } - - Some(build_lines(source, &role_at)) -} - /// Maps a query capture name to a role; unmapped captures render as text. fn role_for_capture(name: &str) -> Option { match name.split('.').next()? { diff --git a/crates/editor/tests/code_view.rs b/crates/editor/tests/code_view.rs index 7310f60..4570ad9 100644 --- a/crates/editor/tests/code_view.rs +++ b/crates/editor/tests/code_view.rs @@ -2,10 +2,10 @@ use std::cell::RefCell; use std::rc::Rc; use gpui::{ - AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, - TestAppContext, VisualTestContext, Window, div, + AppContext as _, Context, Entity, Focusable as _, IntoElement, ParentElement as _, Render, + Styled as _, TestAppContext, VisualTestContext, Window, div, }; -use gpui_native_editor::code_view::CodeViewState; +use gpui_native_editor::code_view::{CodeViewState, bind_code_view_keys}; use gpui_native_editor::highlight::{HighlightedCode, Language, Role, highlight}; fn spans_with_role(code: &HighlightedCode, role: Role) -> Vec<&str> { @@ -66,7 +66,11 @@ fn a_capture_spanning_newlines_styles_both_lines() { #[test] fn set_source_rehighlights_with_the_new_language() { let mut cx = TestAppContext::single(); - let state = cx.new(|_| CodeViewState::new("test-code", "fn main() {}\n", Language::Rust)); + cx.update(|cx| { + gpui_native_theme::Theme::init(cx); + bind_code_view_keys(cx); + }); + let state = cx.new(|cx| CodeViewState::new("test-code", "fn main() {}\n", Language::Rust, cx)); cx.update(|cx| { state.update(cx, |state, cx| { @@ -98,16 +102,19 @@ fn code_view_window( line_count: usize, ) -> (TestAppContext, VisualTestContext, Entity) { let mut cx = TestAppContext::single(); - cx.update(gpui_native_theme::Theme::init); + cx.update(|cx| { + gpui_native_theme::Theme::init(cx); + bind_code_view_keys(cx); + }); let source: String = (0..line_count) .map(|line| format!("fn line_{line}() {{}}\n")) .collect(); - let state = CodeViewState::new("test-code", source, Language::Rust); let state_cell: Rc>>> = Rc::new(RefCell::new(None)); let cell = state_cell.clone(); - let window = cx.add_window(move |_, cx| { - let code = cx.new(|_| state); + let window = cx.add_window(move |window, cx| { + let code = cx.new(|cx| CodeViewState::new("test-code", source, Language::Rust, cx)); cell.borrow_mut().replace(code.clone()); + window.focus(&code.read(cx).focus_handle(cx), cx); Host { code } }); let state = state_cell.borrow().clone().expect("state captured"); @@ -145,3 +152,99 @@ fn rows_render_virtualized_and_scroll() { "the first row scrolled out" ); } + +#[test] +fn hovering_a_line_activates_it() { + let (cx, mut visual, state) = code_view_window(80); + + let row = visual + .debug_bounds("test-code-row-3") + .expect("row 3 renders in the viewport"); + visual.simulate_event(gpui::MouseMoveEvent { + position: row.center(), + ..Default::default() + }); + + let active = cx.update(|cx| state.read(cx).active_line()); + assert_eq!(active, Some(3), "the hovered line becomes the active line"); +} + +#[test] +fn selecting_then_copying_writes_the_clipboard() { + let (cx, mut visual, state) = code_view_window(10); + + cx.update(|cx| { + state.update(cx, |state, cx| { + state.select_range( + gpui_native_editor::code_view::SelectionPoint { row: 0, col: 0 }, + gpui_native_editor::code_view::SelectionPoint { row: 0, col: 3 }, + cx, + ); + }); + }); + + visual.simulate_keystrokes("cmd-c"); + + let clipboard = cx.update(|cx| { + cx.read_from_clipboard() + .and_then(|item| item.text().map(|text| text.to_string())) + }); + assert_eq!( + clipboard.as_deref(), + Some("fn "), + "the copy chord copies the selection" + ); +} + +#[test] +fn update_line_replaces_a_single_line_and_rehighlights() { + let mut cx = TestAppContext::single(); + cx.update(|cx| { + gpui_native_theme::Theme::init(cx); + }); + let state = cx.new(|cx| { + CodeViewState::new( + "test-code", + "fn a() {}\nfn b() {}\nfn c() {}\n", + Language::Rust, + cx, + ) + }); + + cx.update(|cx| { + state.update(cx, |state, cx| state.update_line(1, "struct S;", cx)); + }); + + let (source, line_count, keyword_on_line_1) = cx.update(|cx| { + let state = state.read(cx); + ( + state.source().to_string(), + state.code().lines.len(), + state.code().lines[1] + .spans + .iter() + .any(|span| span.role == Role::Keyword), + ) + }); + + assert_eq!(source, "fn a() {}\nstruct S;\nfn c() {}\n"); + assert_eq!(line_count, 4, "the line count survives the replacement"); + assert!(keyword_on_line_1, "struct highlights as a keyword"); +} + +#[test] +fn update_line_splices_multiline_text() { + let mut cx = TestAppContext::single(); + cx.update(|cx| { + gpui_native_theme::Theme::init(cx); + }); + let state = + cx.new(|cx| CodeViewState::new("test-code", "fn a() {}\nfn b() {}\n", Language::Rust, cx)); + + cx.update(|cx| { + state.update(cx, |state, cx| state.update_line(1, "one\ntwo\nthree", cx)); + }); + + let source = cx.update(|cx| state.read(cx).source().to_string()); + assert_eq!(source, "fn a() {}\none\ntwo\nthree\n"); +} diff --git a/crates/editor/tests/code_view_bench.rs b/crates/editor/tests/code_view_bench.rs index 0019328..bcb7ef2 100644 --- a/crates/editor/tests/code_view_bench.rs +++ b/crates/editor/tests/code_view_bench.rs @@ -7,7 +7,7 @@ use gpui::{ ScrollDelta, ScrollEvent, Styled as _, TestAppContext, VisualTestContext, Window, div, point, px, }; -use gpui_native_editor::code_view::CodeViewState; +use gpui_native_editor::code_view::{CodeViewState, bind_code_view_keys}; use gpui_native_editor::highlight::Language; /// Deterministic pseudo-Rust with mixed tokens, roughly `lines` lines long. @@ -50,12 +50,17 @@ impl Render for Host { #[test] fn scrolling_a_large_file_is_not_pathologically_slow() { let mut cx = TestAppContext::single(); - cx.update(gpui_native_theme::Theme::init); + cx.update(|cx| { + gpui_native_theme::Theme::init(cx); + bind_code_view_keys(cx); + }); let source = large_source(5000); let started = Instant::now(); - let state = CodeViewState::new("bench-code", source.clone(), Language::Rust); - let highlight_elapsed = started.elapsed(); + let (state, highlight_elapsed) = cx.update(|cx| { + let state = CodeViewState::new("bench-code", source.clone(), Language::Rust, cx); + (state, started.elapsed()) + }); let state_cell: Rc>>> = Rc::new(RefCell::new(None)); let cell = state_cell.clone(); diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 329917f..4977406 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -9,15 +9,21 @@ is the first downstream consumer; its screens drive the order. ## Next -- [ ] **Diff view** (M15, `editor`). - - [ ] API sketch - - [ ] Implementation - - [ ] Tests - - [ ] Gallery panel +- [ ] **Code view interactions** (M15, `editor`) — active line, text + selection with copy, incremental line re-highlight. + - [x] API sketch + - [x] Implementation + - [x] Tests + - [x] Gallery panel - [ ] Review ## Queue — to follow +- [ ] **Diff view** (`editor`) — implemented, then reverted to + `stash@{0}` ("pre-revert: diff view + gutter tweaks"); restore with + `git stash pop` and re-land after the code view interactions settle. +- [ ] **Editor follow-ups** — language detection by file path, meta line + overlay, per-view config overrides (line height, gutter visibility). - [ ] **Markdown view** (M16, `editor`) — only if a consumer confirms. - [ ] **Pickers** — Calendar / DatePicker, TimeField, ColorPicker (decide at unit time). diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index ef08788..394ccfe 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -2845,7 +2845,8 @@ struct CodeViewDemo { impl CodeViewDemo { fn new(cx: &mut Context) -> Self { - let code = cx.new(|_| CodeViewState::new("code-view-demo", RUST_SAMPLE, Language::Rust)); + let code = + cx.new(|cx| CodeViewState::new("code-view-demo", RUST_SAMPLE, Language::Rust, cx)); // The panel is a cached view in the dock: it re-renders only when // the code state it renders is notified. let subscription = cx.observe(&code, |_, _, cx| cx.notify()); @@ -3214,6 +3215,7 @@ fn main() { .with_http_client(std::sync::Arc::new(reqwest_client::ReqwestClient::new())) .run(|cx: &mut App| { gpui_native_core::init(cx); + gpui_native_editor::code_view::bind_code_view_keys(cx); cx.bind_keys([ KeyBinding::new("cmd-k", OpenCommandPalette, None), KeyBinding::new("ctrl-k", OpenCommandPalette, None),