Code editor: measure with the current text; the line end is a stop
CI / site (push) Successful in 31s
CI / crate (push) Failing after 4m9s
CI / platforms (windows-latest) (push) Successful in 4m34s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

Bracket and match offsets from the last draw belong to that text's
stamp, so an input-method edit before the next draw no longer splits
a character into font runs. A press past the last glyph's start picks
the nearer of that start and the line end.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-15 14:01:12 -04:00
1 parent 26af4be04f
commit cd61653ff1
5 files changed
+101 -14

No files matched your search

+31 -3
View File
@@ -1,10 +1,28 @@
use std::ops::Range; use std::{
ops::Range,
sync::atomic::{AtomicU64, Ordering},
};
/// Source text and where each line starts, in bytes. /// Source text and where each line starts, in bytes.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug)]
pub struct Buffer { pub struct Buffer {
text: String, text: String,
starts: Vec<usize>, starts: Vec<usize>,
stamp: u64,
}
impl PartialEq for Buffer {
fn eq(&self, other: &Self) -> bool {
self.text == other.text
}
}
impl Eq for Buffer {}
/// A fresh stamp: no two texts share one.
fn stamp() -> u64 {
static NEXT: AtomicU64 = AtomicU64::new(1);
NEXT.fetch_add(1, Ordering::Relaxed)
} }
fn starts(text: &str) -> Vec<usize> { fn starts(text: &str) -> Vec<usize> {
@@ -17,7 +35,16 @@ impl Buffer {
pub fn new(text: impl Into<String>) -> Self { pub fn new(text: impl Into<String>) -> Self {
let text = text.into(); let text = text.into();
let starts = starts(&text); let starts = starts(&text);
Self { text, starts } Self {
text,
starts,
stamp: stamp(),
}
}
/// Names this text; every edit gives a new one.
pub(crate) fn stamp(&self) -> u64 {
self.stamp
} }
pub fn text(&self) -> &str { pub fn text(&self) -> &str {
@@ -107,6 +134,7 @@ impl Buffer {
); );
self.text.replace_range(range, text); self.text.replace_range(range, text);
self.starts = starts(&self.text); self.starts = starts(&self.text);
self.stamp = stamp();
} }
} }
+12 -5
View File
@@ -34,11 +34,13 @@ impl CodeEditor {
span.start < range.end.max(range.start + 1) && range.start < span.end span.start < range.end.max(range.start + 1) && range.start < span.end
}; };
let mut spans = self.line_colors(self.row_text(line), cx); let mut spans = self.line_colors(self.row_text(line), cx);
// Offsets found in an older text are left out.
let fresh = self.frame.stamp == self.buffer.stamp();
let brackets = self let brackets = self
.frame .frame
.brackets .brackets
.iter() .iter()
.filter(|bracket| range.contains(&bracket.offset)); .filter(|bracket| fresh && range.contains(&bracket.offset));
for bracket in brackets { for bracket in brackets {
let at = bracket.offset - range.start; let at = bracket.offset - range.start;
let color = if self.options.rainbow { let color = if self.options.rainbow {
@@ -49,7 +51,12 @@ impl CodeEditor {
spans.retain(|(span, _)| *span != (at..at + 1)); spans.retain(|(span, _)| *span != (at..at + 1));
spans.push((at..at + 1, color.into())); spans.push((at..at + 1, color.into()));
} }
for span in self.frame.occurrences.iter().filter(|span| touches(span)) { for span in self
.frame
.occurrences
.iter()
.filter(|span| fresh && touches(span))
{
spans.push(( spans.push((
local(span), local(span),
HighlightStyle { HighlightStyle {
@@ -90,7 +97,7 @@ impl CodeEditor {
}, },
)); ));
} }
if let Some((open, close)) = self.frame.matched { if let Some((open, close)) = self.frame.matched.filter(|_| fresh) {
for at in [open, close].into_iter().filter(|at| range.contains(at)) { for at in [open, close].into_iter().filter(|at| range.contains(at)) {
let at = at - range.start; let at = at - range.start;
spans.push(( spans.push((
@@ -114,7 +121,7 @@ impl CodeEditor {
(0..indent).step_by(self.options.tab).collect() (0..indent).step_by(self.options.tab).collect()
} }
/// Dots for spaces, arrows for tabs; the rest hidden, so marks fall where drawn. /// Dots for spaces, arrows for tabs; other characters hidden.
fn whitespace(&self, line: usize, cx: &Context<Self>) -> StyledText { fn whitespace(&self, line: usize, cx: &Context<Self>) -> StyledText {
let text = self.row_text(line); let text = self.row_text(line);
let notes = self.notes(line, cx); let notes = self.notes(line, cx);
@@ -165,7 +172,7 @@ impl CodeEditor {
advance, advance,
window, window,
); );
// Text columns at their laid-out place; rulers and guides on the grid. // Text sits where laid out; rulers and guides on the grid.
let x = |column: usize| advance + measured.x(column); let x = |column: usize| advance + measured.x(column);
let grid = |column: usize| advance * (column + 1) as f32; let grid = |column: usize| advance * (column + 1) as f32;
let focused = self.focus.is_focused(window); let focused = self.focus.is_focused(window);
+15 -5
View File
@@ -1,4 +1,4 @@
//! Where a drawn line's columns fall, as gpui lays it out. //! Where drawn columns fall, as gpui lays them out.
use std::sync::Arc; use std::sync::Arc;
@@ -6,7 +6,7 @@ use gpui::{Hsla, LineLayout, Pixels, SharedString, TextRun, Window, font};
use super::layout::Shown; use super::layout::Shown;
/// A drawn line laid out: wide characters take their own width. /// A laid-out line: wide characters keep their own width.
pub(crate) struct Measured { pub(crate) struct Measured {
text: SharedString, text: SharedString,
layout: Arc<LineLayout>, layout: Arc<LineLayout>,
@@ -57,7 +57,7 @@ impl Measured {
} }
} }
/// Where drawn column `column` starts; past the end in whole advances. /// Where `column` starts; past the end in whole advances.
pub fn x(&self, column: usize) -> Pixels { pub fn x(&self, column: usize) -> Pixels {
match self.text.char_indices().nth(column) { match self.text.char_indices().nth(column) {
Some((at, _)) => self.layout.x_for_index(at), Some((at, _)) => self.layout.x_for_index(at),
@@ -68,9 +68,19 @@ impl Measured {
} }
} }
/// The drawn column whose start is nearest `x`. /// The column whose start lies nearest `x`, line end included.
pub fn column(&self, x: Pixels) -> usize { pub fn column(&self, x: Pixels) -> usize {
let at = self.layout.closest_index_for_x(x).min(self.text.len()); let starts = self.layout.runs.iter().flat_map(|run| {
run.glyphs
.iter()
.map(|glyph| (glyph.index, glyph.position.x))
});
let off = |edge: Pixels| f32::from((edge - x).abs());
let at = starts
.chain([(self.text.len(), self.layout.width)])
.min_by(|a, b| off(a.1).total_cmp(&off(b.1)))
.map(|(at, _)| at.min(self.text.len()))
.expect("the line end is always a stop");
self.text[..at].chars().count() self.text[..at].chars().count()
} }
} }
+40 -1
View File
@@ -6,7 +6,7 @@ use super::editor;
#[gpui::test] #[gpui::test]
fn a_wide_character_moves_the_caret_by_its_width(cx: &mut TestAppContext) { fn a_wide_character_moves_the_caret_by_its_width(cx: &mut TestAppContext) {
// The test text system draws "😄" two ems wide, "a" one. // Test text draws "😄" two ems wide, "a" one.
let (editor, cx) = editor("😄a", cx); let (editor, cx) = editor("😄a", cx);
cx.run_until_parked(); cx.run_until_parked();
let x = |utf16: usize, cx: &mut VisualTestContext| { let x = |utf16: usize, cx: &mut VisualTestContext| {
@@ -24,3 +24,42 @@ fn a_wide_character_moves_the_caret_by_its_width(cx: &mut TestAppContext) {
}); });
assert_eq!(at, Some("😄".len()), "a press near its end lands after it"); assert_eq!(at, Some("😄".len()), "a press near its end lands after it");
} }
#[gpui::test]
fn a_press_on_the_last_character_takes_its_nearer_edge(cx: &mut TestAppContext) {
for (text, last) in [("ab", "a".len()), ("测", 0)] {
let (editor, cx) = editor(text, cx);
cx.run_until_parked();
let at = |share: f32, cx: &mut VisualTestContext| {
editor.update_in(cx, |editor, window, cx| {
let mut x = |utf16: usize| {
let bounds =
editor.bounds_for_range(utf16..utf16, Bounds::default(), window, cx);
bounds.expect("a drawn line").origin.x
};
let (start, from, to) = (x(0), x(last), x(text.encode_utf16().count()));
let left = editor.metrics.left;
editor.offset_in(0, left + from - start + (to - from) * share, window, cx)
})
};
assert_eq!(at(0.1, cx), Some(last), "{text}: near its start");
assert_eq!(at(0.9, cx), Some(text.len()), "{text}: near its end");
}
}
#[gpui::test]
fn an_input_method_edit_measures_before_the_next_draw(cx: &mut TestAppContext) {
let (editor, cx) = editor("(a)", cx);
cx.run_until_parked();
editor.update_in(cx, |editor, window, cx| {
editor.select([0..0], cx);
editor.replace_and_mark_text_in_range(None, "测", None, window, cx);
let text = editor.row_text(0).to_string();
for (range, _) in editor.styles(0, cx) {
let whole = text.is_char_boundary(range.start) && text.is_char_boundary(range.end);
assert!(whole, "{range:?} splits a character of {text}");
}
let bounds = editor.bounds_for_range(1..1, Bounds::default(), window, cx);
assert!(bounds.is_some(), "the caret after it is measured");
});
}
+3
View File
@@ -25,6 +25,8 @@ pub(crate) struct Frame {
pub line: Pixels, pub line: Pixels,
pub matched: Option<(usize, usize)>, pub matched: Option<(usize, usize)>,
pub occurrences: Vec<Range<usize>>, pub occurrences: Vec<Range<usize>>,
/// The buffer stamp these offsets were found in.
pub stamp: u64,
/// The minimap's box, from its last paint. /// The minimap's box, from its last paint.
pub minimap: Bounds<Pixels>, pub minimap: Bounds<Pixels>,
} }
@@ -267,6 +269,7 @@ impl Render for CodeEditor {
line, line,
matched, matched,
occurrences, occurrences,
stamp: self.buffer.stamp(),
minimap, minimap,
}; };
self.metrics.advance = advance; self.metrics.advance = advance;