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.
|
/// 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
@@ -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
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user