From 33f198fef15ba17144e3971658a12d4b0ffe26e9 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Wed, 6 Aug 2025 11:01:34 -0300 Subject: [PATCH] Thread view scrollbar (#35655) This also adds a convenient `Scrollbar:auto_hide` function so that we don't have to handle that at the callsite. Release Notes: - N/A --------- Co-authored-by: David Kleingeld --- crates/agent_ui/src/acp/thread_view.rs | 50 +++++++- crates/agent_ui/src/active_thread.rs | 109 +++++------------ .../src/session/running/breakpoint_list.rs | 97 +++++---------- .../src/session/running/memory_view.rs | 103 ++++++---------- crates/ui/src/components/scrollbar.rs | 112 +++++++++++++++--- 5 files changed, 238 insertions(+), 233 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 4566e9af52..6475b7eeee 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -21,10 +21,10 @@ use editor::{ use file_icons::FileIcons; use gpui::{ Action, Animation, AnimationExt, App, BorderStyle, EdgesRefinement, Empty, Entity, EntityId, - FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, PlatformDisplay, SharedString, - StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, Transformation, - UnderlineStyle, WeakEntity, Window, WindowHandle, div, linear_color_stop, linear_gradient, - list, percentage, point, prelude::*, pulsating_between, + FocusHandle, Focusable, Hsla, Length, ListOffset, ListState, MouseButton, PlatformDisplay, + SharedString, Stateful, StyleRefinement, Subscription, Task, TextStyle, TextStyleRefinement, + Transformation, UnderlineStyle, WeakEntity, Window, WindowHandle, div, linear_color_stop, + linear_gradient, list, percentage, point, prelude::*, pulsating_between, }; use language::language_settings::SoftWrap; use language::{Buffer, Language}; @@ -34,7 +34,9 @@ use project::Project; use settings::Settings as _; use text::{Anchor, BufferSnapshot}; use theme::ThemeSettings; -use ui::{Disclosure, Divider, DividerColor, KeyBinding, Tooltip, prelude::*}; +use ui::{ + Disclosure, Divider, DividerColor, KeyBinding, Scrollbar, ScrollbarState, Tooltip, prelude::*, +}; use util::ResultExt; use workspace::{CollaboratorId, Workspace}; use zed_actions::agent::{Chat, NextHistoryMessage, PreviousHistoryMessage}; @@ -69,6 +71,7 @@ pub struct AcpThreadView { notification_subscriptions: HashMap, Vec>, last_error: Option>, list_state: ListState, + scrollbar_state: ScrollbarState, auth_task: Option>, expanded_tool_calls: HashSet, expanded_thinking_blocks: HashSet<(usize, usize)>, @@ -187,7 +190,8 @@ impl AcpThreadView { notifications: Vec::new(), notification_subscriptions: HashMap::default(), diff_editors: Default::default(), - list_state: list_state, + list_state: list_state.clone(), + scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()), last_error: None, auth_task: None, expanded_tool_calls: HashSet::default(), @@ -2479,6 +2483,39 @@ impl AcpThreadView { .child(open_as_markdown) .child(scroll_to_top) } + + fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ + div() + .id("acp-thread-scrollbar") + .occlude() + .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(|_, _, _, cx| { + cx.stop_propagation(); + }), + ) + .on_scroll_wheel(cx.listener(|_, _, _, cx| { + cx.notify(); + })) + .h_full() + .absolute() + .right_1() + .top_1() + .bottom_0() + .w(px(12.)) + .cursor_default() + .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx))) + } } impl Focusable for AcpThreadView { @@ -2553,6 +2590,7 @@ impl Render for AcpThreadView { .flex_grow() .into_any(), ) + .child(self.render_vertical_scrollbar(cx)) .children(match thread_clone.read(cx).status() { ThreadStatus::Idle | ThreadStatus::WaitingForToolConfirmation => { None diff --git a/crates/agent_ui/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs index ed227f22e4..c4d4e72252 100644 --- a/crates/agent_ui/src/active_thread.rs +++ b/crates/agent_ui/src/active_thread.rs @@ -69,8 +69,6 @@ pub struct ActiveThread { messages: Vec, list_state: ListState, scrollbar_state: ScrollbarState, - show_scrollbar: bool, - hide_scrollbar_task: Option>, rendered_messages_by_id: HashMap, rendered_tool_uses: HashMap, editing_message: Option<(MessageId, EditingMessageState)>, @@ -805,9 +803,7 @@ impl ActiveThread { expanded_thinking_segments: HashMap::default(), expanded_code_blocks: HashMap::default(), list_state: list_state.clone(), - scrollbar_state: ScrollbarState::new(list_state), - show_scrollbar: false, - hide_scrollbar_task: None, + scrollbar_state: ScrollbarState::new(list_state).parent_entity(&cx.entity()), editing_message: None, last_error: None, copied_code_block_ids: HashSet::default(), @@ -3502,60 +3498,37 @@ impl ActiveThread { } } - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Option> { - if !self.show_scrollbar && !self.scrollbar_state.is_dragging() { - return None; - } - - Some( - div() - .occlude() - .id("active-thread-scrollbar") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { + fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ + div() + .occlude() + .id("active-thread-scrollbar") + .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(|_, _, _, cx| { cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scrollbar_state.clone())), - ) - } - - fn hide_scrollbar_later(&mut self, cx: &mut Context) { - const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); - self.hide_scrollbar_task = Some(cx.spawn(async move |thread, cx| { - cx.background_executor() - .timer(SCROLLBAR_SHOW_INTERVAL) - .await; - thread - .update(cx, |thread, cx| { - if !thread.scrollbar_state.is_dragging() { - thread.show_scrollbar = false; - cx.notify(); - } - }) - .log_err(); - })) + }), + ) + .on_scroll_wheel(cx.listener(|_, _, _, cx| { + cx.notify(); + })) + .h_full() + .absolute() + .right_1() + .top_1() + .bottom_0() + .w(px(12.)) + .cursor_default() + .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx))) } pub fn is_codeblock_expanded(&self, message_id: MessageId, ix: usize) -> bool { @@ -3596,26 +3569,8 @@ impl Render for ActiveThread { .size_full() .relative() .bg(cx.theme().colors().panel_background) - .on_mouse_move(cx.listener(|this, _, _, cx| { - this.show_scrollbar = true; - this.hide_scrollbar_later(cx); - cx.notify(); - })) - .on_scroll_wheel(cx.listener(|this, _, _, cx| { - this.show_scrollbar = true; - this.hide_scrollbar_later(cx); - cx.notify(); - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(|this, _, _, cx| { - this.hide_scrollbar_later(cx); - }), - ) .child(list(self.list_state.clone(), cx.processor(Self::render_message)).flex_grow()) - .when_some(self.render_vertical_scrollbar(cx), |this, scrollbar| { - this.child(scrollbar) - }) + .child(self.render_vertical_scrollbar(cx)) } } diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index 6ac4b1c878..a6defbbf35 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -29,7 +29,6 @@ use ui::{ Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement, Styled, Toggleable, Tooltip, Window, div, h_flex, px, v_flex, }; -use util::ResultExt; use workspace::Workspace; use zed_actions::{ToggleEnableBreakpoint, UnsetBreakpoint}; @@ -56,8 +55,6 @@ pub(crate) struct BreakpointList { scrollbar_state: ScrollbarState, breakpoints: Vec, session: Option>, - hide_scrollbar_task: Option>, - show_scrollbar: bool, focus_handle: FocusHandle, scroll_handle: UniformListScrollHandle, selected_ix: Option, @@ -103,8 +100,6 @@ impl BreakpointList { worktree_store, scrollbar_state, breakpoints: Default::default(), - hide_scrollbar_task: None, - show_scrollbar: false, workspace, session, focus_handle, @@ -565,21 +560,6 @@ impl BreakpointList { Ok(()) } - fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context) { - const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); - self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| { - cx.background_executor() - .timer(SCROLLBAR_SHOW_INTERVAL) - .await; - panel - .update(cx, |panel, cx| { - panel.show_scrollbar = false; - cx.notify(); - }) - .log_err(); - })) - } - fn render_list(&mut self, cx: &mut Context) -> impl IntoElement { let selected_ix = self.selected_ix; let focus_handle = self.focus_handle.clone(); @@ -614,43 +594,39 @@ impl BreakpointList { .flex_grow() } - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Option> { - if !(self.show_scrollbar || self.scrollbar_state.is_dragging()) { - return None; - } - Some( - div() - .occlude() - .id("breakpoint-list-vertical-scrollbar") - .on_mouse_move(cx.listener(|_, _, _, cx| { - cx.notify(); - cx.stop_propagation() - })) - .on_hover(|_, _, cx| { + fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ + div() + .occlude() + .id("breakpoint-list-vertical-scrollbar") + .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(|_, _, _, cx| { cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scrollbar_state.clone())), - ) + }), + ) + .on_scroll_wheel(cx.listener(|_, _, _, cx| { + cx.notify(); + })) + .h_full() + .absolute() + .right_1() + .top_1() + .bottom_0() + .w(px(12.)) + .cursor_default() + .children(Scrollbar::vertical(self.scrollbar_state.clone()).map(|s| s.auto_hide(cx))) } + pub(crate) fn render_control_strip(&self) -> AnyElement { let selection_kind = self.selection_kind(); let focus_handle = self.focus_handle.clone(); @@ -819,15 +795,6 @@ impl Render for BreakpointList { .id("breakpoint-list") .key_context("BreakpointList") .track_focus(&self.focus_handle) - .on_hover(cx.listener(|this, hovered, window, cx| { - if *hovered { - this.show_scrollbar = true; - this.hide_scrollbar_task.take(); - cx.notify(); - } else if !this.focus_handle.contains_focused(window, cx) { - this.hide_scrollbar(window, cx); - } - })) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_previous)) .on_action(cx.listener(Self::select_first)) @@ -844,7 +811,7 @@ impl Render for BreakpointList { v_flex() .size_full() .child(self.render_list(cx)) - .children(self.render_vertical_scrollbar(cx)), + .child(self.render_vertical_scrollbar(cx)), ) .when_some(self.strip_mode, |this, _| { this.child(Divider::horizontal()).child( diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index 7b62a1d55d..75b8938371 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -23,7 +23,6 @@ use ui::{ ParentElement, Pixels, PopoverMenuHandle, Render, Scrollbar, ScrollbarState, SharedString, StatefulInteractiveElement, Styled, TextSize, Tooltip, Window, div, h_flex, px, v_flex, }; -use util::ResultExt; use workspace::Workspace; use crate::{ToggleDataBreakpoint, session::running::stack_frame_list::StackFrameList}; @@ -34,9 +33,7 @@ pub(crate) struct MemoryView { workspace: WeakEntity, scroll_handle: UniformListScrollHandle, scroll_state: ScrollbarState, - show_scrollbar: bool, stack_frame_list: WeakEntity, - hide_scrollbar_task: Option>, focus_handle: FocusHandle, view_state: ViewState, query_editor: Entity, @@ -150,8 +147,6 @@ impl MemoryView { scroll_state, scroll_handle, stack_frame_list, - show_scrollbar: false, - hide_scrollbar_task: None, focus_handle: cx.focus_handle(), view_state, query_editor, @@ -168,61 +163,42 @@ impl MemoryView { .detach(); this } - fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context) { - const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); - self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| { - cx.background_executor() - .timer(SCROLLBAR_SHOW_INTERVAL) - .await; - panel - .update(cx, |panel, cx| { - panel.show_scrollbar = false; - cx.notify(); - }) - .log_err(); - })) - } - fn render_vertical_scrollbar(&self, cx: &mut Context) -> Option> { - if !(self.show_scrollbar || self.scroll_state.is_dragging()) { - return None; - } - Some( - div() - .occlude() - .id("memory-view-vertical-scrollbar") - .on_drag_move(cx.listener(|this, evt, _, cx| { - let did_handle = this.handle_scroll_drag(evt); - cx.notify(); - if did_handle { - cx.stop_propagation() - } - })) - .on_drag(ScrollbarDragging, |_, _, _, cx| cx.new(|_| Empty)) - .on_hover(|_, _, cx| { + fn render_vertical_scrollbar(&self, cx: &mut Context) -> Stateful
{ + div() + .occlude() + .id("memory-view-vertical-scrollbar") + .on_drag_move(cx.listener(|this, evt, _, cx| { + let did_handle = this.handle_scroll_drag(evt); + cx.notify(); + if did_handle { + cx.stop_propagation() + } + })) + .on_drag(ScrollbarDragging, |_, _, _, cx| cx.new(|_| Empty)) + .on_hover(|_, _, cx| { + cx.stop_propagation(); + }) + .on_any_mouse_down(|_, _, cx| { + cx.stop_propagation(); + }) + .on_mouse_up( + MouseButton::Left, + cx.listener(|_, _, _, cx| { cx.stop_propagation(); - }) - .on_any_mouse_down(|_, _, cx| { - cx.stop_propagation(); - }) - .on_mouse_up( - MouseButton::Left, - cx.listener(|_, _, _, cx| { - cx.stop_propagation(); - }), - ) - .on_scroll_wheel(cx.listener(|_, _, _, cx| { - cx.notify(); - })) - .h_full() - .absolute() - .right_1() - .top_1() - .bottom_0() - .w(px(12.)) - .cursor_default() - .children(Scrollbar::vertical(self.scroll_state.clone())), - ) + }), + ) + .on_scroll_wheel(cx.listener(|_, _, _, cx| { + cx.notify(); + })) + .h_full() + .absolute() + .right_1() + .top_1() + .bottom_0() + .w(px(12.)) + .cursor_default() + .children(Scrollbar::vertical(self.scroll_state.clone()).map(|s| s.auto_hide(cx))) } fn render_memory(&self, cx: &mut Context) -> UniformList { @@ -920,15 +896,6 @@ impl Render for MemoryView { .on_action(cx.listener(Self::page_up)) .size_full() .track_focus(&self.focus_handle) - .on_hover(cx.listener(|this, hovered, window, cx| { - if *hovered { - this.show_scrollbar = true; - this.hide_scrollbar_task.take(); - cx.notify(); - } else if !this.focus_handle.contains_focused(window, cx) { - this.hide_scrollbar(window, cx); - } - })) .child( h_flex() .w_full() @@ -978,7 +945,7 @@ impl Render for MemoryView { ) .with_priority(1) })) - .children(self.render_vertical_scrollbar(cx)), + .child(self.render_vertical_scrollbar(cx)), ) } } diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index 7af55b76b7..605028202f 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -1,11 +1,20 @@ -use std::{any::Any, cell::Cell, fmt::Debug, ops::Range, rc::Rc, sync::Arc}; +use std::{ + any::Any, + cell::{Cell, RefCell}, + fmt::Debug, + ops::Range, + rc::Rc, + sync::Arc, + time::Duration, +}; use crate::{IntoElement, prelude::*, px, relative}; use gpui::{ Along, App, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, Corners, CursorStyle, Edges, Element, ElementId, Entity, EntityId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, IsZero, LayoutId, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, - Point, ScrollHandle, ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, quad, + Point, ScrollHandle, ScrollWheelEvent, Size, Style, Task, UniformListScrollHandle, Window, + quad, }; pub struct Scrollbar { @@ -108,6 +117,25 @@ pub struct ScrollbarState { thumb_state: Rc>, parent_id: Option, scroll_handle: Arc, + auto_hide: Rc>, +} + +#[derive(Debug)] +enum AutoHide { + Disabled, + Hidden { + parent_id: EntityId, + }, + Visible { + parent_id: EntityId, + _task: Task<()>, + }, +} + +impl AutoHide { + fn is_hidden(&self) -> bool { + matches!(self, AutoHide::Hidden { .. }) + } } impl ScrollbarState { @@ -116,6 +144,7 @@ impl ScrollbarState { thumb_state: Default::default(), parent_id: None, scroll_handle: Arc::new(scroll), + auto_hide: Rc::new(RefCell::new(AutoHide::Disabled)), } } @@ -174,6 +203,38 @@ impl ScrollbarState { let thumb_percentage_end = (start_offset + thumb_size) / viewport_size; Some(thumb_percentage_start..thumb_percentage_end) } + + fn show_temporarily(&self, parent_id: EntityId, cx: &mut App) { + const SHOW_INTERVAL: Duration = Duration::from_secs(1); + + let auto_hide = self.auto_hide.clone(); + auto_hide.replace(AutoHide::Visible { + parent_id, + _task: cx.spawn({ + let this = auto_hide.clone(); + async move |cx| { + cx.background_executor().timer(SHOW_INTERVAL).await; + this.replace(AutoHide::Hidden { parent_id }); + cx.update(|cx| { + cx.notify(parent_id); + }) + .ok(); + } + }), + }); + } + + fn unhide(&self, position: &Point, cx: &mut App) { + let parent_id = match &*self.auto_hide.borrow() { + AutoHide::Disabled => return, + AutoHide::Hidden { parent_id } => *parent_id, + AutoHide::Visible { parent_id, _task } => *parent_id, + }; + + if self.scroll_handle().viewport().contains(position) { + self.show_temporarily(parent_id, cx); + } + } } impl Scrollbar { @@ -189,6 +250,14 @@ impl Scrollbar { let thumb = state.thumb_range(kind)?; Some(Self { thumb, state, kind }) } + + /// Automatically hide the scrollbar when idle + pub fn auto_hide(self, cx: &mut Context) -> Self { + if matches!(*self.state.auto_hide.borrow(), AutoHide::Disabled) { + self.state.show_temporarily(cx.entity_id(), cx); + } + self + } } impl Element for Scrollbar { @@ -284,16 +353,18 @@ impl Element for Scrollbar { .apply_along(axis.invert(), |width| width / 1.5), ); - let corners = Corners::all(thumb_bounds.size.along(axis.invert()) / 2.0); + if thumb_state.is_dragging() || !self.state.auto_hide.borrow().is_hidden() { + let corners = Corners::all(thumb_bounds.size.along(axis.invert()) / 2.0); - window.paint_quad(quad( - thumb_bounds, - corners, - thumb_background, - Edges::default(), - Hsla::transparent_black(), - BorderStyle::default(), - )); + window.paint_quad(quad( + thumb_bounds, + corners, + thumb_background, + Edges::default(), + Hsla::transparent_black(), + BorderStyle::default(), + )); + } if thumb_state.is_dragging() { window.set_window_cursor_style(CursorStyle::Arrow); @@ -361,13 +432,18 @@ impl Element for Scrollbar { }); window.on_mouse_event({ + let state = self.state.clone(); let scroll_handle = self.state.scroll_handle().clone(); - move |event: &ScrollWheelEvent, phase, window, _| { - if phase.bubble() && bounds.contains(&event.position) { - let current_offset = scroll_handle.offset(); - scroll_handle.set_offset( - current_offset + event.delta.pixel_delta(window.line_height()), - ); + move |event: &ScrollWheelEvent, phase, window, cx| { + if phase.bubble() { + state.unhide(&event.position, cx); + + if bounds.contains(&event.position) { + let current_offset = scroll_handle.offset(); + scroll_handle.set_offset( + current_offset + event.delta.pixel_delta(window.line_height()), + ); + } } } }); @@ -376,6 +452,8 @@ impl Element for Scrollbar { let state = self.state.clone(); move |event: &MouseMoveEvent, phase, window, cx| { if phase.bubble() { + state.unhide(&event.position, cx); + match state.thumb_state.get() { ThumbState::Dragging(drag_state) if event.dragging() => { let scroll_handle = state.scroll_handle();