Panics · P17 Editor: owner offsets ride the text's edits, stale ranges, rows and globs log instead of panicking

This commit is contained in:
ZacharyZhang-NY committed 2026-10-06 21:13:15 -04:00
1 parent f0b7f779f6
commit b29d66ccc0
13 files changed
+489 -83

No files matched your search

+83
View File
@@ -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>, usize)]) -> Option<usize> {
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<usize>, edits: &[(Range<usize>, usize)]) -> Option<Range<usize>> {
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>, 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<usize>) -> 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)));
}
}
+3 -4
View File
@@ -52,10 +52,9 @@ impl OutputPanel {
) -> Self {
let channels: Vec<SharedString> = 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,
+72 -1
View File
@@ -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>, 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<usize>, String)>) -> Vec<(Range<usize>, String)>
}
out
}
impl CodeEditor {
/// Selects these ranges, the last one primary.
pub fn select(
&mut self,
ranges: impl IntoIterator<Item = Range<usize>>,
cx: &mut Context<Self>,
) {
let chosen: Vec<Selection> = 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<Item = (Range<usize>, String)>,
cx: &mut Context<Self>,
) {
let edits: Vec<(Range<usize>, String)> = edits.into_iter().collect();
let mut starts: Vec<&Range<usize>> = 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<usize>) -> bool {
let text = self.buffer.text();
text.is_char_boundary(range.start) && text.is_char_boundary(range.end)
}
}
+11 -4
View File
@@ -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<regex::Regex> {
let patterns = |list: &str| -> Vec<Option<regex::Regex>> {
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<dyn Fn(bool, &mut Window, &mut App)>;
+7 -4
View File
@@ -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<usize> {
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<usize>) -> Range<usize> {
+6 -2
View File
@@ -353,7 +353,9 @@ impl CodeEditor {
cx: &mut Context<Self>,
) {
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 {
+1
View File
@@ -1,3 +1,4 @@
mod anchors;
mod buffer;
mod chat;
mod console;
+3 -3
View File
@@ -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.
+27 -52
View File
@@ -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<String>, window: &mut Window, cx: &mut Context<Self>) -> 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<Item = Range<usize>>,
cx: &mut Context<Self>,
) {
let chosen: Vec<Selection> = 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<Diagnostic>, cx: &mut Context<Self>) {
pub fn set_diagnostics(&mut self, mut diagnostics: Vec<Diagnostic>, cx: &mut Context<Self>) {
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<usize>, gpui::Hsla)>,
mut ranges: Vec<(Range<usize>, gpui::Hsla)>,
cx: &mut Context<Self>,
) {
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<InlayHint>, cx: &mut Context<Self>) {
pub fn set_inlay_hints(&mut self, mut hints: Vec<InlayHint>, cx: &mut Context<Self>) {
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<GhostText>, cx: &mut Context<Self>) {
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<Item = (Range<usize>, String)>,
cx: &mut Context<Self>,
) {
let edits: Vec<(Range<usize>, 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<Selection>, cx: &mut Context<Self>) {
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);
}
@@ -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<CodeEditor>, &'a mut VisualTestContext) {
+252
View File
@@ -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<Self>,
) -> 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<CodeEditor>);
impl gpui::Render for Boxed {
fn render(
&mut self,
_: &mut gpui::Window,
_: &mut gpui::Context<Self>,
) -> 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::Pixels>) -> 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();
}
+19 -11
View File
@@ -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<Self>) -> 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(
+2 -1
View File
@@ -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.