From b29d66ccc053e94c4e1940732cfd3f8bb09ac21a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 6 Oct 2026 21:13:15 -0400 Subject: [PATCH] =?UTF-8?q?Panics=20=C2=B7=20P17=20Editor:=20owner=20offse?= =?UTF-8?q?ts=20ride=20the=20text's=20edits,=20stale=20ranges,=20rows=20an?= =?UTF-8?q?d=20globs=20log=20instead=20of=20panicking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/editor/anchors.rs | 83 +++++++++ src/editor/console.rs | 7 +- src/editor/edit.rs | 73 +++++++- src/editor/filters.rs | 15 +- src/editor/ime.rs | 11 +- src/editor/line.rs | 8 +- src/editor/mod.rs | 1 + src/editor/moves.rs | 6 +- src/editor/state.rs | 79 +++----- src/editor/{tests.rs => tests/mod.rs} | 4 +- src/editor/tests/stale.rs | 252 ++++++++++++++++++++++++++ src/editor/view.rs | 30 +-- tasks/panics.md | 3 +- 13 files changed, 489 insertions(+), 83 deletions(-) create mode 100644 src/editor/anchors.rs rename src/editor/{tests.rs => tests/mod.rs} (99%) create mode 100644 src/editor/tests/stale.rs diff --git a/src/editor/anchors.rs b/src/editor/anchors.rs new file mode 100644 index 0000000..1eb8b03 --- /dev/null +++ b/src/editor/anchors.rs @@ -0,0 +1,83 @@ +use std::ops::Range; + +use super::state::Marks; + +/// Where `offset` lands after `edits`, sorted and apart, each a replaced range and the length written; none when an edit replaced it. +fn moved(offset: usize, edits: &[(Range, usize)]) -> Option { + let mut at = offset as isize; + for (range, written) in edits { + if offset <= range.start { + break; + } + if offset < range.end { + return None; + } + at += *written as isize - range.len() as isize; + } + Some(at as usize) +} + +/// A range through `edits`; none when an edit cut into it. +fn moved_range(range: &Range, edits: &[(Range, usize)]) -> Option> { + Some(moved(range.start, edits)?..moved(range.end, edits)?) +} + +/// Carries the owner's offsets through an edit of the text, dropping what it replaced. +pub(crate) fn shift(marks: &mut Marks, edits: &[(Range, usize)]) { + marks.diagnostics.retain_mut(|diagnostic| { + moved_range(&diagnostic.range, edits) + .map(|range| diagnostic.range = range) + .is_some() + }); + marks.backgrounds.retain_mut(|(range, _)| { + moved_range(range, edits) + .map(|moved| *range = moved) + .is_some() + }); + marks.hints.retain_mut(|hint| { + moved(hint.offset, edits) + .map(|at| hint.offset = at) + .is_some() + }); + marks.ghost = marks.ghost.take().and_then(|mut ghost| { + ghost.offset = moved(ghost.offset, edits)?; + Some(ghost) + }); +} + +/// Drops the owner's offsets when the whole text changes; they named the old one. +pub(crate) fn clear(marks: &mut Marks) { + marks.diagnostics.clear(); + marks.backgrounds.clear(); + marks.hints.clear(); + marks.ghost = None; +} + +/// Whether `range` runs forward between whole characters of `text`. +pub(crate) fn fits(text: &str, range: &Range) -> bool { + range.start <= range.end + && text.is_char_boundary(range.start) + && text.is_char_boundary(range.end) +} + +#[cfg(test)] +mod tests { + use super::{fits, moved}; + + #[test] + fn offsets_ride_their_edits() { + let edits = [(2..4, 1), (6..6, 3)]; + let cut = 0..2; + assert_eq!(moved(1, &edits), Some(1), "before every edit"); + assert_eq!(moved(2, &edits), Some(2), "at an edit's start"); + assert_eq!(moved(3, &edits), None, "inside what it replaced"); + assert_eq!(moved(5, &edits), Some(4), "past it, by its change"); + assert_eq!( + moved(6, &edits), + Some(5), + "at an insertion, before what it adds" + ); + assert_eq!(moved(8, &edits), Some(10)); + assert!(fits("héllo", &(1..3)) && !fits("héllo", &cut) && !fits("ab", &(1..9))); + } +} diff --git a/src/editor/console.rs b/src/editor/console.rs index 322662e..02f4311 100644 --- a/src/editor/console.rs +++ b/src/editor/console.rs @@ -52,10 +52,9 @@ impl OutputPanel { ) -> Self { let channels: Vec = channels.into_iter().map(Into::into).collect(); let channel = channel.into(); - assert!( - channels.contains(&channel), - "the shown channel is one of the channels" - ); + if !channels.contains(&channel) { + log::error!("output panel: {channel} is not among {channels:?}; none chosen"); + } Self { id: id.into(), channels, diff --git a/src/editor/edit.rs b/src/editor/edit.rs index 3af4735..adfd9e8 100644 --- a/src/editor/edit.rs +++ b/src/editor/edit.rs @@ -1,8 +1,9 @@ use std::ops::Range; -use gpui::{ClipboardItem, Context}; +use gpui::{ClipboardItem, Context, ScrollStrategy}; use super::{ + anchors, buffer::Buffer, cursor::{Motion, Selection, merged, replace_each}, state::CodeEditor, @@ -39,6 +40,10 @@ impl CodeEditor { let before = self.snapshot(); let primary = self.primary().range().start; let edits = joined(edits); + let spans: Vec<(Range, usize)> = edits + .iter() + .map(|(range, text)| (range.clone(), text.len())) + .collect(); let lead = edits .iter() .rposition(|(range, _)| range.start <= primary) @@ -48,6 +53,7 @@ impl CodeEditor { carets.push(lead); self.selections = merged(carets); self.marked = None; + anchors::shift(&mut self.marks, &spans); self.commit(before, typing, cx); self.reveal(true, cx); } @@ -424,3 +430,68 @@ fn joined(mut edits: Vec<(Range, String)>) -> Vec<(Range, String)> } out } + +impl CodeEditor { + /// Selects these ranges, the last one primary. + pub fn select( + &mut self, + ranges: impl IntoIterator>, + cx: &mut Context, + ) { + let chosen: Vec = ranges + .into_iter() + .filter(|range| { + let whole = self.whole(range); + if !whole { + log::error!( + "code editor: no selection {range:?} in {} bytes", + self.buffer.len() + ); + } + whole + }) + .map(|range| Selection { + anchor: range.start, + head: range.end, + goal: None, + }) + .collect(); + if chosen.is_empty() { + return; + } + if merged(chosen.clone()) == self.selections { + return; + } + self.set_selections(chosen, cx); + self.scroll + .scroll_to_item(self.primary_row(), ScrollStrategy::Center); + } + + /// Replaces ranges of the text at once, as one undo step; read-only text refuses. + pub fn edit( + &mut self, + edits: impl IntoIterator, String)>, + cx: &mut Context, + ) { + let edits: Vec<(Range, String)> = edits.into_iter().collect(); + let mut starts: Vec<&Range> = edits.iter().map(|(range, _)| range).collect(); + starts.sort_by_key(|range| range.start); + let apart = starts.windows(2).all(|pair| pair[0].end <= pair[1].start); + let forward = edits.iter().all(|(range, _)| range.start <= range.end); + if !apart || !forward || !edits.iter().all(|(range, _)| self.whole(range)) { + log::error!( + "code editor: edits {:?} do not fit {} bytes; none made", + starts, + self.buffer.len() + ); + return; + } + self.apply(edits, false, cx); + } + + /// Whether both ends of `range` fall between whole characters of the text. + fn whole(&self, range: &Range) -> bool { + let text = self.buffer.text(); + text.is_char_boundary(range.start) && text.is_char_boundary(range.end) + } +} diff --git a/src/editor/filters.rs b/src/editor/filters.rs index 25fb3ad..2a7f5ee 100644 --- a/src/editor/filters.rs +++ b/src/editor/filters.rs @@ -11,7 +11,7 @@ use crate::{ /// Whether a path passes comma-separated globs: some include pattern when there are any, and no exclude one. pub fn passes(path: &str, include: &str, exclude: &str) -> bool { - let patterns = |list: &str| -> Vec { + let patterns = |list: &str| -> Vec> { list.split(',') .map(str::trim) .filter(|glob| !glob.is_empty()) @@ -34,13 +34,20 @@ pub fn passes(path: &str, include: &str, exclude: &str) -> bool { } } pattern.push('$'); - regex::Regex::new(&pattern).expect("an escaped glob compiles") + regex::Regex::new(&pattern) + .inspect_err(|error| { + log::error!( + "search filter: a glob of {} bytes matches nothing: {error}", + pattern.len() + ) + }) + .ok() }) .collect() }; let (include, exclude) = (patterns(include), patterns(exclude)); - (include.is_empty() || include.iter().any(|glob| glob.is_match(path))) - && !exclude.iter().any(|glob| glob.is_match(path)) + (include.is_empty() || include.iter().flatten().any(|glob| glob.is_match(path))) + && !exclude.iter().flatten().any(|glob| glob.is_match(path)) } type OnBool = Rc; diff --git a/src/editor/ime.rs b/src/editor/ime.rs index 020d855..59d1acc 100644 --- a/src/editor/ime.rs +++ b/src/editor/ime.rs @@ -82,17 +82,20 @@ impl CodeEditor { let Row::Line(line) = *row else { return None; }; - Some(self.offset_in(line, position.x, cx)) + self.offset_in(line, position.x, cx) } - /// The offset nearest window x on a line. - pub(crate) fn offset_in(&self, line: usize, x: Pixels, cx: &App) -> usize { + /// The offset nearest window x on a line; none for a line the text has since lost. + pub(crate) fn offset_in(&self, line: usize, x: Pixels, cx: &App) -> Option { + if line >= self.buffer.lines() { + return None; + } let text = self.row_text(line); let shown = Shown::new(text, self.notes(line, cx), Vec::new()); let column = ((x - self.metrics.left) / self.metrics.advance) .round() .max(0.0) as usize; - self.buffer.offset(line, shown.back(text, column)) + Some(self.buffer.offset(line, shown.back(text, column))) } fn utf16_range(&self, range: &Range) -> Range { diff --git a/src/editor/line.rs b/src/editor/line.rs index 6c89585..46f7fc2 100644 --- a/src/editor/line.rs +++ b/src/editor/line.rs @@ -353,7 +353,9 @@ impl CodeEditor { cx: &mut Context, ) { window.focus(&self.focus, cx); - let at = self.offset_in(line, event.position.x, cx); + let Some(at) = self.offset_in(line, event.position.x, cx) else { + return; + }; let chosen = match event.click_count { 2 => { let word = self.buffer.word_at(at); @@ -396,7 +398,9 @@ impl CodeEditor { if !self.dragging || !event.dragging() { return; } - let at = self.offset_in(line, event.position.x, cx); + let Some(at) = self.offset_in(line, event.position.x, cx) else { + return; + }; let mut all = self.selections.clone(); let primary = all.last_mut().expect("an editor keeps a cursor"); if primary.head == at { diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 872d203..6d3a4ac 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -1,3 +1,4 @@ +mod anchors; mod buffer; mod chat; mod console; diff --git a/src/editor/moves.rs b/src/editor/moves.rs index b339184..65e3e0a 100644 --- a/src/editor/moves.rs +++ b/src/editor/moves.rs @@ -150,14 +150,14 @@ impl CodeEditor { }); } - /// The row the primary cursor draws on. + /// The row the primary cursor draws on, or the fold header above it when a fold hides it. pub(crate) fn primary_row(&self) -> usize { let line = self.buffer.line_of(self.primary().head); let hide = hidden(self.buffer.lines(), &folds(&self.buffer), &self.folded); rows(&self.buffer, &hide, &self.marks) .iter() - .position(|row| *row == Row::Line(line)) - .expect("the primary cursor sits on a shown line") + .rposition(|row| matches!(row, Row::Line(shown) if *shown <= line)) + .unwrap_or(0) } /// Scrolls the primary cursor into view: from below when moving down, from above when moving up. diff --git a/src/editor/state.rs b/src/editor/state.rs index db50dd6..a627dfa 100644 --- a/src/editor/state.rs +++ b/src/editor/state.rs @@ -1,11 +1,12 @@ use std::{collections::BTreeSet, ops::Range, time::Duration}; use gpui::{ - App, AppContext as _, Context, EventEmitter, FocusHandle, Focusable, Pixels, ScrollStrategy, - SharedString, Subscription, Task, UniformListScrollHandle, Window, + App, AppContext as _, Context, EventEmitter, FocusHandle, Focusable, Pixels, SharedString, + Subscription, Task, UniformListScrollHandle, Window, }; use super::{ + anchors, buffer::Buffer, cursor::{Selection, merged}, decor::{CodeLens, Diagnostic, DiffHunk, GhostText, GitMark, InlayHint}, @@ -129,6 +130,14 @@ impl Focusable for CodeEditor { } } +/// Whether a mark the owner sent fits the text; one that does not is logged and dropped. +fn kept(fits: bool, what: &str) -> bool { + if !fits { + log::error!("code editor: {what} lies off the text; dropped"); + } + fits +} + impl CodeEditor { pub fn new(text: impl Into, window: &mut Window, cx: &mut Context) -> Self { let focus = cx.focus_handle().tab_stop(true); @@ -291,6 +300,8 @@ impl CodeEditor { self.buffer = Buffer::new(text); self.selections = vec![Selection::caret(0)]; self.folded.clear(); + (self.marked, self.composing) = (None, None); + anchors::clear(&mut self.marks); self.commit(before, false, cx); } @@ -310,37 +321,10 @@ impl CodeEditor { cx.notify(); } - /// Selects these ranges, the last one primary. - pub fn select( - &mut self, - ranges: impl IntoIterator>, - cx: &mut Context, - ) { - let chosen: Vec = ranges - .into_iter() - .map(|range| { - assert!( - range.end <= self.buffer.len() - && self.buffer.text().is_char_boundary(range.start) - && self.buffer.text().is_char_boundary(range.end), - "a selection covers whole characters of the text" - ); - Selection { - anchor: range.start, - head: range.end, - goal: None, - } - }) - .collect(); - if merged(chosen.clone()) == self.selections { - return; - } - self.set_selections(chosen, cx); - self.scroll - .scroll_to_item(self.primary_row(), ScrollStrategy::Center); - } - - pub fn set_diagnostics(&mut self, diagnostics: Vec, cx: &mut Context) { + pub fn set_diagnostics(&mut self, mut diagnostics: Vec, cx: &mut Context) { + let text = self.buffer.text(); + diagnostics + .retain(|diagnostic| kept(anchors::fits(text, &diagnostic.range), "a diagnostic")); if self.marks.diagnostics == diagnostics { return; } @@ -350,9 +334,11 @@ impl CodeEditor { pub fn set_backgrounds( &mut self, - ranges: Vec<(Range, gpui::Hsla)>, + mut ranges: Vec<(Range, gpui::Hsla)>, cx: &mut Context, ) { + let text = self.buffer.text(); + ranges.retain(|(range, _)| kept(anchors::fits(text, range), "a background")); if self.marks.backgrounds == ranges { return; } @@ -360,7 +346,9 @@ impl CodeEditor { cx.notify(); } - pub fn set_inlay_hints(&mut self, hints: Vec, cx: &mut Context) { + pub fn set_inlay_hints(&mut self, mut hints: Vec, cx: &mut Context) { + let text = self.buffer.text(); + hints.retain(|hint| kept(text.is_char_boundary(hint.offset), "an inlay hint")); self.marks.hints = hints; self.marks.hints.sort_by_key(|hint| hint.offset); cx.notify(); @@ -373,7 +361,9 @@ impl CodeEditor { /// Suggested text at an offset, in grey until Tab takes it. pub fn set_ghost_text(&mut self, ghost: Option, cx: &mut Context) { - self.marks.ghost = ghost; + let text = self.buffer.text(); + self.marks.ghost = + ghost.filter(|ghost| kept(text.is_char_boundary(ghost.offset), "ghost text")); cx.notify(); } @@ -412,22 +402,6 @@ impl CodeEditor { cx.notify(); } - /// Replaces ranges of the text at once, as one undo step; read-only text refuses. - pub fn edit( - &mut self, - edits: impl IntoIterator, String)>, - cx: &mut Context, - ) { - let edits: Vec<(Range, String)> = edits.into_iter().collect(); - assert!( - edits.iter().all(|(range, _)| range.end <= self.buffer.len() - && self.buffer.text().is_char_boundary(range.start) - && self.buffer.text().is_char_boundary(range.end)), - "an edit covers whole characters of the text" - ); - self.apply(edits, false, cx); - } - pub(crate) fn set_selections(&mut self, selections: Vec, cx: &mut Context) { self.selections = merged(selections); self.unfold_cursors(); @@ -446,6 +420,7 @@ impl CodeEditor { self.selections = snapshot.selections; self.marked = None; self.composing = None; + anchors::clear(&mut self.marks); cx.emit(EditorEvent::Changed); self.restart_blink(cx); } diff --git a/src/editor/tests.rs b/src/editor/tests/mod.rs similarity index 99% rename from src/editor/tests.rs rename to src/editor/tests/mod.rs index 516f37c..67f3879 100644 --- a/src/editor/tests.rs +++ b/src/editor/tests/mod.rs @@ -1,3 +1,5 @@ +mod stale; + use std::{cell::Cell, rc::Rc, time::Duration}; use gpui::{ @@ -8,7 +10,7 @@ use gpui::{ use super::{CodeEditor, GhostText, InlayHint, LineNumbers, layout::Row}; use crate::theme::Theme; -fn editor<'a>( +pub(super) fn editor<'a>( text: &str, cx: &'a mut TestAppContext, ) -> (Entity, &'a mut VisualTestContext) { diff --git a/src/editor/tests/stale.rs b/src/editor/tests/stale.rs new file mode 100644 index 0000000..a935ba1 --- /dev/null +++ b/src/editor/tests/stale.rs @@ -0,0 +1,252 @@ +use gpui::{Entity, EntityInputHandler, TestAppContext}; + +use super::editor; +use crate::{ + editor::{CodeEditor, GhostText, InlayHint}, + theme::Theme, +}; + +#[gpui::test] +fn ranges_from_an_older_text_are_left(cx: &mut TestAppContext) { + let (editor, cx) = editor("héllo", cx); + editor.update(cx, |editor, cx| { + let caret = 1..1; + editor.select([caret], cx); + editor.select([0..2, 0..99], cx); + assert_eq!( + editor.primary().head, + 1, + "a stale selection leaves the last" + ); + editor.edit([(0..2, "x".to_string())], cx); + editor.edit([(0..3, "x".to_string()), (1..4, "y".to_string())], cx); + editor.edit( + [(std::ops::Range { start: 3, end: 1 }, "x".to_string())], + cx, + ); + assert_eq!( + editor.text(), + "héllo", + "a stale, crossing or backward batch makes no edit" + ); + editor.edit([(0..1, "j".to_string())], cx); + assert_eq!(editor.text(), "jéllo"); + }); +} + +/// An output panel whose shown channel left its list. +struct Output; + +impl gpui::Render for Output { + fn render( + &mut self, + _: &mut gpui::Window, + _: &mut gpui::Context, + ) -> impl gpui::IntoElement { + crate::editor::OutputPanel::new("output", ["Build", "Tests"], "Lint", ["one line"]) + } +} + +#[gpui::test] +fn an_output_channel_gone_from_its_list_draws(cx: &mut TestAppContext) { + cx.update(Theme::init); + let (_, cx) = cx.add_window_view(|_, _| Output); + cx.run_until_parked(); +} + +#[test] +fn a_glob_too_big_for_a_regex_matches_nothing() { + let huge = "?".repeat(500_000); + assert!(!crate::editor::passes("src/a.rs", &huge, "")); + assert!(crate::editor::passes("src/a.rs", "", &huge)); +} + +#[gpui::test] +fn new_text_drops_what_named_the_old(cx: &mut TestAppContext) { + let (editor, cx) = editor("abcd", cx); + editor.update_in(cx, |editor, window, cx| { + let end = 4..4; + editor.select([end], cx); + editor.replace_and_mark_text_in_range(None, "ni", None, window, cx); + editor.set_inlay_hints( + vec![InlayHint { + offset: 1, + text: ": i32".into(), + }], + cx, + ); + editor.set_backgrounds(vec![(1..2, gpui::black())], cx); + editor.set_text("é", cx); + editor.replace_text_in_range(None, "你", window, cx); + editor.set_ghost_text( + Some(GhostText { + offset: 4, + text: "late".into(), + }), + cx, + ); + editor.indent(cx); + }); + cx.run_until_parked(); + assert!(editor.read_with(cx, |editor, _| editor.marks.hints.is_empty())); +} + +#[gpui::test] +fn a_diagnostic_rides_the_typing_before_it(cx: &mut TestAppContext) { + let (editor, cx) = editor("ab", cx); + editor.update(cx, |editor, cx| { + let note = |range| crate::editor::Diagnostic { + range, + severity: crate::primitives::Severity::Danger, + message: "note".into(), + }; + editor.set_diagnostics(vec![note(1..2)], cx); + let start = 0..0; + editor.select([start], cx); + editor.type_text("é", cx); + assert_eq!(editor.marks.diagnostics[0].range, 3..4); + let whole = 0..4; + editor.select([whole], cx); + editor.type_text("x", cx); + assert!( + editor.marks.diagnostics.is_empty(), + "an edit over it takes it" + ); + }); + cx.run_until_parked(); +} + +/// A sticky editor in a box short enough to scroll. +struct Boxed(Entity); + +impl gpui::Render for Boxed { + fn render( + &mut self, + _: &mut gpui::Window, + _: &mut gpui::Context, + ) -> impl gpui::IntoElement { + use gpui::{ParentElement as _, Styled as _}; + gpui::div() + .w(gpui::px(480.0)) + .h(gpui::px(240.0)) + .child(self.0.clone()) + } +} + +#[gpui::test] +fn a_scrolled_sticky_editor_takes_a_shorter_text(cx: &mut TestAppContext) { + use gpui::AppContext as _; + cx.update(Theme::init); + let text = format!("outer\n inner\n{}\nend", vec![" child"; 100].join("\n")); + let (view, cx) = cx.add_window_view(move |window, cx| { + Boxed(cx.new(|cx| CodeEditor::new(text, window, cx).sticky_scroll())) + }); + let editor = view.read_with(cx, |root, _| root.0.clone()); + cx.run_until_parked(); + editor.update(cx, |editor, cx| { + let at = editor.buffer.offset(50, 0); + let caret = at..at; + editor.select([caret], cx); + }); + for _ in 0..3 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + let offset = editor.read_with(cx, |editor, _| { + editor.scroll.0.borrow().base_handle.offset() + }); + assert!( + offset.y < gpui::Pixels::ZERO, + "the editor scrolled: {offset:?}" + ); + editor.update(cx, |editor, cx| editor.set_text("x", cx)); + cx.run_until_parked(); +} + +#[gpui::test] +fn a_point_on_a_row_the_text_lost_has_no_offset(cx: &mut TestAppContext) { + let (editor, cx) = editor("first\nsecond\nthird", cx); + cx.run_until_parked(); + editor.update_in(cx, |editor, window, cx| { + let position = gpui::point( + editor.metrics.left, + editor.metrics.top + editor.metrics.line * 1.5, + ); + assert_eq!( + editor.character_index_for_point(position, window, cx), + Some(6) + ); + editor.set_text("x", cx); + assert_eq!(editor.character_index_for_point(position, window, cx), None); + }); + cx.run_until_parked(); +} + +/// A press at a window point, sent as the platform would. +fn press(position: gpui::Point) -> gpui::PlatformInput { + gpui::PlatformInput::MouseDown(gpui::MouseDownEvent { + position, + button: gpui::MouseButton::Left, + modifiers: gpui::Modifiers::none(), + click_count: 1, + first_mouse: false, + }) +} + +#[gpui::test] +fn a_press_or_drag_on_a_row_the_text_lost_is_dropped(cx: &mut TestAppContext) { + let (editor, cx) = editor("first\nsecond\nthird", cx); + cx.run_until_parked(); + let position = editor.read_with(cx, |editor, _| { + gpui::point( + editor.metrics.left + editor.metrics.advance, + editor.metrics.top + editor.metrics.line * 1.5, + ) + }); + cx.simulate_mouse_down(position, gpui::MouseButton::Left, gpui::Modifiers::none()); + assert_eq!(editor.read_with(cx, |editor, _| editor.position().0), 2); + cx.update(|window, cx| { + editor.update(cx, |editor, cx| editor.set_text("x", cx)); + let held = gpui::PlatformInput::MouseMove(gpui::MouseMoveEvent { + position, + pressed_button: Some(gpui::MouseButton::Left), + modifiers: gpui::Modifiers::none(), + }); + window.dispatch_event(held, cx); + window.dispatch_event(press(position), cx); + }); + cx.run_until_parked(); + assert_eq!(editor.read_with(cx, |editor, _| editor.position().0), 1); +} + +#[gpui::test] +fn a_sticky_press_on_a_line_the_text_lost_is_dropped(cx: &mut TestAppContext) { + use gpui::AppContext as _; + cx.update(Theme::init); + let text = format!("outer\n inner\n{}\nend", vec![" child"; 100].join("\n")); + let (view, cx) = cx.add_window_view(move |window, cx| { + Boxed(cx.new(|cx| CodeEditor::new(text, window, cx).sticky_scroll())) + }); + let editor = view.read_with(cx, |root, _| root.0.clone()); + cx.run_until_parked(); + editor.update(cx, |editor, cx| { + let at = editor.buffer.offset(50, 0); + let caret = at..at; + editor.select([caret], cx); + }); + for _ in 0..3 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + let position = editor.read_with(cx, |editor, _| { + gpui::point( + gpui::px(200.0), + editor.metrics.top + editor.metrics.line * 1.5, + ) + }); + cx.update(|window, cx| { + editor.update(cx, |editor, cx| editor.set_text("x", cx)); + window.dispatch_event(press(position), cx); + }); + cx.run_until_parked(); +} diff --git a/src/editor/view.rs b/src/editor/view.rs index 26409c2..c1dc7a7 100644 --- a/src/editor/view.rs +++ b/src/editor/view.rs @@ -85,6 +85,9 @@ impl CodeEditor { .on_mouse_down(MouseButton::Left, move |_, window, cx| { window.prevent_default(); jump.update(cx, |editor, cx| { + if line >= editor.buffer.lines() { + return; + } let at = editor.buffer.line_range(line).start + editor.buffer.indent(line); let cursor = at..at; editor.select([cursor], cx); @@ -219,23 +222,23 @@ impl CodeEditor { impl Render for CodeEditor { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let sticky = if self.options.sticky { - self.sticky(cx) - } else { - None - }; let theme = cx.theme(); let colors = theme.colors.clone(); + let mono_family = theme.mono_family.clone(); let size = theme.text_size(TextSize::Sm); let pixels = size.to_pixels(window.rem_size()); let mono = window .text_system() .resolve_font(&font(theme.mono_family.clone())); - let advance = window - .text_system() - .advance(mono, pixels, 'm') - .expect("the code font has an m") - .width; + let advance = match window.text_system().advance(mono, pixels, 'm') { + Ok(advance) => advance.width, + Err(error) => { + log::error!( + "code editor: no advance for the code font: {error:#}; columns guessed" + ); + pixels * 0.6 + } + }; let line = (pixels * LEADING).round(); let found = folds(&self.buffer); let hide = hidden(self.buffer.lines(), &found, &self.folded); @@ -260,6 +263,11 @@ impl Render for CodeEditor { }; self.metrics.advance = advance; self.metrics.line = line; + let sticky = if self.options.sticky { + self.sticky(cx) + } else { + None + }; let count = self.frame.rows.len(); let list = uniform_list( "editor-rows", @@ -311,7 +319,7 @@ impl Render for CodeEditor { .flex_col() .overflow_hidden() .bg(colors.surface) - .font_family(theme.mono_family.clone()) + .font_family(mono_family) .text_size(size) .text_color(colors.fg) .on_mouse_up( diff --git a/tasks/panics.md b/tasks/panics.md index 8ff3c8d..cc75e7a 100644 --- a/tasks/panics.md +++ b/tasks/panics.md @@ -31,7 +31,7 @@ Each item reads every site in its folders, fixes what the rules name, updates ca - [x] P14 Tables — `src/tables` - [x] P15 Charts — `src/charts` - [x] P16 Finance — `src/finance` -- [ ] P17 Code Editor — `src/editor` +- [x] P17 Code Editor — `src/editor` - [ ] P18 Terminal — `src/terminal` - [ ] P19 Git — `src/git` - [ ] P20 Debug — `src/debug` @@ -82,3 +82,4 @@ One line per item: what changed, what stayed and why, review rounds. - P15: chart values are finite and within ±`charts::LIMIT` (1e30), so sums and pixels stay in `f32`; scales work in halves and bound a share to ±1e4 ranges; chord and sankey size by shares; quartiles, weekday labels, flat tangents and a zero-width sankey's hover hold; notes, links, edges and waits past their lists are skipped. Review: 5 rounds, the cap; round 5's finding (points at one x) is fixed and tested, and P16's review checks it. - P16: crossed and locked books, empty sizes, leverage and margin past their bounds, stale range and interval keys, short layouts and comparisons, growth from zero, long QR addresses and grids too fine to draw all log and show; ladders mark their middle rung and key cells by row; every system-zone time goes through `datetime`; `nice_step` stays positive. Kept: one-snapshot shapes and one-value contracts. Review: 5 rounds, passed on the fifth; it also passed P15's last fix. - Noted for later items: `chat/params.rs` and `editor/settings.rs` pass `value.abs()` to `decimals`, which asserts a positive step. +- P17: the editor keeps every owner offset on whole characters of its text (checked on intake, carried through edits by `editor::anchors`, cleared when the whole text changes); stale selections and edit batches are dropped; a painted row whose line the text lost takes no click, drag or IME query; the sticky header reads this frame; a glob too big to compile matches nothing; a missing font advance logs. Kept: owner contracts and construction. Review: 5 rounds, passed on the fifth.