Code editor: measure with the current text; the line end is a stop
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:
1 parent
26af4be04f
commit
cd61653ff1
5 files changed
+101
-14
No files matched your search
+31
-3
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user