diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs index df8a709ed9..1da61b6585 100644 --- a/crates/assistant2/src/active_thread.rs +++ b/crates/assistant2/src/active_thread.rs @@ -6,16 +6,15 @@ use crate::thread_store::ThreadStore; use crate::tool_use::{PendingToolUseStatus, ToolUse, ToolUseStatus}; use crate::ui::{ContextPill, ToolReadyPopUp, ToolReadyPopupEvent}; use crate::AssistantPanel; - use assistant_settings::AssistantSettings; use collections::HashMap; use editor::{Editor, MultiBuffer}; use gpui::{ linear_color_stop, linear_gradient, list, percentage, pulsating_between, AbsoluteLength, Animation, AnimationExt, AnyElement, App, ClickEvent, DefiniteLength, EdgesRefinement, Empty, - Entity, Focusable, Hsla, Length, ListAlignment, ListOffset, ListState, ScrollHandle, - StyleRefinement, Subscription, Task, TextStyleRefinement, Transformation, UnderlineStyle, - WeakEntity, WindowHandle, + Entity, Focusable, Hsla, Length, ListAlignment, ListOffset, ListState, MouseButton, + ScrollHandle, Stateful, StyleRefinement, Subscription, Task, TextStyleRefinement, + Transformation, UnderlineStyle, WeakEntity, WindowHandle, }; use language::{Buffer, LanguageRegistry}; use language_model::{LanguageModelRegistry, LanguageModelToolUseId, Role}; @@ -24,7 +23,7 @@ use settings::Settings as _; use std::sync::Arc; use std::time::Duration; use theme::ThemeSettings; -use ui::{prelude::*, Disclosure, IconButton, KeyBinding, Tooltip}; +use ui::{prelude::*, Disclosure, IconButton, KeyBinding, Scrollbar, ScrollbarState, Tooltip}; use util::ResultExt as _; use workspace::{OpenOptions, Workspace}; @@ -39,6 +38,7 @@ pub struct ActiveThread { save_thread_task: Option>, messages: Vec, list_state: ListState, + scrollbar_state: ScrollbarState, rendered_messages_by_id: HashMap, rendered_tool_use_labels: HashMap>, editing_message: Option<(MessageId, EditMessageState)>, @@ -227,6 +227,14 @@ impl ActiveThread { cx.subscribe_in(&thread, window, Self::handle_thread_event), ]; + let list_state = ListState::new(0, ListAlignment::Bottom, px(2048.), { + let this = cx.entity().downgrade(); + move |ix, window: &mut Window, cx: &mut App| { + this.update(cx, |this, cx| this.render_message(ix, window, cx)) + .unwrap() + } + }); + let mut this = Self { language_registry, thread_store, @@ -239,13 +247,8 @@ impl ActiveThread { rendered_tool_use_labels: HashMap::default(), expanded_tool_uses: HashMap::default(), expanded_thinking_segments: HashMap::default(), - list_state: ListState::new(0, ListAlignment::Bottom, px(1024.), { - let this = cx.entity().downgrade(); - move |ix, window: &mut Window, cx: &mut App| { - this.update(cx, |this, cx| this.render_message(ix, window, cx)) - .unwrap() - } - }), + list_state: list_state.clone(), + scrollbar_state: ScrollbarState::new(list_state), editing_message: None, last_error: None, pop_ups: Vec::new(), @@ -1749,13 +1752,48 @@ impl ActiveThread { .ok(); } } + + 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_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())) + } } impl Render for ActiveThread { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .size_full() + .relative() .child(list(self.list_state.clone()).flex_grow()) .children(self.render_confirmations(cx)) + .child(self.render_vertical_scrollbar(cx)) } } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 0e3a98c0b0..5f4b9d8b0d 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -46,6 +46,12 @@ impl List { #[derive(Clone)] pub struct ListState(Rc>); +impl std::fmt::Debug for ListState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ListState") + } +} + struct StateInner { last_layout_bounds: Option>, last_padding: Option>, @@ -57,6 +63,7 @@ struct StateInner { reset: bool, #[allow(clippy::type_complexity)] scroll_handler: Option>, + scrollbar_drag_start_height: Option, } /// Whether the list is scrolling from top to bottom or bottom to top. @@ -198,6 +205,7 @@ impl ListState { overdraw, scroll_handler: None, reset: false, + scrollbar_drag_start_height: None, }))); this.splice(0..0, item_count); this @@ -211,6 +219,7 @@ impl ListState { let state = &mut *self.0.borrow_mut(); state.reset = true; state.logical_scroll_top = None; + state.scrollbar_drag_start_height = None; state.items.summary().count }; @@ -355,6 +364,62 @@ impl ListState { } None } + + /// Call this method when the user starts dragging the scrollbar. + /// + /// This will prevent the height reported to the scrollbar from changing during the drag + /// as items in the overdraw get measured, and help offset scroll position changes accordingly. + pub fn scrollbar_drag_started(&self) { + let mut state = self.0.borrow_mut(); + state.scrollbar_drag_start_height = Some(state.items.summary().height); + } + + /// Called when the user stops dragging the scrollbar. + /// + /// See `scrollbar_drag_started`. + pub fn scrollbar_drag_ended(&self) { + self.0.borrow_mut().scrollbar_drag_start_height.take(); + } + + /// Set the offset from the scrollbar + pub fn set_offset_from_scrollbar(&self, point: Point) { + self.0.borrow_mut().set_offset_from_scrollbar(point); + } + + /// Returns the size of items we have measured. + /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly. + pub fn content_size_for_scrollbar(&self) -> Size { + let state = self.0.borrow(); + let bounds = state.last_layout_bounds.unwrap_or_default(); + + let height = state + .scrollbar_drag_start_height + .unwrap_or_else(|| state.items.summary().height); + + Size::new(bounds.size.width, height) + } + + /// Returns the current scroll offset adjusted for the scrollbar + pub fn scroll_px_offset_for_scrollbar(&self) -> Point { + let state = &self.0.borrow(); + let logical_scroll_top = state.logical_scroll_top(); + + let mut cursor = state.items.cursor::(&()); + let summary: ListItemSummary = + cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right, &()); + let content_height = state.items.summary().height; + let drag_offset = + // if dragging the scrollbar, we want to offset the point if the height changed + content_height - state.scrollbar_drag_start_height.unwrap_or(content_height); + let offset = summary.height + logical_scroll_top.offset_in_item - drag_offset; + + Point::new(px(0.), -offset) + } + + /// Return the bounds of the viewport in pixels. + pub fn viewport_bounds(&self) -> Bounds { + self.0.borrow().last_layout_bounds.unwrap_or_default() + } } impl StateInner { @@ -695,6 +760,37 @@ impl StateInner { Ok(layout_response) }) } + + // Scrollbar support + + fn set_offset_from_scrollbar(&mut self, point: Point) { + let Some(bounds) = self.last_layout_bounds else { + return; + }; + let height = bounds.size.height; + + let padding = self.last_padding.unwrap_or_default(); + let content_height = self.items.summary().height; + let scroll_max = (content_height + padding.top + padding.bottom - height).max(px(0.)); + let drag_offset = + // if dragging the scrollbar, we want to offset the point if the height changed + content_height - self.scrollbar_drag_start_height.unwrap_or(content_height); + let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max); + + if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { + self.logical_scroll_top = None; + } else { + let mut cursor = self.items.cursor::(&()); + cursor.seek(&Height(new_scroll_top), Bias::Right, &()); + + let item_ix = cursor.start().count; + let offset_in_item = new_scroll_top - cursor.start().height; + self.logical_scroll_top = Some(ListOffset { + item_ix, + offset_in_item, + }); + } + } } impl std::fmt::Debug for ListItem { diff --git a/crates/ui/src/components/scrollbar.rs b/crates/ui/src/components/scrollbar.rs index 9ff75263d7..5e76f57da1 100644 --- a/crates/ui/src/components/scrollbar.rs +++ b/crates/ui/src/components/scrollbar.rs @@ -4,8 +4,8 @@ use crate::{prelude::*, px, relative, IntoElement}; use gpui::{ point, quad, Along, App, Axis as ScrollbarAxis, BorderStyle, Bounds, ContentMask, Corners, Edges, Element, ElementId, Entity, EntityId, GlobalElementId, Hitbox, Hsla, LayoutId, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollHandle, ScrollWheelEvent, - Size, Style, UniformListScrollHandle, Window, + ListState, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, ScrollHandle, + ScrollWheelEvent, Size, Style, UniformListScrollHandle, Window, }; pub struct Scrollbar { @@ -39,6 +39,39 @@ impl ScrollableHandle for UniformListScrollHandle { } } +impl ScrollableHandle for ListState { + fn content_size(&self) -> Option { + Some(ContentSize { + size: self.content_size_for_scrollbar(), + scroll_adjustment: None, + }) + } + + fn set_offset(&self, point: Point) { + self.set_offset_from_scrollbar(point); + } + + fn offset(&self) -> Point { + self.scroll_px_offset_for_scrollbar() + } + + fn drag_started(&self) { + self.scrollbar_drag_started(); + } + + fn drag_ended(&self) { + self.scrollbar_drag_ended(); + } + + fn viewport(&self) -> Bounds { + self.viewport_bounds() + } + + fn as_any(&self) -> &dyn Any { + self + } +} + impl ScrollableHandle for ScrollHandle { fn content_size(&self) -> Option { let last_children_index = self.children_count().checked_sub(1)?; @@ -92,6 +125,8 @@ pub trait ScrollableHandle: Debug + 'static { fn offset(&self) -> Point; fn viewport(&self) -> Bounds; fn as_any(&self) -> &dyn Any; + fn drag_started(&self) {} + fn drag_ended(&self) {} } /// A scrollbar state that should be persisted across frames. @@ -300,6 +335,8 @@ impl Element for Scrollbar { return; } + scroll.drag_started(); + if thumb_bounds.contains(&event.position) { let offset = event.position.along(axis) - thumb_bounds.origin.along(axis); state.drag.set(Some(offset)); @@ -349,7 +386,7 @@ impl Element for Scrollbar { }); let state = self.state.clone(); let axis = self.kind; - window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| { + window.on_mouse_event(move |event: &MouseMoveEvent, _, window, cx| { if let Some(drag_state) = state.drag.get().filter(|_| event.dragging()) { if let Some(ContentSize { size: item_size, .. @@ -381,6 +418,7 @@ impl Element for Scrollbar { scroll.set_offset(point(scroll.offset().x, drag_offset)); } }; + window.refresh(); if let Some(id) = state.parent_id { cx.notify(id); } @@ -390,9 +428,11 @@ impl Element for Scrollbar { } }); let state = self.state.clone(); + let scroll = self.state.scroll_handle.clone(); window.on_mouse_event(move |_event: &MouseUpEvent, phase, _, cx| { if phase.bubble() { state.drag.take(); + scroll.drag_ended(); if let Some(id) = state.parent_id { cx.notify(id); }