Add a draggable sidebar resize handle

User reports the sidebar can't be resized. Until now sidebar width
was only mutable through the COLLAPSED/DEFAULT toggle and through
typed settings. Real browsers (Arc, Dia, Zen) all let you drag the
right edge of the sidebar to size it live.

Wire it through the existing space.sidebar_width_px:

* `ElyShell.sidebar_resize_origin: Option<(f32, u16)>` records the
  cursor-x and width-px at the moment the handle is grabbed.
* `begin_sidebar_resize` / `end_sidebar_resize` set and clear it.
* Window-level `on_mouse_move` consults the origin first; while held,
  it forwards the delta to `set_active_sidebar_width` clamped to
  220–480 px so you can't accidentally annihilate either pane.
* Window-level `on_mouse_up` releases the drag.
* The handle itself is a 6 px transparent strip pinned to the right
  edge of the expanded sidebar, `cursor_col_resize`, with a soft white
  hover. mouse-down captures the origin; the rest is window events.

cargo test --workspace: 440 passed, 0 failed.
This commit is contained in:
2026-05-09 21:16:23 -04:00
parent 573eaae4a8
commit 85d1e09916
3 changed files with 88 additions and 6 deletions
+30 -3
View File
@@ -2,9 +2,9 @@ use ely_browser_core::BrowserSnapshot;
use ely_design_system::{colors, spacing}; use ely_design_system::{colors, spacing};
use ely_domain::BrowserTab; use ely_domain::BrowserTab;
use gpui::{ use gpui::{
AnyElement, BoxShadow, Context, InteractiveElement, IntoElement, ParentElement, SharedString, AnyElement, BoxShadow, Context, InteractiveElement, IntoElement, MouseButton,
StatefulInteractiveElement, Styled, div, hsla, linear_color_stop, linear_gradient, point, ParentElement, SharedString, StatefulInteractiveElement, Styled, div, hsla,
prelude::FluentBuilder, px, rgb, rgba, linear_color_stop, linear_gradient, point, prelude::FluentBuilder, px, rgb, rgba,
}; };
use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; use gpui_component::{IconName, StyledExt, scroll::ScrollableElement};
@@ -22,6 +22,7 @@ impl ElyShell {
div() div()
.w(px(sidebar_width)) .w(px(sidebar_width))
.h_full() .h_full()
.relative()
.flex() .flex()
.flex_col() .flex_col()
.rounded(px(spacing::RADIUS_CARD)) .rounded(px(spacing::RADIUS_CARD))
@@ -60,6 +61,7 @@ impl ElyShell {
.child(self.render_new_tab_row(cx)), .child(self.render_new_tab_row(cx)),
) )
.child(self.render_sidebar_footer(snapshot, cx)) .child(self.render_sidebar_footer(snapshot, cx))
.child(render_sidebar_resize_handle(cx))
.into_any_element() .into_any_element()
} }
@@ -419,6 +421,31 @@ const UNREAD_BADGE_BG: u32 = 0x281e140f;
/// never participates in hit testing. /// never participates in hit testing.
const HIGHLIGHT_BORDER: u32 = 0xffffff80; const HIGHLIGHT_BORDER: u32 = 0xffffff80;
/// Sidebar resize affordance pinned to the panel's right edge. The
/// strip itself is invisible until hovered; on mouse-down it tells the
/// shell to capture the drag origin so window-level mouse-move can
/// compute the live width.
fn render_sidebar_resize_handle(cx: &mut Context<ElyShell>) -> AnyElement {
div()
.id(SharedString::from("sidebar-resize-handle"))
.absolute()
.top_0()
.bottom_0()
.right(px(-2.0))
.w(px(6.0))
.cursor_col_resize()
.hover(|style| style.bg(rgba(RESIZE_HANDLE_HOVER_BG)))
.on_mouse_down(
MouseButton::Left,
cx.listener(|shell, event: &gpui::MouseDownEvent, _, cx| {
shell.begin_sidebar_resize(f32::from(event.position.x), cx);
}),
)
.into_any_element()
}
const RESIZE_HANDLE_HOVER_BG: u32 = 0xffffff66;
/// Maps appearance translucency_pct to a panel rgba u32 tinted by the /// Maps appearance translucency_pct to a panel rgba u32 tinted by the
/// active wallpaper theme. /// active wallpaper theme.
/// ///
+5
View File
@@ -68,6 +68,10 @@ pub struct ElyShell {
pub(crate) workspace_picker_open: bool, pub(crate) workspace_picker_open: bool,
pub(crate) sidebar_hover_expanded: bool, pub(crate) sidebar_hover_expanded: bool,
pub(crate) command_selected_index: usize, pub(crate) command_selected_index: usize,
/// Live state for sidebar-resize drag. While the user holds the
/// resize handle: `(mouse_x_at_drag_start, sidebar_width_px_at_drag_start)`.
/// `None` whenever no drag is in flight.
pub(crate) sidebar_resize_origin: Option<(f32, u16)>,
download_action_error: Option<String>, download_action_error: Option<String>,
download_clear_confirmation: bool, download_clear_confirmation: bool,
download_security_confirmation: Option<PendingDownloadFileAction>, download_security_confirmation: Option<PendingDownloadFileAction>,
@@ -182,6 +186,7 @@ impl ElyShell {
workspace_picker_open: false, workspace_picker_open: false,
sidebar_hover_expanded: false, sidebar_hover_expanded: false,
command_selected_index: 0, command_selected_index: 0,
sidebar_resize_origin: None,
download_action_error: None, download_action_error: None,
download_clear_confirmation: false, download_clear_confirmation: false,
download_security_confirmation: None, download_security_confirmation: None,
+53 -3
View File
@@ -2,9 +2,9 @@ use ely_browser_core::BrowserSnapshot;
use ely_design_system::{colors, spacing}; use ely_design_system::{colors, spacing};
use ely_domain::{BrowserTab, DEFAULT_SIDEBAR_WIDTH_PX, HIDDEN_SIDEBAR_WIDTH_PX}; use ely_domain::{BrowserTab, DEFAULT_SIDEBAR_WIDTH_PX, HIDDEN_SIDEBAR_WIDTH_PX};
use gpui::{ use gpui::{
AnyElement, Context, InteractiveElement, IntoElement, KeyDownEvent, MouseMoveEvent, AnyElement, Context, InteractiveElement, IntoElement, KeyDownEvent, MouseButton,
ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, div, MouseMoveEvent, MouseUpEvent, ParentElement, Render, SharedString,
prelude::FluentBuilder, px, rgb, rgba, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, rgb, rgba,
}; };
use super::chrome::command_match::visible_command_rows; use super::chrome::command_match::visible_command_rows;
@@ -68,6 +68,7 @@ impl ElyShell {
.on_action(cx.listener(Self::on_zoom_in)) .on_action(cx.listener(Self::on_zoom_in))
.on_action(cx.listener(Self::on_zoom_out)) .on_action(cx.listener(Self::on_zoom_out))
.on_mouse_move(cx.listener(Self::on_window_mouse_move)) .on_mouse_move(cx.listener(Self::on_window_mouse_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::on_window_mouse_up))
.capture_key_down(cx.listener(Self::on_command_overlay_key_down)) .capture_key_down(cx.listener(Self::on_command_overlay_key_down))
.font_family(SANS_FAMILY) .font_family(SANS_FAMILY)
.text_color(rgb(colors::INK)) .text_color(rgb(colors::INK))
@@ -214,6 +215,15 @@ impl ElyShell {
) { ) {
let cursor_x = f32::from(event.position.x); let cursor_x = f32::from(event.position.x);
if let Some((origin_x, origin_width)) = self.sidebar_resize_origin {
let delta = cursor_x - origin_x;
let candidate = (f32::from(origin_width) + delta)
.clamp(MIN_RESIZE_WIDTH_PX, MAX_RESIZE_WIDTH_PX)
.round() as u16;
self.set_active_sidebar_width(candidate, cx);
return;
}
if cursor_x >= REVEAL_THRESHOLD_PX && cursor_x <= COLLAPSE_THRESHOLD_PX { if cursor_x >= REVEAL_THRESHOLD_PX && cursor_x <= COLLAPSE_THRESHOLD_PX {
return; return;
} }
@@ -235,6 +245,36 @@ impl ElyShell {
} }
} }
pub(super) fn begin_sidebar_resize(
&mut self,
cursor_x: f32,
cx: &mut Context<Self>,
) {
let ShellState::Ready(core) = &self.state else {
return;
};
let Some(width) = core.active_space_sidebar_width() else {
return;
};
self.sidebar_resize_origin = Some((cursor_x, width));
cx.notify();
}
pub(super) fn end_sidebar_resize(&mut self, cx: &mut Context<Self>) {
if self.sidebar_resize_origin.take().is_some() {
cx.notify();
}
}
fn on_window_mouse_up(
&mut self,
_event: &MouseUpEvent,
_window: &mut Window,
cx: &mut Context<Self>,
) {
self.end_sidebar_resize(cx);
}
fn render_hidden_sidebar_rail(&mut self, cx: &mut Context<Self>) -> AnyElement { fn render_hidden_sidebar_rail(&mut self, cx: &mut Context<Self>) -> AnyElement {
div() div()
.id(SharedString::from("hidden-sidebar-rail")) .id(SharedString::from("hidden-sidebar-rail"))
@@ -256,6 +296,16 @@ impl ElyShell {
/// never participates in hit testing. /// never participates in hit testing.
const MAIN_PANE_HIGHLIGHT_BORDER: u32 = 0xffffff80; const MAIN_PANE_HIGHLIGHT_BORDER: u32 = 0xffffff80;
/// Lower clamp for the live sidebar-resize drag. Stays a touch above
/// the per-space DEFAULT so a casual flick doesn't snap the user to a
/// nearly-collapsed pane that's awkward to recover from. The toggle
/// button still owns the discrete collapse-to-rail behavior.
pub(super) const MIN_RESIZE_WIDTH_PX: f32 = 220.0;
/// Upper clamp — wider than this and the main pane disappears on
/// smaller windows. Tuned to fit comfortably on a 1280-wide window.
pub(super) const MAX_RESIZE_WIDTH_PX: f32 = 480.0;
/// Cursor x within this px from the left edge auto-reveals the hidden sidebar. /// Cursor x within this px from the left edge auto-reveals the hidden sidebar.
/// Sized so the user only triggers the reveal when they actually approach the /// Sized so the user only triggers the reveal when they actually approach the
/// rail, not when they hover the page content. /// rail, not when they hover the page content.