diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index 012092b775..7898895b7d 100644 --- a/crates/base/src/input/base/element.rs +++ b/crates/base/src/input/base/element.rs @@ -1,6 +1,7 @@ use crate::input::{InputExtras as _, InputModeKind}; use gpui::Corners; use gpui::Half; +use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, Bounds, Edges, Element, ElementId, ElementInputHandler, Entity, GlobalElementId, @@ -16,12 +17,12 @@ use smallvec::SmallVec; use std::{ops::Range, rc::Rc}; use crate::{ - Scrollbar, ScrollbarPlacement, + Scrollbar, ScrollbarPlacement, Side, input::{RopeExt as _, blink_cursor::CURSOR_WIDTH, display_map::LineLayout}, }; use super::{ - InputBaseState, RangeDecorationStyle, TextDecoration, + GutterColumn, InputBaseState, RangeDecorationStyle, TextDecoration, layout::{LastLayout, WhitespaceIndicators}, mode::LayoutMode, }; @@ -139,6 +140,7 @@ pub(super) struct EditorScrollbarSnapshot { cursor_scroll_offset: Point, soft_wrap: bool, placement: ScrollbarPlacement, + horizontal_bounds: Option>, } impl EditorScrollbarSnapshot { @@ -149,14 +151,33 @@ impl EditorScrollbarSnapshot { cursor_scroll_offset: Point, state: &InputBaseState, ) -> Self { + let layout = EditorScrollbarLayout::new( + input_bounds, + last_layout.text_origin_x, + scroll_size, + state.editor_paddings, + state.scrollbar_placement, + ); + let horizontal_bounds = (state.gutter_side.is_right() + && state.scrollbar_placement.is_left() + && last_layout.line_number_width > px(0.)) + .then(|| { + // Mirror the left gutter's track inset, including its outer + // padding. The full viewport still anchors the vertical bar. + let inset = (state.editor_paddings.right * 2. + last_layout.line_number_width + - LINE_NUMBER_RIGHT_MARGIN) + .max(px(0.)); + Bounds::new( + layout.bounds.origin, + size( + (layout.bounds.size.width - inset).max(px(0.)), + layout.bounds.size.height, + ), + ) + }); Self { - layout: EditorScrollbarLayout::new( - input_bounds, - last_layout.line_number_width, - scroll_size, - state.editor_paddings, - state.scrollbar_placement, - ), + layout, + horizontal_bounds, cursor_scroll_offset, soft_wrap: state.soft_wrap, placement: state.scrollbar_placement, @@ -167,17 +188,17 @@ impl EditorScrollbarSnapshot { impl EditorScrollbarLayout { fn new( input_bounds: Bounds, - line_number_width: Pixels, + text_origin_x: Pixels, scroll_size: Size, paddings: Edges, placement: ScrollbarPlacement, ) -> Self { let left = if placement.is_left() { -paddings.left - } else if line_number_width == px(0.) { + } else if text_origin_x == px(0.) { px(0.) } else { - paddings.left + line_number_width - LINE_NUMBER_RIGHT_MARGIN + paddings.left + text_origin_x - LINE_NUMBER_RIGHT_MARGIN }; Self { @@ -270,6 +291,9 @@ impl Element for EditorScrollbar { } .placement(snapshot.placement) .viewport_bounds(snapshot.layout.bounds) + .when_some(snapshot.horizontal_bounds, |this, bounds| { + this.horizontal_viewport_bounds(bounds) + }) .scroll_size(snapshot.layout.scroll_size) .into_any_element(); @@ -332,19 +356,108 @@ fn editor_gutter_bounds( line_number_width: Pixels, ghost_lines_height: Pixels, paddings: Edges, + side: Side, ) -> Bounds { + let (x, outer_padding) = if side.is_left() { + (input_bounds.origin.x - paddings.left, paddings.left) + } else { + (input_bounds.right() - line_number_width, paddings.right) + }; Bounds { - origin: point( - input_bounds.origin.x - paddings.left, - input_bounds.origin.y - paddings.top, - ), + origin: point(x, input_bounds.origin.y - paddings.top), size: size( - line_number_width + paddings.left, + line_number_width + outer_padding, input_bounds.size.height + ghost_lines_height + paddings.top + paddings.bottom, ), } } +/// Return the x and the width of a gutter column, relative to the input bounds. +/// +/// From the text outward, a gutter holds a margin, then its columns in the +/// order of `gutter_order`. A gutter on the right is the mirror of one on the +/// left. +fn gutter_column( + last_layout: &LastLayout, + folding: bool, + column: GutterColumn, +) -> (Pixels, Pixels) { + let width = |column| match column { + GutterColumn::FoldIcons => fold_column_width(folding), + GutterColumn::LineNumbers => line_number_column_width(last_layout, folding), + GutterColumn::Markers => last_layout.gutter_marker_width, + }; + let inset = last_layout + .gutter_order + .iter() + .take_while(|&&other| other != column) + .fold(LINE_NUMBER_RIGHT_MARGIN, |inset, &other| { + inset + width(other) + }); + ( + gutter_column_x(last_layout, inset, width(column)), + width(column), + ) +} + +fn fold_column_width(folding: bool) -> Pixels { + if folding { + FOLD_ICON_HITBOX_WIDTH + } else { + px(0.) + } +} + +fn line_number_column_width(last_layout: &LastLayout, folding: bool) -> Pixels { + last_layout.line_number_width + - last_layout.gutter_marker_width + - LINE_NUMBER_RIGHT_MARGIN + - fold_column_width(folding) +} + +/// Return the x of a gutter column `inset` from the text, relative to the input bounds. +fn gutter_column_x(last_layout: &LastLayout, inset: Pixels, width: Pixels) -> Pixels { + if last_layout.gutter_side.is_left() { + last_layout.gutter_origin_x + last_layout.line_number_width - inset - width + } else { + last_layout.gutter_origin_x + inset + } +} + +/// Return the x of a line number `width` wide, aligned toward the text, +/// relative to the input bounds. +fn line_number_x(last_layout: &LastLayout, folding: bool, width: Pixels) -> Pixels { + let (numbers_x, numbers_width) = gutter_column(last_layout, folding, GutterColumn::LineNumbers); + if last_layout.gutter_side.is_left() { + numbers_x + numbers_width - width + } else { + numbers_x + } +} + +/// Return the x of a gutter marker `size` wide, relative to the input bounds. +/// +/// The rest of the markers' column is the gap toward the line numbers. +fn gutter_marker_x(last_layout: &LastLayout, folding: bool, size: Pixels) -> Pixels { + let (slot_x, slot_width) = gutter_column(last_layout, folding, GutterColumn::Markers); + let position = |column| { + last_layout + .gutter_order + .iter() + .position(|&other| other == column) + }; + let inset = if position(GutterColumn::LineNumbers) < position(GutterColumn::Markers) { + slot_width - size + } else { + px(0.) + }; + if last_layout.gutter_side.is_left() { + slot_x + slot_width - inset - size + } else { + slot_x + inset + } +} + use super::MASK_CHAR; /// Convert a byte offset in the original text to a byte offset in the masked display string. @@ -554,7 +667,6 @@ impl TextElement { let line_height = last_layout.line_height; let visible_range = &last_layout.visible_range; let lines = &last_layout.lines; - let line_number_width = last_layout.line_number_width; let active_id = state.active_selection().id; let mut scroll_offset = state.scroll_handle.offset(); @@ -642,10 +754,10 @@ impl TextElement { }; scroll_offset.x = if scroll_offset.x + cursor_pos.x - > (bounds.size.width - line_number_width - safety_margin) + > (last_layout.text_width - safety_margin) { // cursor is out of right - bounds.size.width - line_number_width - safety_margin - cursor_pos.x + last_layout.text_width - safety_margin - cursor_pos.x } else if scroll_offset.x + cursor_pos.x < px(0.) { // cursor is out of left -cursor_pos.x @@ -700,7 +812,7 @@ impl TextElement { // Apply the final horizontal offset to every caret after cursor-follow and // deferred scrolling have been resolved, regardless of selection order. - let cursor_x = bounds.left() + cursor_pos.x + line_number_width; + let cursor_x = bounds.left() + cursor_pos.x + last_layout.text_origin_x; cursor_infos.push(CursorRenderInfo { bounds: Bounds::new( point( @@ -749,7 +861,7 @@ impl TextElement { ) -> Option> { let corners = Self::layout_range_corners(&range, last_layout)?; let points = frame_outline_points(&corners); - let origin = bounds.origin + point(last_layout.line_number_width, px(0.)); + let origin = bounds.origin + point(last_layout.text_origin_x, px(0.)); let mut builder = gpui::PathBuilder::fill(); builder.move_to(origin + *points.first()?); for point in points.iter().skip(1) { @@ -857,7 +969,7 @@ impl TextElement { else { continue; }; - let origin = bounds.origin + point(last_layout.line_number_width, px(0.)); + let origin = bounds.origin + point(last_layout.text_origin_x, px(0.)); let corners = pad_frame_corners(&corners, px(1.)); let points = frame_outline_points(&corners) .into_iter() @@ -1102,14 +1214,14 @@ impl TextElement { (visible_range, visible_buffer_lines, visible_top) } - /// Return (line_number_width, line_number_len) + /// Return the width of the gutter. fn layout_line_numbers( state: &InputBaseState, text: &Rope, font_size: Pixels, style: &TextStyle, window: &mut Window, - ) -> (Pixels, usize) { + ) -> Pixels { let total_lines = text.lines_len(); // Reserve three digits for small documents, then follow the actual // line count up to seven digits. @@ -1144,10 +1256,10 @@ impl TextElement { line_number_width += FOLD_ICON_HITBOX_WIDTH } - (line_number_width, line_number_len) + line_number_width } - /// Return the width reserved for gutter markers at the left of the line numbers. + /// Return the width reserved for the gutter markers' column. /// /// The slot is reserved while line numbers are shown, a marker renderer is set /// and a line decoration collection has a provider, so the gutter keeps its @@ -1318,7 +1430,7 @@ impl TextElement { /// Return (line_number_width, line_number_len) /// Layout fold icon hitboxes during prepaint phase. /// - /// This creates hitboxes for the fold icon area, positioned to the right of line numbers. + /// This creates hitboxes for the fold icon area, positioned between the line numbers and the text. /// Icons are created and prepainted here to avoid panics. fn layout_fold_icons( &self, @@ -1338,7 +1450,10 @@ impl TextElement { let line_number_hitbox = window.insert_hitbox( Bounds::new( - point(origin_x, bounds.origin.y + last_layout.visible_top), + point( + origin_x + last_layout.gutter_origin_x, + bounds.origin.y + last_layout.visible_top, + ), size(last_layout.line_number_width, bounds.size.height), ), HitboxBehavior::Normal, @@ -1381,19 +1496,18 @@ impl TextElement { // Second pass: create and prepaint icons let line_height = last_layout.line_height; - let line_number_width = - last_layout.line_number_width - LINE_NUMBER_RIGHT_MARGIN - FOLD_ICON_HITBOX_WIDTH; + let (fold_icon_x, _) = gutter_column(last_layout, true, GutterColumn::FoldIcons); let icon_relative_pos = point( (FOLD_ICON_HITBOX_WIDTH - FOLD_ICON_WIDTH).half(), (line_height - FOLD_ICON_WIDTH).half(), ); for (ix, info) in fold_infos.iter().enumerate() { - // Position fold icon to the right of line numbers. + // Position fold icon between the line numbers and the text. // Use origin_x (unscrolled) so icons stay fixed in the gutter during horizontal scroll. let fold_icon_bounds = Bounds::new( point( - origin_x + icon_relative_pos.x + line_number_width, + origin_x + icon_relative_pos.x + fold_icon_x, bounds.origin.y + icon_relative_pos.y + info.offset_y, ), size(FOLD_ICON_HITBOX_WIDTH, line_height), @@ -1465,8 +1579,8 @@ impl TextElement { /// Layout line decoration backgrounds and gutter markers for the visible rows. /// - /// Markers are only laid out while line numbers are shown, in the slot at - /// their left. + /// Markers are only laid out while line numbers are shown, in their column + /// of the gutter. fn layout_line_decorations( &self, origin_x: Pixels, @@ -1481,7 +1595,7 @@ impl TextElement { ) else { return LineDecorationLayout::default(); }; - let (decorations, renderer, marker_size) = { + let (decorations, renderer, marker_size, folding) = { let state = self.state.read(cx); let renderer = state .mode @@ -1495,6 +1609,7 @@ impl TextElement { .editor_style .gutter_marker_size() .to_pixels(window.rem_size()), + state.mode.is_folding(), ) }; if decorations.is_empty() { @@ -1503,6 +1618,7 @@ impl TextElement { let line_height = last_layout.line_height; let rows: Vec<_> = last_layout.row_extents().collect(); + let marker_x = origin_x + gutter_marker_x(last_layout, folding, marker_size); let mut layout = LineDecorationLayout::default(); for decoration in &decorations { @@ -1522,7 +1638,7 @@ impl TextElement { && marker_size > Pixels::ZERO { let origin = point( - origin_x, + marker_x, bounds.origin.y + top + (line_height - marker_size).half(), ); let mut element = render(marker); @@ -2069,7 +2185,7 @@ impl TextElement { { placements.push(( state.token_context(span, layout.line_height, width), - bounds.origin + position + point(layout.line_number_width, y), + bounds.origin + position + point(layout.text_origin_x, y), )); } } @@ -2673,12 +2789,32 @@ impl Element for TextElement { }; // Calculate the width of the line numbers - let (line_number_width, line_number_len) = + let line_number_width = Self::layout_line_numbers(&state, &text, text_size, &text_style, window); + let gutter_side = state.gutter_side; + let (mut text_origin_x, gutter_origin_x) = if gutter_side.is_left() { + (line_number_width, px(0.)) + } else { + (px(0.), bounds.size.width - line_number_width) + }; + // The text keeps clear of a scrollbar on the left, and at least the margin it keeps + // from the right edge. + let mut reserved_width = line_number_width; + if state.scrollbar_placement.is_left() { + let clearance = (Scrollbar::vertical(&state.scroll_handle).full_width(cx) + - state.editor_paddings.left) + .max(RIGHT_MARGIN); + if text_origin_x < clearance { + reserved_width += clearance - text_origin_x; + text_origin_x = clearance; + } + } + let text_width = bounds.size.width - reserved_width; + let mut bounds = bounds; let wrap_width = if multi_line && state.soft_wrap { - Some(bounds.size.width - line_number_width - RIGHT_MARGIN) + Some(text_width - RIGHT_MARGIN) } else { None }; @@ -2705,7 +2841,7 @@ impl Element for TextElement { let line_height = window.line_height(); let token_elements = self.measure_tokens( - (bounds.size.width - line_number_width - RIGHT_MARGIN).max(px(1.)), + (text_width - RIGHT_MARGIN).max(px(1.)), line_height, bounds.size.height, window, @@ -2763,6 +2899,11 @@ impl Element for TextElement { line_number_width, gutter_marker_width: Self::gutter_marker_width(state, window), ghost_lines: None, + gutter_side, + gutter_order: state.gutter_order, + text_origin_x, + gutter_origin_x, + text_width, space_width, lines: Rc::new(vec![]), cursor_bounds: None, @@ -2921,8 +3062,8 @@ impl Element for TextElement { // last content row, so take the max rather than summing — summing // left a band of empty space the cursor could never reach. let mut scroll_size = size( - if longest_line_width + line_number_width + RIGHT_MARGIN > bounds.size.width { - longest_line_width + line_number_width + RIGHT_MARGIN + if longest_line_width + reserved_width + RIGHT_MARGIN > bounds.size.width { + longest_line_width + reserved_width + RIGHT_MARGIN } else { longest_line_width }, @@ -2934,7 +3075,7 @@ impl Element for TextElement { // TODO: should be add some gap to right, to convenient to focus on boundary position if last_layout.text_align == TextAlign::Right || last_layout.text_align == TextAlign::Center { - scroll_size.width = longest_line_width + line_number_width; + scroll_size.width = longest_line_width + reserved_width; } // `position_for_index` for example @@ -3002,22 +3143,22 @@ impl Element for TextElement { let state = self.state.read(cx); let line_numbers = if state.mode.line_number() { let mut line_numbers = Vec::with_capacity(last_layout.visible_buffer_lines.len()); - let other_line_runs = vec![TextRun { - len: line_number_len, + let other_line_run = TextRun { + len: 0, font: style.font(), color: state.editor_style.muted_foreground, background_color: None, underline: None, strikethrough: None, - }]; - let current_line_runs = vec![TextRun { - len: line_number_len, + }; + let current_line_run = TextRun { + len: 0, font: style.font(), color: state.editor_style.foreground, background_color: None, underline: None, strikethrough: None, - }]; + }; // build line numbers for (line, &buffer_line) in last_layout @@ -3025,18 +3166,20 @@ impl Element for TextElement { .iter() .zip(last_layout.visible_buffer_lines.iter()) { - let line_no: SharedString = format!( - "{:>width$}", - displayed_line_number(buffer_line + 1), - width = line_number_len - ) - .into(); + // Aligned when painted, as padding with spaces only lines up in a + // monospace font. + let line_no: SharedString = + displayed_line_number(buffer_line + 1).to_string().into(); - let runs = if current_row == Some(buffer_line) { - ¤t_line_runs + let run = if current_row == Some(buffer_line) { + ¤t_line_run } else { - &other_line_runs + &other_line_run }; + let runs = [TextRun { + len: line_no.len(), + ..run.clone() + }]; let mut sub_lines: SmallVec<[ShapedLine; 1]> = SmallVec::new(); sub_lines.push( @@ -3217,7 +3360,7 @@ impl Element for TextElement { .zip(prepaint.last_layout.visible_buffer_lines.iter()) { let p = point( - origin.x + prepaint.last_layout.line_number_width + scroll_offset, + origin.x + prepaint.last_layout.text_origin_x + scroll_offset, origin.y + offset_y, ); @@ -3298,7 +3441,7 @@ impl Element for TextElement { let row = buffer_line; let line_y = origin.y + offset_y; let p = point( - origin.x + prepaint.last_layout.line_number_width + (scroll_offset), + origin.x + prepaint.last_layout.text_origin_x + (scroll_offset), line_y, ); @@ -3319,19 +3462,14 @@ impl Element for TextElement { // After the cursor row, paint ghost lines (which shifts subsequent content down) if has_ghost_lines && Some(row) == prepaint.current_row { - let ghost_x = origin.x + prepaint.last_layout.line_number_width; + let ghost_x = origin.x + prepaint.last_layout.text_origin_x; for ghost_line in ghost_lines { let ghost_p = point(ghost_x, origin.y + offset_y); // Paint semi-transparent background for ghost line - let ghost_bounds = Bounds::new( - ghost_p, - size( - bounds.size.width - prepaint.last_layout.line_number_width, - line_height, - ), - ); + let ghost_bounds = + Bounds::new(ghost_p, size(prepaint.last_layout.text_width, line_height)); window.paint_quad(fill(ghost_bounds, editor_background)); // Paint ghost line text @@ -3376,6 +3514,7 @@ impl Element for TextElement { prepaint.last_layout.line_number_width, prepaint.ghost_lines_height, editor_paddings, + prepaint.last_layout.gutter_side, ); window.paint_quad(fill(gutter_bounds, gutter_bg)); // Repaint line decoration backgrounds over the gutter background. @@ -3388,16 +3527,14 @@ impl Element for TextElement { color, )); } + let folding = self.state.read(cx).mode.is_folding(); // Each item is the normal lines. for (lines, &buffer_line) in line_numbers .iter() .zip(prepaint.last_layout.visible_buffer_lines.iter()) { - let p = point( - input_bounds.origin.x + prepaint.last_layout.gutter_marker_width, - origin.y + offset_y, - ); + let y = origin.y + offset_y; let is_active = prepaint.current_row == Some(buffer_line); let height = line_height * lines.len() as f32; @@ -3406,7 +3543,7 @@ impl Element for TextElement { if let Some(bg_color) = active_line_color { window.paint_quad(fill( Bounds::new( - point(gutter_bounds.origin.x, p.y), + point(gutter_bounds.origin.x, y), size(gutter_bounds.size.width, height), ), bg_color, @@ -3415,6 +3552,8 @@ impl Element for TextElement { } for line in lines { + let x = line_number_x(&prepaint.last_layout, folding, line.width); + let p = point(input_bounds.origin.x + x, y); _ = line.paint(p, line_height, TextAlign::Left, None, window, cx); offset_y += line_height; } @@ -3740,6 +3879,7 @@ fn split_runs_by_bg_segments( mod tests { use super::*; use crate::input::{EditorMode, EditorState, FoldRange, RangeDecoration, Redo, Undo}; + use crate::{ScrollbarMode, ScrollbarStyles, ScrollbarTheme, Theme}; use gpui::{ AppContext as _, Context, EntityInputHandler as _, Render, TestAppContext, VisualTestContext, div, @@ -4076,6 +4216,55 @@ mod tests { } } + #[gpui::test] + fn gutter_markers_keep_to_the_outer_edge_of_a_right_gutter(cx: &mut TestAppContext) { + let (editor, window) = decoration_editor(cx, &"x\n".repeat(20), false); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.update(|window, cx| { + let laid_out = record_markers(&editor, cx); + editor.update(cx, |state, cx| { + state.set_gutter_side(Side::Right, cx); + state.create_line_decorations_collection(Bands::new(true), cx); + }); + window.draw(cx).clear(cx); + laid_out.borrow_mut().clear(); + window.draw(cx).clear(cx); + + let state = editor.read(cx); + let layout = state.last_layout.as_ref().unwrap(); + let input_bounds = state.input_bounds; + let slot = layout.gutter_marker_width; + assert!(slot > px(0.)); + let (numbers_x, _) = gutter_column(layout, true, GutterColumn::LineNumbers); + assert_eq!( + numbers_x, + layout.gutter_origin_x + LINE_NUMBER_RIGHT_MARGIN + FOLD_ICON_HITBOX_WIDTH + ); + let markers = laid_out.borrow(); + assert!(!markers.is_empty()); + for marker in markers.iter() { + assert!( + marker.left() >= input_bounds.right() - slot, + "{marker:?} is not in the slot right of the line numbers" + ); + assert!( + (marker.right() - input_bounds.right()).abs() < px(0.01), + "{marker:?} is not at the outer edge" + ); + } + drop(markers); + + // On the left, the line numbers follow the slot. + editor.update(cx, |state, cx| state.set_gutter_side(Side::Left, cx)); + window.draw(cx).clear(cx); + let layout = editor.read(cx).last_layout.clone().unwrap(); + assert_eq!( + gutter_column(&layout, true, GutterColumn::LineNumbers).0, + layout.gutter_marker_width + ); + }); + } + #[gpui::test] fn rows_below_a_multiline_inline_completion_are_bounded_where_painted(cx: &mut TestAppContext) { let (editor, window) = decoration_editor(cx, "one\ntwo\nthree\nfour", false); @@ -4382,6 +4571,620 @@ mod tests { }); } + #[gpui::test] + fn a_gutter_on_the_right_leaves_the_text_at_the_left_edge(cx: &mut TestAppContext) { + let (editor, window) = decoration_editor(cx, &"word ".repeat(40), true); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.update(|window, cx| { + window.draw(cx).clear(cx); + let left = editor.read(cx).last_layout.clone().unwrap(); + editor.update(cx, |state, cx| state.set_gutter_side(Side::Right, cx)); + window.draw(cx).clear(cx); + let state = editor.read(cx); + let right = state.last_layout.clone().unwrap(); + let width = state.input_bounds.size.width; + + assert!(left.line_number_width > px(0.)); + assert_eq!(right.line_number_width, left.line_number_width); + assert_eq!(left.text_origin_x, left.line_number_width); + assert_eq!(left.gutter_origin_x, px(0.)); + assert_eq!(right.text_origin_x, px(0.)); + assert_eq!(right.gutter_origin_x, width - right.line_number_width); + // The text keeps its width, so it wraps where it did. + assert!(right.wrap_width.is_some()); + assert_eq!(right.wrap_width, left.wrap_width); + assert!(right.lines[0].wrapped_lines.len() > 1); + assert_eq!( + right.lines[0].wrapped_lines.len(), + left.lines[0].wrapped_lines.len() + ); + }); + } + + /// Every order the gutter's columns can take, from the text outward. + const GUTTER_ORDERS: [[GutterColumn; 3]; 6] = { + use GutterColumn::*; + [ + [FoldIcons, LineNumbers, Markers], + [FoldIcons, Markers, LineNumbers], + [LineNumbers, FoldIcons, Markers], + [LineNumbers, Markers, FoldIcons], + [Markers, FoldIcons, LineNumbers], + [Markers, LineNumbers, FoldIcons], + ] + }; + + /// How far `column` is from the text in `order`, given each column's width. + fn inset_from_text( + order: &[GutterColumn], + column: GutterColumn, + width: impl Fn(GutterColumn) -> Pixels, + ) -> Pixels { + order + .iter() + .take_while(|&&other| other != column) + .fold(LINE_NUMBER_RIGHT_MARGIN, |inset, &other| { + inset + width(other) + }) + } + + #[test] + fn a_gutter_order_lists_every_column_once() { + use GutterColumn::*; + assert_eq!(GutterColumn::order([]), [FoldIcons, LineNumbers, Markers]); + assert_eq!( + GutterColumn::order([Markers]), + [Markers, FoldIcons, LineNumbers] + ); + assert_eq!( + GutterColumn::order([LineNumbers, LineNumbers, Markers, LineNumbers]), + [LineNumbers, Markers, FoldIcons] + ); + for order in GUTTER_ORDERS { + assert_eq!(GutterColumn::order(order), order); + } + } + + /// Projects a fold icon renderer whose icons record where they are laid out. + fn record_fold_icons( + editor: &Entity, + cx: &mut App, + ) -> Rc>>> { + let icons = Rc::new(std::cell::RefCell::new(Vec::new())); + let record = icons.clone(); + editor.update(cx, |state, _| { + let mut style = state.editor_style.clone(); + style.fold_icon_renderer = Some(Rc::new(move |_, _| { + let record = record.clone(); + gpui::canvas( + move |bounds, _, _| record.borrow_mut().push(bounds), + |_, _, _, _| {}, + ) + .size_full() + .into_any_element() + })); + state.set_editor_style(style); + }); + icons + } + + #[gpui::test] + fn a_right_gutter_is_mirrored(cx: &mut TestAppContext) { + let (editor, window) = decoration_editor(cx, "a\n b\nc\n".repeat(40).as_str(), false); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.update(|window, cx| { + let markers = record_markers(&editor, cx); + let icons = record_fold_icons(&editor, cx); + let paddings = Edges { + top: px(2.), + right: px(7.), + bottom: px(5.), + left: px(7.), + }; + editor.update(cx, |state, cx| { + state.set_editor_paddings(paddings); + state.apply_highlighter_fold_candidates(vec![FoldRange::new(0, 1)], cx); + state.create_line_decorations_collection(Bands::new(true), cx); + }); + + // Every span the gutter paints, measured from the gutter's outer edge to + // the span's near side, then the text's distance from the gutter. + let measure = |side: Side, + order: [GutterColumn; 3], + folding: bool, + window: &mut Window, + cx: &mut App| { + editor.update(cx, |state, cx| { + state.set_gutter_side(side, cx); + state.set_gutter_order(order, cx); + state.set_folding(folding, window, cx); + state.set_selected_range(0..0, cx); + }); + icons.borrow_mut().clear(); + markers.borrow_mut().clear(); + window.draw(cx).clear(cx); + let state = editor.read(cx); + let layout = state.last_layout.clone().unwrap(); + let input_bounds = state.input_bounds; + let from_outer = |x: Pixels, width: Pixels| { + if side.is_left() { + x - (input_bounds.left() - paddings.left) + } else { + input_bounds.right() + paddings.right - (x + width) + } + }; + let at = |x: Pixels, width: Pixels| from_outer(input_bounds.left() + x, width); + + let gutter = editor_gutter_bounds(input_bounds, layout.line_number_width, px(0.), paddings, side); + let (numbers_x, numbers_width) = + gutter_column(&layout, folding, GutterColumn::LineNumbers); + let (fold_x, _) = gutter_column(&layout, folding, GutterColumn::FoldIcons); + assert!(layout.gutter_marker_width > px(0.)); + let marker = *markers.borrow().first().expect("a gutter marker is laid out"); + let mut spans = vec![ + from_outer(marker.left(), marker.size.width), + layout.gutter_marker_width, + from_outer(gutter.left(), gutter.size.width), + gutter.size.width, + at(numbers_x, numbers_width), + numbers_width, + ]; + // Numbers of any width, as a proportional font shapes them. + for width in [px(5.5), px(11.), numbers_width] { + spans.push(at(line_number_x(&layout, folding, width), width)); + } + if folding { + spans.push(at(fold_x, FOLD_ICON_HITBOX_WIDTH)); + let icon = *icons.borrow().first().expect("a fold icon is laid out"); + spans.push(from_outer(icon.left(), icon.size.width)); + } + let text_gap = if side.is_left() { + layout.text_origin_x - (layout.gutter_origin_x + layout.line_number_width) + } else { + layout.gutter_origin_x - (layout.text_origin_x + layout.text_width) + }; + (spans, text_gap, layout.text_width) + }; + + for (order, folding) in GUTTER_ORDERS + .into_iter() + .flat_map(|order| [(order, true), (order, false)]) + { + let (left, left_gap, left_width) = measure(Side::Left, order, folding, window, cx); + let (right, right_gap, right_width) = + measure(Side::Right, order, folding, window, cx); + assert_eq!(left.len(), right.len()); + for (ix, (left, right)) in left.iter().zip(&right).enumerate() { + assert!( + (*left - *right).abs() < px(0.01), + "span {ix}, {order:?} with folding {folding}: {left:?} on the left, {right:?} on the right" + ); + } + assert_eq!(left_gap, px(0.)); + assert_eq!(right_gap, px(0.)); + assert_eq!(left_width, right_width); + } + }); + } + + #[gpui::test] + fn the_gutter_order_counts_from_the_text(cx: &mut TestAppContext) { + let (editor, window) = decoration_editor(cx, "a\n b\nc", false); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.update(|window, cx| { + let markers = record_markers(&editor, cx); + let icons = record_fold_icons(&editor, cx); + editor.update(cx, |state, cx| { + state.apply_highlighter_fold_candidates(vec![FoldRange::new(0, 1)], cx); + state.create_line_decorations_collection(Bands::new(true), cx); + }); + for side in [Side::Left, Side::Right] { + for order in GUTTER_ORDERS { + editor.update(cx, |state, cx| { + state.set_gutter_side(side, cx); + state.set_gutter_order(order, cx); + state.set_selected_range(0..0, cx); + }); + icons.borrow_mut().clear(); + markers.borrow_mut().clear(); + window.draw(cx).clear(cx); + let state = editor.read(cx); + let layout = state.last_layout.clone().unwrap(); + let input_bounds = state.input_bounds; + // How far a span's near side is from the text. + let from_text = |x: Pixels, width: Pixels| { + if side.is_left() { + input_bounds.left() + layout.text_origin_x - (x + width) + } else { + x - (input_bounds.left() + layout.text_origin_x + layout.text_width) + } + }; + let numbers_width = line_number_column_width(&layout, true); + let digit = numbers_width / 3.; + let number = from_text( + input_bounds.left() + line_number_x(&layout, true, digit), + digit, + ); + let icon = *icons.borrow().first().expect("a fold icon is laid out"); + let icon = from_text(icon.left(), icon.size.width); + let icon_inset = (FOLD_ICON_HITBOX_WIDTH - FOLD_ICON_WIDTH).half(); + + let marker = *markers + .borrow() + .first() + .expect("a gutter marker is laid out"); + let marker_width = marker.size.width; + let marker = from_text(marker.left(), marker_width); + let slot = layout.gutter_marker_width; + assert!(slot > marker_width); + + let width = |column| match column { + GutterColumn::FoldIcons => FOLD_ICON_HITBOX_WIDTH, + GutterColumn::LineNumbers => numbers_width, + GutterColumn::Markers => slot, + }; + let number_from_text = + inset_from_text(&order, GutterColumn::LineNumbers, width); + let icon_from_text = + inset_from_text(&order, GutterColumn::FoldIcons, width) + icon_inset; + // The marker keeps its gap toward the line numbers. + let numbers_inside = + number_from_text < inset_from_text(&order, GutterColumn::Markers, width); + let marker_from_text = inset_from_text(&order, GutterColumn::Markers, width) + + if numbers_inside { + slot - marker_width + } else { + px(0.) + }; + // The marker is laid out on whole pixels. + assert!( + (marker - marker_from_text).abs() <= px(0.5), + "{side:?} gutter, {order:?}: the marker is {marker:?} from the text" + ); + assert!( + (number - number_from_text).abs() < px(0.01), + "{side:?} gutter, {order:?}: the line number is {number:?} from the text" + ); + // The icon is laid out on whole pixels. + assert!( + (icon - icon_from_text).abs() <= px(0.5), + "{side:?} gutter, {order:?}: the fold icon is {icon:?} from the text" + ); + } + } + }); + } + + #[gpui::test] + fn a_click_lands_on_the_fold_icon_or_the_line_number_in_every_gutter(cx: &mut TestAppContext) { + let (editor, window) = decoration_editor(cx, "abc\n b\nc", false); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.update(|_, cx| { + record_markers(&editor, cx); + editor.update(cx, |state, cx| { + state.create_line_decorations_collection(Bands::new(true), cx); + }); + }); + for side in [Side::Left, Side::Right] { + for order in GUTTER_ORDERS { + let (on_number, on_icon) = cx.update(|window, cx| { + editor.update(cx, |state, cx| { + state.display_map.clear_folds(); + state.apply_highlighter_fold_candidates(vec![FoldRange::new(0, 1)], cx); + state.set_gutter_side(side, cx); + state.set_gutter_order(order, cx); + state.set_selected_range(2..2, cx); + }); + window.draw(cx).clear(cx); + let state = editor.read(cx); + let layout = state.last_layout.as_ref().unwrap(); + let input_bounds = state.input_bounds; + let numbers_width = line_number_column_width(layout, true); + assert!(layout.gutter_marker_width > px(0.)); + let width = |column| match column { + GutterColumn::FoldIcons => FOLD_ICON_HITBOX_WIDTH, + GutterColumn::LineNumbers => numbers_width, + GutterColumn::Markers => layout.gutter_marker_width, + }; + let numbers_inset = inset_from_text(&order, GutterColumn::LineNumbers, width); + let fold_inset = inset_from_text(&order, GutterColumn::FoldIcons, width); + // The center of a column `inset` from the text. + let center = |inset: Pixels, width: Pixels| { + let inset = inset + width.half(); + if side.is_left() { + input_bounds.left() + layout.text_origin_x - inset + } else { + input_bounds.left() + layout.text_origin_x + layout.text_width + inset + } + }; + let y = input_bounds.top() + layout.line_height.half(); + ( + point(center(numbers_inset, numbers_width), y), + point(center(fold_inset, FOLD_ICON_HITBOX_WIDTH), y), + ) + }); + + // The line number goes to the line, at the gutter's side of it. + cx.simulate_click(on_number, gpui::Modifiers::default()); + editor.read_with(&cx, |state, _| { + let at = if side.is_left() { 0 } else { 3 }; + assert_eq!(state.selected_range(), at..at, "{side:?} gutter, {order:?}"); + assert!( + !state.display_map.is_folded_at(0), + "{side:?} gutter, {order:?}" + ); + }); + + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_click(on_icon, gpui::Modifiers::default()); + editor.read_with(&cx, |state, _| { + assert!( + state.display_map.is_folded_at(0), + "{side:?} gutter, {order:?}" + ); + }); + } + } + } + + #[gpui::test] + fn horizontal_scrollbar_does_not_claim_clicks_in_the_right_gutter(cx: &mut TestAppContext) { + let text = format!("{}\n", "long line ".repeat(30)).repeat(40); + let (editor, window) = decoration_editor(cx, &text, false); + let mut cx = VisualTestContext::from_window(window.into(), cx); + let click = cx.update(|window, cx| { + Theme::global_mut(cx).scrollbar = + ScrollbarTheme::new().with_mode(ScrollbarMode::Always); + editor.update(cx, |state, cx| { + state.set_gutter_side(Side::Right, cx); + state.set_scrollbar_placement(ScrollbarPlacement::BottomLeft, cx); + }); + window.draw(cx).clear(cx); + let state = editor.read(cx); + let layout = state.last_layout.as_ref().unwrap(); + let (numbers_x, numbers_width) = gutter_column(layout, true, GutterColumn::LineNumbers); + point( + state.input_bounds.left() + numbers_x + numbers_width.half(), + state.input_bounds.bottom() - px(2.), + ) + }); + cx.simulate_click(click, gpui::Modifiers::default()); + editor.read_with(&cx, |state, _| { + assert_ne!( + state.selected_range(), + 0..0, + "the click must reach the editor" + ); + }); + } + + #[gpui::test] + fn a_scrollbar_beside_the_gutter_stays_at_the_outer_edge(cx: &mut TestAppContext) { + let (editor, window) = decoration_editor(cx, &"x\n".repeat(50), false); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.update(|window, cx| { + let paddings = Edges { + top: px(2.), + right: px(3.), + bottom: px(5.), + left: px(7.), + }; + editor.update(cx, |state, _| state.set_editor_paddings(paddings)); + for (gutter, scrollbar) in [ + (Side::Left, ScrollbarPlacement::BottomLeft), + (Side::Right, ScrollbarPlacement::BottomLeft), + (Side::Right, ScrollbarPlacement::BottomRight), + ] { + editor.update(cx, |state, cx| { + state.set_gutter_side(gutter, cx); + state.set_scrollbar_placement(scrollbar, cx); + }); + window.draw(cx).clear(cx); + let state = editor.read(cx); + let input_bounds = state.input_bounds; + let viewport = state.editor_scrollbar_snapshot.get().unwrap().layout.bounds; + assert_eq!( + viewport.right(), + input_bounds.right() + paddings.right, + "{gutter:?} gutter, {scrollbar:?} scrollbar" + ); + let left = if scrollbar.is_left() { + input_bounds.left() - paddings.left + } else { + input_bounds.left() + }; + assert_eq!( + viewport.left(), + left, + "{gutter:?} gutter, {scrollbar:?} scrollbar" + ); + } + }); + } + + #[gpui::test] + fn the_text_keeps_clear_of_a_scrollbar_on_the_left(cx: &mut TestAppContext) { + let (editor, window) = decoration_editor(cx, &"word ".repeat(40), true); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.update(|window, cx| { + editor.update(cx, |state, cx| { + state.set_editor_paddings(Edges { + top: px(4.), + right: px(10.), + bottom: px(4.), + left: px(6.), + }); + state.set_gutter_side(Side::Right, cx); + state.set_scrollbar_placement(ScrollbarPlacement::BottomLeft, cx); + }); + window.draw(cx).clear(cx); + let state = editor.read(cx); + let layout = state.last_layout.as_ref().unwrap(); + let input_bounds = state.input_bounds; + let scrollbar = state.editor_scrollbar_snapshot.get().unwrap().layout.bounds; + + assert!( + input_bounds.left() + layout.text_origin_x >= scrollbar.left() + Scrollbar::width(), + "the text starts under the scrollbar" + ); + // The text gives the room up, the gutter does not move. + assert_eq!( + layout.gutter_origin_x, + input_bounds.size.width - layout.line_number_width + ); + assert_eq!( + layout.text_origin_x + layout.text_width, + layout.gutter_origin_x + ); + assert_eq!(layout.wrap_width, Some(layout.text_width - RIGHT_MARGIN)); + }); + } + + #[gpui::test] + fn the_text_keeps_clear_of_the_whole_track_of_a_scrollbar_on_the_left(cx: &mut TestAppContext) { + let (editor, window) = decoration_editor(cx, &"word\n".repeat(40), true); + let mut cx = VisualTestContext::from_window(window.into(), cx); + for track_width in [None, Some(px(24.))] { + let in_text = cx.update(|window, cx| { + Theme::global_mut(cx).scrollbar = ScrollbarTheme::new() + .with_mode(ScrollbarMode::Always) + .with_styles( + ScrollbarStyles::default().when_some(track_width, |styles, width| { + styles.track(|track| track.width(width)) + }), + ); + editor.update(cx, |state, cx| { + state.set_gutter_side(Side::Right, cx); + state.set_scrollbar_placement(ScrollbarPlacement::BottomLeft, cx); + state.set_selected_range(4..4, cx); + }); + window.draw(cx).clear(cx); + let state = editor.read(cx); + let layout = state.last_layout.as_ref().unwrap(); + let input_bounds = state.input_bounds; + let scrollbar = state.editor_scrollbar_snapshot.get().unwrap().layout.bounds; + + assert_eq!(scrollbar.left(), input_bounds.left()); + assert!( + layout.text_origin_x >= track_width.unwrap_or(Scrollbar::width()), + "{track_width:?} track: the text starts under the scrollbar" + ); + input_bounds.origin + point(layout.text_origin_x + px(1.), layout.line_height * 0.5) + }); + + cx.simulate_click(in_text, gpui::Modifiers::default()); + editor.read_with(&cx, |state, _| { + assert_eq!(state.selected_range(), 0..0, "{track_width:?} track"); + }); + } + } + + #[gpui::test] + fn a_click_lands_in_the_text_beside_a_gutter_on_either_side(cx: &mut TestAppContext) { + let (editor, window) = decoration_editor(cx, "abcdef\nab", false); + let mut cx = VisualTestContext::from_window(window.into(), cx); + for (side, gutter_click) in [(Side::Left, 7), (Side::Right, 9)] { + let (in_text, in_gutter) = cx.update(|window, cx| { + editor.update(cx, |state, cx| { + state.set_gutter_side(side, cx); + state.set_selected_range(0..0, cx); + }); + window.draw(cx).clear(cx); + let state = editor.read(cx); + let layout = state.last_layout.as_ref().unwrap(); + let input_bounds = state.input_bounds; + let x = layout.lines[0] + .position_for_index(3, layout, false) + .unwrap() + .x; + ( + input_bounds.origin + point(layout.text_origin_x + x, layout.line_height * 0.5), + input_bounds.origin + + point( + layout.gutter_origin_x + layout.line_number_width.half(), + layout.line_height * 1.5, + ), + ) + }); + + cx.simulate_click(in_text, gpui::Modifiers::default()); + editor.read_with(&cx, |state, _| { + assert_eq!(state.selected_range(), 3..3, "{side:?} gutter"); + }); + + // A click in the gutter goes to the line, at the gutter's side of it. + cx.simulate_click(in_gutter, gpui::Modifiers::default()); + editor.read_with(&cx, |state, _| { + assert_eq!( + state.selected_range(), + gutter_click..gutter_click, + "{side:?} gutter" + ); + }); + } + } + + #[gpui::test] + fn the_caret_and_the_ime_bounds_follow_the_text_beside_a_right_gutter(cx: &mut TestAppContext) { + let text = "wide ".repeat(80); + let (editor, window) = decoration_editor(cx, &format!("abcdef\n{text}"), false); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.update(|window, cx| { + editor.update(cx, |state, cx| { + state.set_gutter_side(Side::Right, cx); + state.focus(window, cx); + state.set_selected_range(3..3, cx); + }); + window.draw(cx).clear(cx); + let (layout, bounds, input_bounds) = { + let state = editor.read(cx); + ( + state.last_layout.clone().unwrap(), + state.last_bounds.unwrap(), + state.input_bounds, + ) + }; + let x = layout.lines[0] + .position_for_index(3, &layout, false) + .unwrap() + .x; + assert_eq!( + layout.cursor_bounds.unwrap().left(), + input_bounds.left() + x + ); + let ime = editor + .update(cx, |state, cx| { + state.bounds_for_range(3..4, bounds, window, cx) + }) + .unwrap(); + assert_eq!(ime.left(), input_bounds.left() + x); + let range = editor.read(cx).range_to_bounds(&(3..4)).unwrap(); + assert_eq!(range.left(), input_bounds.left() + x); + editor.update(cx, |state, cx| state.set_selected_range(3..4, cx)); + let mut bounds = input_bounds; + let selection = TextElement::new(editor.clone()) + .layout_selections(&layout, &mut bounds, window, cx) + .remove(0); + assert_eq!(selection.bounds.left(), input_bounds.left() + x); + + // The caret at the end of a long line scrolls into view, left of the gutter. + editor.update(cx, |state, cx| { + let end = state.text.len(); + state.set_selected_range(end..end, cx); + }); + window.draw(cx).clear(cx); + window.draw(cx).clear(cx); + let state = editor.read(cx); + let layout = state.last_layout.as_ref().unwrap(); + let caret = layout.cursor_bounds.unwrap(); + assert!(state.scroll_handle.offset().x < px(0.)); + assert!(caret.right() <= input_bounds.left() + layout.gutter_origin_x); + assert!( + caret.left() > input_bounds.left() + layout.gutter_origin_x - RIGHT_MARGIN * 2. + ); + }); + } + #[gpui::test] fn geometric_decorations_clip_scrolled_viewport_and_cull_offscreen_ranges( cx: &mut TestAppContext, @@ -4897,9 +5700,25 @@ mod tests { bottom: px(5.), left: px(7.), }, + Side::Left, ), Bounds::new(point(px(3.), px(18.)), size(px(55.), px(103.))) ); + assert_eq!( + editor_gutter_bounds( + input_bounds, + px(48.), + px(16.), + Edges { + top: px(2.), + right: px(3.), + bottom: px(5.), + left: px(7.), + }, + Side::Right, + ), + Bounds::new(point(px(262.), px(18.)), size(px(51.), px(103.))) + ); } #[test] diff --git a/crates/base/src/input/base/layout.rs b/crates/base/src/input/base/layout.rs index d61cc22b21..656053605d 100644 --- a/crates/base/src/input/base/layout.rs +++ b/crates/base/src/input/base/layout.rs @@ -2,6 +2,8 @@ use std::{ops::Range, rc::Rc}; use gpui::{Bounds, Half, Pixels, ShapedLine, TextAlign, px}; +use crate::Side; + use super::{WrappingIndent, display_map::LineLayout}; #[derive(Clone, Default)] @@ -10,6 +12,43 @@ pub(crate) struct WhitespaceIndicators { pub(crate) tab: ShapedLine, } +/// A column of the editor's gutter, ordered from the text outward with +/// `gutter_order`. +/// +/// The order is the same on either [`Side`], so a gutter on the right is the +/// mirror of one on the left. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GutterColumn { + /// The fold icons. + FoldIcons, + /// The line numbers. + LineNumbers, + /// The gutter markers of the line decorations. + Markers, +} + +impl GutterColumn { + /// The columns in their default order, from the text outward. + pub(super) const DEFAULT_ORDER: [Self; 3] = [Self::FoldIcons, Self::LineNumbers, Self::Markers]; + + /// Return every column once, those listed first and the rest in their + /// default order. + pub(super) fn order(columns: impl IntoIterator) -> [Self; 3] { + let mut order = Self::DEFAULT_ORDER; + let mut len = 0; + for column in columns.into_iter().chain(Self::DEFAULT_ORDER) { + if len == order.len() { + break; + } + if !order[..len].contains(&column) { + order[len] = column; + len += 1; + } + } + order + } +} + #[derive(Clone)] pub(super) struct LastLayout { pub(super) visible_range: Range, @@ -22,11 +61,21 @@ pub(super) struct LastLayout { pub(super) wrap_width: Option, pub(super) wrapping_indent: WrappingIndent, pub(super) line_number_width: Pixels, - /// Width reserved at the left of the line numbers for gutter markers, zero - /// when none can be painted. Part of `line_number_width`. + /// Width reserved between the line numbers and the outer edge of the gutter + /// for gutter markers, zero when none can be painted. Part of + /// `line_number_width`. pub(super) gutter_marker_width: Pixels, /// The buffer row that inline completion ghost lines follow, and their height. pub(super) ghost_lines: Option<(usize, Pixels)>, + pub(super) gutter_side: Side, + pub(super) gutter_order: [GutterColumn; 3], + /// The x of the text, relative to the input bounds. + pub(super) text_origin_x: Pixels, + /// The x of the gutter, relative to the input bounds. + pub(super) gutter_origin_x: Pixels, + /// The width of the text, without the gutter and the margin kept from a + /// scrollbar on the left. + pub(super) text_width: Pixels, /// Width of one space in the editor font. /// /// Past the end of a line there are no glyphs to hit-test against, so this is the diff --git a/crates/base/src/input/base/state.rs b/crates/base/src/input/base/state.rs index 7ad3cf0e38..c104de628d 100644 --- a/crates/base/src/input/base/state.rs +++ b/crates/base/src/input/base/state.rs @@ -20,7 +20,7 @@ use sum_tree::Bias; use unicode_segmentation::*; use super::{ - DiagnosticSet, DisplayMap, InputContextMenuCapabilities, InputEditorStyle, + DiagnosticSet, DisplayMap, GutterColumn, InputContextMenuCapabilities, InputEditorStyle, InputHighlighterFactory, MASK_CHAR, MaskPattern, NativeMenu, NumberStep, WrappingIndent, blink_cursor::BlinkCursor, change::Change, @@ -35,6 +35,7 @@ use super::{ undo_manager::{EditIntent, UndoManager}, }; use crate::ScrollbarPlacement; +use crate::Side; use crate::actions::{SelectDown, SelectLeft, SelectRight, SelectUp}; use crate::input::blink_cursor::CURSOR_WIDTH; use crate::input::movement::MoveDirection; @@ -432,6 +433,8 @@ pub struct InputBaseState { pub(crate) scroll_size: gpui::Size, pub(super) editor_scrollbar_snapshot: Cell>, pub(super) scrollbar_placement: ScrollbarPlacement, + pub(super) gutter_side: Side, + pub(super) gutter_order: [GutterColumn; 3], /// The unwrapped width of the longest line and what it was measured for. pub(super) longest_line_width: Cell>, pub(super) editor_paddings: Edges, @@ -506,6 +509,7 @@ pub struct InputPresentation { masked: bool, multi_line: bool, code_editor: bool, + gutter_side: Side, text_align: TextAlign, placeholder: SharedString, mask_placeholder: Option, @@ -547,6 +551,10 @@ impl InputPresentation { self.code_editor } + pub fn gutter_side(&self) -> Side { + self.gutter_side + } + pub fn text_align(&self) -> TextAlign { self.text_align } @@ -591,6 +599,7 @@ impl InputBaseState { masked: self.masked, multi_line: self.is_multi_line(), code_editor: self.is_code_editor(), + gutter_side: self.gutter_side, text_align: self.text_align, placeholder: self.placeholder.clone(), mask_placeholder: self.mask_pattern.placeholder(), @@ -768,6 +777,8 @@ impl InputBaseState { scroll_size: gpui::size(px(0.), px(0.)), editor_scrollbar_snapshot: Cell::new(None), scrollbar_placement: ScrollbarPlacement::default(), + gutter_side: Side::Left, + gutter_order: GutterColumn::DEFAULT_ORDER, longest_line_width: Cell::new(None), editor_paddings: Edges::default(), deferred_scroll_offset: None, @@ -931,7 +942,7 @@ impl InputBaseState { let local_offset = offset.saturating_sub(prev_lines_offset); if let Some(pos) = line.position_for_index(local_offset, last_layout, false) { let sub_line_index = (pos.y / line_height) as usize; - let adjusted_pos = point(pos.x + last_layout.line_number_width, pos.y + y_offset); + let adjusted_pos = point(pos.x + last_layout.text_origin_x, pos.y + y_offset); return (vi, sub_line_index, Some(adjusted_pos)); } @@ -2581,7 +2592,7 @@ impl InputBaseState { let line = &last_layout.lines[vi]; let local_offset = offset.saturating_sub(last_layout.visible_line_byte_offsets[vi]); if let Some(pos) = line.position_for_index(local_offset, last_layout, false) { - let bounds_width = bounds.size.width - last_layout.line_number_width; + let bounds_width = last_layout.text_width; let col_offset_x = pos.x; if col_offset_x - safety_margin < -scroll_offset.x { // If the position is out of the visible area, scroll to make it visible @@ -3083,7 +3094,7 @@ impl InputBaseState { }; let line_height = last_layout.line_height; - let line_number_width = last_layout.line_number_width; + let text_origin_x = last_layout.text_origin_x; // TIP: About the IBeam cursor // @@ -3095,7 +3106,7 @@ impl InputBaseState { // // - included the input padding. // - included the scroll offset. - let inner_position = position - bounds.origin - point(line_number_width, px(0.)); + let inner_position = position - bounds.origin - point(text_origin_x, px(0.)); let mut y_offset = last_layout.visible_top; // Position relative to the last line walked, kept for a pointer that ends up @@ -4372,12 +4383,11 @@ impl EntityInputHandler for InputBaseState { ) -> Option> { let last_layout = self.last_layout.as_ref()?; let line_height = last_layout.line_height; - let line_number_width = last_layout.line_number_width; let range = self.range_from_utf16(&range_utf16); let mut start_origin = None; let mut end_origin = None; - let line_number_origin = point(line_number_width, px(0.)); + let text_origin = point(last_layout.text_origin_x, px(0.)); let mut y_offset = last_layout.visible_top; for (vi, line) in last_layout.lines.iter().enumerate() { @@ -4413,16 +4423,16 @@ impl EntityInputHandler for InputBaseState { let start_origin = start_origin.or_else(|| { let offset = self.last_cursor.or(Some(self.cursor()))?; let (_, _, origin) = self.line_and_position_for_offset(offset); - origin.map(|origin| origin - line_number_origin) + origin.map(|origin| origin - text_origin) })?; let mut end_origin = end_origin.unwrap_or(start_origin); // Ensure at same line. end_origin.y = start_origin.y; Some(Bounds::from_corners( - bounds.origin + line_number_origin + start_origin, + bounds.origin + text_origin + start_origin, // + line_height for show IME panel under the cursor line. - bounds.origin + line_number_origin + point(end_origin.x, end_origin.y + line_height), + bounds.origin + text_origin + point(end_origin.x, end_origin.y + line_height), )) } @@ -10673,8 +10683,9 @@ impl InputBaseState { /// The placement of the scrollbars, default is [`ScrollbarPlacement::BottomRight`]. /// /// On the left, the vertical scrollbar sits on the input's left edge, over - /// the line numbers. At the top, the horizontal scrollbar overlays the - /// first line, as at the bottom it overlays the last. + /// the gutter when that is on the left too; otherwise the text keeps clear + /// of it. At the top, the horizontal scrollbar overlays the first line, as + /// at the bottom it overlays the last. pub fn scrollbar_placement(mut self, placement: ScrollbarPlacement) -> Self { self.scrollbar_placement = placement; self @@ -10690,6 +10701,46 @@ impl InputBaseState { cx.notify(); } + /// The side of the gutter with the line numbers and fold icons, default is + /// [`Side::Left`]. + /// + /// [`Side::Right`] is for the left pane of a side-by-side diff, so that both + /// gutters face the center and corresponding line numbers sit next to each + /// other. On the right, the gutter is mirrored: its columns keep their order + /// from the text outward, see [`InputBaseState::gutter_order`], and the + /// line numbers are aligned toward the text. A vertical scrollbar on the + /// same side stays outermost. + pub fn gutter_side(mut self, side: Side) -> Self { + self.gutter_side = side; + self + } + + /// See [`InputBaseState::gutter_side`]. + pub fn set_gutter_side(&mut self, side: Side, cx: &mut Context) { + self.gutter_side = side; + cx.notify(); + } + + /// The order of the gutter's columns from the text outward, default is + /// `[GutterColumn::FoldIcons, GutterColumn::LineNumbers, GutterColumn::Markers]`. + /// + /// A column left out follows the listed ones in its default order, and a + /// repeated one is ignored. The order holds on either side. + pub fn gutter_order(mut self, columns: impl IntoIterator) -> Self { + self.gutter_order = GutterColumn::order(columns); + self + } + + /// See [`InputBaseState::gutter_order`]. + pub fn set_gutter_order( + &mut self, + columns: impl IntoIterator, + cx: &mut Context, + ) { + self.gutter_order = GutterColumn::order(columns); + cx.notify(); + } + /// Set the soft wrap mode, default is true. #[doc(hidden)] pub fn soft_wrap(mut self, wrap: bool) -> Self { diff --git a/crates/base/src/input/editor/display_map/text_wrapper.rs b/crates/base/src/input/editor/display_map/text_wrapper.rs index a9eca53d52..a7afe2aea1 100644 --- a/crates/base/src/input/editor/display_map/text_wrapper.rs +++ b/crates/base/src/input/editor/display_map/text_wrapper.rs @@ -1611,6 +1611,11 @@ mod tests { line_number_width: px(0.), gutter_marker_width: px(0.), ghost_lines: None, + gutter_side: crate::Side::Left, + gutter_order: crate::input::GutterColumn::DEFAULT_ORDER, + text_origin_x: px(0.), + gutter_origin_x: px(0.), + text_width: px(0.), space_width: px(0.), cursor_bounds: None, text_align: TextAlign::Left, @@ -1914,6 +1919,11 @@ mod tests { line_number_width: px(0.), gutter_marker_width: px(0.), ghost_lines: None, + gutter_side: crate::Side::Left, + gutter_order: crate::input::GutterColumn::DEFAULT_ORDER, + text_origin_x: px(0.), + gutter_origin_x: px(0.), + text_width: px(0.), space_width: px(0.), cursor_bounds: None, text_align: TextAlign::Left, diff --git a/crates/base/src/input/editor/indent.rs b/crates/base/src/input/editor/indent.rs index d50bff1c10..8f5d99bbd5 100644 --- a/crates/base/src/input/editor/indent.rs +++ b/crates/base/src/input/editor/indent.rs @@ -138,7 +138,7 @@ impl TextElement { px(0.) }; - let pos = point(x + last_layout.line_number_width, offset_y); + let pos = point(x + last_layout.text_origin_x, offset_y); builder.move_to(pos); builder.line_to(point(pos.x, pos.y + line_height)); diff --git a/crates/base/src/input/editor/line_decorations.rs b/crates/base/src/input/editor/line_decorations.rs index 26ff9520b2..2ffb9af18e 100644 --- a/crates/base/src/input/editor/line_decorations.rs +++ b/crates/base/src/input/editor/line_decorations.rs @@ -207,10 +207,12 @@ impl InputBaseState { /// range decorations, rows are not tracked across edits: the provider answers /// for the text as it is when asked. /// - /// A background spans the row from the gutter to the right edge, across its - /// soft wraps, under the active line, indent guides, selections and text. A - /// marker is painted in a slot at the left of the line numbers, only while - /// they are shown; the gutter reserves the slot while any collection has a + /// A background spans the whole row, gutter included, across its soft wraps, + /// under the active line, indent guides, selections and text. A marker is + /// painted in a slot of its own, only while the line numbers are shown. In + /// the default order the slot sits between the line numbers and the outer + /// edge of the gutter; [`InputBaseState::gutter_order`] can move it nearer + /// the text. The gutter reserves the slot while any collection has a /// provider and a marker renderer is set. Later collections paint over earlier /// ones. Neither affects hit testing or focus. Collections live until /// explicitly disposed or the editor is dropped. diff --git a/crates/base/src/input/mod.rs b/crates/base/src/input/mod.rs index c568e00c50..69ed499ccb 100644 --- a/crates/base/src/input/mod.rs +++ b/crates/base/src/input/mod.rs @@ -108,6 +108,7 @@ pub(crate) use language::EditorLanguage; pub use language::{LanguageProvider, set_language_config, set_language_provider}; pub(crate) use language_config::LanguageConfig; pub use language_config::{AutoClosingPair, BracketPair, IndentationRules}; +pub use layout::GutterColumn; pub use line_decorations::{ GutterMarker, LineDecoration, LineDecorationCollection, LineDecorationProvider, }; diff --git a/crates/base/src/scrollbar.rs b/crates/base/src/scrollbar.rs index da436b1b2e..256a7e3cdb 100644 --- a/crates/base/src/scrollbar.rs +++ b/crates/base/src/scrollbar.rs @@ -861,6 +861,7 @@ pub struct Scrollbar { scroll_handle: Rc, scroll_size: Option>, viewport_bounds: Option>, + horizontal_viewport_bounds: Option>, use_layout_bounds: bool, /// Maximum frames per second for scrolling by drag. Default is 120 FPS. /// @@ -886,6 +887,7 @@ impl Scrollbar { max_fps: 120, scroll_size: None, viewport_bounds: None, + horizontal_viewport_bounds: None, use_layout_bounds: false, styles: ScrollbarStyles::default(), } @@ -911,6 +913,13 @@ impl Scrollbar { self } + /// Editors can exclude a fixed gutter from horizontal scrolling while the + /// vertical track stays at the outside edge of the full viewport. + pub(crate) fn horizontal_viewport_bounds(mut self, bounds: Bounds) -> Self { + self.horizontal_viewport_bounds = Some(bounds); + self + } + /// Set the scrollbar show mode [`ScrollbarMode`]. /// /// If unset, the current application theme projection is used. @@ -995,6 +1004,21 @@ impl Scrollbar { WIDTH } + fn track_width(&self, cx: &App) -> Pixels { + self.styles + .track + .width + .or(cx.theme().scrollbar.styles().track.width) + .unwrap_or(WIDTH) + } + + /// The width the scrollbar takes from the edge it sits on: its track, or + /// its active thumb where that reaches further. + pub(crate) fn full_width(&self, cx: &App) -> Pixels { + let (_, _, _, width, inset, _, _) = self.style_for_active(cx); + self.track_width(cx).max(inset + width) + } + fn resolve_track( &self, cx: &App, @@ -1425,22 +1449,24 @@ impl Element for Scrollbar { for axis in self.axis.all().into_iter() { let is_vertical = axis.is_vertical(); - let track_width = self - .styles - .track - .width - .or(cx.theme().scrollbar.styles().track.width) - .unwrap_or(WIDTH); + let viewport = if is_vertical { + hitbox.bounds + } else { + self.horizontal_viewport_bounds.unwrap_or(hitbox.bounds) + }; + let track_width = self.track_width(cx); let (scroll_area_size, container_size, scroll_position) = if is_vertical { ( scroll_size.height, - hitbox.size.height, + viewport.size.height, self.scroll_handle.offset().y, ) } else { ( - scroll_size.width, - hitbox.size.width, + // Keep the scroll extent unchanged when only the track's + // viewport excludes a fixed gutter. + scroll_size.width - (hitbox.size.width - viewport.size.width), + viewport.size.width, self.scroll_handle.offset().x, ) }; @@ -1461,29 +1487,29 @@ impl Element for Scrollbar { let bounds = Bounds { origin: if is_vertical { if self.placement.is_left() { - hitbox.origin + viewport.origin } else { point( - hitbox.origin.x + hitbox.size.width - track_width, - hitbox.origin.y, + viewport.origin.x + viewport.size.width - track_width, + viewport.origin.y, ) } } else if self.placement.is_top() { - hitbox.origin + viewport.origin } else { point( - hitbox.origin.x, - hitbox.origin.y + hitbox.size.height - track_width, + viewport.origin.x, + viewport.origin.y + viewport.size.height - track_width, ) }, size: gpui::Size { width: if is_vertical { track_width } else { - hitbox.size.width + viewport.size.width }, height: if is_vertical { - hitbox.size.height + viewport.size.height } else { track_width }, @@ -2511,6 +2537,23 @@ mod tests { }); } + #[gpui::test] + fn the_full_width_is_the_track_or_an_active_thumb_reaching_further(cx: &mut TestAppContext) { + cx.update(|cx| { + let scrollbar = Scrollbar::vertical(&TestHandle::new(Size::default())); + assert_eq!(scrollbar.full_width(cx), Scrollbar::width()); + + crate::Theme::global_mut(cx).scrollbar = crate::ScrollbarTheme::new() + .with_styles(ScrollbarStyles::default().track(|style| style.width(px(24.)))); + assert_eq!(scrollbar.full_width(cx), px(24.)); + + crate::Theme::global_mut(cx).scrollbar = crate::ScrollbarTheme::new().with_styles( + ScrollbarStyles::default().thumb_active(|style| style.width(px(20.)).inset(px(4.))), + ); + assert_eq!(scrollbar.full_width(cx), px(24.)); + }); + } + #[gpui::test] fn auto_hide_modes_use_a_six_pixel_resting_thumb(cx: &mut TestAppContext) { cx.update(|cx| { @@ -2553,6 +2596,47 @@ mod tests { assert_eq!(vertical.offset().x, px(0.)); } + #[gpui::test] + fn horizontal_viewport_excludes_fixed_content_without_changing_scroll_extent( + cx: &mut TestAppContext, + ) { + struct FixedGutterHarness(TestHandle); + impl Render for FixedGutterHarness { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().relative().size(px(100.)).child( + Scrollbar::new(&self.0) + .mode(ScrollbarMode::Always) + .placement(ScrollbarPlacement::BottomLeft) + .horizontal_viewport_bounds(Bounds::new( + Point::default(), + size(px(70.), px(100.)), + )), + ) + } + } + let handle = TestHandle::new(size(px(500.), px(500.))); + let (_, cx) = cx.add_window_view(|_, _| FixedGutterHarness(handle.clone())); + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.simulate_click(point(px(90.), px(95.)), Modifiers::default()); + assert_eq!( + handle.offset(), + Point::default(), + "fixed gutter must not be a horizontal track" + ); + cx.simulate_click(point(px(69.), px(95.)), Modifiers::default()); + assert_eq!( + handle.offset(), + point(px(-400.), px(0.)), + "cropping the track must preserve the horizontal range" + ); + cx.simulate_click(point(px(5.), px(80.)), Modifiers::default()); + assert_eq!( + handle.offset(), + point(px(-400.), px(-400.)), + "vertical range must use the full viewport" + ); + } + #[gpui::test] fn left_placement_puts_the_vertical_track_and_thumb_on_the_left_edge(cx: &mut TestAppContext) { let (cx, handle) = harness_with_placement( diff --git a/crates/component-shell/component-inventory.json b/crates/component-shell/component-inventory.json index a0ccf93b02..9d8b377ccb 100644 --- a/crates/component-shell/component-inventory.json +++ b/crates/component-shell/component-inventory.json @@ -1623,6 +1623,12 @@ ] } }, + { + "source": "story", + "name": "editor_diff", + "classification": "infrastructure", + "explanation": "The Editor Diff story lays out two Editors, registered by the Editor story, as a side-by-side diff; it adds no constructor." + }, { "source": "story", "name": "empty", diff --git a/crates/component/src/input/editor.rs b/crates/component/src/input/editor.rs index ab4dd3dc4c..4efbdcb4d3 100644 --- a/crates/component/src/input/editor.rs +++ b/crates/component/src/input/editor.rs @@ -217,6 +217,35 @@ mod tests { assert_eq!(line_height(cx, Some(px(24.))), px(36.)); assert_eq!(line_height(cx, Some(px(40.))), px(60.)); } + #[gpui::test] + fn a_gutter_on_the_right_keeps_the_padding_it_has_on_the_left(cx: &mut TestAppContext) { + cx.update(crate::init); + let mut state = None; + let (_, cx) = cx.add_window_view(|window, cx| { + let editor = cx.new(|cx| EditorState::new(window, cx).default_value("fn main() {}")); + state = Some(editor.clone()); + Harness { + state: editor, + text_size: None, + } + }); + let state = state.unwrap(); + let mut padding = |side: crate::Side| { + VisualTestContext::update(cx, |window, cx| { + state.update(cx, |state, cx| state.set_gutter_side(side, cx)); + window.draw(cx).clear(cx); + let bounds = state.read(cx).input_bounds(); + (bounds.left(), window.viewport_size().width - bounds.right()) + }) + }; + + let (left_outer, left_inner) = padding(crate::Side::Left); + let (right_inner, right_outer) = padding(crate::Side::Right); + assert!(left_outer < left_inner); + assert_eq!(right_outer, left_outer); + assert_eq!(right_inner, left_inner); + } + #[gpui::test] fn language_config_works_without_render_sync(cx: &mut TestAppContext) { use crate::input::{AutoClosingPair, language_config::LanguageConfig, set_language_config}; diff --git a/crates/component/src/input/input.rs b/crates/component/src/input/input.rs index 4263d97a8b..bc755e1e15 100644 --- a/crates/component/src/input/input.rs +++ b/crates/component/src/input/input.rs @@ -623,17 +623,24 @@ impl RenderOnce for Input { sync_focused_input_registry(&state, window, cx); state.ensure_highlighter_factory(crate::highlighter::input_highlighter_factory(), cx); + let presentation = state.presentation(cx); state.set_editor_paddings( - if state.presentation(cx).is_multi_line() { + if presentation.is_multi_line() { + let gutter_padding = if presentation.is_code_editor() { + self.size.input_px().min(px(6.)) + } else { + self.size.input_px() + }; + let (left, right) = if presentation.gutter_side().is_left() { + (gutter_padding, self.size.input_px()) + } else { + (self.size.input_px(), gutter_padding) + }; Edges { top: self.size.input_py(), - right: self.size.input_px(), + right, bottom: self.size.input_py(), - left: if state.presentation(cx).is_code_editor() { - self.size.input_px().min(px(6.)) - } else { - self.size.input_px() - }, + left, } } else { Edges::default() diff --git a/crates/component/src/input/mod.rs b/crates/component/src/input/mod.rs index cfd59a2803..19af241a29 100644 --- a/crates/component/src/input/mod.rs +++ b/crates/component/src/input/mod.rs @@ -27,17 +27,17 @@ pub use gpui_base::input::{ CompletionProvider, Copy, Cut, DefinitionProvider, Delete, DeleteToBeginningOfLine, DeleteToEndOfLine, DeleteToNextWordEnd, DeleteToPreviousWordStart, DisplayMap, DisplayPoint, DocumentColorProvider, DocumentRangeSemanticTokensProvider, EditorState, Enter, Escape, - FoldRange, GoToDefinition, GutterMarker, HighlightStyleResolver, HoverPopoverState, - HoverProvider, Indent, IndentInline, InputEdit, InputEvent, InputHighlighter, - InputHighlighterFactory, InputState, LineDecoration, LineDecorationCollection, - LineDecorationProvider, Lsp, MaskPattern, MoveDown, MoveEnd, MoveHome, MoveLeft, MovePageDown, - MovePageUp, MoveRight, MoveToEnd, MoveToEndOfLine, MoveToNextWord, MoveToPreviousWord, - MoveToStart, MoveToStartOfLine, MoveUp, Outdent, OutdentInline, Paste, Point, RangeDecoration, - RangeDecorationCollection, RangeDecorationStyle, Redo, Replace, Rope, RopeExt, RopeLines, - Search, SelectAll, SelectToEnd, SelectToEndOfLine, SelectToNextWordEnd, - SelectToPreviousWordStart, SelectToStart, SelectToStartOfLine, Selection, ShowCharacterPalette, - ShowDocumentHandler, TabSize, TextDecoration, TextDecorationCollection, TextareaState, - ToggleCodeActions, Undo, WrappingIndent, + FoldRange, GoToDefinition, GutterColumn, GutterMarker, HighlightStyleResolver, + HoverPopoverState, HoverProvider, Indent, IndentInline, InputEdit, InputEvent, + InputHighlighter, InputHighlighterFactory, InputState, LineDecoration, + LineDecorationCollection, LineDecorationProvider, Lsp, MaskPattern, MoveDown, MoveEnd, + MoveHome, MoveLeft, MovePageDown, MovePageUp, MoveRight, MoveToEnd, MoveToEndOfLine, + MoveToNextWord, MoveToPreviousWord, MoveToStart, MoveToStartOfLine, MoveUp, Outdent, + OutdentInline, Paste, Point, RangeDecoration, RangeDecorationCollection, RangeDecorationStyle, + Redo, Replace, Rope, RopeExt, RopeLines, Search, SelectAll, SelectToEnd, SelectToEndOfLine, + SelectToNextWordEnd, SelectToPreviousWordStart, SelectToStart, SelectToStartOfLine, Selection, + ShowCharacterPalette, ShowDocumentHandler, TabSize, TextDecoration, TextDecorationCollection, + TextareaState, ToggleCodeActions, Undo, WrappingIndent, }; pub use gpui_base::input::{EditorMode, InputMode, InputModeKind, TextareaMode}; #[doc(hidden)] diff --git a/crates/story/src/gallery.rs b/crates/story/src/gallery.rs index 4c0b7c6bed..787b071eb0 100644 --- a/crates/story/src/gallery.rs +++ b/crates/story/src/gallery.rs @@ -88,6 +88,7 @@ impl Gallery { StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), + StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), StoryContainer::panel::(window, cx), diff --git a/crates/story/src/stories/editor_diff_story.rs b/crates/story/src/stories/editor_diff_story.rs new file mode 100644 index 0000000000..6c4bd353a7 --- /dev/null +++ b/crates/story/src/stories/editor_diff_story.rs @@ -0,0 +1,417 @@ +use std::{ops::Range, rc::Rc}; + +use gpui_kit::component::{ + ActiveTheme, Side, + button::Button, + h_flex, + input::{ + Editor, EditorState, GutterColumn, GutterMarker, LineDecoration, LineDecorationCollection, + LineDecorationProvider, + }, + menu::PopupMenuItem, + scroll::ScrollbarPlacement, + v_flex, +}; +use gpui_kit::{ + App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Pixels, Render, + Styled as _, Subscription, Window, div, px, +}; + +use crate::story_toolbar_group; + +const ORIGINAL: &str = include_str!("editor_preview.rs"); + +struct ChangedLines { + rows: Rc>, + removed: bool, +} + +impl LineDecorationProvider for ChangedLines { + fn line_decorations(&self, visible: Range, cx: &App) -> Vec { + let (color, marker) = if self.removed { + (cx.theme().danger, GutterMarker::DiffRemoved) + } else { + (cx.theme().success, GutterMarker::DiffAdded) + }; + self.rows + .iter() + .copied() + .filter(|row| visible.contains(row)) + .map(|row| { + LineDecoration::new(row) + .with_background(color.opacity(0.12)) + .with_marker(marker.clone()) + }) + .collect() + } +} + +pub struct EditorDiffStory { + original: Entity, + modified: Entity, + mirrored: bool, + markers_before_numbers: bool, + scroll_y: [Pixels; 2], + _decorations: [LineDecorationCollection; 2], + _subscriptions: Vec, +} + +impl super::Story for EditorDiffStory { + fn title() -> &'static str { + "Editor Diff" + } + fn description() -> &'static str { + "Compare two source versions with ordinary or center-facing gutters." + } + fn new_view(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Self::new(window, cx)) + } +} + +impl EditorDiffStory { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let modified_text = ORIGINAL + .replace("progress: 72.", "progress: 84.") + .replace( + "Everything is moving on schedule.", + "One milestone needs attention.", + ) + .replace(".gap_3()", ".gap_4()"); + let rows: Rc> = Rc::new( + ORIGINAL + .lines() + .zip(modified_text.lines()) + .enumerate() + .filter_map(|(row, (before, after))| (before != after).then_some(row)) + .collect(), + ); + let original = cx.new(|cx| { + EditorState::new(window, cx) + .language("rust") + .folding(true) + .soft_wrap(false) + .gutter_side(Side::Right) + .scrollbar_placement(ScrollbarPlacement::BottomLeft) + .default_value(ORIGINAL) + }); + let modified = cx.new(|cx| { + EditorState::new(window, cx) + .language("rust") + .folding(true) + .soft_wrap(false) + .default_value(modified_text) + }); + let decorations = [(&original, true), (&modified, false)].map(|(editor, removed)| { + editor.update(cx, |state, cx| { + state.create_line_decorations_collection( + Rc::new(ChangedLines { + rows: rows.clone(), + removed, + }), + cx, + ) + }) + }); + let subscriptions = + [&original, &modified].map(|editor| cx.observe(editor, |_, _, cx| cx.notify())); + Self { + original, + modified, + mirrored: true, + markers_before_numbers: false, + scroll_y: [px(0.); 2], + _decorations: decorations, + _subscriptions: subscriptions.into(), + } + } + + fn synchronize_scroll(&mut self, cx: &mut Context) { + let current = [ + self.original.read(cx).scroll_offset().y, + self.modified.read(cx).scroll_offset().y, + ]; + let changed = if current[0] != self.scroll_y[0] { + Some(0) + } else if current[1] != self.scroll_y[1] { + Some(1) + } else { + None + }; + if let Some(source) = changed { + let y = current[source]; + let target = if source == 0 { + &self.modified + } else { + &self.original + }; + if current[1 - source] != y { + target.update(cx, |state, cx| { + let mut offset = state.scroll_offset(); + offset.y = y; + state.set_scroll_offset(offset, cx); + }); + } + self.scroll_y = [y; 2]; + } + } + + fn set_mirrored(&mut self, mirrored: bool, cx: &mut Context) { + self.mirrored = mirrored; + self.original.update(cx, |state, cx| { + state.set_gutter_side(if mirrored { Side::Right } else { Side::Left }, cx); + state.set_scrollbar_placement( + if mirrored { + ScrollbarPlacement::BottomLeft + } else { + ScrollbarPlacement::BottomRight + }, + cx, + ); + }); + cx.notify(); + } + + fn render_toolbar(&self, cx: &Context) -> impl IntoElement { + let story = cx.entity(); + let mirrored = self.mirrored; + let markers_before_numbers = self.markers_before_numbers; + story_toolbar_group().dropdown_child( + Button::new("diff-options").label("Options"), + move |menu, window, _| { + [false, true] + .into_iter() + .fold(menu.label("Gutter layout"), |menu, value| { + menu.item( + PopupMenuItem::new(if value { + "Facing center" + } else { + "Both on left" + }) + .checked(mirrored == value) + .on_click(window.listener_for( + &story, + move |this, _, _, cx| { + this.set_mirrored(value, cx); + }, + )), + ) + }) + .separator() + .item( + PopupMenuItem::new("Markers before line numbers") + .checked(markers_before_numbers) + .on_click(window.listener_for(&story, |this, _, _, cx| { + this.markers_before_numbers = !this.markers_before_numbers; + let columns = if this.markers_before_numbers { + [ + GutterColumn::FoldIcons, + GutterColumn::Markers, + GutterColumn::LineNumbers, + ] + } else { + [ + GutterColumn::FoldIcons, + GutterColumn::LineNumbers, + GutterColumn::Markers, + ] + }; + for editor in [&this.original, &this.modified] { + editor.update(cx, |state, cx| { + state.set_gutter_order(columns, cx) + }); + } + cx.notify(); + })), + ) + }, + ) + } + + fn render_pane( + &self, + label: &'static str, + state: &Entity, + cx: &App, + ) -> impl IntoElement { + v_flex() + .flex_1() + .min_w_0() + .h_full() + .child( + div() + .px_3() + .py_2() + .text_sm() + .text_color(cx.theme().muted_foreground) + .border_b_1() + .border_color(cx.theme().border) + .child(label), + ) + .child( + div().flex_1().min_h_0().child( + Editor::new(state) + .readonly(true) + .bordered(false) + .size_full(), + ), + ) + } +} + +impl Render for EditorDiffStory { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + // Wheel input notifies the editor; thumb dragging notifies the current + // Story view. Synchronize both paths before either pane is laid out. + self.synchronize_scroll(cx); + v_flex() + .size_full() + .gap_3() + .child(self.render_toolbar(cx)) + .child( + h_flex() + .flex_1() + .min_h_0() + .w_full() + .border_1() + .border_color(cx.theme().border) + .child(self.render_pane("Original · editor_preview.rs", &self.original, cx)) + .child(div().w_px().h_full().bg(cx.theme().border)) + .child(self.render_pane("Modified · editor_preview.rs", &self.modified, cx)), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui_kit::{ + Bounds, ScrollDelta, ScrollWheelEvent, TestAppContext, VisualTestContext, WindowBounds, + WindowOptions, point, size, + }; + + #[gpui_kit::test] + fn changing_diff_layout_preserves_sources_and_synchronized_scroll(cx: &mut TestAppContext) { + cx.update(gpui_kit::init); + let (window, story) = cx.update(|cx| { + gpui_kit::open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + point(px(0.), px(0.)), + size(px(900.), px(300.)), + ))), + ..Default::default() + }, + cx, + |window, cx| cx.new(|cx| EditorDiffStory::new(window, cx)), + ) + .unwrap() + }); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let (original, modified) = story.read_with(&cx, |story, _| { + (story.original.clone(), story.modified.clone()) + }); + let modified_source = modified.read_with(&cx, |state, _| state.text().to_string()); + assert_ne!(modified_source, ORIGINAL); + for mirrored in [false, true] { + story.update(&mut cx, |story, cx| story.set_mirrored(mirrored, cx)); + cx.update(|window, cx| window.draw(cx).clear(cx)); + original.read_with(&cx, |state, _| { + assert_eq!( + state.presentation().gutter_side(), + if mirrored { Side::Right } else { Side::Left } + ); + assert!(state.presentation().is_readonly()); + assert_eq!(state.text().to_string(), ORIGINAL); + }); + modified.read_with(&cx, |state, _| { + assert_eq!(state.presentation().gutter_side(), Side::Left); + assert_eq!(state.text().to_string(), modified_source); + }); + } + for (source, target) in [(&original, &modified), (&modified, &original)] { + let position = source.read_with(&cx, |state, _| state.input_bounds().center()); + cx.simulate_event(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point(px(0.), px(-120.))), + ..Default::default() + }); + for frame in 0..3 { + cx.update(|window, cx| window.draw(cx).clear(cx)); + let row = + source.read_with(&cx, |state, _| state.visible_row_range().unwrap().start + 1); + let source_y = + source.read_with(&cx, |state, _| state.row_bounds(row).unwrap().top()); + let target_y = + target.read_with(&cx, |state, _| state.row_bounds(row).unwrap().top()); + assert_eq!( + source_y, target_y, + "row {row} is misaligned on frame {frame}" + ); + cx.run_until_parked(); + } + let y = source.read_with(&cx, |state, _| state.scroll_offset().y); + assert!(y < px(0.)); + assert_eq!(target.read_with(&cx, |state, _| state.scroll_offset().y), y); + cx.simulate_event(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point(px(0.), px(120.))), + ..Default::default() + }); + for _ in 0..3 { + cx.update(|window, cx| window.draw(cx).clear(cx)); + cx.run_until_parked(); + } + } + for (source, target, left_track) in + [(&original, &modified, true), (&modified, &original, false)] + { + let bounds = source.read_with(&cx, |state, _| state.input_bounds()); + cx.simulate_event(ScrollWheelEvent { + position: bounds.center(), + delta: ScrollDelta::Pixels(point(px(0.), px(10000.))), + ..Default::default() + }); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let grab = point( + if left_track { + bounds.left() + px(1.) + } else { + bounds.right() - px(1.) + }, + bounds.top() + px(8.), + ); + cx.simulate_mouse_down( + grab, + gpui_kit::MouseButton::Left, + gpui_kit::Modifiers::default(), + ); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let dragged = grab + point(px(0.), px(20.)); + cx.simulate_mouse_move( + dragged, + Some(gpui_kit::MouseButton::Left), + gpui_kit::Modifiers::default(), + ); + cx.update(|window, cx| window.draw(cx).clear(cx)); + let y = source.read_with(&cx, |state, _| state.scroll_offset().y); + assert!(y < px(0.), "the real scrollbar drag must move the pane"); + assert_eq!( + target.read_with(&cx, |state, _| state.scroll_offset().y), + y, + "scrollbar dragging must align both panes on the same frame" + ); + let row = + source.read_with(&cx, |state, _| state.visible_row_range().unwrap().start + 1); + assert_eq!( + source.read_with(&cx, |state, _| state.row_bounds(row).unwrap().top()), + target.read_with(&cx, |state, _| state.row_bounds(row).unwrap().top()) + ); + cx.simulate_mouse_up( + dragged, + gpui_kit::MouseButton::Left, + gpui_kit::Modifiers::default(), + ); + } + } +} diff --git a/crates/story/src/stories/mod.rs b/crates/story/src/stories/mod.rs index 307e7f5fb4..1c2290e458 100644 --- a/crates/story/src/stories/mod.rs +++ b/crates/story/src/stories/mod.rs @@ -25,6 +25,7 @@ mod description_list_story; mod dialog_story; mod dock_story; mod dropdown_button_story; +mod editor_diff_story; mod editor_story; mod empty_story; mod form_story; @@ -106,6 +107,7 @@ pub use description_list_story::DescriptionListStory; pub use dialog_story::DialogStory; pub use dock_story::DockStory; pub use dropdown_button_story::DropdownButtonStory; +pub use editor_diff_story::EditorDiffStory; pub use editor_story::EditorStory; pub use empty_story::EmptyStory; pub use form_story::FormStory; diff --git a/examples/js_story/fixtures/verify-coverage.mjs b/examples/js_story/fixtures/verify-coverage.mjs index 56dd6e8fca..6303faa047 100644 --- a/examples/js_story/fixtures/verify-coverage.mjs +++ b/examples/js_story/fixtures/verify-coverage.mjs @@ -83,6 +83,12 @@ const NOT_MIRRORED = new Map([ "Theme is not a component. Every route already renders through the " + "active theme, so a swatch board would restate what the gallery shows.", ], + [ + "editor_diff", + "EditorDiffStory lays out two Editors as a side-by-side diff. The " + + "editor route already mirrors the component, and the diff adds no " + + "constructor of its own.", + ], ]); const inventoryNameFor = (rustStory) => { const name = rustStory.replace(/Story$/, ""); diff --git a/website/component/editor.md b/website/component/editor.md index 50286623a1..866372a474 100644 --- a/website/component/editor.md +++ b/website/component/editor.md @@ -120,14 +120,40 @@ let editor = cx.new(|cx| { The scrollbars sit on the right and bottom edges by default. Use `scrollbar_placement` to move them; a vertical scrollbar on the left sits on -the editor's left edge, over the line numbers. Like the bottom scrollbar over -the last line, a top scrollbar overlays the first line rather than reserving -space for itself. `set_scrollbar_placement` replaces the placement at runtime. +the editor's left edge, over the line numbers, or clear of the text when the +gutter is on the right. Like the bottom scrollbar over the last line, a top +scrollbar overlays the first line rather than reserving space for itself. +`set_scrollbar_placement` replaces the placement at runtime. + +In a mirrored pane with a right gutter and left scrollbar, the horizontal track stops before the gutter while the vertical track stays at the outer edge. ```rust EditorState::new(window, cx).scrollbar_placement(ScrollbarPlacement::BottomLeft) ``` +The gutter with the line numbers and fold icons sits on the left by default. +In a side-by-side diff, both gutters can face the center, so the line numbers +of corresponding lines sit next to each other across the divider. The left pane +puts its gutter on its right with `gutter_side` — mirrored, the fold icons next +to the text — and its scrollbar on its outer edge: + +```rust +EditorState::new(window, cx) + .scrollbar_placement(ScrollbarPlacement::BottomLeft) + .gutter_side(Side::Right) +``` + +On either side, the fold icons sit next to the text, then the line numbers, +then the gutter markers. Use `gutter_order` to list the columns from the text +outward; a column left out follows the listed ones. In a diff, this puts the +change markers between the text and the line numbers: + +```rust +EditorState::new(window, cx).gutter_order([GutterColumn::FoldIcons, GutterColumn::Markers]) +``` + +The Editor Diff story shows both layouts, with the two panes scrolling together. + ## Keyboard shortcuts and column selection These defaults apply while the editor is focused. On macOS, Option is the Alt @@ -364,18 +390,19 @@ data by row, and read colors from `cx` when asked so they follow theme changes. Collections are independent, like the other decoration collections; later ones paint over earlier ones. -A background spans the row from the gutter to the right edge, across all of its -soft-wrapped lines, under the active line, indent guides, selection and text. A marker -is painted in a slot of its own at the left of the line numbers, so it is shown only -while line numbers are. The gutter reserves the slot while any collection has a -provider, and keeps it as marked rows scroll in and out of view. The styled editor -sizes the marker at 90% of its effective font size and the gap at 30%. The icon, -reserved slot and vertical centering share the same size, so markers follow both -interface zoom and an editor-specific `.text_size(...)` change. `DiffAdded`, -`DiffRemoved`, `DiffChanged`, `Conflict`, `Bookmark` and `Breakpoint` take the -theme's success, danger, warning and info colors; `GutterMarker::Custom { icon, color }` -paints an icon asset path as given. Neither affects hit testing or focus, and neither -has a pointer or keyboard action. +A background spans the whole row, gutter included, across all of its soft-wrapped +lines, under the active line, indent guides, selection and text. A marker is painted +in a slot of its own, shown only while line numbers are. In the default order the +slot is on the outer side of the line numbers; `gutter_order` can move it nearer the +text, as IntelliJ IDEA places its markers. The gutter reserves the slot while any +collection has a provider, and keeps it as marked rows scroll in and out of view. +The styled editor sizes the marker at 90% of its effective font size and the gap at +30%. The icon, reserved slot and vertical centering share the same size, so markers +follow both interface zoom and an editor-specific `.text_size(...)` change. +`DiffAdded`, `DiffRemoved`, `DiffChanged`, `Conflict`, `Bookmark` and `Breakpoint` +take the theme's success, danger, warning and info colors; +`GutterMarker::Custom { icon, color }` paints an icon asset path as given. Neither +affects hit testing or focus, and neither has a pointer or keyboard action. `EditorState::row_bounds(row)` returns the band a row occupies in window coordinates, where it is painted: below a multi-line inline completion, rows move down with the diff --git a/website/zh-CN/component/editor.md b/website/zh-CN/component/editor.md index 5b559846e4..d4d18ef0e5 100644 --- a/website/zh-CN/component/editor.md +++ b/website/zh-CN/component/editor.md @@ -104,12 +104,30 @@ let editor = cx.new(|cx| { }); ``` -滚动条默认位于右侧和底部。使用 `scrollbar_placement` 可调整其位置;放在左侧的纵向滚动条位于编辑器左边缘,覆盖在行号之上。与覆盖最后一行的底部滚动条一样,顶部滚动条覆盖在第一行之上,不为自身预留空间。运行时可用 `set_scrollbar_placement` 替换位置。 +滚动条默认位于右侧和底部。使用 `scrollbar_placement` 可调整其位置;放在左侧的纵向滚动条位于编辑器左边缘,覆盖在行号之上;若 gutter 位于右侧,文本会与滚动条保持间距。与覆盖最后一行的底部滚动条一样,顶部滚动条覆盖在第一行之上,不为自身预留空间。运行时可用 `set_scrollbar_placement` 替换位置。 + +在右侧 gutter、左侧滚动条的镜像窗格中,横向轨道会避开 gutter,纵向轨道仍位于窗格外侧边缘。 ```rust EditorState::new(window, cx).scrollbar_placement(ScrollbarPlacement::BottomLeft) ``` +行号和折叠图标所在的 gutter 默认位于左侧。在并排 diff 中,两侧的 gutter 可以朝向中间,使对应行的行号隔着分隔线相邻。左侧窗格用 `gutter_side` 将 gutter 放到右侧(镜像排列,折叠图标紧邻文本),并把滚动条放到外侧边缘: + +```rust +EditorState::new(window, cx) + .scrollbar_placement(ScrollbarPlacement::BottomLeft) + .gutter_side(Side::Right) +``` + +无论 gutter 位于哪一侧,折叠图标默认紧邻文本,向外依次是行号和 gutter 标记。使用 `gutter_order` 可按从文本向外的顺序列出各列,未列出的列依次排在其后。在 diff 中,这样可以把变更标记放在文本与行号之间: + +```rust +EditorState::new(window, cx).gutter_order([GutterColumn::FoldIcons, GutterColumn::Markers]) +``` + +Editor Diff 示例展示了这两种布局,两个窗格同步滚动。 + ## 快捷键与矩形列选 以下默认快捷键在编辑器聚焦时生效。macOS 的 Option 对应 Alt 修饰键;Linux 的这些操作不使用 Super/Win。 @@ -313,8 +331,9 @@ added.dispose(cx); // 释放集合,使该句柄及其克隆全部失效。 在被询问时从 `cx` 读取颜色,使其跟随主题变化。与其他装饰集合一样,各集合相互独立; 后创建的集合绘制在先创建的集合之上。 -背景从行号槽一直延伸到右边缘,覆盖该行软换行后的全部显示行,位于当前行高亮、缩进参考线、 -选区和文字下方。标记绘制在行号左侧的独立槽位中,因此只在显示行号时出现。只要有集合设置了 +背景横跨整行(包括行号槽),覆盖该行软换行后的全部显示行,位于当前行高亮、缩进参考线、 +选区和文字下方。标记绘制在独立槽位中,只在显示行号时出现。默认顺序下,该槽位位于行号与行号槽外缘之间; +使用 `gutter_order` 可将其移到更靠近文本的位置,与 IntelliJ IDEA 放置标记的方式一致。只要有集合设置了 provider,行号槽就会保留该槽位,标记行滚入或滚出视图时宽度不变。 样式化编辑器的标记尺寸为实际字号的 90%,间距为字号的 30%。图标、预留槽位和垂直居中使用同一尺寸, 因此标记既跟随界面缩放,也跟随编辑器单独设置的 `.text_size(...)` 变化。`DiffAdded`、`DiffRemoved`、