From ad51df7644e66b53c758b919c27273438be6f16c Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 30 Dec 2024 09:23:11 -0300 Subject: [PATCH] Improve multibuffer excerpt affordances (#22167) Changes: - [x] Increase expand affordance surface area - [x] Ensure expand buttons have tooltips with keybindings - [x] Make line numbers clickable to jump you to location (only in multibuffers) - [x] Hide the "Jump To File" element in not-focused excerpts Before merging it: - [x] Fix off-by-one header focus styles glitch Improvements to consider for follow-up PRs: 1. Experiment with increasing the width of the clickable surface area for line numbers 2. Don't show (or disable) the "expand excerpt" button when at the top or bottom edge of the file 3. Once you jump to location, centralize the cursor scroll position Release Notes: - Improved multibuffer's "expand excerpt" affordance - Fixed "jump to file/location" and "expand excerpt" keybinding display - Made clicking on line numbers in multibuffers jump you to cursor location in file --------- Co-authored-by: Thorsten Ball Co-authored-by: Agus Zubiaga Co-authored-by: Kirill Bulatov Co-authored-by: Agus Zubiaga --- crates/diagnostics/src/diagnostics.rs | 111 ++-- crates/editor/src/display_map/block_map.rs | 1 + crates/editor/src/editor.rs | 44 +- crates/editor/src/element.rs | 632 +++++++++++++-------- crates/multi_buffer/src/multi_buffer.rs | 18 + crates/ui/src/components/tooltip.rs | 17 + 6 files changed, 510 insertions(+), 313 deletions(-) diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 897089cf9b650..cb6160e6591ed 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -836,65 +836,76 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { let (message, code_ranges) = highlight_diagnostic_message(&diagnostic, None); let message: SharedString = message; Arc::new(move |cx| { - let highlight_style: HighlightStyle = cx.theme().colors().text_accent.into(); + let color = cx.theme().colors(); + let highlight_style: HighlightStyle = color.text_accent.into(); + h_flex() .id(DIAGNOSTIC_HEADER) - .block_mouse_down() - .h(2. * cx.line_height()) - .pl_10() - .pr_5() .w_full() - .justify_between() - .gap_2() + .relative() + .child( + div() + .top(px(0.)) + .absolute() + .w_full() + .h_px() + .bg(color.border_variant), + ) .child( h_flex() - .gap_3() - .map(|stack| { - stack.child( - svg() - .size(cx.text_style().font_size) - .flex_none() - .map(|icon| { - if diagnostic.severity == DiagnosticSeverity::ERROR { - icon.path(IconName::XCircle.path()) - .text_color(Color::Error.color(cx)) - } else { - icon.path(IconName::Warning.path()) - .text_color(Color::Warning.color(cx)) - } - }), - ) - }) + .block_mouse_down() + .h(2. * cx.line_height()) + .pl_10() + .pr_5() + .w_full() + .justify_between() + .gap_2() .child( h_flex() - .gap_1() + .gap_3() + .map(|stack| { + stack.child(svg().size(cx.text_style().font_size).flex_none().map( + |icon| { + if diagnostic.severity == DiagnosticSeverity::ERROR { + icon.path(IconName::XCircle.path()) + .text_color(Color::Error.color(cx)) + } else { + icon.path(IconName::Warning.path()) + .text_color(Color::Warning.color(cx)) + } + }, + )) + }) .child( - StyledText::new(message.clone()).with_highlights( - &cx.text_style(), - code_ranges - .iter() - .map(|range| (range.clone(), highlight_style)), - ), + h_flex() + .gap_1() + .child( + StyledText::new(message.clone()).with_highlights( + &cx.text_style(), + code_ranges + .iter() + .map(|range| (range.clone(), highlight_style)), + ), + ) + .when_some(diagnostic.code.as_ref(), |stack, code| { + stack.child( + div() + .child(SharedString::from(format!("({code})"))) + .text_color(cx.theme().colors().text_muted), + ) + }), + ), + ) + .child(h_flex().gap_1().when_some( + diagnostic.source.as_ref(), + |stack, source| { + stack.child( + div() + .child(SharedString::from(source.clone())) + .text_color(cx.theme().colors().text_muted), ) - .when_some(diagnostic.code.as_ref(), |stack, code| { - stack.child( - div() - .child(SharedString::from(format!("({code})"))) - .text_color(cx.theme().colors().text_muted), - ) - }), - ), - ) - .child( - h_flex() - .gap_1() - .when_some(diagnostic.source.as_ref(), |stack, source| { - stack.child( - div() - .child(SharedString::from(source.clone())) - .text_color(cx.theme().colors().text_muted), - ) - }), + }, + )), ) .into_any_element() }) diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index 33242e6c8c53d..73e1b32c556a3 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -1757,6 +1757,7 @@ impl<'a> BlockChunks<'a> { pub struct StickyHeaderExcerpt<'a> { pub excerpt: &'a ExcerptInfo, pub next_excerpt_controls_present: bool, + // TODO az remove option pub next_buffer_row: Option, } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8c6132d0a9439..899ccb4eada00 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -992,12 +992,14 @@ pub(crate) struct FocusedBlock { } #[derive(Clone)] -struct JumpData { - excerpt_id: ExcerptId, - position: Point, - anchor: text::Anchor, - path: Option, - line_offset_from_top: u32, +enum JumpData { + MultiBufferRow(MultiBufferRow), + MultiBufferPoint { + excerpt_id: ExcerptId, + position: Point, + anchor: text::Anchor, + line_offset_from_top: u32, + }, } impl Editor { @@ -12458,28 +12460,46 @@ impl Editor { let mut new_selections_by_buffer = HashMap::default(); match &jump_data { - Some(jump_data) => { + Some(JumpData::MultiBufferPoint { + excerpt_id, + position, + anchor, + line_offset_from_top, + }) => { let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); if let Some(buffer) = multi_buffer_snapshot - .buffer_id_for_excerpt(jump_data.excerpt_id) + .buffer_id_for_excerpt(*excerpt_id) .and_then(|buffer_id| self.buffer.read(cx).buffer(buffer_id)) { let buffer_snapshot = buffer.read(cx).snapshot(); - let jump_to_point = if buffer_snapshot.can_resolve(&jump_data.anchor) { - language::ToPoint::to_point(&jump_data.anchor, &buffer_snapshot) + let jump_to_point = if buffer_snapshot.can_resolve(anchor) { + language::ToPoint::to_point(anchor, &buffer_snapshot) } else { - buffer_snapshot.clip_point(jump_data.position, Bias::Left) + buffer_snapshot.clip_point(*position, Bias::Left) }; let jump_to_offset = buffer_snapshot.point_to_offset(jump_to_point); new_selections_by_buffer.insert( buffer, ( vec![jump_to_offset..jump_to_offset], - Some(jump_data.line_offset_from_top), + Some(*line_offset_from_top), ), ); } } + Some(JumpData::MultiBufferRow(row)) => { + let point = MultiBufferPoint::new(row.0, 0); + if let Some((buffer, buffer_point, _)) = + self.buffer.read(cx).point_to_buffer_point(point, cx) + { + let buffer_offset = buffer.read(cx).point_to_offset(buffer_point); + new_selections_by_buffer + .entry(buffer) + .or_insert((Vec::new(), None)) + .0 + .push(buffer_offset..buffer_offset) + } + } None => { let selections = self.selections.all::(cx); let buffer = self.buffer.read(cx); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 379b16c09b6d7..d7a669666b43f 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -52,10 +52,7 @@ use multi_buffer::{ Anchor, AnchorRangeExt, ExcerptId, ExcerptInfo, ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow, MultiBufferSnapshot, ToOffset, }; -use project::{ - project_settings::{GitGutterSetting, ProjectSettings}, - ProjectPath, -}; +use project::project_settings::{GitGutterSetting, ProjectSettings}; use settings::Settings; use smallvec::{smallvec, SmallVec}; use std::{ @@ -69,12 +66,13 @@ use std::{ sync::Arc, }; use sum_tree::Bias; +use text::BufferId; use theme::{ActiveTheme, Appearance, PlayerColor}; -use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip}; -use ui::{prelude::*, POPOVER_Y_PADDING}; +use ui::{ + prelude::*, ButtonLike, ButtonStyle, ContextMenu, KeyBinding, Tooltip, POPOVER_Y_PADDING, +}; use unicode_segmentation::UnicodeSegmentation; -use util::RangeExt; -use util::ResultExt; +use util::{RangeExt, ResultExt}; use workspace::{item::Item, Workspace}; struct SelectionLayout { @@ -504,6 +502,7 @@ impl EditorElement { ) } + #[allow(clippy::too_many_arguments)] fn mouse_left_down( editor: &mut Editor, event: &MouseDownEvent, @@ -511,6 +510,7 @@ impl EditorElement { position_map: &PositionMap, text_hitbox: &Hitbox, gutter_hitbox: &Hitbox, + line_numbers: &HashMap)>, cx: &mut ViewContext, ) { if cx.default_prevented() { @@ -530,7 +530,9 @@ impl EditorElement { return; } - if click_count == 2 && !editor.buffer().read(cx).is_singleton() { + let is_singleton = editor.buffer().read(cx).is_singleton(); + + if click_count == 2 && !is_singleton { match EditorSettings::get_global(cx).double_click_in_multibuffer { DoubleClickInMultibuffer::Select => { // do nothing special on double click, all selection logic is below @@ -585,8 +587,27 @@ impl EditorElement { cx, ); } - cx.stop_propagation(); + + if !is_singleton { + let display_row = (((event.position - gutter_hitbox.bounds.origin).y + + position_map.scroll_pixel_position.y) + / position_map.line_height) as u32; + let multi_buffer_row = position_map + .snapshot + .display_point_to_point(DisplayPoint::new(DisplayRow(display_row), 0), Bias::Right) + .row; + if let Some((_, Some(hitbox))) = line_numbers.get(&MultiBufferRow(multi_buffer_row)) { + if hitbox.contains(&event.position) { + editor.open_excerpts_common( + Some(JumpData::MultiBufferRow(MultiBufferRow(multi_buffer_row))), + modifiers.alt, + cx, + ); + cx.stop_propagation(); + } + } + } } fn mouse_right_down( @@ -1975,20 +1996,25 @@ impl EditorElement { relative_rows } + #[allow(clippy::too_many_arguments)] fn layout_line_numbers( &self, + gutter_hitbox: Option<&Hitbox>, + gutter_dimensions: GutterDimensions, + line_height: Pixels, + scroll_position: gpui::Point, rows: Range, buffer_rows: impl Iterator>, active_rows: &BTreeMap, newest_selection_head: Option, snapshot: &EditorSnapshot, cx: &mut WindowContext, - ) -> Vec> { + ) -> Arc)>> { let include_line_numbers = snapshot.show_line_numbers.unwrap_or_else(|| { EditorSettings::get_global(cx).gutter.line_numbers && snapshot.mode == EditorMode::Full }); if !include_line_numbers { - return Vec::new(); + return Arc::default(); } let (newest_selection_head, is_relative) = self.editor.update(cx, |editor, cx| { @@ -2008,7 +2034,6 @@ impl EditorElement { let is_relative = editor.should_use_relative_line_numbers(cx); (newest_selection_head, is_relative) }); - let font_size = self.style.text.font_size.to_pixels(cx.rem_size()); let relative_to = if is_relative { Some(newest_selection_head.row()) @@ -2017,11 +2042,11 @@ impl EditorElement { }; let relative_rows = self.calculate_relative_line_numbers(snapshot, &rows, relative_to); let mut line_number = String::new(); - buffer_rows + let line_numbers = buffer_rows .into_iter() .enumerate() - .map(|(ix, multibuffer_row)| { - let multibuffer_row = multibuffer_row?; + .flat_map(|(ix, buffer_row)| { + let buffer_row = buffer_row?; let display_row = DisplayRow(rows.start.0 + ix as u32); let color = if active_rows.contains_key(&display_row) { cx.theme().colors().editor_active_line_number @@ -2029,26 +2054,43 @@ impl EditorElement { cx.theme().colors().editor_line_number }; line_number.clear(); - let default_number = multibuffer_row.0 + 1; + let default_number = buffer_row.0 + 1; let number = relative_rows .get(&DisplayRow(ix as u32 + rows.start.0)) .unwrap_or(&default_number); write!(&mut line_number, "{number}").unwrap(); - let run = TextRun { - len: line_number.len(), - font: self.style.text.font(), - color, - background_color: None, - underline: None, - strikethrough: None, + + let shaped_line = self + .shape_line_number(SharedString::from(&line_number), color, cx) + .log_err()?; + let scroll_top = scroll_position.y * line_height; + let line_origin = gutter_hitbox.map(|hitbox| { + hitbox.origin + + point( + hitbox.size.width - shaped_line.width - gutter_dimensions.right_padding, + ix as f32 * line_height - (scroll_top % line_height), + ) + }); + + #[cfg(not(test))] + let hitbox = line_origin.map(|line_origin| { + cx.insert_hitbox( + Bounds::new(line_origin, size(shaped_line.width, line_height)), + false, + ) + }); + #[cfg(test)] + let hitbox = { + let _ = line_origin; + None }; - let shaped_line = cx - .text_system() - .shape_line(line_number.clone().into(), font_size, &[run]) - .unwrap(); - Some(shaped_line) + + let multi_buffer_row = DisplayPoint::new(display_row, 0).to_point(snapshot).row; + let multi_buffer_row = MultiBufferRow(multi_buffer_row); + Some((multi_buffer_row, (shaped_line, hitbox))) }) - .collect() + .collect(); + Arc::new(line_numbers) } fn layout_crease_toggles( @@ -2209,6 +2251,7 @@ impl EditorElement { scroll_width: &mut Pixels, resized_blocks: &mut HashMap, selections: &[Selection], + selected_buffer_ids: &Vec, is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, sticky_header_excerpt_id: Option, cx: &mut WindowContext, @@ -2268,46 +2311,43 @@ impl EditorElement { show_excerpt_controls, height, } => { - let block_start = DisplayPoint::new(block_row_start, 0).to_point(snapshot); - let block_end = DisplayPoint::new(block_row_start + *height, 0).to_point(snapshot); - let selected = selections - .binary_search_by(|selection| { - if selection.end <= block_start { - Ordering::Less - } else if selection.start >= block_end { - Ordering::Greater - } else { - Ordering::Equal - } - }) - .is_ok(); + let selected = selected_buffer_ids.contains(&first_excerpt.buffer_id); let icon_offset = gutter_dimensions.width - (gutter_dimensions.left_padding + gutter_dimensions.margin); - let mut result = v_flex().id(block_id).w_full(); + if let Some(prev_excerpt) = prev_excerpt { if *show_excerpt_controls { result = result.child( h_flex() + .id("expand_down_hit_area") .w(icon_offset) .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height()) .flex_none() .justify_end() .child(self.render_expand_excerpt_button( - prev_excerpt.id, - ExpandExcerptDirection::Down, IconName::ArrowDownFromLine, + None, cx, - )), + )) + .on_click(cx.listener_for(&self.editor, { + let excerpt_id = prev_excerpt.id; + let direction = ExpandExcerptDirection::Down; + move |editor, _, cx| { + editor.expand_excerpt(excerpt_id, direction, cx); + cx.stop_propagation(); + } + })), ); } } - let jump_data = jump_data(snapshot, block_row_start, *height, first_excerpt, cx); + let jump_data = header_jump_data(snapshot, block_row_start, *height, first_excerpt); result .child(self.render_buffer_header(first_excerpt, true, selected, jump_data, cx)) .into_any_element() } + Block::ExcerptBoundary { prev_excerpt, next_excerpt, @@ -2317,34 +2357,76 @@ impl EditorElement { } => { let icon_offset = gutter_dimensions.width - (gutter_dimensions.left_padding + gutter_dimensions.margin); + let header_height = MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height(); + let color = cx.theme().colors().clone(); + let hover_color = color.border_variant.opacity(0.5); + let focus_handle = self.editor.focus_handle(cx).clone(); let mut result = v_flex().id(block_id).w_full(); + let expand_area = |id: SharedString| { + h_flex() + .id(id) + .w_full() + .cursor_pointer() + .block_mouse_down() + .on_mouse_move(|_, cx| cx.stop_propagation()) + .hover(|style| style.bg(hover_color)) + .tooltip({ + let focus_handle = focus_handle.clone(); + move |cx| { + Tooltip::for_action_in( + "Expand Excerpt", + &ExpandExcerpts { lines: 0 }, + &focus_handle, + cx, + ) + } + }) + }; + if let Some(prev_excerpt) = prev_excerpt { if *show_excerpt_controls { + let group_name = "expand-down"; + result = result.child( - h_flex() - .w(icon_offset) - .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height()) - .flex_none() - .justify_end() - .child(self.render_expand_excerpt_button( - prev_excerpt.id, - ExpandExcerptDirection::Down, - IconName::ArrowDownFromLine, - cx, - )), + expand_area(format!("block-{}-down", block_id).into()) + .group(group_name) + .child( + h_flex() + .w(icon_offset) + .h(header_height) + .flex_none() + .justify_end() + .child(self.render_expand_excerpt_button( + IconName::ArrowDownFromLine, + Some(group_name.to_string()), + cx, + )), + ) + .on_click(cx.listener_for(&self.editor, { + let excerpt_id = prev_excerpt.id; + let direction = ExpandExcerptDirection::Down; + move |editor, _, cx| { + editor.expand_excerpt(excerpt_id, direction, cx); + cx.stop_propagation(); + } + })), ); } } if let Some(next_excerpt) = next_excerpt { - let jump_data = jump_data(snapshot, block_row_start, *height, next_excerpt, cx); + let jump_data = + header_jump_data(snapshot, block_row_start, *height, next_excerpt); + if *starts_new_buffer { if sticky_header_excerpt_id != Some(next_excerpt.id) { + let selected = selected_buffer_ids.contains(&next_excerpt.buffer_id); + result = result.child(self.render_buffer_header( next_excerpt, false, - false, + selected, jump_data, cx, )); @@ -2354,125 +2436,103 @@ impl EditorElement { } if *show_excerpt_controls { + let group_name = "expand-up-first"; + result = result.child( - h_flex() - .w(icon_offset) - .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height()) - .flex_none() - .justify_end() - .child(self.render_expand_excerpt_button( - next_excerpt.id, - ExpandExcerptDirection::Up, - IconName::ArrowUpFromLine, - cx, - )), + h_flex().group(group_name).child( + expand_area(format!("block-{}-up-first", block_id).into()) + .h(header_height) + .child( + h_flex() + .w(icon_offset) + .h(header_height) + .flex_none() + .justify_end() + .child(self.render_expand_excerpt_button( + IconName::ArrowUpFromLine, + Some(group_name.to_string()), + cx, + )), + ) + .on_click(cx.listener_for(&self.editor, { + let excerpt_id = next_excerpt.id; + let direction = ExpandExcerptDirection::Up; + move |editor, _, cx| { + editor.expand_excerpt(excerpt_id, direction, cx); + cx.stop_propagation(); + } + })), + ), ); } } else { - let editor = self.editor.clone(); - result = result.child( - h_flex() - .id("excerpt header block") - .group("excerpt-jump-action") - .justify_start() - .w_full() - .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height()) - .relative() - .child( - div() - .top(px(0.)) - .absolute() - .w_full() - .h_px() - .bg(cx.theme().colors().border_variant) - .group_hover("excerpt-jump-action", |style| { - style.bg(cx.theme().colors().border) - }), - ) - .cursor_pointer() - .on_click({ - let jump_data = jump_data.clone(); - cx.listener_for(&self.editor, { - let jump_data = jump_data.clone(); - move |editor, e: &ClickEvent, cx| { - cx.stop_propagation(); - editor.open_excerpts_common( - Some(jump_data.clone()), - e.down.modifiers.secondary(), - cx, - ); - } - }) - }) - .tooltip({ - let jump_data = jump_data.clone(); - move |cx| { - let jump_message = format!( - "Jump to {}:L{}", - match &jump_data.path { - Some(project_path) => - project_path.path.display().to_string(), - None => { - let editor = editor.read(cx); - editor - .file_at(jump_data.position, cx) - .map(|file| { - file.full_path(cx).display().to_string() - }) - .or_else(|| { - Some( - editor - .tab_description(0, cx)? - .to_string(), + let group_name = "expand-up-subsequent"; + + if *show_excerpt_controls { + result = result.child( + h_flex() + .relative() + .group(group_name) + .child( + div() + .top(px(0.)) + .absolute() + .w_full() + .h_px() + .bg(color.border_variant), + ) + .child( + expand_area(format!("block-{}-up", block_id).into()) + .h(header_height) + .child( + h_flex() + .w(icon_offset) + .h(header_height) + .flex_none() + .justify_end() + .child(if *show_excerpt_controls { + self.render_expand_excerpt_button( + IconName::ArrowUpFromLine, + Some(group_name.to_string()), + cx, + ) + } else { + ButtonLike::new("jump-icon") + .style(ButtonStyle::Transparent) + .child( + svg() + .path( + IconName::ArrowUpRight + .path(), + ) + .size(IconSize::XSmall.rems()) + .text_color( + color.border_variant, + ) + .group_hover( + group_name, + |style| { + style.text_color( + color.border, + ) + }, + ), ) - }) - .unwrap_or_else(|| { - "Unknown buffer".to_string() - }) - } - }, - jump_data.position.row + 1 - ); - Tooltip::for_action(jump_message, &OpenExcerpts, cx) - } - }) - .child( - h_flex() - .w(icon_offset) - .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 - * cx.line_height()) - .flex_none() - .justify_end() - .child(if *show_excerpt_controls { - self.render_expand_excerpt_button( - next_excerpt.id, - ExpandExcerptDirection::Up, - IconName::ArrowUpFromLine, - cx, + }), ) - } else { - ButtonLike::new("jump-icon") - .style(ButtonStyle::Transparent) - .child( - svg() - .path(IconName::ArrowUpRight.path()) - .size(IconSize::XSmall.rems()) - .text_color( - cx.theme().colors().border_variant, - ) - .group_hover( - "excerpt-jump-action", - |style| { - style.text_color( - cx.theme().colors().border, - ) - }, - ), - ) - }), - ), - ); - } + .on_click(cx.listener_for(&self.editor, { + let excerpt_id = next_excerpt.id; + let direction = ExpandExcerptDirection::Up; + move |editor, _, cx| { + editor + .expand_excerpt(excerpt_id, direction, cx); + cx.stop_propagation(); + } + })), + ), + ); + } + }; } result.into_any() @@ -2524,8 +2584,8 @@ impl EditorElement { let parent_path = path .as_ref() .and_then(|path| Some(path.parent()?.to_string_lossy().to_string() + "/")); - let focus_handle = self.editor.focus_handle(cx); + let colors = cx.theme().colors(); div() .px_2() @@ -2538,20 +2598,20 @@ impl EditorElement { .gap_2() .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667))) .pl_0p5() - .pr_4() + .pr_5() .rounded_md() .shadow_md() .border_1() .map(|div| { - let border_color = if is_selected { - cx.theme().colors().border_focused + let border_color = if is_selected && is_folded { + colors.border_focused } else { - cx.theme().colors().border + colors.border }; div.border_color(border_color) }) - .bg(cx.theme().colors().editor_subheader_background) - .hover(|style| style.bg(cx.theme().colors().element_hover)) + .bg(colors.editor_subheader_background) + .hover(|style| style.bg(colors.element_hover)) .map(|header| { let editor = self.editor.clone(); let buffer_id = for_excerpt.buffer_id; @@ -2559,7 +2619,7 @@ impl EditorElement { FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path); header.child( div() - .hover(|style| style.bg(cx.theme().colors().element_selected)) + .hover(|style| style.bg(colors.element_selected)) .rounded_sm() .child( ButtonLike::new("toggle-buffer-fold") @@ -2594,6 +2654,7 @@ impl EditorElement { }) .child( h_flex() + .cursor_pointer() .id("path header block") .size_full() .justify_between() @@ -2606,25 +2667,24 @@ impl EditorElement { .unwrap_or_else(|| "untitled".into()), ) .when_some(parent_path, |then, path| { - then.child( - div() - .child(path) - .text_color(cx.theme().colors().text_muted), - ) + then.child(div().child(path).text_color(colors.text_muted)) }), ) - .child(Icon::new(IconName::ArrowUpRight).size(IconSize::Small)) - .cursor_pointer() - .tooltip({ - let focus_handle = focus_handle.clone(); - move |cx| { - Tooltip::for_action_in( - "Jump To File", - &OpenExcerpts, - &focus_handle, - cx, - ) - } + .when(is_selected, |el| { + el.child( + h_flex() + .id("jump-to-file-button") + .gap_2p5() + .child(Label::new("Jump To File")) + .children( + KeyBinding::for_action_in( + &OpenExcerpts, + &focus_handle, + cx, + ) + .map(|binding| binding.into_any_element()), + ), + ) }) .on_mouse_down(MouseButton::Left, |_, cx| cx.stop_propagation()) .on_click(cx.listener_for(&self.editor, { @@ -2642,11 +2702,11 @@ impl EditorElement { fn render_expand_excerpt_button( &self, - excerpt_id: ExcerptId, - direction: ExpandExcerptDirection, icon: IconName, + group_name: impl Into>, cx: &mut WindowContext, ) -> ButtonLike { + let group_name = group_name.into(); ButtonLike::new("expand-icon") .style(ButtonStyle::Transparent) .child( @@ -2654,17 +2714,12 @@ impl EditorElement { .path(icon.path()) .size(IconSize::XSmall.rems()) .text_color(cx.theme().colors().editor_line_number) - .group("") - .hover(|style| style.text_color(cx.theme().colors().editor_active_line_number)), + .when_some(group_name, |svg, group_name| { + svg.group_hover(group_name, |style| { + style.text_color(cx.theme().colors().editor_active_line_number) + }) + }), ) - .on_click(cx.listener_for(&self.editor, { - move |editor, _, cx| { - editor.expand_excerpt(excerpt_id, direction, cx); - } - })) - .tooltip({ - move |cx| Tooltip::for_action("Expand Excerpt", &ExpandExcerpts { lines: 0 }, cx) - }) } #[allow(clippy::too_many_arguments)] @@ -2682,6 +2737,7 @@ impl EditorElement { line_height: Pixels, line_layouts: &[LineWithInvisibles], selections: &[Selection], + selected_buffer_ids: &Vec, is_row_soft_wrapped: impl Copy + Fn(usize) -> bool, sticky_header_excerpt_id: Option, cx: &mut WindowContext, @@ -2721,6 +2777,7 @@ impl EditorElement { scroll_width, &mut resized_blocks, selections, + selected_buffer_ids, is_row_soft_wrapped, sticky_header_excerpt_id, cx, @@ -2769,6 +2826,7 @@ impl EditorElement { scroll_width, &mut resized_blocks, selections, + selected_buffer_ids, is_row_soft_wrapped, sticky_header_excerpt_id, cx, @@ -2817,6 +2875,7 @@ impl EditorElement { scroll_width, &mut resized_blocks, selections, + selected_buffer_ids, is_row_soft_wrapped, sticky_header_excerpt_id, cx, @@ -2885,6 +2944,7 @@ impl EditorElement { } } + #[allow(clippy::too_many_arguments)] fn layout_sticky_buffer_header( &self, StickyHeaderExcerpt { @@ -2896,12 +2956,15 @@ impl EditorElement { line_height: Pixels, snapshot: &EditorSnapshot, hitbox: &Hitbox, + selected_buffer_ids: &Vec, cx: &mut WindowContext, ) -> AnyElement { - let jump_data = jump_data(snapshot, DisplayRow(0), FILE_HEADER_HEIGHT, excerpt, cx); + let jump_data = header_jump_data(snapshot, DisplayRow(0), FILE_HEADER_HEIGHT, excerpt); let editor_bg_color = cx.theme().colors().editor_background; + let selected = selected_buffer_ids.contains(&excerpt.buffer_id); + let mut header = v_flex() .relative() .child( @@ -2917,7 +2980,7 @@ impl EditorElement { .top_0(), ) .child( - self.render_buffer_header(excerpt, false, false, jump_data, cx) + self.render_buffer_header(excerpt, false, selected, jump_data, cx) .into_any_element(), ) .into_any_element(); @@ -3860,24 +3923,30 @@ impl EditorElement { } fn paint_line_numbers(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { - let line_height = layout.position_map.line_height; - let scroll_position = layout.position_map.snapshot.scroll_position(); - let scroll_top = scroll_position.y * line_height; + let is_singleton = self.editor.read(cx).is_singleton(cx); + let line_height = layout.position_map.line_height; cx.set_cursor_style(CursorStyle::Arrow, &layout.gutter_hitbox); - for (ix, line) in layout.line_numbers.iter().enumerate() { - if let Some(line) = line { - let line_origin = layout.gutter_hitbox.origin - + point( - layout.gutter_hitbox.size.width - - line.width - - layout.gutter_dimensions.right_padding, - ix as f32 * line_height - (scroll_top % line_height), - ); - - line.paint(line_origin, line_height, cx).log_err(); - } + for (_, (line, hitbox)) in layout.line_numbers.iter() { + let Some(hitbox) = hitbox else { + continue; + }; + let color = if !is_singleton && hitbox.is_hovered(cx) { + cx.theme().colors().editor_active_line_number + } else { + cx.theme().colors().editor_line_number + }; + let Some(line) = self + .shape_line_number(line.text.clone(), color, cx) + .log_err() + else { + continue; + }; + let Some(()) = line.paint(hitbox.origin, line_height, cx).log_err() else { + continue; + }; + cx.set_cursor_style(CursorStyle::PointingHand, hitbox); } } @@ -4876,6 +4945,7 @@ impl EditorElement { let editor = self.editor.clone(); let text_hitbox = layout.text_hitbox.clone(); let gutter_hitbox = layout.gutter_hitbox.clone(); + let line_numbers = layout.line_numbers.clone(); move |event: &MouseDownEvent, phase, cx| { if phase == DispatchPhase::Bubble { @@ -4888,6 +4958,7 @@ impl EditorElement { &position_map, &text_hitbox, &gutter_hitbox, + line_numbers.as_ref(), cx, ); }), @@ -4984,21 +5055,37 @@ impl EditorElement { let digit_count = (snapshot.widest_line_number() as f32).log10().floor() as usize + 1; self.column_pixels(digit_count, cx) } + + fn shape_line_number( + &self, + text: SharedString, + color: Hsla, + cx: &WindowContext, + ) -> anyhow::Result { + let run = TextRun { + len: text.len(), + font: self.style.text.font(), + color, + background_color: None, + underline: None, + strikethrough: None, + }; + cx.text_system().shape_line( + text, + self.style.text.font_size.to_pixels(cx.rem_size()), + &[run], + ) + } } -fn jump_data( +fn header_jump_data( snapshot: &EditorSnapshot, block_row_start: DisplayRow, height: u32, for_excerpt: &ExcerptInfo, - cx: &mut WindowContext, ) -> JumpData { let range = &for_excerpt.range; let buffer = &for_excerpt.buffer; - let jump_path = project::File::from_dyn(buffer.file()).map(|file| ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path.clone(), - }); let jump_anchor = range .primary .as_ref() @@ -5012,6 +5099,7 @@ fn jump_data( let excerpt_start_row = language::ToPoint::to_point(&jump_anchor, buffer).row; jump_position.row - excerpt_start_row }; + let line_offset_from_top = block_row_start.0 + height + offset_from_excerpt_start.saturating_sub( @@ -5020,11 +5108,11 @@ fn jump_data( .scroll_position(&snapshot.display_snapshot) .y as u32, ); - JumpData { + + JumpData::MultiBufferPoint { excerpt_id: for_excerpt.id, anchor: jump_anchor, position: language::ToPoint::to_point(&jump_anchor, buffer), - path: jump_path, line_offset_from_top, } } @@ -6079,14 +6167,37 @@ impl Element for EditorElement { cx, ); - let local_selections: Vec> = - self.editor.update(cx, |editor, cx| { - let mut selections = editor - .selections - .disjoint_in_range(start_anchor..end_anchor, cx); - selections.extend(editor.selections.pending(cx)); - selections - }); + let (local_selections, selected_buffer_ids): ( + Vec>, + Vec, + ) = self.editor.update(cx, |editor, cx| { + let all_selections = editor.selections.all::(cx); + let selected_buffer_ids = if editor.is_singleton(cx) { + Vec::new() + } else { + let mut selected_buffer_ids = Vec::with_capacity(all_selections.len()); + + for selection in all_selections { + for buffer_id in snapshot + .buffer_snapshot + .buffer_ids_in_selected_rows(selection) + { + if selected_buffer_ids.last() != Some(&buffer_id) { + selected_buffer_ids.push(buffer_id); + } + } + } + + selected_buffer_ids + }; + + let mut selections = editor + .selections + .disjoint_in_range(start_anchor..end_anchor, cx); + selections.extend(editor.selections.pending(cx)); + + (selections, selected_buffer_ids) + }); let (selections, active_rows, newest_selection_head) = self.layout_selections( start_anchor, @@ -6099,6 +6210,10 @@ impl Element for EditorElement { ); let line_numbers = self.layout_line_numbers( + Some(&gutter_hitbox), + gutter_dimensions, + line_height, + scroll_position, start_row..end_row, buffer_rows.iter().copied(), &active_rows, @@ -6188,6 +6303,7 @@ impl Element for EditorElement { line_height, &line_layouts, &local_selections, + &selected_buffer_ids, is_row_soft_wrapped, sticky_header_excerpt_id, cx, @@ -6211,6 +6327,7 @@ impl Element for EditorElement { line_height, &snapshot, &hitbox, + &selected_buffer_ids, cx, ) }) @@ -6610,7 +6727,6 @@ impl Element for EditorElement { hitbox, text_hitbox, gutter_hitbox, - gutter_dimensions, display_hunks, content_origin, scrollbars_layout, @@ -6652,6 +6768,7 @@ impl Element for EditorElement { ) { let focus_handle = self.editor.focus_handle(cx); let key_context = self.editor.update(cx, |editor, cx| editor.key_context(cx)); + cx.set_key_context(key_context); cx.handle_input( &focus_handle, @@ -6801,7 +6918,6 @@ pub struct EditorLayout { hitbox: Hitbox, text_hitbox: Hitbox, gutter_hitbox: Hitbox, - gutter_dimensions: GutterDimensions, content_origin: gpui::Point, scrollbars_layout: AxisPair>, mode: EditorMode, @@ -6811,7 +6927,7 @@ pub struct EditorLayout { active_rows: BTreeMap, highlighted_rows: BTreeMap, line_elements: SmallVec<[AnyElement; 1]>, - line_numbers: Vec>, + line_numbers: Arc)>>, display_hunks: Vec<(DisplayDiffHunk, Option)>, blamed_display_rows: Option>, inline_blame: Option, @@ -7446,6 +7562,7 @@ mod tests { use gpui::{TestAppContext, VisualTestContext}; use language::language_settings; use log::info; + use similar::DiffableStr; use std::num::NonZeroU32; use util::test::sample_text; @@ -7459,12 +7576,25 @@ mod tests { let editor = window.root(cx).unwrap(); let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let line_height = window + .update(cx, |_, cx| style.text.line_height_in_pixels(cx.rem_size())) + .unwrap(); let element = EditorElement::new(&editor, style); let snapshot = window.update(cx, |editor, cx| editor.snapshot(cx)).unwrap(); let layouts = cx .update_window(*window, |_, cx| { element.layout_line_numbers( + None, + GutterDimensions { + left_padding: Pixels::ZERO, + right_padding: Pixels::ZERO, + width: px(30.0), + margin: Pixels::ZERO, + git_blame_entries_width: None, + }, + line_height, + gpui::Point::default(), DisplayRow(0)..DisplayRow(6), (0..6).map(MultiBufferRow).map(Some), &Default::default(), @@ -7712,13 +7842,13 @@ mod tests { EditorElement::new(&editor, style) }); assert_eq!(state.position_map.line_layouts.len(), 4); + assert_eq!(state.line_numbers.len(), 1); assert_eq!( state .line_numbers - .iter() - .map(Option::is_some) - .collect::>(), - &[false, false, false, true] + .get(&MultiBufferRow(0)) + .and_then(|(line, _)| line.text.as_str()), + Some("1") ); } diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 2330d12ee4d1c..108b70eb296d1 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -3449,6 +3449,24 @@ impl MultiBufferSnapshot { } } + pub fn buffer_ids_in_selected_rows( + &self, + selection: Selection, + ) -> impl Iterator + '_ { + let mut cursor = self.excerpts.cursor::(&()); + cursor.seek(&Point::new(selection.start.row, 0), Bias::Right, &()); + cursor.prev(&()); + + iter::from_fn(move || { + cursor.next(&()); + if cursor.start().row <= selection.end.row { + cursor.item().map(|item| item.buffer_id) + } else { + None + } + }) + } + pub fn excerpts( &self, ) -> impl Iterator)> { diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 54609661893eb..e8bb8cfb2c376 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -49,6 +49,7 @@ impl Tooltip { }) .into() } + pub fn with_meta( title: impl Into, action: Option<&dyn Action>, @@ -63,6 +64,22 @@ impl Tooltip { .into() } + pub fn with_meta_in( + title: impl Into, + action: Option<&dyn Action>, + meta: impl Into, + focus_handle: &FocusHandle, + cx: &mut WindowContext, + ) -> AnyView { + cx.new_view(|cx| Self { + title: title.into(), + meta: Some(meta.into()), + key_binding: action + .and_then(|action| KeyBinding::for_action_in(action, focus_handle, cx)), + }) + .into() + } + pub fn new(title: impl Into) -> Self { Self { title: title.into(),