From cf9cc1971cfa8a0cc74217fd334eec4be260afd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 09:13:49 -0400 Subject: [PATCH] editor: typewriter room as rows, so drawing and text queries share one origin; a placement resets centering --- src/documents/document/place.rs | 4 +- src/documents/document/tests/look.rs | 64 ++++++++++++++++++++++++++++ src/editor/ime.rs | 2 +- src/editor/layout.rs | 2 + src/editor/moves.rs | 1 + src/editor/row.rs | 5 +++ src/editor/state.rs | 8 ++-- src/editor/view.rs | 24 +++++------ 8 files changed, 91 insertions(+), 19 deletions(-) diff --git a/src/documents/document/place.rs b/src/documents/document/place.rs index 0df7aaf..89e691f 100644 --- a/src/documents/document/place.rs +++ b/src/documents/document/place.rs @@ -106,7 +106,7 @@ impl MarkdownDocument { if matches!(self.mode, MarkdownMode::Source | MarkdownMode::Split) { editor .scroll - .scroll_to_item_strict(line, ScrollStrategy::Top); + .scroll_to_item_strict(line + editor.pad, ScrollStrategy::Top); self.placing(ask); // The editor's offset holds it from the next frame. return cx.on_next_frame(window, move |document, _, cx| { @@ -158,6 +158,8 @@ impl MarkdownDocument { }); } self.top = None; + // The view moved: center again. + self.centered = None; cx.notify(); } diff --git a/src/documents/document/tests/look.rs b/src/documents/document/tests/look.rs index af8aa96..bb70af8 100644 --- a/src/documents/document/tests/look.rs +++ b/src/documents/document/tests/look.rs @@ -216,3 +216,67 @@ fn the_last_source_line_centers_and_a_resize_follows(cx: &mut TestAppContext) { "top {top} to {resized}; mid-view is {middle}" ); } + +/// Typewriter source, caret on line 60 of 120. +fn centered_source( + cx: &mut TestAppContext, +) -> ( + gpui::Entity, + &mut gpui::VisualTestContext, +) { + let text: String = (0..120).map(|n| format!("Line {n}.\n")).collect(); + let (document, cx) = open(&text, MarkdownMode::Source, cx); + document.update(cx, |document, cx| { + document.set_look(typewriter(), cx); + document.set_caret(60, 0, cx); + }); + frames(cx); + (document, cx) +} + +#[gpui::test] +fn source_lines_below_the_center_draw(cx: &mut TestAppContext) { + let (_, cx) = centered_source(cx); + let caret = cx.debug_bounds("editor-line-60").expect("caret line"); + let next = cx.debug_bounds("editor-line-61").expect("the line below"); + assert!(next.top() >= caret.bottom() - px(1.0)); +} + +#[gpui::test] +fn platform_text_queries_meet_the_drawn_line(cx: &mut TestAppContext) { + use gpui::EntityInputHandler as _; + let (document, cx) = centered_source(cx); + let drawn = cx.debug_bounds("editor-line-60").expect("caret line"); + let editor = document.read_with(cx, |document, _| document.editor().clone()); + let (anchor, line) = editor.update_in(cx, |editor, window, cx| { + let at = editor.text()[..editor.primary().head] + .encode_utf16() + .count(); + let anchor = editor.bounds_for_range(at..at, gpui::Bounds::default(), window, cx); + let point = gpui::point(editor.metrics.left, drawn.center().y); + let index = editor.character_index_for_point(point, window, cx); + let line = index.map(|at| editor.buffer.line_of(editor.byte_range(&(at..at)).start)); + (anchor.expect("anchor"), line) + }); + assert!( + (anchor.top() - drawn.top()).abs() < px(2.0), + "{anchor:?} vs {drawn:?}" + ); + assert_eq!(line, Some(60)); +} + +#[gpui::test] +fn switching_to_visual_centers_the_caret_block(cx: &mut TestAppContext) { + let (document, cx) = open(&long(), MarkdownMode::Source, cx); + document.update(cx, |document, cx| { + document.set_look(typewriter(), cx); + document.set_caret(100, 0, cx); + }); + frames(cx); + document.update_in(cx, |document, window, cx| { + document.set_mode(MarkdownMode::Visual, window, cx) + }); + frames(cx); + let off = off_middle(&document, 50, cx); + assert!(off < px(4.0), "{off:?} from the middle"); +} diff --git a/src/editor/ime.rs b/src/editor/ime.rs index e5effea..c1f2c5f 100644 --- a/src/editor/ime.rs +++ b/src/editor/ime.rs @@ -104,7 +104,7 @@ impl CodeEditor { to_utf16(text, range.start)..to_utf16(text, range.end) } - fn byte_range(&self, range: &Range) -> Range { + pub(crate) fn byte_range(&self, range: &Range) -> Range { let text = self.buffer.text(); from_utf16(text, range.start)..from_utf16(text, range.end) } diff --git a/src/editor/layout.rs b/src/editor/layout.rs index 0632dc0..5e13bb7 100644 --- a/src/editor/layout.rs +++ b/src/editor/layout.rs @@ -19,6 +19,8 @@ pub(crate) enum Row { Ghost(usize), /// The inline chat's rows; the first draws it. Chat(usize), + /// Typewriter room past an end. + Room, } /// Which lines folds hide. diff --git a/src/editor/moves.rs b/src/editor/moves.rs index 65e3e0a..4d43c10 100644 --- a/src/editor/moves.rs +++ b/src/editor/moves.rs @@ -158,6 +158,7 @@ impl CodeEditor { .iter() .rposition(|row| matches!(row, Row::Line(shown) if *shown <= line)) .unwrap_or(0) + + self.pad } /// Scrolls the primary cursor into view: from below when moving down, from above when moving up. diff --git a/src/editor/row.rs b/src/editor/row.rs index 6ab541d..639ded0 100644 --- a/src/editor/row.rs +++ b/src/editor/row.rs @@ -23,6 +23,10 @@ impl CodeEditor { .h(self.frame.line) .flex(); let caret = self.buffer.line_of(self.primary().head); + let base = match self.frame.rows[ix] { + Row::Line(line) => base.debug_selector(move || format!("editor-line-{line}")), + _ => base, + }; match self.frame.rows[ix] { Row::Line(line) if self.options.focus_line && line != caret => { self.line_row(base.opacity(FADED), ix, line, window, cx) @@ -32,6 +36,7 @@ impl CodeEditor { Row::Removed(hunk, at) => self.removed_row(base, ix, hunk, at, cx), Row::Ghost(at) => self.ghost_row(base, ix, at, cx), Row::Chat(at) => self.chat_row(base, ix, at, cx), + Row::Room => base.into_any_element(), } } diff --git a/src/editor/state.rs b/src/editor/state.rs index 5a97487..f245b34 100644 --- a/src/editor/state.rs +++ b/src/editor/state.rs @@ -100,7 +100,7 @@ pub(crate) struct Metrics { pub top: Pixels, pub advance: Pixels, pub line: Pixels, - /// The view's height, under any typewriter room. + /// The view's height. pub height: Pixels, } @@ -124,8 +124,8 @@ pub struct CodeEditor { pub(crate) metrics: Metrics, /// The caret row typewriter scrolling last centered. pub(crate) centered: Option, - /// Typewriter room past each end, as last drawn. - pub(crate) room: Pixels, + /// Typewriter room rows at each end. + pub(crate) pad: usize, pub(crate) dragging: bool, pub(crate) caret_on: bool, /// Blink runs only while focused, or unseen cursors redraw forever. @@ -212,7 +212,7 @@ impl CodeEditor { composing: None, metrics: Metrics::default(), centered: None, - room: Pixels::ZERO, + pad: 0, dragging: false, caret_on: true, focused: false, diff --git a/src/editor/view.rs b/src/editor/view.rs index 94b19c5..86c2fda 100644 --- a/src/editor/view.rs +++ b/src/editor/view.rs @@ -54,8 +54,7 @@ impl CodeEditor { /// The first line the list shows, after scrolling. pub(crate) fn top_line(&self) -> Option { - // Typewriter room above the first line is no line. - let scrolled = -self.scroll.0.borrow().base_handle.offset().y - self.room; + let scrolled = -self.scroll.0.borrow().base_handle.offset().y; let first = (scrolled / self.frame.line).floor().max(0.0) as usize; self.frame.rows[first.min(self.frame.rows.len().saturating_sub(1))..] .iter() @@ -252,8 +251,16 @@ impl Render for CodeEditor { _ => Vec::new(), }; let minimap = self.frame.minimap; + // Half a view of room rows: the ends center too. + self.pad = match self.options.typewriter && line > Pixels::ZERO { + true => (self.metrics.height / 2.0 / line).ceil() as usize, + false => 0, + }; + let mut shown = vec![Row::Room; self.pad]; + shown.extend(rows(&self.buffer, &hide, &self.marks)); + shown.extend(std::iter::repeat_n(Row::Room, self.pad)); self.frame = Frame { - rows: rows(&self.buffer, &hide, &self.marks), + rows: shown, folds: found, brackets: found_brackets, advance, @@ -271,13 +278,6 @@ impl Render for CodeEditor { .scroll_to_item_strict(row, ScrollStrategy::Center); self.centered = Some(row); } - // Half a view of room: a resize keeps the center. - let height = self.metrics.height; - self.room = match self.options.typewriter { - true => height / 2.0, - false => gpui::px(0.0), - }; - let room = self.room; let sticky = if self.options.sticky { self.sticky(cx) } else { @@ -292,9 +292,7 @@ impl Render for CodeEditor { }), ) .track_scroll(&self.scroll) - .size_full() - .pt(room) - .pb(room); + .size_full(); let (top, focus, input) = (cx.entity(), self.focus.clone(), cx.entity()); let unlock = cx.entity(); let banner = self.options.read_only.then(|| {