Code editor: columns sit where gpui lays them out
CI / platforms (windows-latest) (push) Failing after 10s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m53s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

Carets, selections, presses and the input method box measured the line
as columns times one 'm', so a wide character (CJK, emoji) put the caret
mid-glyph. editor::measure lays out the drawn line; rulers and indent
guides stay on the column grid; whitespace marks hide the rest.

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 12:21:22 -04:00
1 parent 93efa7f70b
commit 9fb607beb4
10 files changed
+220 -46

No files matched your search

+6 -1
View File
@@ -199,7 +199,12 @@ fn a_secondary_press_in_a_quote_lands_past_its_mark(cx: &mut TestAppContext) {
let (body, _) = sizes(cx); let (body, _) = sizes(cx);
// "beta" draws on its own line. // "beta" draws on its own line.
let x = bounds.left() + px(31.0) + body * 0.6 * 1.25; let x = bounds.left() + px(31.0) + body * 0.6 * 1.25;
press_at(x, bounds.top() + px(10.0) + body * 1.6, MouseButton::Right, cx); press_at(
x,
bounds.top() + px(10.0) + body * 1.6,
MouseButton::Right,
cx,
);
let at = source.find("eta").unwrap(); let at = source.find("eta").unwrap();
assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]); assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]);
} }
+5 -1
View File
@@ -69,7 +69,11 @@ fn an_image_splits_its_paragraph_and_references_resolve() {
#[test] #[test]
fn a_soft_break_draws_as_a_line_and_joins_for_output() { fn a_soft_break_draws_as_a_line_and_joins_for_output() {
for source in ["one\ntwo \nthree", "one\r\ntwo \r\nthree", "one\rtwo \rthree"] { for source in [
"one\ntwo \nthree",
"one\r\ntwo \r\nthree",
"one\rtwo \rthree",
] {
let nodes = parse(source); let nodes = parse(source);
assert!( assert!(
matches!(&nodes[0], Node::Paragraph(text) if text.text == "one\ntwo\nthree"), matches!(&nodes[0], Node::Paragraph(text) if text.text == "one\ntwo\nthree"),
+42 -17
View File
@@ -8,6 +8,7 @@ use gpui::{
use super::{ use super::{
cursor::Selection, cursor::Selection,
layout::{Row, Shown}, layout::{Row, Shown},
measure::Measured,
state::CodeEditor, state::CodeEditor,
}; };
use crate::{ use crate::{
@@ -72,7 +73,12 @@ impl CodeEditor {
} }
/// The offset under a window point, when a row of code is there. /// The offset under a window point, when a row of code is there.
pub(crate) fn offset_at(&self, position: Point<Pixels>, cx: &App) -> Option<usize> { pub(crate) fn offset_at(
&self,
position: Point<Pixels>,
window: &Window,
cx: &Context<Self>,
) -> Option<usize> {
let metrics = self.metrics; let metrics = self.metrics;
if metrics.line <= Pixels::ZERO || metrics.advance <= Pixels::ZERO { if metrics.line <= Pixels::ZERO || metrics.advance <= Pixels::ZERO {
return None; return None;
@@ -83,20 +89,41 @@ impl CodeEditor {
let Row::Line(line) = *row else { let Row::Line(line) = *row else {
return None; return None;
}; };
self.offset_in(line, position.x, cx) self.offset_in(line, position.x, window, cx)
} }
/// The offset nearest window x on a line; none for a line the text has since lost. /// 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> { pub(crate) fn offset_in(
&self,
line: usize,
x: Pixels,
window: &Window,
cx: &Context<Self>,
) -> Option<usize> {
if line >= self.buffer.lines() { if line >= self.buffer.lines() {
return None; return None;
} }
let (shown, measured) = self.measured(line, window, cx);
let column = measured.column(x - self.metrics.left);
Some(
self.buffer
.offset(line, shown.back(self.row_text(line), column)),
)
}
/// A line as drawn and laid out.
fn measured(&self, line: usize, window: &Window, cx: &Context<Self>) -> (Shown, Measured) {
let text = self.row_text(line); let text = self.row_text(line);
let shown = Shown::new(text, self.notes(line, cx), Vec::new()); let shown = Shown::new(text, self.notes(line, cx), self.styles(line, cx));
let column = ((x - self.metrics.left) / self.metrics.advance) let family = cx.theme().mono_family.clone();
.round() let measured = Measured::new(
.max(0.0) as usize; &shown,
Some(self.buffer.offset(line, shown.back(text, column))) family,
self.metrics.size,
self.metrics.advance,
window,
);
(shown, measured)
} }
fn utf16_range(&self, range: &Range<usize>) -> Range<usize> { fn utf16_range(&self, range: &Range<usize>) -> Range<usize> {
@@ -206,7 +233,7 @@ impl EntityInputHandler for CodeEditor {
&mut self, &mut self,
range_utf16: Range<usize>, range_utf16: Range<usize>,
_: Bounds<Pixels>, _: Bounds<Pixels>,
_: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<Bounds<Pixels>> { ) -> Option<Bounds<Pixels>> {
let range = self.byte_range(&range_utf16); let range = self.byte_range(&range_utf16);
@@ -217,27 +244,25 @@ impl EntityInputHandler for CodeEditor {
.iter() .iter()
.position(|row| *row == Row::Line(line))?; .position(|row| *row == Row::Line(line))?;
let text = self.row_text(line); let text = self.row_text(line);
let shown = Shown::new(text, self.notes(line, cx), Vec::new()); let (shown, measured) = self.measured(line, window, cx);
let column = shown.column(text, self.buffer.point(range.start).1.min(text.len())); let column = shown.column(text, self.buffer.point(range.start).1.min(text.len()));
let metrics = self.metrics; let metrics = self.metrics;
let scrolled = -self.scroll.0.borrow().base_handle.offset().y; let scrolled = -self.scroll.0.borrow().base_handle.offset().y;
let origin = point( let origin = point(
metrics.left + metrics.advance * column as f32, metrics.left + measured.x(column),
metrics.top + metrics.line * row as f32 - scrolled, metrics.top + metrics.line * row as f32 - scrolled,
); );
Some(Bounds::new( let wide = measured.x(column + 1) - measured.x(column);
origin, Some(Bounds::new(origin, gpui::size(wide, metrics.line)))
gpui::size(metrics.advance, metrics.line),
))
} }
fn character_index_for_point( fn character_index_for_point(
&mut self, &mut self,
position: Point<Pixels>, position: Point<Pixels>,
_: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Option<usize> { ) -> Option<usize> {
let offset = self.offset_at(position, cx)?; let offset = self.offset_at(position, window, cx)?;
Some(to_utf16(self.buffer.text(), offset)) Some(to_utf16(self.buffer.text(), offset))
} }
} }
+60 -27
View File
@@ -10,6 +10,7 @@ use super::{
cursor::Selection, cursor::Selection,
gutter::Cell, gutter::Cell,
layout::{Shown, stack}, layout::{Shown, stack},
measure::Measured,
state::{CodeEditor, CursorShape}, state::{CodeEditor, CursorShape},
}; };
use crate::{ use crate::{
@@ -19,7 +20,11 @@ use crate::{
impl CodeEditor { impl CodeEditor {
/// A line's own styles: syntax colors, rainbow brackets, problems, washes, matches and the matched pair. /// A line's own styles: syntax colors, rainbow brackets, problems, washes, matches and the matched pair.
fn styles(&self, line: usize, cx: &Context<Self>) -> Vec<(Range<usize>, HighlightStyle)> { pub(super) fn styles(
&self,
line: usize,
cx: &Context<Self>,
) -> Vec<(Range<usize>, HighlightStyle)> {
let colors = &cx.theme().colors; let colors = &cx.theme().colors;
let range = self.row_range(line); let range = self.row_range(line);
let local = |span: &Range<usize>| { let local = |span: &Range<usize>| {
@@ -109,24 +114,34 @@ impl CodeEditor {
(0..indent).step_by(self.options.tab).collect() (0..indent).step_by(self.options.tab).collect()
} }
/// Dots where the line has spaces and arrows for tabs, spaces elsewhere, so they fall on the text's columns. /// Dots for spaces, arrows for tabs; the rest hidden, so marks fall where drawn.
fn whitespace(&self, line: usize, shown: &Shown, cx: &Context<Self>) -> String { 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);
let mut out = String::new(); let hidden = HighlightStyle {
color: Some(gpui::transparent_black()),
..HighlightStyle::default()
};
let (mut out, mut hide) = (String::new(), Vec::new());
let mut note = notes.iter().peekable(); let mut note = notes.iter().peekable();
for (at, ch) in text.char_indices() { for (at, ch) in text.char_indices() {
let start = out.len();
while let Some((_, words, _)) = note.next_if(|(place, ..)| *place <= at) { while let Some((_, words, _)) = note.next_if(|(place, ..)| *place <= at) {
out.extend(std::iter::repeat_n(' ', words.chars().count())); out.push_str(words);
}
match ch {
' ' | '\t' => {
hide.push((start..out.len(), hidden));
out.push(if ch == ' ' { '·' } else { '→' });
}
_ => {
out.push(ch);
hide.push((start..out.len(), hidden));
}
} }
out.push(match ch {
' ' => '·',
'\t' => '→',
_ => ' ',
});
} }
debug_assert!(out.chars().count() <= shown.text.chars().count()); hide.retain(|(range, _)| !range.is_empty());
out StyledText::new(out).with_highlights(hide)
} }
pub(crate) fn line_row( pub(crate) fn line_row(
@@ -143,7 +158,16 @@ impl CodeEditor {
let text = self.row_text(line); let text = self.row_text(line);
let range = self.row_range(line); let range = self.row_range(line);
let shown = Shown::new(text, self.notes(line, cx), self.styles(line, cx)); let shown = Shown::new(text, self.notes(line, cx), self.styles(line, cx));
let x = |column: usize| advance * (column + 1) as f32; let measured = Measured::new(
&shown,
theme.mono_family.clone(),
self.metrics.size,
advance,
window,
);
// Text columns at their laid-out place; 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); let focused = self.focus.is_focused(window);
let primary = self.primary(); let primary = self.primary();
let current = primary.is_empty() && self.buffer.line_of(primary.head) == line; let current = primary.is_empty() && self.buffer.line_of(primary.head) == line;
@@ -170,7 +194,7 @@ impl CodeEditor {
.absolute() .absolute()
.top_0() .top_0()
.left(x(start)) .left(x(start))
.w(advance * (end - start) as f32) .w(x(end) - x(start))
.h(height) .h(height)
.bg(if focused { .bg(if focused {
colors.selection colors.selection
@@ -198,7 +222,7 @@ impl CodeEditor {
div() div()
.absolute() .absolute()
.top_0() .top_0()
.left(x(*column)) .left(grid(*column))
.h(height) .h(height)
.w_0() .w_0()
.border_l_1() .border_l_1()
@@ -209,7 +233,7 @@ impl CodeEditor {
div() div()
.absolute() .absolute()
.top_0() .top_0()
.left(x(column)) .left(grid(column))
.h(height) .h(height)
.w_0() .w_0()
.border_l_1() .border_l_1()
@@ -225,19 +249,20 @@ impl CodeEditor {
.filter(|selection| self.buffer.line_of(selection.head) == line) .filter(|selection| self.buffer.line_of(selection.head) == line)
.filter(|_| focused && self.caret_on) .filter(|_| focused && self.caret_on)
.map(|selection| { .map(|selection| {
let left = x(column_of(selection.head)); let column = column_of(selection.head);
let (left, wide) = (x(column), x(column + 1) - x(column));
let caret = theme.caret_width().to_pixels(window.rem_size()); let caret = theme.caret_width().to_pixels(window.rem_size());
let mark = div().absolute().left(left); let mark = div().absolute().left(left);
match self.options.cursor { match self.options.cursor {
CursorShape::Line => mark.top_0().w(caret).h(height).bg(colors.focus), CursorShape::Line => mark.top_0().w(caret).h(height).bg(colors.focus),
CursorShape::Block => mark CursorShape::Block => mark
.top_0() .top_0()
.w(advance) .w(wide)
.h(height) .h(height)
.bg(colors.focus.opacity(0.35)), .bg(colors.focus.opacity(0.35)),
CursorShape::Underline => mark CursorShape::Underline => mark
.top(height - caret * 1.5) .top(height - caret * 1.5)
.w(advance) .w(wide)
.h(caret * 1.5) .h(caret * 1.5)
.bg(colors.focus), .bg(colors.focus),
} }
@@ -251,7 +276,7 @@ impl CodeEditor {
.line_height(height) .line_height(height)
.whitespace_nowrap() .whitespace_nowrap()
.text_color(colors.fg_subtle.opacity(0.6)) .text_color(colors.fg_subtle.opacity(0.6))
.child(self.whitespace(line, &shown, cx)) .child(self.whitespace(line, cx))
}); });
let folded = self.folded.contains(&line).then(|| { let folded = self.folded.contains(&line).then(|| {
let entity = cx.entity(); let entity = cx.entity();
@@ -341,9 +366,11 @@ impl CodeEditor {
editor.secondary_press(line, event, window, cx) editor.secondary_press(line, event, window, cx)
}), }),
) )
.on_mouse_move(cx.listener(move |editor, event: &MouseMoveEvent, _, cx| { .on_mouse_move(
editor.drag(line, event, cx) cx.listener(move |editor, event: &MouseMoveEvent, window, cx| {
})); editor.drag(line, event, window, cx)
}),
);
base.child(self.gutter(Cell::Line(line), row, cx)) base.child(self.gutter(Cell::Line(line), row, cx))
.child(code) .child(code)
.into_any_element() .into_any_element()
@@ -358,7 +385,7 @@ impl CodeEditor {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
window.focus(&self.focus, cx); window.focus(&self.focus, cx);
let Some(at) = self.offset_in(line, event.position.x, cx) else { let Some(at) = self.offset_in(line, event.position.x, window, cx) else {
return; return;
}; };
let chosen = match event.click_count { let chosen = match event.click_count {
@@ -407,7 +434,7 @@ impl CodeEditor {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
window.focus(&self.focus, cx); window.focus(&self.focus, cx);
let Some(at) = self.offset_in(line, event.position.x, cx) else { let Some(at) = self.offset_in(line, event.position.x, window, cx) else {
return; return;
}; };
let held = (self.selections.iter()).any(|selection| { let held = (self.selections.iter()).any(|selection| {
@@ -420,11 +447,17 @@ impl CodeEditor {
} }
/// A drag over a line stretches the primary selection to where the pointer is. /// A drag over a line stretches the primary selection to where the pointer is.
pub(crate) fn drag(&mut self, line: usize, event: &MouseMoveEvent, cx: &mut Context<Self>) { pub(crate) fn drag(
&mut self,
line: usize,
event: &MouseMoveEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.dragging || !event.dragging() { if !self.dragging || !event.dragging() {
return; return;
} }
let Some(at) = self.offset_in(line, event.position.x, cx) else { let Some(at) = self.offset_in(line, event.position.x, window, cx) else {
return; return;
}; };
let mut all = self.selections.clone(); let mut all = self.selections.clone();
+76
View File
@@ -0,0 +1,76 @@
//! Where a drawn line's columns fall, as gpui lays it out.
use std::sync::Arc;
use gpui::{Hsla, LineLayout, Pixels, SharedString, TextRun, Window, font};
use super::layout::Shown;
/// A drawn line laid out: wide characters take their own width.
pub(crate) struct Measured {
text: SharedString,
layout: Arc<LineLayout>,
advance: Pixels,
}
impl Measured {
/// `shown` in the code font `family` at `size`.
pub fn new(
shown: &Shown,
family: SharedString,
size: Pixels,
advance: Pixels,
window: &Window,
) -> Self {
let base = font(family);
let run = |len: usize, style: Option<&gpui::HighlightStyle>| {
let mut font = base.clone();
if let Some(style) = style {
font.weight = style.font_weight.unwrap_or(font.weight);
font.style = style.font_style.unwrap_or(font.style);
}
TextRun {
len,
font,
color: Hsla::default(),
background_color: None,
underline: None,
strikethrough: None,
}
};
let (text, mut runs, mut at) = (shown.text.clone(), Vec::new(), 0);
for (range, style) in shown.styles.iter().filter(|(range, _)| !range.is_empty()) {
if range.start > at {
runs.push(run(range.start - at, None));
}
runs.push(run(range.len(), Some(style)));
at = range.end;
}
if at < text.len() {
runs.push(run(text.len() - at, None));
}
let layout = window.text_system().layout_line(&text, size, &runs, None);
Self {
text,
layout,
advance,
}
}
/// Where drawn column `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),
None => {
let past = column - self.text.chars().count();
self.layout.width + self.advance * past as f32
}
}
}
/// The drawn column whose start is nearest `x`.
pub fn column(&self, x: Pixels) -> usize {
let at = self.layout.closest_index_for_x(x).min(self.text.len());
self.text[..at].chars().count()
}
}
+1
View File
@@ -16,6 +16,7 @@ mod keybindings;
mod keys; mod keys;
mod layout; mod layout;
mod line; mod line;
mod measure;
mod moves; mod moves;
mod preview; mod preview;
mod problems; mod problems;
+2
View File
@@ -102,6 +102,8 @@ pub(crate) struct Metrics {
pub top: Pixels, pub top: Pixels,
pub advance: Pixels, pub advance: Pixels,
pub line: Pixels, pub line: Pixels,
/// The code font's size.
pub size: Pixels,
/// The view's height. /// The view's height.
pub height: Pixels, pub height: Pixels,
} }
+1
View File
@@ -1,6 +1,7 @@
mod chinese; mod chinese;
mod history; mod history;
mod stale; mod stale;
mod wide;
use std::{cell::Cell, rc::Rc, time::Duration}; use std::{cell::Cell, rc::Rc, time::Duration};
+26
View File
@@ -0,0 +1,26 @@
//! A wide character takes its laid-out width, not one column.
use gpui::{Bounds, EntityInputHandler, TestAppContext, VisualTestContext};
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.
let (editor, cx) = editor("😄a", cx);
cx.run_until_parked();
let x = |utf16: usize, cx: &mut VisualTestContext| {
editor.update_in(cx, |editor, window, cx| {
let bounds = editor.bounds_for_range(utf16..utf16, Bounds::default(), window, cx);
bounds.expect("a drawn line").origin.x
})
};
let (start, wide, narrow) = (x(0, cx), x(2, cx), x(3, cx));
let off = (wide - start) - (narrow - wide) * 2.0;
assert!(off.abs() < gpui::px(0.01), "{start:?} {wide:?} {narrow:?}");
let at = editor.update_in(cx, |editor, window, cx| {
let left = editor.metrics.left;
editor.offset_in(0, left + (wide - start) * 0.9, window, cx)
});
assert_eq!(at, Some("😄".len()), "a press near its end lands after it");
}
+1
View File
@@ -271,6 +271,7 @@ impl Render for CodeEditor {
}; };
self.metrics.advance = advance; self.metrics.advance = advance;
self.metrics.line = line; self.metrics.line = line;
self.metrics.size = pixels;
// Typewriter: the caret's row centers as it moves. // Typewriter: the caret's row centers as it moves.
let centered = (self.primary_row(), self.metrics.height, line); let centered = (self.primary_row(), self.metrics.height, line);
if self.options.typewriter && self.centered != Some(centered) { if self.options.typewriter && self.centered != Some(centered) {