scrollbar: Implement minimum thumb size (#25288)
This PR addresses 3 issues with the common scrollbar component used in the Terminal, Outline Panel, etc. 1. Extremely small or invisible scrollbar for long content. 2. Flickering issue when the thumb is already at the bottom-most position, and the user tries to overscroll. 3. Scrollbar appearing even when there is no excessive content to scroll. Before: <img width="300" alt="image" src="https://github.com/user-attachments/assets/8a124a72-3b56-4bef-858a-a4942c871829" /> After: <img width="300" alt="Screenshot 2025-02-21 at 3 26 32 AM" src="https://github.com/user-attachments/assets/2a8a5796-b332-4c06-84b2-226d2de6e300" /> Release Notes: - Fixed extremely small scrollbar thumb for long content in Terminal, Outline Panel, and more. --------- Co-authored-by: Danilo <danilo@zed.dev> Co-authored-by: Richard Feldman <oss@rtfeldman.com>
This commit is contained in:
parent
5e1dd91ee5
commit
d45aaa1745
2 changed files with 181 additions and 169 deletions
|
@ -69,9 +69,10 @@ impl ScrollableHandle for TerminalScrollHandle {
|
||||||
let offset_delta = (point.y.0 / state.line_height.0).round() as i32;
|
let offset_delta = (point.y.0 / state.line_height.0).round() as i32;
|
||||||
|
|
||||||
let max_offset = state.total_lines - state.viewport_lines;
|
let max_offset = state.total_lines - state.viewport_lines;
|
||||||
let display_offset = ((max_offset as i32 + offset_delta) as usize).min(max_offset);
|
let display_offset = (max_offset as i32 + offset_delta).clamp(0, max_offset as i32);
|
||||||
|
|
||||||
self.future_display_offset.set(Some(display_offset));
|
self.future_display_offset
|
||||||
|
.set(Some(display_offset as usize));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn viewport(&self) -> Bounds<Pixels> {
|
fn viewport(&self) -> Bounds<Pixels> {
|
||||||
|
|
|
@ -99,7 +99,7 @@ pub trait ScrollableHandle: Debug + 'static {
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct ScrollbarState {
|
pub struct ScrollbarState {
|
||||||
// If Some(), there's an active drag, offset by percentage from the origin of a thumb.
|
// If Some(), there's an active drag, offset by percentage from the origin of a thumb.
|
||||||
drag: Rc<Cell<Option<f32>>>,
|
drag: Rc<Cell<Option<Pixels>>>,
|
||||||
parent_id: Option<EntityId>,
|
parent_id: Option<EntityId>,
|
||||||
scroll_handle: Arc<dyn ScrollableHandle>,
|
scroll_handle: Arc<dyn ScrollableHandle>,
|
||||||
}
|
}
|
||||||
|
@ -128,12 +128,12 @@ impl ScrollbarState {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn thumb_range(&self, axis: ScrollbarAxis) -> Option<Range<f32>> {
|
fn thumb_range(&self, axis: ScrollbarAxis) -> Option<Range<f32>> {
|
||||||
const MINIMUM_SCROLLBAR_PERCENTAGE_SIZE: f32 = 0.005;
|
const MINIMUM_THUMB_SIZE: f32 = 25.;
|
||||||
let ContentSize {
|
let ContentSize {
|
||||||
size: main_dimension_size,
|
size: main_dimension_size,
|
||||||
scroll_adjustment,
|
scroll_adjustment,
|
||||||
} = self.scroll_handle.content_size()?;
|
} = self.scroll_handle.content_size()?;
|
||||||
let main_dimension_size = main_dimension_size.along(axis).0;
|
let content_size = main_dimension_size.along(axis).0;
|
||||||
let mut current_offset = self.scroll_handle.offset().along(axis).min(px(0.)).abs().0;
|
let mut current_offset = self.scroll_handle.offset().along(axis).min(px(0.)).abs().0;
|
||||||
if let Some(adjustment) = scroll_adjustment.and_then(|adjustment| {
|
if let Some(adjustment) = scroll_adjustment.and_then(|adjustment| {
|
||||||
let adjust = adjustment.along(axis).0;
|
let adjust = adjustment.along(axis).0;
|
||||||
|
@ -145,25 +145,21 @@ impl ScrollbarState {
|
||||||
}) {
|
}) {
|
||||||
current_offset -= adjustment;
|
current_offset -= adjustment;
|
||||||
}
|
}
|
||||||
|
let viewport_size = self.scroll_handle.viewport().size.along(axis).0;
|
||||||
let mut percentage = current_offset / main_dimension_size;
|
if content_size < viewport_size {
|
||||||
let viewport_size = self.scroll_handle.viewport().size;
|
|
||||||
let end_offset = (current_offset + viewport_size.along(axis).0) / main_dimension_size;
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
if percentage + MINIMUM_SCROLLBAR_PERCENTAGE_SIZE > 1.0 || end_offset > main_dimension_size
|
|
||||||
{
|
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if main_dimension_size < viewport_size.along(axis).0 {
|
let visible_percentage = viewport_size / content_size;
|
||||||
|
let thumb_size = MINIMUM_THUMB_SIZE.max(viewport_size * visible_percentage);
|
||||||
|
if thumb_size > viewport_size {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let end_offset = end_offset.clamp(percentage + MINIMUM_SCROLLBAR_PERCENTAGE_SIZE, 1.);
|
let max_offset = content_size - viewport_size;
|
||||||
Some(percentage..end_offset)
|
current_offset = current_offset.clamp(0., max_offset);
|
||||||
|
let start_offset = (current_offset / max_offset) * (viewport_size - thumb_size);
|
||||||
|
let thumb_percentage_start = start_offset / viewport_size;
|
||||||
|
let thumb_percentage_end = (start_offset + thumb_size) / viewport_size;
|
||||||
|
Some(thumb_percentage_start..thumb_percentage_end)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -228,174 +224,189 @@ impl Element for Scrollbar {
|
||||||
fn paint(
|
fn paint(
|
||||||
&mut self,
|
&mut self,
|
||||||
_id: Option<&GlobalElementId>,
|
_id: Option<&GlobalElementId>,
|
||||||
bounds: Bounds<Pixels>,
|
padded_bounds: Bounds<Pixels>,
|
||||||
_request_layout: &mut Self::RequestLayoutState,
|
_request_layout: &mut Self::RequestLayoutState,
|
||||||
_prepaint: &mut Self::PrepaintState,
|
_prepaint: &mut Self::PrepaintState,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
window.with_content_mask(Some(ContentMask { bounds }), |window| {
|
window.with_content_mask(
|
||||||
let colors = cx.theme().colors();
|
Some(ContentMask {
|
||||||
let thumb_background = colors
|
bounds: padded_bounds,
|
||||||
.surface_background
|
}),
|
||||||
.blend(colors.scrollbar_thumb_background);
|
|window| {
|
||||||
let is_vertical = self.kind == ScrollbarAxis::Vertical;
|
let colors = cx.theme().colors();
|
||||||
let extra_padding = px(5.0);
|
let thumb_background = colors
|
||||||
let padded_bounds = if is_vertical {
|
.surface_background
|
||||||
Bounds::from_corners(
|
.blend(colors.scrollbar_thumb_background);
|
||||||
bounds.origin + point(Pixels::ZERO, extra_padding),
|
let is_vertical = self.kind == ScrollbarAxis::Vertical;
|
||||||
bounds.bottom_right() - point(Pixels::ZERO, extra_padding * 3),
|
let extra_padding = px(5.0);
|
||||||
)
|
let padded_bounds = if is_vertical {
|
||||||
} else {
|
Bounds::from_corners(
|
||||||
Bounds::from_corners(
|
padded_bounds.origin + point(Pixels::ZERO, extra_padding),
|
||||||
bounds.origin + point(extra_padding, Pixels::ZERO),
|
padded_bounds.bottom_right() - point(Pixels::ZERO, extra_padding * 3),
|
||||||
bounds.bottom_right() - point(extra_padding * 3, Pixels::ZERO),
|
)
|
||||||
)
|
} else {
|
||||||
};
|
Bounds::from_corners(
|
||||||
|
padded_bounds.origin + point(extra_padding, Pixels::ZERO),
|
||||||
|
padded_bounds.bottom_right() - point(extra_padding * 3, Pixels::ZERO),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let mut thumb_bounds = if is_vertical {
|
let mut thumb_bounds = if is_vertical {
|
||||||
let thumb_offset = self.thumb.start * padded_bounds.size.height;
|
let thumb_offset = self.thumb.start * padded_bounds.size.height;
|
||||||
let thumb_end = self.thumb.end * padded_bounds.size.height;
|
let thumb_end = self.thumb.end * padded_bounds.size.height;
|
||||||
let thumb_upper_left = point(
|
let thumb_upper_left = point(
|
||||||
padded_bounds.origin.x,
|
padded_bounds.origin.x,
|
||||||
padded_bounds.origin.y + thumb_offset,
|
padded_bounds.origin.y + thumb_offset,
|
||||||
);
|
);
|
||||||
let thumb_lower_right = point(
|
let thumb_lower_right = point(
|
||||||
padded_bounds.origin.x + padded_bounds.size.width,
|
padded_bounds.origin.x + padded_bounds.size.width,
|
||||||
padded_bounds.origin.y + thumb_end,
|
padded_bounds.origin.y + thumb_end,
|
||||||
);
|
);
|
||||||
Bounds::from_corners(thumb_upper_left, thumb_lower_right)
|
Bounds::from_corners(thumb_upper_left, thumb_lower_right)
|
||||||
} else {
|
} else {
|
||||||
let thumb_offset = self.thumb.start * padded_bounds.size.width;
|
let thumb_offset = self.thumb.start * padded_bounds.size.width;
|
||||||
let thumb_end = self.thumb.end * padded_bounds.size.width;
|
let thumb_end = self.thumb.end * padded_bounds.size.width;
|
||||||
let thumb_upper_left = point(
|
let thumb_upper_left = point(
|
||||||
padded_bounds.origin.x + thumb_offset,
|
padded_bounds.origin.x + thumb_offset,
|
||||||
padded_bounds.origin.y,
|
padded_bounds.origin.y,
|
||||||
);
|
);
|
||||||
let thumb_lower_right = point(
|
let thumb_lower_right = point(
|
||||||
padded_bounds.origin.x + thumb_end,
|
padded_bounds.origin.x + thumb_end,
|
||||||
padded_bounds.origin.y + padded_bounds.size.height,
|
padded_bounds.origin.y + padded_bounds.size.height,
|
||||||
);
|
);
|
||||||
Bounds::from_corners(thumb_upper_left, thumb_lower_right)
|
Bounds::from_corners(thumb_upper_left, thumb_lower_right)
|
||||||
};
|
};
|
||||||
let corners = if is_vertical {
|
let corners = if is_vertical {
|
||||||
thumb_bounds.size.width /= 1.5;
|
thumb_bounds.size.width /= 1.5;
|
||||||
Corners::all(thumb_bounds.size.width / 2.0)
|
Corners::all(thumb_bounds.size.width / 2.0)
|
||||||
} else {
|
} else {
|
||||||
thumb_bounds.size.height /= 1.5;
|
thumb_bounds.size.height /= 1.5;
|
||||||
Corners::all(thumb_bounds.size.height / 2.0)
|
Corners::all(thumb_bounds.size.height / 2.0)
|
||||||
};
|
};
|
||||||
window.paint_quad(quad(
|
window.paint_quad(quad(
|
||||||
thumb_bounds,
|
thumb_bounds,
|
||||||
corners,
|
corners,
|
||||||
thumb_background,
|
thumb_background,
|
||||||
Edges::default(),
|
Edges::default(),
|
||||||
Hsla::transparent_black(),
|
Hsla::transparent_black(),
|
||||||
));
|
));
|
||||||
|
|
||||||
let scroll = self.state.scroll_handle.clone();
|
let scroll = self.state.scroll_handle.clone();
|
||||||
let kind = self.kind;
|
|
||||||
let thumb_percentage_size = self.thumb.end - self.thumb.start;
|
|
||||||
|
|
||||||
window.on_mouse_event({
|
|
||||||
let scroll = scroll.clone();
|
|
||||||
let state = self.state.clone();
|
|
||||||
let axis = self.kind;
|
let axis = self.kind;
|
||||||
move |event: &MouseDownEvent, phase, _, _| {
|
|
||||||
if !(phase.bubble() && bounds.contains(&event.position)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if thumb_bounds.contains(&event.position) {
|
window.on_mouse_event({
|
||||||
let thumb_offset = (event.position.along(axis)
|
let scroll = scroll.clone();
|
||||||
- thumb_bounds.origin.along(axis))
|
let state = self.state.clone();
|
||||||
/ bounds.size.along(axis);
|
move |event: &MouseDownEvent, phase, _, _| {
|
||||||
state.drag.set(Some(thumb_offset));
|
if !(phase.bubble() && padded_bounds.contains(&event.position)) {
|
||||||
} else if let Some(ContentSize {
|
return;
|
||||||
size: item_size, ..
|
}
|
||||||
}) = scroll.content_size()
|
|
||||||
{
|
if thumb_bounds.contains(&event.position) {
|
||||||
match kind {
|
let offset =
|
||||||
ScrollbarAxis::Horizontal => {
|
event.position.along(axis) - thumb_bounds.origin.along(axis);
|
||||||
let percentage =
|
state.drag.set(Some(offset));
|
||||||
(event.position.x - bounds.origin.x) / bounds.size.width;
|
} else if let Some(ContentSize {
|
||||||
let max_offset = item_size.width;
|
size: item_size, ..
|
||||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
}) = scroll.content_size()
|
||||||
scroll
|
{
|
||||||
.set_offset(point(-max_offset * percentage, scroll.offset().y));
|
let click_offset = {
|
||||||
}
|
let viewport_size = padded_bounds.size.along(axis);
|
||||||
ScrollbarAxis::Vertical => {
|
|
||||||
let percentage =
|
let thumb_size = thumb_bounds.size.along(axis);
|
||||||
(event.position.y - bounds.origin.y) / bounds.size.height;
|
let thumb_start = (event.position.along(axis)
|
||||||
let max_offset = item_size.height;
|
- padded_bounds.origin.along(axis)
|
||||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
- (thumb_size / 2.))
|
||||||
scroll
|
.clamp(px(0.), viewport_size - thumb_size);
|
||||||
.set_offset(point(scroll.offset().x, -max_offset * percentage));
|
|
||||||
|
let max_offset =
|
||||||
|
(item_size.along(axis) - viewport_size).max(px(0.));
|
||||||
|
let percentage = if viewport_size > thumb_size {
|
||||||
|
thumb_start / (viewport_size - thumb_size)
|
||||||
|
} else {
|
||||||
|
0.
|
||||||
|
};
|
||||||
|
|
||||||
|
-max_offset * percentage
|
||||||
|
};
|
||||||
|
match axis {
|
||||||
|
ScrollbarAxis::Horizontal => {
|
||||||
|
scroll.set_offset(point(click_offset, scroll.offset().y));
|
||||||
|
}
|
||||||
|
ScrollbarAxis::Vertical => {
|
||||||
|
scroll.set_offset(point(scroll.offset().x, click_offset));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
window.on_mouse_event({
|
||||||
window.on_mouse_event({
|
let scroll = scroll.clone();
|
||||||
let scroll = scroll.clone();
|
move |event: &ScrollWheelEvent, phase, window, _| {
|
||||||
move |event: &ScrollWheelEvent, phase, window, _| {
|
if phase.bubble() && padded_bounds.contains(&event.position) {
|
||||||
if phase.bubble() && bounds.contains(&event.position) {
|
let current_offset = scroll.offset();
|
||||||
let current_offset = scroll.offset();
|
scroll.set_offset(
|
||||||
scroll.set_offset(
|
current_offset + event.delta.pixel_delta(window.line_height()),
|
||||||
current_offset + event.delta.pixel_delta(window.line_height()),
|
);
|
||||||
);
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
let state = self.state.clone();
|
||||||
let state = self.state.clone();
|
let axis = self.kind;
|
||||||
let kind = self.kind;
|
window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| {
|
||||||
window.on_mouse_event(move |event: &MouseMoveEvent, _, _, cx| {
|
if let Some(drag_state) = state.drag.get().filter(|_| event.dragging()) {
|
||||||
if let Some(drag_state) = state.drag.get().filter(|_| event.dragging()) {
|
if let Some(ContentSize {
|
||||||
if let Some(ContentSize {
|
size: item_size, ..
|
||||||
size: item_size, ..
|
}) = scroll.content_size()
|
||||||
}) = scroll.content_size()
|
{
|
||||||
{
|
let drag_offset = {
|
||||||
match kind {
|
let viewport_size = padded_bounds.size.along(axis);
|
||||||
ScrollbarAxis::Horizontal => {
|
|
||||||
let max_offset = item_size.width;
|
|
||||||
let percentage = (event.position.x - bounds.origin.x)
|
|
||||||
/ bounds.size.width
|
|
||||||
- drag_state;
|
|
||||||
|
|
||||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
let thumb_size = thumb_bounds.size.along(axis);
|
||||||
scroll
|
let thumb_start = (event.position.along(axis)
|
||||||
.set_offset(point(-max_offset * percentage, scroll.offset().y));
|
- padded_bounds.origin.along(axis)
|
||||||
|
- drag_state)
|
||||||
|
.clamp(px(0.), viewport_size - thumb_size);
|
||||||
|
|
||||||
|
let max_offset =
|
||||||
|
(item_size.along(axis) - viewport_size).max(px(0.));
|
||||||
|
let percentage = if viewport_size > thumb_size {
|
||||||
|
thumb_start / (viewport_size - thumb_size)
|
||||||
|
} else {
|
||||||
|
0.
|
||||||
|
};
|
||||||
|
|
||||||
|
-max_offset * percentage
|
||||||
|
};
|
||||||
|
match axis {
|
||||||
|
ScrollbarAxis::Horizontal => {
|
||||||
|
scroll.set_offset(point(drag_offset, scroll.offset().y));
|
||||||
|
}
|
||||||
|
ScrollbarAxis::Vertical => {
|
||||||
|
scroll.set_offset(point(scroll.offset().x, drag_offset));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(id) = state.parent_id {
|
||||||
|
cx.notify(id);
|
||||||
}
|
}
|
||||||
ScrollbarAxis::Vertical => {
|
}
|
||||||
let max_offset = item_size.height;
|
} else {
|
||||||
let percentage = (event.position.y - bounds.origin.y)
|
state.drag.set(None);
|
||||||
/ bounds.size.height
|
}
|
||||||
- drag_state;
|
});
|
||||||
|
let state = self.state.clone();
|
||||||
let percentage = percentage.min(1. - thumb_percentage_size);
|
window.on_mouse_event(move |_event: &MouseUpEvent, phase, _, cx| {
|
||||||
scroll
|
if phase.bubble() {
|
||||||
.set_offset(point(scroll.offset().x, -max_offset * percentage));
|
state.drag.take();
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(id) = state.parent_id {
|
if let Some(id) = state.parent_id {
|
||||||
cx.notify(id);
|
cx.notify(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
state.drag.set(None);
|
},
|
||||||
}
|
)
|
||||||
});
|
|
||||||
let state = self.state.clone();
|
|
||||||
window.on_mouse_event(move |_event: &MouseUpEvent, phase, _, cx| {
|
|
||||||
if phase.bubble() {
|
|
||||||
state.drag.take();
|
|
||||||
if let Some(id) = state.parent_id {
|
|
||||||
cx.notify(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue