diff --git a/assets/settings/default.json b/assets/settings/default.json index f6c498e027..133ff9451d 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -356,9 +356,19 @@ /// Scrollbar-related settings "scrollbar": { /// When to show the scrollbar in the project panel. + /// This setting can take four values: /// - /// Default: always - "show": "always" + /// 1. null (default): Inherit editor settings + /// 2. Show the scrollbar if there's important information or + /// follow the system's configured behavior (default): + /// "auto" + /// 3. Match the system's configured behavior: + /// "system" + /// 4. Always show the scrollbar: + /// "always" + /// 5. Never show the scrollbar: + /// "never" + "show": null } }, "outline_panel": { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d1ca70f705..61a47d7f63 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -61,7 +61,7 @@ use debounced_delay::DebouncedDelay; use display_map::*; pub use display_map::{DisplayPoint, FoldPlaceholder}; pub use editor_settings::{ - CurrentLineHighlight, EditorSettings, ScrollBeyondLastLine, SearchSettings, + CurrentLineHighlight, EditorSettings, ScrollBeyondLastLine, SearchSettings, ShowScrollbar, }; pub use editor_settings_controls::*; use element::LineWithInvisibles; diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 4e92f7f82c..284e574627 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -2057,6 +2057,7 @@ impl Interactivity { fn paint_scroll_listener(&self, hitbox: &Hitbox, style: &Style, cx: &mut WindowContext) { if let Some(scroll_offset) = self.scroll_offset.clone() { let overflow = style.overflow; + let allow_concurrent_scroll = style.allow_concurrent_scroll; let line_height = cx.line_height(); let hitbox = hitbox.clone(); cx.on_mouse_event(move |event: &ScrollWheelEvent, phase, cx| { @@ -2065,27 +2066,31 @@ impl Interactivity { let old_scroll_offset = *scroll_offset; let delta = event.delta.pixel_delta(line_height); + let mut delta_x = Pixels::ZERO; if overflow.x == Overflow::Scroll { - let mut delta_x = Pixels::ZERO; if !delta.x.is_zero() { delta_x = delta.x; } else if overflow.y != Overflow::Scroll { delta_x = delta.y; } - - scroll_offset.x += delta_x; } - + let mut delta_y = Pixels::ZERO; if overflow.y == Overflow::Scroll { - let mut delta_y = Pixels::ZERO; if !delta.y.is_zero() { delta_y = delta.y; } else if overflow.x != Overflow::Scroll { delta_y = delta.x; } - - scroll_offset.y += delta_y; } + if !allow_concurrent_scroll && !delta_x.is_zero() && !delta_y.is_zero() { + if delta_x.abs() > delta_y.abs() { + delta_y = Pixels::ZERO; + } else { + delta_x = Pixels::ZERO; + } + } + scroll_offset.y += delta_y; + scroll_offset.x += delta_x; cx.stop_propagation(); if *scroll_offset != old_scroll_offset { diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 6ac6d2a9bf..d77c91e655 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -89,6 +89,16 @@ pub enum ListSizingBehavior { Auto, } +/// The horizontal sizing behavior to apply during layout. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum ListHorizontalSizingBehavior { + /// List items' width can never exceed the width of the list. + #[default] + FitList, + /// List items' width may go over the width of the list, if any item is wider. + Unconstrained, +} + struct LayoutItemsResponse { max_item_width: Pixels, scroll_top: ListOffset, diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 4dc2f5335d..54297d1214 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -5,8 +5,8 @@ //! elements with uniform height. use crate::{ - point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementId, - GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, LayoutId, + point, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementId, + GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId, ListSizingBehavior, Pixels, Render, ScrollHandle, Size, StyleRefinement, Styled, View, ViewContext, WindowContext, }; @@ -14,6 +14,8 @@ use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; use taffy::style::Overflow; +use super::ListHorizontalSizingBehavior; + /// uniform_list provides lazy rendering for a set of items that are of uniform height. /// When rendered into a container with overflow-y: hidden and a fixed (or max) height, /// uniform_list will only render the visible subset of items. @@ -57,6 +59,7 @@ where }, scroll_handle: None, sizing_behavior: ListSizingBehavior::default(), + horizontal_sizing_behavior: ListHorizontalSizingBehavior::default(), } } @@ -69,11 +72,11 @@ pub struct UniformList { interactivity: Interactivity, scroll_handle: Option, sizing_behavior: ListSizingBehavior, + horizontal_sizing_behavior: ListHorizontalSizingBehavior, } /// Frame state used by the [UniformList]. pub struct UniformListFrameState { - item_size: Size, items: SmallVec<[AnyElement; 32]>, } @@ -87,7 +90,18 @@ pub struct UniformListScrollHandle(pub Rc>); pub struct UniformListScrollState { pub base_handle: ScrollHandle, pub deferred_scroll_to_item: Option, - pub last_item_height: Option, + /// Size of the item, captured during last layout. + pub last_item_size: Option, +} + +#[derive(Copy, Clone, Debug, Default)] +/// The size of the item and its contents. +pub struct ItemSize { + /// The size of the item. + pub item: Size, + /// The size of the item's contents, which may be larger than the item itself, + /// if the item was bounded by a parent element. + pub contents: Size, } impl UniformListScrollHandle { @@ -96,7 +110,7 @@ impl UniformListScrollHandle { Self(Rc::new(RefCell::new(UniformListScrollState { base_handle: ScrollHandle::new(), deferred_scroll_to_item: None, - last_item_height: None, + last_item_size: None, }))) } @@ -170,7 +184,6 @@ impl Element for UniformList { ( layout_id, UniformListFrameState { - item_size, items: SmallVec::new(), }, ) @@ -193,17 +206,30 @@ impl Element for UniformList { - point(border.right + padding.right, border.bottom + padding.bottom), ); + let can_scroll_horizontally = matches!( + self.horizontal_sizing_behavior, + ListHorizontalSizingBehavior::Unconstrained + ); + + let longest_item_size = self.measure_item(None, cx); + let content_width = if can_scroll_horizontally { + padded_bounds.size.width.max(longest_item_size.width) + } else { + padded_bounds.size.width + }; let content_size = Size { - width: padded_bounds.size.width, - height: frame_state.item_size.height * self.item_count + padding.top + padding.bottom, + width: content_width, + height: longest_item_size.height * self.item_count + padding.top + padding.bottom, }; let shared_scroll_offset = self.interactivity.scroll_offset.clone().unwrap(); - - let item_height = self.measure_item(Some(padded_bounds.size.width), cx).height; + let item_height = longest_item_size.height; let shared_scroll_to_item = self.scroll_handle.as_mut().and_then(|handle| { let mut handle = handle.0.borrow_mut(); - handle.last_item_height = Some(item_height); + handle.last_item_size = Some(ItemSize { + item: padded_bounds.size, + contents: content_size, + }); handle.deferred_scroll_to_item.take() }); @@ -228,12 +254,19 @@ impl Element for UniformList { if self.item_count > 0 { let content_height = item_height * self.item_count + padding.top + padding.bottom; - let min_scroll_offset = padded_bounds.size.height - content_height; - let is_scrolled = scroll_offset.y != px(0.); + let is_scrolled_vertically = !scroll_offset.y.is_zero(); + let min_vertical_scroll_offset = padded_bounds.size.height - content_height; + if is_scrolled_vertically && scroll_offset.y < min_vertical_scroll_offset { + shared_scroll_offset.borrow_mut().y = min_vertical_scroll_offset; + scroll_offset.y = min_vertical_scroll_offset; + } - if is_scrolled && scroll_offset.y < min_scroll_offset { - shared_scroll_offset.borrow_mut().y = min_scroll_offset; - scroll_offset.y = min_scroll_offset; + let content_width = content_size.width + padding.left + padding.right; + let is_scrolled_horizontally = + can_scroll_horizontally && !scroll_offset.x.is_zero(); + if is_scrolled_horizontally && content_width <= padded_bounds.size.width { + shared_scroll_offset.borrow_mut().x = Pixels::ZERO; + scroll_offset.x = Pixels::ZERO; } if let Some(ix) = shared_scroll_to_item { @@ -263,9 +296,17 @@ impl Element for UniformList { cx.with_content_mask(Some(content_mask), |cx| { for (mut item, ix) in items.into_iter().zip(visible_range) { let item_origin = padded_bounds.origin - + point(px(0.), item_height * ix + scroll_offset.y + padding.top); + + point( + scroll_offset.x + padding.left, + item_height * ix + scroll_offset.y + padding.top, + ); + let available_width = if can_scroll_horizontally { + padded_bounds.size.width + scroll_offset.x.abs() + } else { + padded_bounds.size.width + }; let available_space = size( - AvailableSpace::Definite(padded_bounds.size.width), + AvailableSpace::Definite(available_width), AvailableSpace::Definite(item_height), ); item.layout_as_root(available_space, cx); @@ -318,6 +359,25 @@ impl UniformList { self } + /// Sets the horizontal sizing behavior, controlling the way list items laid out horizontally. + /// With [`ListHorizontalSizingBehavior::Unconstrained`] behavior, every item and the list itself will + /// have the size of the widest item and lay out pushing the `end_slot` to the right end. + pub fn with_horizontal_sizing_behavior( + mut self, + behavior: ListHorizontalSizingBehavior, + ) -> Self { + self.horizontal_sizing_behavior = behavior; + match behavior { + ListHorizontalSizingBehavior::FitList => { + self.interactivity.base_style.overflow.x = None; + } + ListHorizontalSizingBehavior::Unconstrained => { + self.interactivity.base_style.overflow.x = Some(Overflow::Scroll); + } + } + self + } + fn measure_item(&self, list_width: Option, cx: &mut WindowContext) -> Size { if self.item_count == 0 { return Size::default(); diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index c3148fcfa8..455a2e162d 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -156,6 +156,8 @@ pub struct Style { pub overflow: Point, /// How much space (in points) should be reserved for the scrollbars of `Overflow::Scroll` and `Overflow::Auto` nodes. pub scrollbar_width: f32, + /// Whether both x and y axis should be scrollable at the same time. + pub allow_concurrent_scroll: bool, // Position properties /// What should the `position` value of this struct use as a base offset? @@ -667,6 +669,7 @@ impl Default for Style { x: Overflow::Visible, y: Overflow::Visible, }, + allow_concurrent_scroll: false, scrollbar_width: 0.0, position: Position::Relative, inset: Edges::auto(), diff --git a/crates/language/src/language_settings.rs b/crates/language/src/language_settings.rs index d610ab0986..de37e52290 100644 --- a/crates/language/src/language_settings.rs +++ b/crates/language/src/language_settings.rs @@ -381,7 +381,7 @@ pub struct FeaturesContent { pub enum SoftWrap { /// Prefer a single line generally, unless an overly long line is encountered. None, - /// Deprecated: use None instead. Left to avoid breakin existing users' configs. + /// Deprecated: use None instead. Left to avoid breaking existing users' configs. /// Prefer a single line generally, unless an overly long line is encountered. PreferLine, /// Soft wrap lines that exceed the editor width. diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 6958bfb331..53b274ee6f 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -8,20 +8,22 @@ use db::kvp::KEY_VALUE_STORE; use editor::{ items::entry_git_aware_label_color, scroll::{Autoscroll, ScrollbarAutoHide}, - Editor, + Editor, EditorEvent, EditorSettings, ShowScrollbar, }; use file_icons::FileIcons; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, Context as _, Result}; use collections::{hash_map, BTreeSet, HashMap}; +use core::f32; use git::repository::GitFileStatus; use gpui::{ actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement, AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, DragMoveEvent, - EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, KeyContext, - ListSizingBehavior, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, - PromptLevel, Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View, - ViewContext, VisualContext as _, WeakView, WindowContext, + Entity, EventEmitter, ExternalPaths, FocusHandle, FocusableView, InteractiveElement, + KeyContext, ListHorizontalSizingBehavior, ListSizingBehavior, Model, MouseButton, + MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, + Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, + WindowContext, }; use indexmap::IndexMap; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev}; @@ -29,7 +31,7 @@ use project::{ relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId, }; -use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings, ShowScrollbar}; +use project_panel_settings::{ProjectPanelDockPosition, ProjectPanelSettings}; use serde::{Deserialize, Serialize}; use std::{ cell::{Cell, OnceCell}, @@ -80,8 +82,10 @@ pub struct ProjectPanel { width: Option, pending_serialization: Task>, show_scrollbar: bool, - scrollbar_drag_thumb_offset: Rc>>, + vertical_scrollbar_drag_thumb_offset: Rc>>, + horizontal_scrollbar_drag_thumb_offset: Rc>>, hide_scrollbar_task: Option>, + max_width_item_index: Option, } #[derive(Clone, Debug)] @@ -90,6 +94,8 @@ struct EditState { entry_id: ProjectEntryId, is_new_entry: bool, is_dir: bool, + is_symlink: bool, + depth: usize, processing_filename: Option, } @@ -254,23 +260,26 @@ impl ProjectPanel { let filename_editor = cx.new_view(Editor::single_line); - cx.subscribe(&filename_editor, |this, _, event, cx| match event { - editor::EditorEvent::BufferEdited - | editor::EditorEvent::SelectionsChanged { .. } => { - this.autoscroll(cx); - } - editor::EditorEvent::Blurred => { - if this - .edit_state - .as_ref() - .map_or(false, |state| state.processing_filename.is_none()) - { - this.edit_state = None; - this.update_visible_entries(None, cx); + cx.subscribe( + &filename_editor, + |project_panel, _, editor_event, cx| match editor_event { + EditorEvent::BufferEdited | EditorEvent::SelectionsChanged { .. } => { + project_panel.autoscroll(cx); } - } - _ => {} - }) + EditorEvent::Blurred => { + if project_panel + .edit_state + .as_ref() + .map_or(false, |state| state.processing_filename.is_none()) + { + project_panel.edit_state = None; + project_panel.update_visible_entries(None, cx); + cx.notify(); + } + } + _ => {} + }, + ) .detach(); cx.observe_global::(|_, cx| { @@ -311,7 +320,9 @@ impl ProjectPanel { pending_serialization: Task::ready(None), show_scrollbar: !Self::should_autohide_scrollbar(cx), hide_scrollbar_task: None, - scrollbar_drag_thumb_offset: Default::default(), + vertical_scrollbar_drag_thumb_offset: Default::default(), + horizontal_scrollbar_drag_thumb_offset: Default::default(), + max_width_item_index: None, }; this.update_visible_entries(None, cx); @@ -827,7 +838,7 @@ impl ProjectPanel { Some(cx.spawn(|project_panel, mut cx| async move { let new_entry = edit_task.await; project_panel.update(&mut cx, |project_panel, cx| { - project_panel.edit_state.take(); + project_panel.edit_state = None; cx.notify(); })?; @@ -970,6 +981,8 @@ impl ProjectPanel { is_new_entry: true, is_dir, processing_filename: None, + is_symlink: false, + depth: 0, }); self.filename_editor.update(cx, |editor, cx| { editor.clear(cx); @@ -992,6 +1005,7 @@ impl ProjectPanel { leaf_entry_id } } + fn rename(&mut self, _: &Rename, cx: &mut ViewContext) { if let Some(SelectedEntry { worktree_id, @@ -1007,6 +1021,8 @@ impl ProjectPanel { is_new_entry: false, is_dir: entry.is_dir(), processing_filename: None, + is_symlink: entry.is_symlink, + depth: 0, }); let file_name = entry .path @@ -1750,6 +1766,7 @@ impl ProjectPanel { let old_ancestors = std::mem::take(&mut self.ancestors); self.visible_entries.clear(); + let mut max_width_item = None; for worktree in project.visible_worktrees(cx) { let snapshot = worktree.read(cx).snapshot(); let worktree_id = snapshot.id(); @@ -1805,6 +1822,12 @@ impl ProjectPanel { .get(&entry.id) .map(|ancestor| ancestor.current_ancestor_depth) .unwrap_or_default(); + if let Some(edit_state) = &mut self.edit_state { + if edit_state.entry_id == entry.id { + edit_state.is_symlink = entry.is_symlink; + edit_state.depth = depth; + } + } let mut ancestors = std::mem::take(&mut auto_folded_ancestors); if ancestors.len() > 1 { ancestors.reverse(); @@ -1837,6 +1860,78 @@ impl ProjectPanel { is_fifo: entry.is_fifo, }); } + let worktree_abs_path = worktree.read(cx).abs_path(); + let (depth, path) = if Some(entry) == worktree.read(cx).root_entry() { + let Some(path_name) = worktree_abs_path + .file_name() + .with_context(|| { + format!("Worktree abs path has no file name, root entry: {entry:?}") + }) + .log_err() + else { + continue; + }; + let path = Arc::from(Path::new(path_name)); + let depth = 0; + (depth, path) + } else if entry.is_file() { + let Some(path_name) = entry + .path + .file_name() + .with_context(|| format!("Non-root entry has no file name: {entry:?}")) + .log_err() + else { + continue; + }; + let path = Arc::from(Path::new(path_name)); + let depth = entry.path.ancestors().count() - 1; + (depth, path) + } else { + let path = self + .ancestors + .get(&entry.id) + .and_then(|ancestors| { + let outermost_ancestor = ancestors.ancestors.last()?; + let root_folded_entry = worktree + .read(cx) + .entry_for_id(*outermost_ancestor)? + .path + .as_ref(); + entry + .path + .strip_prefix(root_folded_entry) + .ok() + .and_then(|suffix| { + let full_path = Path::new(root_folded_entry.file_name()?); + Some(Arc::::from(full_path.join(suffix))) + }) + }) + .unwrap_or_else(|| entry.path.clone()); + let depth = path + .strip_prefix(worktree_abs_path) + .map(|suffix| suffix.components().count()) + .unwrap_or_default(); + (depth, path) + }; + let width_estimate = item_width_estimate( + depth, + path.to_string_lossy().chars().count(), + entry.is_symlink, + ); + + match max_width_item.as_mut() { + Some((id, worktree_id, width)) => { + if *width < width_estimate { + *id = entry.id; + *worktree_id = worktree.read(cx).id(); + *width = width_estimate; + } + } + None => { + max_width_item = Some((entry.id, worktree.read(cx).id(), width_estimate)) + } + } + if expanded_dir_ids.binary_search(&entry.id).is_err() && entry_iter.advance_to_sibling() { @@ -1851,6 +1946,22 @@ impl ProjectPanel { .push((worktree_id, visible_worktree_entries, OnceCell::new())); } + if let Some((project_entry_id, worktree_id, _)) = max_width_item { + let mut visited_worktrees_length = 0; + let index = self.visible_entries.iter().find_map(|(id, entries, _)| { + if worktree_id == *id { + entries + .iter() + .position(|entry| entry.id == project_entry_id) + } else { + visited_worktrees_length += entries.len(); + None + } + }); + if let Some(index) = index { + self.max_width_item_index = Some(visited_worktrees_length + index); + } + } if let Some((worktree_id, entry_id)) = new_selected_entry { self.selection = Some(SelectedEntry { worktree_id, @@ -2474,7 +2585,8 @@ impl ProjectPanel { cx.stop_propagation(); this.deploy_context_menu(event.position, entry_id, cx); }, - )), + )) + .overflow_x(), ) .border_1() .border_r_2() @@ -2498,22 +2610,19 @@ impl ProjectPanel { ) } - fn render_scrollbar( - &self, - items_count: usize, - cx: &mut ViewContext, - ) -> Option> { - let settings = ProjectPanelSettings::get_global(cx); - if settings.scrollbar.show == ShowScrollbar::Never { + fn render_vertical_scrollbar(&self, cx: &mut ViewContext) -> Option> { + if !Self::should_show_scrollbar(cx) { return None; } let scroll_handle = self.scroll_handle.0.borrow(); - - let height = scroll_handle - .last_item_height - .filter(|_| self.show_scrollbar || self.scrollbar_drag_thumb_offset.get().is_some())?; - - let total_list_length = height.0 as f64 * items_count as f64; + let total_list_length = scroll_handle + .last_item_size + .filter(|_| { + self.show_scrollbar || self.vertical_scrollbar_drag_thumb_offset.get().is_some() + })? + .contents + .height + .0 as f64; let current_offset = scroll_handle.base_handle.offset().y.0.min(0.).abs() as f64; let mut percentage = current_offset / total_list_length; let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.height.0 as f64) @@ -2536,7 +2645,7 @@ impl ProjectPanel { Some( div() .occlude() - .id("project-panel-scroll") + .id("project-panel-vertical-scroll") .on_mouse_move(cx.listener(|_, _, cx| { cx.notify(); cx.stop_propagation() @@ -2550,7 +2659,7 @@ impl ProjectPanel { .on_mouse_up( MouseButton::Left, cx.listener(|this, _, cx| { - if this.scrollbar_drag_thumb_offset.get().is_none() + if this.vertical_scrollbar_drag_thumb_offset.get().is_none() && !this.focus_handle.contains_focused(cx) { this.hide_scrollbar(cx); @@ -2565,21 +2674,101 @@ impl ProjectPanel { })) .h_full() .absolute() - .right_0() - .top_0() - .bottom_0() + .right_1() + .top_1() + .bottom_1() .w(px(12.)) .cursor_default() - .child(ProjectPanelScrollbar::new( + .child(ProjectPanelScrollbar::vertical( percentage as f32..end_offset as f32, self.scroll_handle.clone(), - self.scrollbar_drag_thumb_offset.clone(), - cx.view().clone().into(), - items_count, + self.vertical_scrollbar_drag_thumb_offset.clone(), + cx.view().entity_id(), )), ) } + fn render_horizontal_scrollbar(&self, cx: &mut ViewContext) -> Option> { + if !Self::should_show_scrollbar(cx) { + return None; + } + let scroll_handle = self.scroll_handle.0.borrow(); + let longest_item_width = scroll_handle + .last_item_size + .filter(|_| { + self.show_scrollbar || self.horizontal_scrollbar_drag_thumb_offset.get().is_some() + }) + .filter(|size| size.contents.width > size.item.width)? + .contents + .width + .0 as f64; + let current_offset = scroll_handle.base_handle.offset().x.0.min(0.).abs() as f64; + let mut percentage = current_offset / longest_item_width; + let end_offset = (current_offset + scroll_handle.base_handle.bounds().size.width.0 as f64) + / longest_item_width; + // Uniform scroll handle might briefly report an offset greater than the length of a list; + // in such case we'll adjust the starting offset as well to keep the scrollbar thumb length stable. + let overshoot = (end_offset - 1.).clamp(0., 1.); + if overshoot > 0. { + percentage -= overshoot; + } + const MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH: f64 = 0.005; + if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH > 1.0 || end_offset > longest_item_width + { + return None; + } + if longest_item_width < scroll_handle.base_handle.bounds().size.width.0 as f64 { + return None; + } + let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_WIDTH, 1.); + Some( + div() + .occlude() + .id("project-panel-horizontal-scroll") + .on_mouse_move(cx.listener(|_, _, cx| { + cx.notify(); + cx.stop_propagation() + })) + .on_hover(|_, cx| { + cx.stop_propagation(); + }) + .on_any_mouse_down(|_, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + cx.listener(|this, _, cx| { + if this.horizontal_scrollbar_drag_thumb_offset.get().is_none() + && !this.focus_handle.contains_focused(cx) + { + this.hide_scrollbar(cx); + cx.notify(); + } + + cx.stop_propagation(); + }), + ) + .on_scroll_wheel(cx.listener(|_, _, cx| { + cx.notify(); + })) + .w_full() + .absolute() + .right_1() + .left_1() + .bottom_1() + .h(px(12.)) + .cursor_default() + .when(self.width.is_some(), |this| { + this.child(ProjectPanelScrollbar::horizontal( + percentage as f32..end_offset as f32, + self.scroll_handle.clone(), + self.horizontal_scrollbar_drag_thumb_offset.clone(), + cx.view().entity_id(), + )) + }), + ) + } + fn dispatch_context(&self, cx: &ViewContext) -> KeyContext { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("ProjectPanel"); @@ -2595,9 +2784,32 @@ impl ProjectPanel { dispatch_context } + fn should_show_scrollbar(cx: &AppContext) -> bool { + let show = ProjectPanelSettings::get_global(cx) + .scrollbar + .show + .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show); + match show { + ShowScrollbar::Auto => true, + ShowScrollbar::System => true, + ShowScrollbar::Always => true, + ShowScrollbar::Never => false, + } + } + fn should_autohide_scrollbar(cx: &AppContext) -> bool { - cx.try_global::() - .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0) + let show = ProjectPanelSettings::get_global(cx) + .scrollbar + .show + .unwrap_or_else(|| EditorSettings::get_global(cx).scrollbar.show); + match show { + ShowScrollbar::Auto => true, + ShowScrollbar::System => cx + .try_global::() + .map_or_else(|| cx.should_auto_hide_scrollbars(), |autohide| autohide.0), + ShowScrollbar::Always => false, + ShowScrollbar::Never => true, + } } fn hide_scrollbar(&mut self, cx: &mut ViewContext) { @@ -2623,7 +2835,7 @@ impl ProjectPanel { project: Model, entry_id: ProjectEntryId, skip_ignored: bool, - cx: &mut ViewContext<'_, ProjectPanel>, + cx: &mut ViewContext<'_, Self>, ) { if let Some(worktree) = project.read(cx).worktree_for_entry(entry_id, cx) { let worktree = worktree.read(cx); @@ -2645,13 +2857,22 @@ impl ProjectPanel { } } +fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize { + const ICON_SIZE_FACTOR: usize = 2; + let mut item_width = depth * ICON_SIZE_FACTOR + item_text_chars; + if is_symlink { + item_width += ICON_SIZE_FACTOR; + } + item_width +} + impl Render for ProjectPanel { fn render(&mut self, cx: &mut gpui::ViewContext) -> impl IntoElement { let has_worktree = !self.visible_entries.is_empty(); let project = self.project.read(cx); if has_worktree { - let items_count = self + let item_count = self .visible_entries .iter() .map(|(_, worktree_entries, _)| worktree_entries.len()) @@ -2742,7 +2963,7 @@ impl Render for ProjectPanel { ) .track_focus(&self.focus_handle) .child( - uniform_list(cx.view().clone(), "entries", items_count, { + uniform_list(cx.view().clone(), "entries", item_count, { |this, range, cx| { let mut items = Vec::with_capacity(range.end - range.start); this.for_each_visible_entry(range, cx, |id, details, cx| { @@ -2753,9 +2974,12 @@ impl Render for ProjectPanel { }) .size_full() .with_sizing_behavior(ListSizingBehavior::Infer) + .with_horizontal_sizing_behavior(ListHorizontalSizingBehavior::Unconstrained) + .with_width_from_item(self.max_width_item_index) .track_scroll(self.scroll_handle.clone()), ) - .children(self.render_scrollbar(items_count, cx)) + .children(self.render_vertical_scrollbar(cx)) + .children(self.render_horizontal_scrollbar(cx)) .children(self.context_menu.as_ref().map(|(menu, position, _)| { deferred( anchored() @@ -2934,6 +3158,7 @@ mod tests { use serde_json::json; use settings::SettingsStore; use std::path::{Path, PathBuf}; + use ui::Context; use workspace::{ item::{Item, ProjectItem}, register_project_item, AppState, diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 4d73ae9245..0114b3968d 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -1,3 +1,4 @@ +use editor::ShowScrollbar; use gpui::Pixels; use schemars::JsonSchema; use serde_derive::{Deserialize, Serialize}; @@ -24,33 +25,20 @@ pub struct ProjectPanelSettings { pub scrollbar: ScrollbarSettings, } -/// When to show the scrollbar in the project panel. -/// -/// Default: always -#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ShowScrollbar { - #[default] - /// Always show the scrollbar. - Always, - /// Never show the scrollbar. - Never, -} - #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct ScrollbarSettings { /// When to show the scrollbar in the project panel. /// - /// Default: always - pub show: ShowScrollbar, + /// Default: inherits editor scrollbar settings + pub show: Option, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct ScrollbarSettingsContent { /// When to show the scrollbar in the project panel. /// - /// Default: always - pub show: Option, + /// Default: inherits editor scrollbar settings + pub show: Option>, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] diff --git a/crates/project_panel/src/scrollbar.rs b/crates/project_panel/src/scrollbar.rs index 0da9db7cb7..cb7b15386c 100644 --- a/crates/project_panel/src/scrollbar.rs +++ b/crates/project_panel/src/scrollbar.rs @@ -1,34 +1,54 @@ use std::{cell::Cell, ops::Range, rc::Rc}; use gpui::{ - point, AnyView, Bounds, ContentMask, Hitbox, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - ScrollWheelEvent, Style, UniformListScrollHandle, + point, quad, Bounds, ContentMask, Corners, Edges, EntityId, Hitbox, Hsla, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, ScrollWheelEvent, Style, UniformListScrollHandle, }; use ui::{prelude::*, px, relative, IntoElement}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ScrollbarKind { + Horizontal, + Vertical, +} + pub(crate) struct ProjectPanelScrollbar { thumb: Range, scroll: UniformListScrollHandle, // If Some(), there's an active drag, offset by percentage from the top of thumb. scrollbar_drag_state: Rc>>, - item_count: usize, - view: AnyView, + kind: ScrollbarKind, + parent_id: EntityId, } impl ProjectPanelScrollbar { - pub(crate) fn new( + pub(crate) fn vertical( thumb: Range, scroll: UniformListScrollHandle, scrollbar_drag_state: Rc>>, - view: AnyView, - item_count: usize, + parent_id: EntityId, ) -> Self { Self { thumb, scroll, scrollbar_drag_state, - item_count, - view, + kind: ScrollbarKind::Vertical, + parent_id, + } + } + + pub(crate) fn horizontal( + thumb: Range, + scroll: UniformListScrollHandle, + scrollbar_drag_state: Rc>>, + parent_id: EntityId, + ) -> Self { + Self { + thumb, + scroll, + scrollbar_drag_state, + kind: ScrollbarKind::Horizontal, + parent_id, } } } @@ -50,8 +70,14 @@ impl gpui::Element for ProjectPanelScrollbar { let mut style = Style::default(); style.flex_grow = 1.; style.flex_shrink = 1.; - style.size.width = px(12.).into(); - style.size.height = relative(1.).into(); + if self.kind == ScrollbarKind::Vertical { + style.size.width = px(12.).into(); + style.size.height = relative(1.).into(); + } else { + style.size.width = relative(1.).into(); + style.size.height = px(12.).into(); + } + (cx.request_layout(style, None), ()) } @@ -77,25 +103,65 @@ impl gpui::Element for ProjectPanelScrollbar { ) { cx.with_content_mask(Some(ContentMask { bounds }), |cx| { let colors = cx.theme().colors(); - let scrollbar_background = colors.scrollbar_track_background; let thumb_background = colors.scrollbar_thumb_background; - cx.paint_quad(gpui::fill(bounds, scrollbar_background)); + let is_vertical = self.kind == ScrollbarKind::Vertical; + let extra_padding = px(5.0); + let padded_bounds = if is_vertical { + Bounds::from_corners( + bounds.origin + point(Pixels::ZERO, extra_padding), + bounds.lower_right() - point(Pixels::ZERO, extra_padding * 3), + ) + } else { + Bounds::from_corners( + bounds.origin + point(extra_padding, Pixels::ZERO), + bounds.lower_right() - point(extra_padding * 3, Pixels::ZERO), + ) + }; - let thumb_offset = self.thumb.start * bounds.size.height; - let thumb_end = self.thumb.end * bounds.size.height; - - let thumb_percentage_size = self.thumb.end - self.thumb.start; - let thumb_bounds = { - let thumb_upper_left = point(bounds.origin.x, bounds.origin.y + thumb_offset); + let mut thumb_bounds = if is_vertical { + let thumb_offset = self.thumb.start * padded_bounds.size.height; + let thumb_end = self.thumb.end * padded_bounds.size.height; + let thumb_upper_left = point( + padded_bounds.origin.x, + padded_bounds.origin.y + thumb_offset, + ); let thumb_lower_right = point( - bounds.origin.x + bounds.size.width, - bounds.origin.y + thumb_end, + padded_bounds.origin.x + padded_bounds.size.width, + padded_bounds.origin.y + thumb_end, + ); + Bounds::from_corners(thumb_upper_left, thumb_lower_right) + } else { + let thumb_offset = self.thumb.start * padded_bounds.size.width; + let thumb_end = self.thumb.end * padded_bounds.size.width; + let thumb_upper_left = point( + padded_bounds.origin.x + thumb_offset, + padded_bounds.origin.y, + ); + let thumb_lower_right = point( + padded_bounds.origin.x + thumb_end, + padded_bounds.origin.y + padded_bounds.size.height, ); Bounds::from_corners(thumb_upper_left, thumb_lower_right) }; - cx.paint_quad(gpui::fill(thumb_bounds, thumb_background)); + let corners = if is_vertical { + thumb_bounds.size.width /= 1.5; + Corners::all(thumb_bounds.size.width / 2.0) + } else { + thumb_bounds.size.height /= 1.5; + Corners::all(thumb_bounds.size.height / 2.0) + }; + cx.paint_quad(quad( + thumb_bounds, + corners, + thumb_background, + Edges::default(), + Hsla::transparent_black(), + )); + let scroll = self.scroll.clone(); - let item_count = self.item_count; + let kind = self.kind; + let thumb_percentage_size = self.thumb.end - self.thumb.start; + cx.on_mouse_event({ let scroll = self.scroll.clone(); let is_dragging = self.scrollbar_drag_state.clone(); @@ -103,20 +169,37 @@ impl gpui::Element for ProjectPanelScrollbar { if phase.bubble() && bounds.contains(&event.position) { if !thumb_bounds.contains(&event.position) { let scroll = scroll.0.borrow(); - if let Some(last_height) = scroll.last_item_height { - let max_offset = item_count as f32 * last_height; - let percentage = - (event.position.y - bounds.origin.y) / bounds.size.height; - - let percentage = percentage.min(1. - thumb_percentage_size); - scroll - .base_handle - .set_offset(point(px(0.), -max_offset * percentage)); + if let Some(item_size) = scroll.last_item_size { + match kind { + ScrollbarKind::Horizontal => { + let percentage = (event.position.x - bounds.origin.x) + / bounds.size.width; + let max_offset = item_size.contents.width; + let percentage = percentage.min(1. - thumb_percentage_size); + scroll.base_handle.set_offset(point( + -max_offset * percentage, + scroll.base_handle.offset().y, + )); + } + ScrollbarKind::Vertical => { + let percentage = (event.position.y - bounds.origin.y) + / bounds.size.height; + let max_offset = item_size.contents.height; + let percentage = percentage.min(1. - thumb_percentage_size); + scroll.base_handle.set_offset(point( + scroll.base_handle.offset().x, + -max_offset * percentage, + )); + } + } } } else { - let thumb_top_offset = - (event.position.y - thumb_bounds.origin.y) / bounds.size.height; - is_dragging.set(Some(thumb_top_offset)); + let thumb_offset = if is_vertical { + (event.position.y - thumb_bounds.origin.y) / bounds.size.height + } else { + (event.position.x - thumb_bounds.origin.x) / bounds.size.width + }; + is_dragging.set(Some(thumb_offset)); } } } @@ -127,6 +210,7 @@ impl gpui::Element for ProjectPanelScrollbar { if phase.bubble() && bounds.contains(&event.position) { let scroll = scroll.0.borrow_mut(); let current_offset = scroll.base_handle.offset(); + scroll .base_handle .set_offset(current_offset + event.delta.pixel_delta(cx.line_height())); @@ -134,19 +218,39 @@ impl gpui::Element for ProjectPanelScrollbar { } }); let drag_state = self.scrollbar_drag_state.clone(); - let view_id = self.view.entity_id(); + let view_id = self.parent_id; + let kind = self.kind; cx.on_mouse_event(move |event: &MouseMoveEvent, _, cx| { if let Some(drag_state) = drag_state.get().filter(|_| event.dragging()) { let scroll = scroll.0.borrow(); - if let Some(last_height) = scroll.last_item_height { - let max_offset = item_count as f32 * last_height; - let percentage = - (event.position.y - bounds.origin.y) / bounds.size.height - drag_state; + if let Some(item_size) = scroll.last_item_size { + match kind { + ScrollbarKind::Horizontal => { + let max_offset = item_size.contents.width; + let percentage = (event.position.x - bounds.origin.x) + / bounds.size.width + - drag_state; + + let percentage = percentage.min(1. - thumb_percentage_size); + scroll.base_handle.set_offset(point( + -max_offset * percentage, + scroll.base_handle.offset().y, + )); + } + ScrollbarKind::Vertical => { + let max_offset = item_size.contents.height; + let percentage = (event.position.y - bounds.origin.y) + / bounds.size.height + - drag_state; + + let percentage = percentage.min(1. - thumb_percentage_size); + scroll.base_handle.set_offset(point( + scroll.base_handle.offset().x, + -max_offset * percentage, + )); + } + }; - let percentage = percentage.min(1. - thumb_percentage_size); - scroll - .base_handle - .set_offset(point(px(0.), -max_offset * percentage)); cx.notify(view_id); } } else { diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index e1c90894fd..e13fb8ef26 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -36,6 +36,7 @@ pub struct ListItem { on_secondary_mouse_down: Option>, children: SmallVec<[AnyElement; 2]>, selectable: bool, + overflow_x: bool, } impl ListItem { @@ -58,6 +59,7 @@ impl ListItem { tooltip: None, children: SmallVec::new(), selectable: true, + overflow_x: false, } } @@ -131,6 +133,11 @@ impl ListItem { self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element); self } + + pub fn overflow_x(mut self) -> Self { + self.overflow_x = true; + self + } } impl Disableable for ListItem { @@ -239,7 +246,13 @@ impl RenderOnce for ListItem { .flex_shrink_0() .flex_basis(relative(0.25)) .gap(Spacing::Small.rems(cx)) - .overflow_hidden() + .map(|list_content| { + if self.overflow_x { + list_content + } else { + list_content.overflow_hidden() + } + }) .children(self.start_slot) .children(self.children), ) diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 1e531f7c74..fbd5fa53cf 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -1954,7 +1954,7 @@ Run the `theme selector: toggle` action in the command palette to see a current "auto_reveal_entries": true, "auto_fold_dirs": true, "scrollbar": { - "show": "always" + "show": null } } } @@ -2074,13 +2074,13 @@ Run the `theme selector: toggle` action in the command palette to see a current ### Scrollbar -- Description: Scrollbar related settings. Possible values: "always", "never". +- Description: Scrollbar related settings. Possible values: null, "auto", "system", "always", "never". Inherits editor settings when absent, see its description for more details. - Setting: `scrollbar` - Default: ```json "scrollbar": { - "show": "always" + "show": null } ```