diff --git a/crates/base/src/input/base/element.rs b/crates/base/src/input/base/element.rs index 7898895b7d..bcddf800a7 100644 --- a/crates/base/src/input/base/element.rs +++ b/crates/base/src/input/base/element.rs @@ -3,7 +3,7 @@ use gpui::Corners; use gpui::Half; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, Bounds, Edges, Element, ElementId, ElementInputHandler, Entity, + AnyElement, App, Bounds, ContentMask, Edges, Element, ElementId, ElementInputHandler, Entity, GlobalElementId, }; use gpui::{ @@ -372,6 +372,23 @@ fn editor_gutter_bounds( } } +/// Return the part of `bounds` on the text's side of the gutter. +fn editor_text_bounds( + bounds: Bounds, + gutter_bounds: Bounds, + side: Side, +) -> Bounds { + let (left, right) = if side.is_left() { + (gutter_bounds.right().max(bounds.left()), bounds.right()) + } else { + (bounds.left(), gutter_bounds.left().min(bounds.right())) + }; + Bounds::from_corners( + point(left.min(right), bounds.top()), + point(right, bounds.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 @@ -3347,175 +3364,190 @@ impl Element for TextElement { px(0.) }; - // Paint glyph backgrounds - // - // gpui's `paint` only draws glyphs, underlines, and strikethroughs, so decoration - // backgrounds need their own pass. It runs ahead of the indent guides, selections, - // and text so a highlighted range sits under them instead of covering them. - let mut offset_y = invisible_top_padding; - for (line, &buffer_line) in prepaint - .last_layout - .lines - .iter() - .zip(prepaint.last_layout.visible_buffer_lines.iter()) - { - let p = point( - origin.x + prepaint.last_layout.text_origin_x + scroll_offset, - origin.y + offset_y, - ); + // The gutter paints over horizontally scrolling text, so the text is + // clipped to its own side of the gutter. A gutter without line numbers + // still holds the fold icons and the margin, so it clips the text too. + let gutter_bounds = editor_gutter_bounds( + input_bounds, + prepaint.last_layout.line_number_width, + prepaint.ghost_lines_height, + editor_paddings, + prepaint.last_layout.gutter_side, + ); + let text_mask = (prepaint.last_layout.line_number_width > px(0.)).then(|| ContentMask { + bounds: editor_text_bounds( + window.content_mask().bounds, + gutter_bounds, + prepaint.last_layout.gutter_side, + ), + }); - line.paint_background( - p, - line_height, - text_align, - Some(prepaint.last_layout.content_width), - window, - cx, - ); + let cursor_row_y = window.with_content_mask(text_mask, |window| { + // Paint glyph backgrounds + // + // gpui's `paint` only draws glyphs, underlines, and strikethroughs, so decoration + // backgrounds need their own pass. It runs ahead of the indent guides, selections, + // and text so a highlighted range sits under them instead of covering them. + let mut offset_y = invisible_top_padding; + for (line, &buffer_line) in prepaint + .last_layout + .lines + .iter() + .zip(prepaint.last_layout.visible_buffer_lines.iter()) + { + let p = point( + origin.x + prepaint.last_layout.text_origin_x + scroll_offset, + origin.y + offset_y, + ); + + line.paint_background( + p, + line_height, + text_align, + Some(prepaint.last_layout.content_width), + window, + cx, + ); - offset_y += line.size(line_height).height; + offset_y += line.size(line_height).height; - // Ghost lines shift every later line down. - if Some(buffer_line) == prepaint.current_row { - offset_y += prepaint.ghost_lines_height; + // Ghost lines shift every later line down. + if Some(buffer_line) == prepaint.current_row { + offset_y += prepaint.ghost_lines_height; + } } - } - // Paint indent guides - if let Some(path) = prepaint.indent_guides_path.take() { - window.paint_path(path, editor_style.border.opacity(0.85)); - } + // Paint indent guides + if let Some(path) = prepaint.indent_guides_path.take() { + window.paint_path(path, editor_style.border.opacity(0.85)); + } - // Application decorations sit below the user's selection. - for (path, color) in &prepaint.range_decoration_fills { - window.paint_path(path.clone(), *color); - } - for (path, color) in &prepaint.range_decoration_frames { - window.paint_path(path.clone(), *color); - } + // Application decorations sit below the user's selection. + for (path, color) in &prepaint.range_decoration_fills { + window.paint_path(path.clone(), *color); + } + for (path, color) in &prepaint.range_decoration_frames { + window.paint_path(path.clone(), *color); + } - // Paint selections - if window.is_window_active() { - let secondary_selection = Hsla { - s: 0.1, - ..editor_style.selection - }; - for (path, is_active) in prepaint.search_match_paths.iter() { - window.paint_path(path.clone(), secondary_selection); + // Paint selections + if window.is_window_active() { + let secondary_selection = Hsla { + s: 0.1, + ..editor_style.selection + }; + for (path, is_active) in prepaint.search_match_paths.iter() { + window.paint_path(path.clone(), secondary_selection); - if *is_active { - window.paint_path(path.clone(), editor_style.selection); + if *is_active { + window.paint_path(path.clone(), editor_style.selection); + } } - } - for path in prepaint.selection_paths.drain(..) { - window.paint_path(path, editor_style.selection); - } + for path in prepaint.selection_paths.drain(..) { + window.paint_path(path, editor_style.selection); + } - // Paint hover highlight - if let Some(path) = prepaint.hover_highlight_path.take() { - window.paint_path(path, secondary_selection); + // Paint hover highlight + if let Some(path) = prepaint.hover_highlight_path.take() { + window.paint_path(path, secondary_selection); + } } - } - - // Paint document colors - for (path, color) in prepaint.document_color_paths.iter() { - let color = if disabled { color.opacity(0.5) } else { *color }; - window.paint_path(path.clone(), color); - } - // Paint text with inline completion ghost line support - let mut offset_y = invisible_top_padding; - let ghost_lines = &prepaint.ghost_lines; - let has_ghost_lines = !ghost_lines.is_empty(); + // Paint document colors + for (path, color) in prepaint.document_color_paths.iter() { + let color = if disabled { color.opacity(0.5) } else { *color }; + window.paint_path(path.clone(), color); + } - // Track the y-position of the cursor row for positioning the first line suffix - let mut cursor_row_y = None; + // Paint text with inline completion ghost line support + let mut offset_y = invisible_top_padding; + let ghost_lines = &prepaint.ghost_lines; + let has_ghost_lines = !ghost_lines.is_empty(); - for (line, &buffer_line) in prepaint - .last_layout - .lines - .iter() - .zip(prepaint.last_layout.visible_buffer_lines.iter()) - { - let row = buffer_line; - let line_y = origin.y + offset_y; - let p = point( - origin.x + prepaint.last_layout.text_origin_x + (scroll_offset), - line_y, - ); + // Track the y-position of the cursor row for positioning the first line suffix + let mut cursor_row_y = None; - // Paint the actual line - _ = line.paint( - p, - line_height, - text_align, - Some(prepaint.last_layout.content_width), - window, - cx, - ); - offset_y += line.size(line_height).height; + for (line, &buffer_line) in prepaint + .last_layout + .lines + .iter() + .zip(prepaint.last_layout.visible_buffer_lines.iter()) + { + let row = buffer_line; + let line_y = origin.y + offset_y; + let p = point( + origin.x + prepaint.last_layout.text_origin_x + (scroll_offset), + line_y, + ); - if Some(row) == prepaint.current_row { - cursor_row_y = Some(line_y); - } + // Paint the actual line + _ = line.paint( + p, + line_height, + text_align, + Some(prepaint.last_layout.content_width), + window, + cx, + ); + offset_y += line.size(line_height).height; - // 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.text_origin_x; + if Some(row) == prepaint.current_row { + cursor_row_y = Some(line_y); + } - for ghost_line in ghost_lines { - let ghost_p = point(ghost_x, origin.y + offset_y); + // 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.text_origin_x; - // Paint semi-transparent background for ghost line - let ghost_bounds = - Bounds::new(ghost_p, size(prepaint.last_layout.text_width, line_height)); - window.paint_quad(fill(ghost_bounds, editor_background)); + for ghost_line in ghost_lines { + let ghost_p = point(ghost_x, origin.y + offset_y); - // Paint ghost line text - _ = ghost_line.paint( - ghost_p, - line_height, - text_align, - Some(prepaint.last_layout.content_width), - window, - cx, - ); - offset_y += line_height; + // Paint semi-transparent background for ghost line + 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 + _ = ghost_line.paint( + ghost_p, + line_height, + text_align, + Some(prepaint.last_layout.content_width), + window, + cx, + ); + offset_y += line_height; + } } } - } - for element in &mut prepaint.token_elements { - element.paint(window, cx); - } + for element in &mut prepaint.token_elements { + element.paint(window, cx); + } - // Paint blinking cursors (shared blink state for all carets) - if focused && show_cursor { - for cursor_info in prepaint.cursor_infos_with_scroll() { - window.paint_quad(fill(cursor_info.bounds, editor_style.caret)); + // Paint blinking cursors (shared blink state for all carets) + if focused && show_cursor { + for cursor_info in prepaint.cursor_infos_with_scroll() { + window.paint_quad(fill(cursor_info.bounds, editor_style.caret)); + } } - } + + cursor_row_y + }); // Paint line numbers let mut offset_y = px(0.); if let Some(line_numbers) = prepaint.line_numbers.as_ref() { offset_y += invisible_top_padding; - // The gutter is a fixed overlay above horizontally scrolling text. - // Always give it an opaque editor background when the theme does not - // provide a dedicated gutter color, and cover the complete gutter so - // text cannot show through its right-side spacing/fold-icon area. + // Give the gutter the editor background when the theme does not + // provide a dedicated gutter color, and cover the complete gutter. let gutter_bg = editor_style .editor_gutter_background .unwrap_or(editor_background); - let gutter_bounds = editor_gutter_bounds( - input_bounds, - 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. for &(y, height, color) in &line_backgrounds { @@ -3616,26 +3648,32 @@ impl Element for TextElement { } // Paint inline completion first line suffix (after cursor on same line) - if focused { - if let Some(first_line) = &prepaint.ghost_first_line { - let active_cursor = prepaint - .cursor_infos_with_scroll() - .into_iter() - .find(|info| info.is_active); - if let (Some(cursor_info), Some(cursor_row_y)) = (active_cursor, cursor_row_y) { - let cursor_bounds = cursor_info.bounds; - let first_line_x = cursor_bounds.origin.x + cursor_bounds.size.width; - let p = point(first_line_x, cursor_row_y); - - // Paint background to cover any existing text - let bg_bounds = Bounds::new(p, size(first_line.width + px(4.), line_height)); - window.paint_quad(fill(bg_bounds, editor_background)); - - // Paint first line completion text - _ = first_line.paint(p, line_height, text_align, None, window, cx); + // + // It paints after the gutter and the fold icons so its background covers + // the text it overlaps, and so it stays in its own mask. + window.with_content_mask(text_mask, |window| { + if focused { + if let Some(first_line) = &prepaint.ghost_first_line { + let active_cursor = prepaint + .cursor_infos_with_scroll() + .into_iter() + .find(|info| info.is_active); + if let (Some(cursor_info), Some(cursor_row_y)) = (active_cursor, cursor_row_y) { + let cursor_bounds = cursor_info.bounds; + let first_line_x = cursor_bounds.origin.x + cursor_bounds.size.width; + let p = point(first_line_x, cursor_row_y); + + // Paint background to cover any existing text + let bg_bounds = + Bounds::new(p, size(first_line.width + px(4.), line_height)); + window.paint_quad(fill(bg_bounds, editor_background)); + + // Paint first line completion text + _ = first_line.paint(p, line_height, text_align, None, window, cx); + } } } - } + }); self.paint_mouse_listeners(&prepaint.hitbox, window, cx); } @@ -5040,6 +5078,91 @@ mod tests { }); } + #[gpui::test] + fn the_text_is_clipped_to_its_side_of_the_gutter(cx: &mut TestAppContext) { + let text = format!("{}\n", "word ".repeat(40)).repeat(3); + let (editor, window) = decoration_editor(cx, &text, false); + let mut cx = VisualTestContext::from_window(window.into(), cx); + cx.update(|window, cx| { + editor.update(cx, |state, cx| { + state.create_decorations_collection( + vec![TextDecoration::new( + 0..text.len(), + HighlightStyle { + background_color: Some(gpui::red()), + underline: Some(UnderlineStyle { + color: Some(gpui::red()), + thickness: px(1.), + wavy: false, + }), + ..Default::default() + }, + )], + cx, + ); + }); + + for (line_number, side, placement) in [ + (true, Side::Left, ScrollbarPlacement::BottomRight), + (true, Side::Right, ScrollbarPlacement::BottomRight), + (true, Side::Left, ScrollbarPlacement::BottomLeft), + (true, Side::Right, ScrollbarPlacement::BottomLeft), + // Without line numbers the gutter holds only the fold icons. + (false, Side::Left, ScrollbarPlacement::BottomRight), + (false, Side::Right, ScrollbarPlacement::BottomRight), + ] { + editor.update(cx, |state, cx| { + state.set_line_number(line_number, window, cx); + state.set_gutter_side(side, cx); + state.set_scrollbar_placement(placement, cx); + state.set_scroll_offset(point(px(-100.), px(0.)), cx); + }); + // Deferred scroll is committed by prepaint and reflected next frame. + window.draw(cx).clear(cx); + window.draw(cx).clear(cx); + let state = editor.read(cx); + let layout = state.last_layout.as_ref().unwrap(); + let gutter = editor_gutter_bounds( + state.input_bounds, + layout.line_number_width, + px(0.), + state.editor_paddings, + side, + ) + .scale(window.scale_factor()); + + // Glyph backgrounds, then the text's own underlines. + let red = gpui::Background::from(gpui::red()); + let backgrounds = window + .painted_quads() + .into_iter() + .filter(|quad| quad.background == red) + .map(|quad| (quad.bounds, quad.content_mask.bounds)) + .collect::>(); + let underlines = window + .painted_underlines() + .into_iter() + .filter(|underline| underline.color == gpui::red()) + .map(|underline| (underline.bounds, underline.content_mask.bounds)) + .collect::>(); + for painted in [backgrounds, underlines] { + assert!( + painted.iter().any(|(bounds, _)| bounds.intersects(&gutter)), + "{side:?} gutter, line numbers {line_number}, {placement:?}: the text runs under the gutter" + ); + for (bounds, mask) in painted { + // A content mask is snapped to whole device pixels. + let shown = bounds.intersect(&mask).intersect(&gutter); + assert!( + shown.size.width < gpui::ScaledPixels::from(1.), + "{side:?} gutter, line numbers {line_number}, {placement:?}: {bounds:?} under {mask:?} shows through the gutter {gutter:?}" + ); + } + } + } + }); + } + #[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);