diff --git a/src/documents/document/tests/point.rs b/src/documents/document/tests/point.rs index e759255..5d42434 100644 --- a/src/documents/document/tests/point.rs +++ b/src/documents/document/tests/point.rs @@ -199,7 +199,12 @@ fn a_secondary_press_in_a_quote_lands_past_its_mark(cx: &mut TestAppContext) { let (body, _) = sizes(cx); // "beta" draws on its own line. 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(); assert_eq!(*events.borrow(), [target(at, TargetBlock::Quote)]); } diff --git a/src/documents/tree/tests.rs b/src/documents/tree/tests.rs index 25896f6..8b6aa00 100644 --- a/src/documents/tree/tests.rs +++ b/src/documents/tree/tests.rs @@ -69,7 +69,11 @@ fn an_image_splits_its_paragraph_and_references_resolve() { #[test] 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); assert!( matches!(&nodes[0], Node::Paragraph(text) if text.text == "one\ntwo\nthree"), diff --git a/src/editor/ime.rs b/src/editor/ime.rs index c1f2c5f..74b1df8 100644 --- a/src/editor/ime.rs +++ b/src/editor/ime.rs @@ -8,6 +8,7 @@ use gpui::{ use super::{ cursor::Selection, layout::{Row, Shown}, + measure::Measured, state::CodeEditor, }; use crate::{ @@ -72,7 +73,12 @@ impl CodeEditor { } /// The offset under a window point, when a row of code is there. - pub(crate) fn offset_at(&self, position: Point, cx: &App) -> Option { + pub(crate) fn offset_at( + &self, + position: Point, + window: &Window, + cx: &Context, + ) -> Option { let metrics = self.metrics; if metrics.line <= Pixels::ZERO || metrics.advance <= Pixels::ZERO { return None; @@ -83,20 +89,41 @@ impl CodeEditor { let Row::Line(line) = *row else { 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. - pub(crate) fn offset_in(&self, line: usize, x: Pixels, cx: &App) -> Option { + pub(crate) fn offset_in( + &self, + line: usize, + x: Pixels, + window: &Window, + cx: &Context, + ) -> Option { if line >= self.buffer.lines() { 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) -> (Shown, Measured) { let text = self.row_text(line); - let shown = Shown::new(text, self.notes(line, cx), Vec::new()); - let column = ((x - self.metrics.left) / self.metrics.advance) - .round() - .max(0.0) as usize; - Some(self.buffer.offset(line, shown.back(text, column))) + let shown = Shown::new(text, self.notes(line, cx), self.styles(line, cx)); + let family = cx.theme().mono_family.clone(); + let measured = Measured::new( + &shown, + family, + self.metrics.size, + self.metrics.advance, + window, + ); + (shown, measured) } fn utf16_range(&self, range: &Range) -> Range { @@ -206,7 +233,7 @@ impl EntityInputHandler for CodeEditor { &mut self, range_utf16: Range, _: Bounds, - _: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Option> { let range = self.byte_range(&range_utf16); @@ -217,27 +244,25 @@ impl EntityInputHandler for CodeEditor { .iter() .position(|row| *row == Row::Line(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 metrics = self.metrics; let scrolled = -self.scroll.0.borrow().base_handle.offset().y; let origin = point( - metrics.left + metrics.advance * column as f32, + metrics.left + measured.x(column), metrics.top + metrics.line * row as f32 - scrolled, ); - Some(Bounds::new( - origin, - gpui::size(metrics.advance, metrics.line), - )) + let wide = measured.x(column + 1) - measured.x(column); + Some(Bounds::new(origin, gpui::size(wide, metrics.line))) } fn character_index_for_point( &mut self, position: Point, - _: &mut Window, + window: &mut Window, cx: &mut Context, ) -> Option { - let offset = self.offset_at(position, cx)?; + let offset = self.offset_at(position, window, cx)?; Some(to_utf16(self.buffer.text(), offset)) } } diff --git a/src/editor/line.rs b/src/editor/line.rs index 3c00338..626b834 100644 --- a/src/editor/line.rs +++ b/src/editor/line.rs @@ -10,6 +10,7 @@ use super::{ cursor::Selection, gutter::Cell, layout::{Shown, stack}, + measure::Measured, state::{CodeEditor, CursorShape}, }; use crate::{ @@ -19,7 +20,11 @@ use crate::{ impl CodeEditor { /// A line's own styles: syntax colors, rainbow brackets, problems, washes, matches and the matched pair. - fn styles(&self, line: usize, cx: &Context) -> Vec<(Range, HighlightStyle)> { + pub(super) fn styles( + &self, + line: usize, + cx: &Context, + ) -> Vec<(Range, HighlightStyle)> { let colors = &cx.theme().colors; let range = self.row_range(line); let local = |span: &Range| { @@ -109,24 +114,34 @@ impl CodeEditor { (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. - fn whitespace(&self, line: usize, shown: &Shown, cx: &Context) -> String { + /// Dots for spaces, arrows for tabs; the rest hidden, so marks fall where drawn. + fn whitespace(&self, line: usize, cx: &Context) -> StyledText { let text = self.row_text(line); 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(); for (at, ch) in text.char_indices() { + let start = out.len(); 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()); - out + hide.retain(|(range, _)| !range.is_empty()); + StyledText::new(out).with_highlights(hide) } pub(crate) fn line_row( @@ -143,7 +158,16 @@ impl CodeEditor { let text = self.row_text(line); let range = self.row_range(line); 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 primary = self.primary(); let current = primary.is_empty() && self.buffer.line_of(primary.head) == line; @@ -170,7 +194,7 @@ impl CodeEditor { .absolute() .top_0() .left(x(start)) - .w(advance * (end - start) as f32) + .w(x(end) - x(start)) .h(height) .bg(if focused { colors.selection @@ -198,7 +222,7 @@ impl CodeEditor { div() .absolute() .top_0() - .left(x(*column)) + .left(grid(*column)) .h(height) .w_0() .border_l_1() @@ -209,7 +233,7 @@ impl CodeEditor { div() .absolute() .top_0() - .left(x(column)) + .left(grid(column)) .h(height) .w_0() .border_l_1() @@ -225,19 +249,20 @@ impl CodeEditor { .filter(|selection| self.buffer.line_of(selection.head) == line) .filter(|_| focused && self.caret_on) .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 mark = div().absolute().left(left); match self.options.cursor { CursorShape::Line => mark.top_0().w(caret).h(height).bg(colors.focus), CursorShape::Block => mark .top_0() - .w(advance) + .w(wide) .h(height) .bg(colors.focus.opacity(0.35)), CursorShape::Underline => mark .top(height - caret * 1.5) - .w(advance) + .w(wide) .h(caret * 1.5) .bg(colors.focus), } @@ -251,7 +276,7 @@ impl CodeEditor { .line_height(height) .whitespace_nowrap() .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 entity = cx.entity(); @@ -341,9 +366,11 @@ impl CodeEditor { editor.secondary_press(line, event, window, cx) }), ) - .on_mouse_move(cx.listener(move |editor, event: &MouseMoveEvent, _, cx| { - editor.drag(line, event, cx) - })); + .on_mouse_move( + cx.listener(move |editor, event: &MouseMoveEvent, window, cx| { + editor.drag(line, event, window, cx) + }), + ); base.child(self.gutter(Cell::Line(line), row, cx)) .child(code) .into_any_element() @@ -358,7 +385,7 @@ impl CodeEditor { cx: &mut Context, ) { 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; }; let chosen = match event.click_count { @@ -407,7 +434,7 @@ impl CodeEditor { cx: &mut Context, ) { 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; }; 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. - pub(crate) fn drag(&mut self, line: usize, event: &MouseMoveEvent, cx: &mut Context) { + pub(crate) fn drag( + &mut self, + line: usize, + event: &MouseMoveEvent, + window: &mut Window, + cx: &mut Context, + ) { if !self.dragging || !event.dragging() { 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; }; let mut all = self.selections.clone(); diff --git a/src/editor/measure.rs b/src/editor/measure.rs new file mode 100644 index 0000000..cfd7c79 --- /dev/null +++ b/src/editor/measure.rs @@ -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, + 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() + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 642964b..04271de 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -16,6 +16,7 @@ mod keybindings; mod keys; mod layout; mod line; +mod measure; mod moves; mod preview; mod problems; diff --git a/src/editor/state.rs b/src/editor/state.rs index 0051665..5d7284d 100644 --- a/src/editor/state.rs +++ b/src/editor/state.rs @@ -102,6 +102,8 @@ pub(crate) struct Metrics { pub top: Pixels, pub advance: Pixels, pub line: Pixels, + /// The code font's size. + pub size: Pixels, /// The view's height. pub height: Pixels, } diff --git a/src/editor/tests/mod.rs b/src/editor/tests/mod.rs index 6f285c0..ebd2ccf 100644 --- a/src/editor/tests/mod.rs +++ b/src/editor/tests/mod.rs @@ -1,6 +1,7 @@ mod chinese; mod history; mod stale; +mod wide; use std::{cell::Cell, rc::Rc, time::Duration}; diff --git a/src/editor/tests/wide.rs b/src/editor/tests/wide.rs new file mode 100644 index 0000000..9fc1b0e --- /dev/null +++ b/src/editor/tests/wide.rs @@ -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"); +} diff --git a/src/editor/view.rs b/src/editor/view.rs index bc3fb6d..6af61dd 100644 --- a/src/editor/view.rs +++ b/src/editor/view.rs @@ -271,6 +271,7 @@ impl Render for CodeEditor { }; self.metrics.advance = advance; self.metrics.line = line; + self.metrics.size = pixels; // Typewriter: the caret's row centers as it moves. let centered = (self.primary_row(), self.metrics.height, line); if self.options.typewriter && self.centered != Some(centered) {