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

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.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug)]
pub struct Buffer {
text: String,
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> {
@@ -17,7 +35,16 @@ impl Buffer {
pub fn new(text: impl Into<String>) -> Self {
let text = text.into();
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 {
@@ -107,6 +134,7 @@ impl Buffer {
);
self.text.replace_range(range, 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
};
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
.frame
.brackets
.iter()
.filter(|bracket| range.contains(&bracket.offset));
.filter(|bracket| fresh && range.contains(&bracket.offset));
for bracket in brackets {
let at = bracket.offset - range.start;
let color = if self.options.rainbow {
@@ -49,7 +51,12 @@ impl CodeEditor {
spans.retain(|(span, _)| *span != (at..at + 1));
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((
local(span),
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)) {
let at = at - range.start;
spans.push((
@@ -114,7 +121,7 @@ impl CodeEditor {
(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 {
let text = self.row_text(line);
let notes = self.notes(line, cx);
@@ -165,7 +172,7 @@ impl CodeEditor {
advance,
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 grid = |column: usize| advance * (column + 1) as f32;
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;
@@ -6,7 +6,7 @@ use gpui::{Hsla, LineLayout, Pixels, SharedString, TextRun, Window, font};
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 {
text: SharedString,
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 {
match self.text.char_indices().nth(column) {
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 {
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()
}
}
+40 -1
View File
@@ -6,7 +6,7 @@ use super::editor;
#[gpui::test]
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);
cx.run_until_parked();
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");
}
#[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 matched: Option<(usize, 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.
pub minimap: Bounds<Pixels>,
}
@@ -267,6 +269,7 @@ impl Render for CodeEditor {
line,
matched,
occurrences,
stamp: self.buffer.stamp(),
minimap,
};
self.metrics.advance = advance;