From da24fc6d4f2d30cd74e9a98ac7b2a50ad38f8340 Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Fri, 9 Oct 2026 01:21:39 -0400 Subject: [PATCH 1/7] Keep context menus within visible window bounds --- crates/echo-desktop/src/main.rs | 9 +- crates/echo-desktop/src/views.rs | 253 +++++++++--------- .../echo-desktop/src/views/menu_placement.rs | 194 ++++++++++++++ 3 files changed, 325 insertions(+), 131 deletions(-) create mode 100644 crates/echo-desktop/src/views/menu_placement.rs diff --git a/crates/echo-desktop/src/main.rs b/crates/echo-desktop/src/main.rs index 417ce62..6acc3e6 100644 --- a/crates/echo-desktop/src/main.rs +++ b/crates/echo-desktop/src/main.rs @@ -262,6 +262,7 @@ pub(crate) struct EchoApp { pub(crate) context_menu: Option, pub(crate) track_menu: Option, pub(crate) submenu_scroll: ScrollHandle, + pub(crate) track_menu_scroll: ScrollHandle, pub(crate) submenu_row_bounds: Rc>>, pub(crate) submenu_bounds: Rc>>, submenu_apex: gpui::Point, @@ -605,6 +606,7 @@ impl EchoApp { context_menu: None, track_menu: None, submenu_scroll: ScrollHandle::new(), + track_menu_scroll: ScrollHandle::new(), submenu_row_bounds: Rc::default(), submenu_bounds: Rc::default(), submenu_apex: gpui::Point::default(), @@ -806,6 +808,7 @@ impl EchoApp { self.submenu_scroll.scroll_to_item(index); } else { menu.selected = index; + self.track_menu_scroll.scroll_to_item(index); } } else if self.sort_menu_open { self.sort_menu_index = index; @@ -1366,6 +1369,7 @@ impl EchoApp { if let Some(menu) = self.track_menu.as_mut() { if focus_row { menu.selected = row; + self.track_menu_scroll.scroll_to_item(row); } if menu.submenu.is_none() { self.state.ui.playlist_add_filter.clear(); @@ -3613,7 +3617,10 @@ impl Render for EchoApp { let context_menu = self .context_menu .is_some() - .then(|| views::context_menu(self, cx).into_any_element()); + .then(|| views::context_menu(self, window, cx).into_any_element()); + if self.track_menu.is_none() { + self.track_menu_scroll.scroll_to_item(0); + } let track_menu = self .track_menu .is_some() diff --git a/crates/echo-desktop/src/views.rs b/crates/echo-desktop/src/views.rs index 2535c3b..8734d23 100644 --- a/crates/echo-desktop/src/views.rs +++ b/crates/echo-desktop/src/views.rs @@ -13,6 +13,7 @@ use echo_core::app::{ActiveView, AppMode, LibraryTab, QueueRow, QueueTab, Search use echo_core::config::RightPanel; use echo_core::models::{ActionMenuAction, ActionMenuContext, LibraryNode}; use echo_core::thumbnails::{ThumbState, ThumbTier, tier_for_edge}; +mod menu_placement; mod playlist_edit; mod search; use gpui::{ @@ -20,6 +21,7 @@ use gpui::{ SharedString, Stateful, Window, canvas, div, ease_out_quint, prelude::*, px, relative, svg, uniform_list, }; +use menu_placement::{MenuAnchor, menu_bounds, menu_placement}; pub use playlist_edit::{playlist_edit_modal, playlist_page_menu}; use crate::backdrop::{Backdrop, ImmersiveColors}; @@ -27,11 +29,9 @@ use crate::theme::{DesktopPalette, ToGpui, WINDOW_FG}; use crate::{ControlColors, EchoApp, MenuAction, TrackMenuItem, UpdateState, format_time}; pub(crate) const SIDEBAR_WIDTH: f32 = 240.0; -/// The add-to-playlist flyout's box. The row height is the measured height of one choice, used -/// only to keep the panel on screen — see [`playlist_submenu`]. +/// The add-to-playlist flyout's preferred dimensions. const SUBMENU_WIDTH: f32 = 190.0; const SUBMENU_MAX_H: f32 = 270.0; -const SUBMENU_ROW_HEIGHT: f32 = 31.0; const THUMB_EDGE: f32 = 26.0; // Native caption metrics: Windows titlebars are a fixed 32px, macOS gets a touch more. const TITLEBAR_HEIGHT: f32 = if cfg!(target_os = "windows") { @@ -6690,7 +6690,11 @@ fn lerp_hsla(a: gpui::Hsla, b: gpui::Hsla, t: f32) -> gpui::Hsla { /// The sidebar right-click menu: items depend on what the row is, actions run through /// [`EchoApp::run_menu_action`]. Destructive items stage a prompt for [`prompt_modal`]. -pub fn context_menu(app: &mut EchoApp, cx: &mut Context) -> impl IntoElement { +pub fn context_menu( + app: &mut EchoApp, + window: &Window, + cx: &mut Context, +) -> impl IntoElement { let menu = app .context_menu .clone() @@ -6792,13 +6796,13 @@ pub fn context_menu(app: &mut EchoApp, cx: &mut Context) -> impl IntoEl cx.notify(); }), ) - .child( + .child(menu_placement( + MenuAnchor::Pointer(menu.position), div() .id("context-menu") - .absolute() - .left(menu.position.x) - .top(menu.position.y) - .w(px(210.0)) + .occlude() + .w(px(210.0).min(menu_bounds(window).size.width)) + .max_h(menu_bounds(window).size.height) .rounded_md() .border_1() .border_color(palette.menu_border) @@ -6806,7 +6810,7 @@ pub fn context_menu(app: &mut EchoApp, cx: &mut Context) -> impl IntoEl .py_1() .flex() .flex_col() - .overflow_hidden() + .overflow_y_scroll() // Clicks on the menu itself must not reach the backdrop's close handler. .on_click(cx.listener(|_this, _event, _window, cx| cx.stop_propagation())) .children(items.into_iter().map(|(label, action, danger)| { @@ -6814,6 +6818,7 @@ pub fn context_menu(app: &mut EchoApp, cx: &mut Context) -> impl IntoEl // off the panel's rounded corners on the first and last item. div() .id(label.clone()) + .flex_shrink_0() .mx_1() .px_2() .py_1() @@ -6827,7 +6832,7 @@ pub fn context_menu(app: &mut EchoApp, cx: &mut Context) -> impl IntoEl })) .child(label) })), - ) + )) } /// Right-click menu for a track row. The item set and labels come from the core action-menu @@ -6886,7 +6891,7 @@ pub fn track_context_menu( // menu panel clips its own children. let submenu = menu .submenu - .map(|choice| playlist_submenu(app, choice, window.viewport_size(), cx)); + .map(|choice| playlist_submenu(app, choice, window, cx)); let theme = &app.state.ui.active_theme; let palette = DesktopPalette::resolve(theme); @@ -6915,17 +6920,15 @@ pub fn track_context_menu( cx.notify(); }), ) - .when(menu.position.is_none(), |el| { - // Keyboard-opened: no click to anchor to, so center it like the other modals. - el.flex().items_center().justify_center() - }) - .child( + .child(menu_placement( + menu.position + .map_or(MenuAnchor::Center, MenuAnchor::Pointer), div() .id("track-menu") - .when_some(menu.position, |el, position| { - el.absolute().left(position.x).top(position.y) - }) - .w(px(210.0)) + .occlude() + .track_scroll(&app.track_menu_scroll) + .w(px(210.0).min(menu_bounds(window).size.width)) + .max_h(menu_bounds(window).size.height) .rounded_md() .border_1() .border_color(palette.menu_border) @@ -6933,7 +6936,7 @@ pub fn track_context_menu( .py_1() .flex() .flex_col() - .overflow_hidden() + .overflow_y_scroll() // Clicks on the menu itself must not reach the backdrop's close handler. .on_click(cx.listener(|_this, _event, _window, cx| cx.stop_propagation())) .children( @@ -6950,6 +6953,7 @@ pub fn track_context_menu( let row_bounds = row_bounds.clone(); div() .id(label.clone()) + .flex_shrink_0() .relative() .mx_1() .px_2() @@ -7007,7 +7011,7 @@ pub fn track_context_menu( }) }), ), - ) + )) .children(submenu) } @@ -7017,7 +7021,7 @@ pub fn track_context_menu( fn playlist_submenu( app: &EchoApp, choice: usize, - viewport: gpui::Size, + window: &Window, cx: &mut Context, ) -> AnyElement { let theme = &app.state.ui.active_theme; @@ -7026,7 +7030,6 @@ fn playlist_submenu( let muted = theme.text_muted.gpui(WINDOW_FG()); let surface = theme.surface.gpui(crate::theme::PANEL_BG()); - let row = app.submenu_row_bounds.get(); let bounds = app.submenu_bounds.clone(); let local_label = tr(&app.state, "ui.local"); let empty_label = tr(&app.state, "desktop.playlist_add_none"); @@ -7045,118 +7048,108 @@ fn playlist_submenu( .chain(choices) .collect(); - // A right-click near the window's right edge would otherwise hang the flyout off-screen, - // so it flips to the menu's other side; the same for a menu low enough that the list would - // run past the bottom. Height is estimated from the row count rather than measured — it is - // only needed to keep the panel on screen, and measuring costs a frame of jitter. - let height = px((choices.len().max(1) as f32 * SUBMENU_ROW_HEIGHT + 10.0).min(SUBMENU_MAX_H)); - let flipped = row.right() + px(SUBMENU_WIDTH) > viewport.width - px(8.0); - let left = if flipped { - row.left() - px(SUBMENU_WIDTH) - } else { - row.right() - }; - let top = (row.top() - px(5.0)) - .min(viewport.height - height - px(8.0)) - .max(px(8.0)); - - div() - .id("track-menu-submenu") - .absolute() - // `py_1` plus the border: lines the first choice up with the row it hangs off. - .left(left) - .top(top) - .w(px(SUBMENU_WIDTH)) - .rounded_md() - .border_1() - .border_color(palette.menu_border) - .bg(surface) - .py_1() - .flex() - .flex_col() - // Swallows clicks and wheel events so neither reaches the backdrop or the list behind. - .occlude() - .on_click(cx.listener(|_this, _event, _window, cx| cx.stop_propagation())) - .when(!app.state.ui.playlist_add_filter.is_empty(), |el| { - el.child( + menu_placement( + MenuAnchor::Row(app.submenu_row_bounds.clone()), + div() + .id("track-menu-submenu") + .w(px(SUBMENU_WIDTH).min(menu_bounds(window).size.width)) + .max_h(px(SUBMENU_MAX_H).min(menu_bounds(window).size.height)) + .rounded_md() + .border_1() + .border_color(palette.menu_border) + .bg(surface) + .py_1() + .flex() + .flex_col() + // Swallows clicks and wheel events so neither reaches the backdrop or the list behind. + .occlude() + .on_click(cx.listener(|_this, _event, _window, cx| cx.stop_propagation())) + .when(!app.state.ui.playlist_add_filter.is_empty(), |el| { + el.child( + div() + .flex_shrink_0() + .px_2() + .py_1() + .text_sm() + .text_color(muted) + .truncate() + .child(app.state.ui.playlist_add_filter.clone()), + ) + }) + .child( + canvas( + move |painted, _window, _cx| bounds.set(painted), + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .child(if choices.is_empty() { div() + .mx_1() .px_2() .py_1() .text_sm() .text_color(muted) - .truncate() - .child(app.state.ui.playlist_add_filter.clone()), - ) - }) - .child( - canvas( - move |painted, _window, _cx| bounds.set(painted), - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ) - .child(if choices.is_empty() { - div() - .mx_1() - .px_2() - .py_1() - .text_sm() - .text_color(muted) - .child(empty_label) - .into_any_element() - } else { - div() - .id("track-menu-submenu-list") - .flex() - .flex_col() - .max_h(px(SUBMENU_MAX_H - 10.0)) - .overflow_y_scroll() - .track_scroll(&app.submenu_scroll) - .children(choices.into_iter().enumerate().map(|(ix, (name, local))| { - let is_selected = ix == choice; - let local_label = local_label.clone(); - div() - .id(ix) - .mx_1() - .px_2() - .py_1() - .rounded_md() - .flex() - .flex_row() - .items_center() - .gap_2() - .text_sm() - .when(is_selected, |el| el.bg(palette.menu_selected)) - .when(!is_selected, |el| { - el.hover(move |style| style.bg(palette.menu_hover)) - }) - .cursor_pointer() - .on_click(cx.listener(move |this: &mut EchoApp, _event, _window, cx| { - this.commit_playlist_submenu(ix, cx); - })) - .child( - div() - .flex_grow(1.0) - .overflow_hidden() - .text_ellipsis() - .whitespace_nowrap() - .text_color(fg) - .child(name), - ) - .when(local, |el| { - el.child( + .child(empty_label) + .into_any_element() + } else { + div() + .id("track-menu-submenu-list") + .flex() + .flex_col() + .min_h_0() + .max_h(px(SUBMENU_MAX_H - 10.0)) + .overflow_y_scroll() + .track_scroll(&app.submenu_scroll) + .children(choices.into_iter().enumerate().map(|(ix, (name, local))| { + let is_selected = ix == choice; + let local_label = local_label.clone(); + div() + .id(ix) + .flex_shrink_0() + .mx_1() + .px_2() + .py_1() + .rounded_md() + .flex() + .flex_row() + .items_center() + .gap_2() + .text_sm() + .when(is_selected, |el| el.bg(palette.menu_selected)) + .when(!is_selected, |el| { + el.hover(move |style| style.bg(palette.menu_hover)) + }) + .cursor_pointer() + .on_click(cx.listener( + move |this: &mut EchoApp, _event, _window, cx| { + this.commit_playlist_submenu(ix, cx); + }, + )) + .child( div() - .flex_none() - .text_xs() - .text_color(muted) - .child(local_label), + .flex_grow(1.0) + .overflow_hidden() + .text_ellipsis() + .whitespace_nowrap() + .text_color(fg) + .child(name), ) - }) - })) - .into_any_element() - }) - .into_any_element() + .when(local, |el| { + el.child( + div() + .flex_none() + .text_xs() + .text_color(muted) + .child(local_label), + ) + }) + })) + .into_any_element() + }), + ) + .into_any_element() } /// Confirm dialog for whichever destructive prompt is staged, resolved through diff --git a/crates/echo-desktop/src/views/menu_placement.rs b/crates/echo-desktop/src/views/menu_placement.rs new file mode 100644 index 0000000..7400755 --- /dev/null +++ b/crates/echo-desktop/src/views/menu_placement.rs @@ -0,0 +1,194 @@ +//! Measure and fit popups before painting, in window coordinates. +//! +//! GPUI's anchored element only applies its inset when a panel crosses the raw viewport +//! edge. Fit against the visible client area instead, including when a panel would only +//! overlap the transparent frame. Measuring in prepaint avoids height estimates and jitter. +use std::{cell::Cell, rc::Rc}; + +use gpui::{ + AnyElement, App, Bounds, Display, Element, ElementId, GlobalElementId, InspectorElementId, + IntoElement, LayoutId, Pixels, Point, Position, Size, Style, Tiling, Window, point, px, +}; + +pub(super) enum MenuAnchor { + Pointer(Point), + Center, + Row(Rc>>), +} + +pub(super) fn menu_bounds(window: &Window) -> Bounds { + safe_bounds(window.viewport_size(), super::client_corners(window)) +} + +fn safe_bounds(size: Size, tiling: Option) -> Bounds { + // Eight pixels also keeps rectangular panels out of the ten-pixel corner arcs. + let inset = |tiled: bool| { + px(8.0 + + if tiling.is_some() && !tiled { + super::CLIENT_SHADOW + } else { + 0.0 + }) + }; + let tiled = tiling.unwrap_or_default(); + let origin = point(inset(tiled.left), inset(tiled.top)); + Bounds::new( + origin, + gpui::size( + (size.width - origin.x - inset(tiled.right)).max(px(0.0)), + (size.height - origin.y - inset(tiled.bottom)).max(px(0.0)), + ), + ) +} + +fn fit(origin: Point, size: Size, limits: Bounds) -> Point { + point( + origin.x.min(limits.right() - size.width).max(limits.left()), + origin + .y + .min(limits.bottom() - size.height) + .max(limits.top()), + ) +} + +pub(super) fn menu_placement(anchor: MenuAnchor, child: impl IntoElement) -> MenuPlacement { + MenuPlacement { + anchor, + child: child.into_any_element(), + } +} + +pub(super) struct MenuPlacement { + anchor: MenuAnchor, + child: AnyElement, +} + +impl IntoElement for MenuPlacement { + type Element = Self; + fn into_element(self) -> Self { + self + } +} + +impl Element for MenuPlacement { + type RequestLayoutState = LayoutId; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, LayoutId) { + let child = self.child.request_layout(window, cx); + let layout = window.request_layout( + Style { + position: Position::Absolute, + display: Display::Flex, + ..Style::default() + }, + [child], + cx, + ); + (layout, child) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + child: &mut LayoutId, + window: &mut Window, + cx: &mut App, + ) { + let size = window.layout_bounds(*child).size; + let limits = menu_bounds(window); + let origin = match &self.anchor { + MenuAnchor::Pointer(position) => *position, + MenuAnchor::Center => { + limits.origin + + point( + (limits.size.width - size.width) / 2.0, + (limits.size.height - size.height) / 2.0, + ) + } + MenuAnchor::Row(row) => { + // The parent menu prepaints first, so this includes its final placement. + let row = row.get(); + let left = if row.right() + size.width > limits.right() { + row.left() - size.width + } else { + row.right() + }; + point(left, row.top() - px(5.0)) + } + }; + let offset = fit(origin, size, limits) - bounds.origin; + window.with_element_offset(offset, |window| self.child.prepaint(window, cx)); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut LayoutId, + _: &mut (), + window: &mut Window, + cx: &mut App, + ) { + self.child.paint(window, cx); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::size; + + #[test] + fn fits_edges_without_moving_interior_clicks() { + let bounds = safe_bounds(size(px(640.0), px(480.0)), None); + let panel = size(px(210.0), px(170.0)); + assert_eq!( + fit(point(px(100.0), px(100.0)), panel, bounds), + point(px(100.0), px(100.0)) + ); + assert_eq!( + fit(point(px(630.0), px(470.0)), panel, bounds), + point(px(422.0), px(302.0)) + ); + // Still inside the raw viewport, but overlapping the safe gutter. + assert_eq!( + fit(point(px(428.0), px(308.0)), panel, bounds), + point(px(422.0), px(302.0)) + ); + assert_eq!( + fit(point(px(-5.0), px(-5.0)), panel, bounds), + point(px(8.0), px(8.0)) + ); + } + + #[test] + fn excludes_only_untiled_client_frame_edges() { + let bounds = safe_bounds( + size(px(640.0), px(480.0)), + Some(Tiling { + left: true, + top: true, + ..Tiling::default() + }), + ); + assert_eq!(bounds.origin, point(px(8.0), px(8.0))); + assert_eq!(bounds.bottom_right(), point(px(622.0), px(462.0))); + } +} From e76e3ac135d062782249bf0a273784aeef8d4fef Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Fri, 9 Oct 2026 01:32:32 -0400 Subject: [PATCH 2/7] Make queue and lyrics share a resizable right panel --- crates/echo-core/src/config.rs | 19 ++++ crates/echo-desktop/src/main.rs | 94 +++++++++++++++---- crates/echo-desktop/src/views.rs | 86 ++++++++++++----- crates/echo-desktop/src/views/panel_layout.rs | 49 ++++++++++ crates/echo-desktop/src/views/search.rs | 7 +- 5 files changed, 206 insertions(+), 49 deletions(-) create mode 100644 crates/echo-desktop/src/views/panel_layout.rs diff --git a/crates/echo-core/src/config.rs b/crates/echo-core/src/config.rs index ad363e8..6d9e7e6 100644 --- a/crates/echo-core/src/config.rs +++ b/crates/echo-core/src/config.rs @@ -430,6 +430,9 @@ pub struct LibraryConfig { /// The desktop app's docked right-hand panel, if one was open when it last closed. #[serde(default)] pub right_panel: Option, + /// One desktop dock width, shared by queue and lyrics. + #[serde(default)] + pub right_panel_width: Option, #[serde(default)] pub window_bounds: Option, /// The desktop close button hides the window to the tray and keeps playing. @@ -524,6 +527,7 @@ impl Default for LibraryConfig { sidebar_width: None, sidebar_collapsed: None, right_panel: None, + right_panel_width: None, window_bounds: None, close_to_tray: true, top_items_range: crate::models::TopItemsRange::default(), @@ -908,6 +912,21 @@ fn load_themes_from_dir( #[cfg(test)] mod tests { + #[test] + fn right_panel_width_defaults_and_survives_panel_switches() { + let mut config: LibraryConfig = toml::from_str("sidebar_width = 250.0").unwrap(); + assert_eq!(config.right_panel_width, None); + config.right_panel_width = Some(235.0); + for panel in [Some(RightPanel::Queue), Some(RightPanel::Lyrics), None] { + config.right_panel = panel; + let decoded: LibraryConfig = + toml::from_str(&toml::to_string(&config).unwrap()).unwrap(); + assert_eq!(decoded.right_panel_width, Some(235.0)); + assert_eq!(decoded.sidebar_width, Some(250.0)); + assert_eq!(decoded.right_panel, panel); + } + } + #[test] fn update_cache_keeps_every_writers_fields() { AppConfig::update_cache(|cache| cache.cooldown_failures.insert("first".into(), 1)); diff --git a/crates/echo-desktop/src/main.rs b/crates/echo-desktop/src/main.rs index 6acc3e6..463a33f 100644 --- a/crates/echo-desktop/src/main.rs +++ b/crates/echo-desktop/src/main.rs @@ -137,9 +137,10 @@ enum Scrub { } #[derive(Clone, Copy)] -struct SidebarResize { +struct PanelResize { start_x: Pixels, start_width: f32, + right: bool, } #[derive(Clone)] @@ -235,6 +236,7 @@ pub(crate) struct EchoApp { pub(crate) device_modal_scroll: ScrollHandle, pub(crate) theme_modal_scroll: ScrollHandle, pub(crate) sidebar_width: f32, + pub(crate) right_panel_width: f32, pub(crate) sidebar_collapsed: bool, pub(crate) theme_modal_open: bool, pub(crate) immersive: bool, @@ -273,7 +275,7 @@ pub(crate) struct EchoApp { /// The Home shelf whose scrollbar is being dragged; tracked at the window level like a /// scrub so the drag survives the pointer leaving the bar. shelf_scrub: Option, - sidebar_resizing: Option, + panel_resizing: Option, titlebar_should_move: bool, mono_font: SharedString, focus_handle: FocusHandle, @@ -538,6 +540,13 @@ impl EchoApp { .sidebar_width .unwrap_or(views::SIDEBAR_WIDTH); let sidebar_collapsed = state.ui.library_config.sidebar_collapsed.unwrap_or(false); + let right_panel_width = views::panel_layout::preferred_width( + state + .ui + .library_config + .right_panel_width + .unwrap_or(views::panel_layout::RIGHT_PANEL_DEFAULT), + ); let right_panel = state.ui.library_config.right_panel; state.ui.queue_panel_open = right_panel == Some(RightPanel::Queue); if state.ui.queue_panel_open { @@ -579,6 +588,7 @@ impl EchoApp { device_modal_scroll: ScrollHandle::new(), theme_modal_scroll: ScrollHandle::new(), sidebar_width: sidebar_width.clamp(180.0, 480.0), + right_panel_width, sidebar_collapsed, theme_modal_open: false, immersive: false, @@ -615,7 +625,7 @@ impl EchoApp { volume_bounds: Rc::default(), scrubbing: None, shelf_scrub: None, - sidebar_resizing: None, + panel_resizing: None, titlebar_should_move: false, mono_font: resolve_mono_font(cx), focus_handle, @@ -1102,6 +1112,9 @@ impl EchoApp { /// Opens, closes or swaps the docked panel. Opening the queue fetches it once; the core's /// `queue_visible` keeps it fresh from there. pub(crate) fn set_right_panel(&mut self, panel: Option, cx: &mut Context) { + if self.panel_resizing.is_some_and(|resize| resize.right) { + self.finish_panel_resize(cx); + } self.right_panel = panel; self.state.ui.library_config.right_panel = panel; self.state.save_library_config(); @@ -2675,31 +2688,76 @@ impl EchoApp { scroll.set_offset(gpui::point(-scroll.max_offset().x * fraction, px(0.0))); } - // Drag-to-resize of the library sidebar: mouse-down on its right edge starts it, window-level - // mouse moves update the width optimistically, and release settles and saves it. + fn width_beside_library(&self, window: &Window) -> f32 { + (views::client_content_width(window) + - if self.sidebar_collapsed { + 0.0 + } else { + self.sidebar_width + }) + .max(0.0) + } + + pub(crate) fn effective_right_panel_width(&self, window: &Window) -> f32 { + views::panel_layout::fitted_width(self.right_panel_width, self.width_beside_library(window)) + } + + pub(crate) fn main_content_width(&self, window: &Window) -> f32 { + (self.width_beside_library(window) + - if self.right_panel.is_some() { + self.effective_right_panel_width(window) + } else { + 0.0 + }) + .max(0.0) + } + + // Both docks track drags at window level and persist only on release. The right dock's + // left edge moves in the opposite direction to the library's right edge. fn begin_sidebar_resize(&mut self, x: Pixels, cx: &mut Context) { - self.sidebar_resizing = Some(SidebarResize { + self.panel_resizing = Some(PanelResize { start_x: x, start_width: self.sidebar_width, + right: false, + }); + cx.notify(); + } + + fn begin_right_panel_resize(&mut self, x: Pixels, window: &Window, cx: &mut Context) { + self.panel_resizing = Some(PanelResize { + start_x: x, + start_width: self.effective_right_panel_width(window), + right: true, }); cx.notify(); } - fn update_sidebar_resize(&mut self, x: Pixels, cx: &mut Context) { - let Some(resize) = self.sidebar_resizing else { + fn update_panel_resize(&mut self, x: Pixels, window: &Window, cx: &mut Context) { + let Some(resize) = self.panel_resizing else { return; }; let delta = x - resize.start_x; - self.sidebar_width = (resize.start_width + f32::from(delta)).clamp(180.0, 480.0); + if resize.right { + self.right_panel_width = views::panel_layout::fitted_width( + resize.start_width - f32::from(delta), + self.width_beside_library(window), + ); + } else { + self.sidebar_width = (resize.start_width + f32::from(delta)).clamp(180.0, 480.0); + } cx.notify(); } - fn finish_sidebar_resize(&mut self, cx: &mut Context) { - if self.sidebar_resizing.take().is_some() { + fn finish_panel_resize(&mut self, cx: &mut Context) { + if let Some(resize) = self.panel_resizing.take() { // Saved on release rather than on every pointer move — a drag is one decision, not // a few hundred config writes. - self.state.ui.library_config.sidebar_width = Some(self.sidebar_width); + if resize.right { + self.state.ui.library_config.right_panel_width = Some(self.right_panel_width); + } else { + self.state.ui.library_config.sidebar_width = Some(self.sidebar_width); + } self.state.save_library_config(); cx.notify(); } @@ -3687,12 +3745,12 @@ impl Render for EchoApp { // Scrubs track the pointer at the window level so dragging keeps working when the // pointer leaves the bar; release (or a move without the button) ends them. .on_mouse_move( - cx.listener(|this, event: &gpui::MouseMoveEvent, _window, cx| { - if this.sidebar_resizing.is_some() { + cx.listener(|this, event: &gpui::MouseMoveEvent, window, cx| { + if this.panel_resizing.is_some() { if event.pressed_button == Some(gpui::MouseButton::Left) { - this.update_sidebar_resize(event.position.x, cx); + this.update_panel_resize(event.position.x, window, cx); } else { - this.finish_sidebar_resize(cx); + this.finish_panel_resize(cx); } } if this.scrubbing.is_some() { @@ -3715,7 +3773,7 @@ impl Render for EchoApp { .on_mouse_up( gpui::MouseButton::Left, cx.listener(|this, event: &gpui::MouseUpEvent, _window, cx| { - this.finish_sidebar_resize(cx); + this.finish_panel_resize(cx); this.finish_scrub(event.position.x, cx); this.shelf_scrub = None; }), @@ -3937,7 +3995,7 @@ impl Render for EchoApp { }) .child(views::main_area(self, window, cx)) .when_some(self.right_panel, |el, panel| { - el.child(views::right_panel(self, panel, cx)) + el.child(views::right_panel(self, panel, window, cx)) }), ) .when_some(status_line, |el, line| el.child(line)) diff --git a/crates/echo-desktop/src/views.rs b/crates/echo-desktop/src/views.rs index 8734d23..3a2ec64 100644 --- a/crates/echo-desktop/src/views.rs +++ b/crates/echo-desktop/src/views.rs @@ -14,6 +14,7 @@ use echo_core::config::RightPanel; use echo_core::models::{ActionMenuAction, ActionMenuContext, LibraryNode}; use echo_core::thumbnails::{ThumbState, ThumbTier, tier_for_edge}; mod menu_placement; +pub(crate) mod panel_layout; mod playlist_edit; mod search; use gpui::{ @@ -486,6 +487,14 @@ pub(crate) fn client_corners(window: &Window) -> Option { } } +/// Width of the visible app, excluding client-drawn shadow/resize margins. +pub(crate) fn client_content_width(window: &Window) -> f32 { + let inset = client_corners(window).map_or(0.0, |tiling| { + f32::from(!tiling.left) * CLIENT_SHADOW + f32::from(!tiling.right) * CLIENT_SHADOW + }); + (f32::from(window.viewport_size().width) - inset).max(0.0) +} + /// Rounds the corners an element shares with the window's. /// /// gpui's content mask is a plain rectangle, so rounding a container does **not** clip what is @@ -2030,12 +2039,7 @@ fn track_list( let theme = &app.state.ui.active_theme; let fg = theme.text.gpui(WINDOW_FG()); let muted = theme.text_muted.gpui(WINDOW_FG()); - let sidebar_width = if app.sidebar_collapsed { - 0.0 - } else { - app.sidebar_width - }; - let wide = f32::from(window.viewport_size().width) - sidebar_width >= 640.0; + let wide = app.main_content_width(window) >= 640.0; let hero = wide && app .state @@ -2851,13 +2855,23 @@ fn queue_header( } }) .justify_between() + .gap_2() .text_xs() .text_color(muted) - .child(SharedString::from(text)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(SharedString::from(text)), + ) .when_some(clear_label, |el, label| { el.child( div() .id("clear-queue") + .flex_none() + .max_w(relative(0.6)) + .truncate() .cursor_pointer() .text_color(fg) .hover(move |style| style.text_color(muted)) @@ -4412,12 +4426,17 @@ fn lyric_window(app: &EchoApp, colors: LyricColors, rows: usize, row_height: f32 const PANEL_LYRIC_ROW: f32 = 26.0; const LYRIC_GLIDE: std::time::Duration = std::time::Duration::from_millis(240); -pub(crate) const RIGHT_PANEL_WIDTH: f32 = 320.0; /// The docked column to the right of the main area: the queue or the lyrics, whichever the /// playback bar's buttons (or `shift-L`) opened. Persistent UI rather than an overlay, so /// escape leaves it alone and it comes back on the next launch. -pub fn right_panel(app: &mut EchoApp, panel: RightPanel, cx: &mut Context) -> AnyElement { +pub fn right_panel( + app: &mut EchoApp, + panel: RightPanel, + window: &Window, + cx: &mut Context, +) -> AnyElement { + let width = app.effective_right_panel_width(window); let theme = &app.state.ui.active_theme; let palette = DesktopPalette::resolve(theme); let fg = theme.text.gpui(WINDOW_FG()); @@ -4432,14 +4451,15 @@ pub fn right_panel(app: &mut EchoApp, panel: RightPanel, cx: &mut Context queue_panel_body(app, cx), + RightPanel::Queue => queue_panel_body(app, width, cx), RightPanel::Lyrics => lyric_status(app, muted).unwrap_or_else(|| lyric_list(app, cx)), }; div() .id("right-panel") + .relative() .flex_none() - .w(px(RIGHT_PANEL_WIDTH)) + .w(px(width)) .h_full() .flex() .flex_col() @@ -4467,6 +4487,7 @@ pub fn right_panel(app: &mut EchoApp, panel: RightPanel, cx: &mut Context) -> AnyElement { +fn queue_panel_body(app: &mut EchoApp, width: f32, cx: &mut Context) -> AnyElement { + let show_duration = panel_layout::show_queue_duration(width); let theme = &app.state.ui.active_theme; let muted = theme.text_muted.gpui(WINDOW_FG()); let accent = theme.primary.gpui(WINDOW_FG()); @@ -4675,13 +4714,15 @@ fn queue_panel_body(app: &mut EchoApp, cx: &mut Context) -> AnyElement .child(SharedString::from(track.artist.clone())), ), ) - .child( - div() - .flex_none() - .text_xs() - .text_color(muted) - .child(SharedString::from(format_time(track.duration_ms))), - ) + .when(show_duration, |row| { + row.child( + div() + .flex_none() + .text_xs() + .text_color(muted) + .child(SharedString::from(format_time(track.duration_ms))), + ) + }) }) .into_any_element() }) @@ -6522,12 +6563,7 @@ fn home_view( let muted = app.state.ui.active_theme.text_muted.gpui(WINDOW_FG()); let accent = app.state.ui.active_theme.primary.gpui(WINDOW_FG()); let mut shelves = home_shelves(&app.state); - let width = f32::from(window.viewport_size().width) - - if app.sidebar_collapsed { - 0.0 - } else { - app.sidebar_width - }; + let width = app.main_content_width(window); let columns = if width >= 640.0 { 4 } else if width >= 320.0 { diff --git a/crates/echo-desktop/src/views/panel_layout.rs b/crates/echo-desktop/src/views/panel_layout.rs new file mode 100644 index 0000000..61d7f72 --- /dev/null +++ b/crates/echo-desktop/src/views/panel_layout.rs @@ -0,0 +1,49 @@ +//! Width policy shared by the queue and lyrics dock. +pub(crate) const RIGHT_PANEL_DEFAULT: f32 = 320.0; +pub(crate) const RIGHT_PANEL_MIN: f32 = 180.0; +const RIGHT_PANEL_MAX: f32 = 480.0; +const MAIN_AREA_PREFERRED_MIN: f32 = 320.0; +const QUEUE_DURATION_MIN_WIDTH: f32 = 280.0; + +pub(crate) fn preferred_width(width: f32) -> f32 { + if width.is_finite() { + width.clamp(RIGHT_PANEL_MIN, RIGHT_PANEL_MAX) + } else { + RIGHT_PANEL_DEFAULT + } +} + +/// Reserve room for the center where possible; at very small window sizes keep the +/// dock's controls usable. Window constraints never overwrite the saved preference. +pub(crate) fn fitted_width(preferred: f32, available: f32) -> f32 { + preferred_width(preferred).min((available - MAIN_AREA_PREFERRED_MIN).max(RIGHT_PANEL_MIN)) +} + +pub(crate) fn show_queue_duration(width: f32) -> bool { + width >= QUEUE_DURATION_MIN_WIDTH +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn window_constraints_are_temporary_and_keep_controls_usable() { + let preferred = preferred_width(420.0); + assert_eq!(fitted_width(preferred, 900.0), 420.0); + assert_eq!(fitted_width(preferred, 600.0), 280.0); + assert_eq!(fitted_width(preferred, 300.0), 180.0); + assert_eq!(fitted_width(preferred, 900.0), 420.0); + assert_eq!(preferred_width(-10.0), 180.0); + assert_eq!(preferred_width(900.0), 480.0); + assert_eq!(preferred_width(f32::NAN), 320.0); + } + + #[test] + fn duration_tracks_effective_width_including_window_constraints() { + assert!(!show_queue_duration(fitted_width(320.0, 599.0))); + assert!(show_queue_duration(fitted_width(320.0, 600.0))); + assert!(!show_queue_duration(279.5)); + assert!(show_queue_duration(280.0)); + } +} diff --git a/crates/echo-desktop/src/views/search.rs b/crates/echo-desktop/src/views/search.rs index 6bd40fb..ab1301c 100644 --- a/crates/echo-desktop/src/views/search.rs +++ b/crates/echo-desktop/src/views/search.rs @@ -397,12 +397,7 @@ pub fn all_results( &app.state.ui.search_context_query, ); app.search_all_positions = vec![(0, None); rows.len()]; - let sidebar = if app.sidebar_collapsed { - 0.0 - } else { - app.sidebar_width - }; - let narrow = f32::from(window.viewport_size().width) - sidebar < 620.0; + let narrow = app.main_content_width(window) < 620.0; let mut top_band = div().flex().gap_4().when(narrow, |el| el.flex_col()); if let Some(row) = rows.first() && let Some(item) = search_row_item(&app.state.data.search_results, *row) From 6a3de85169b4ca562c286f4960e7d93f7b8db51a Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Fri, 9 Oct 2026 01:40:05 -0400 Subject: [PATCH 3/7] Clip scrolling artwork at the content boundary --- crates/echo-desktop/src/views.rs | 10 ++++++++-- crates/echo-desktop/src/views/search.rs | 8 +++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/echo-desktop/src/views.rs b/crates/echo-desktop/src/views.rs index 3a2ec64..76b3f9e 100644 --- a/crates/echo-desktop/src/views.rs +++ b/crates/echo-desktop/src/views.rs @@ -6582,13 +6582,15 @@ fn home_view( .w_full() .overflow_y_scroll() .track_scroll(&app.home_scroll) - .p_4() + // Shelves clip at the main area's edge; only non-scrolling content is inset. + .py_4() .flex() .flex_col() .gap_6() .child( div() .flex_none() + .px_4() .flex() .flex_col() .gap_2() @@ -6617,6 +6619,7 @@ fn home_view( content = content.child( div() .flex_none() + .px_4() .flex() .flex_col() .gap_2() @@ -6642,7 +6645,7 @@ fn home_view( app.home_section_indices.insert(kind, section); section += 1; if shelf.kind == HomeShelfKind::QuickPicks { - let mut grid = div().flex_none().flex().flex_col().gap_2(); + let mut grid = div().flex_none().px_4().flex().flex_col().gap_2(); for chunk in shelf.items.chunks(columns) { let mut row = div().flex().gap_2().w_full(); for item in chunk { @@ -6667,6 +6670,8 @@ fn home_view( .id(SharedString::from(format!("home-shelf-{:?}", shelf.kind))) .flex() .gap_3() + // Scroll the end spacing with the cards instead of clipping before the gutter. + .px_4() .overflow_x_scroll() .restrict_scroll_to_axis() .track_scroll(&scroll) @@ -6702,6 +6707,7 @@ fn home_view( .text_size(px(18.0)) .font_weight(gpui::FontWeight::BOLD) .text_color(fg) + .px_4() .child(tr(&app.state, shelf.kind.title_key())), ) .child(row) diff --git a/crates/echo-desktop/src/views/search.rs b/crates/echo-desktop/src/views/search.rs index ab1301c..272635b 100644 --- a/crates/echo-desktop/src/views/search.rs +++ b/crates/echo-desktop/src/views/search.rs @@ -433,13 +433,13 @@ pub fn all_results( .id("search-all-scroll") .flex_1() .min_h_0() - .p_4() + .py_4() .flex() .flex_col() .gap_6() .overflow_y_scroll() .track_scroll(&app.search_all_scroll) - .child(top_band.flex_none()); + .child(top_band.flex_none().px_4()); let mut section = 1; for (tab, key) in [ (SearchTab::Albums, "ui.albums"), @@ -460,6 +460,8 @@ pub fn all_results( .id("cards") .flex() .gap_3() + // Keep spacing inside the scrolling viewport so artwork clips at the page edge. + .px_4() .overflow_x_scroll() .track_scroll(&scroll); for (flat, row) in cards { @@ -498,7 +500,7 @@ pub fn all_results( .flex() .flex_col() .gap_3() - .child(section_title(app, key, tab, cx)) + .child(div().px_4().child(section_title(app, key, tab, cx))) .child(card_row), ); section += 1; From 1c35d0d66cea12938f7add2491bc09696b2008e1 Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Fri, 9 Oct 2026 01:47:00 -0400 Subject: [PATCH 4/7] Balance search header spacing and add a bottom divider --- crates/echo-desktop/src/views.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/echo-desktop/src/views.rs b/crates/echo-desktop/src/views.rs index 76b3f9e..3a77e55 100644 --- a/crates/echo-desktop/src/views.rs +++ b/crates/echo-desktop/src/views.rs @@ -1473,11 +1473,13 @@ fn search_bar( div() .flex_none() - // pl_2/pt_2 mirror the sidebar header's metrics so the button cluster lands on the - // same pixels whether it renders here (collapsed) or in the sidebar (expanded). + // Left/top spacing mirrors the sidebar header so its button cluster stays aligned. + // Equal bottom spacing keeps scrolling content clear of the search box. .pl_2() .pr_4() - .pt_2() + .py_2() + .border_b_1() + .border_color(palette.border) .flex() .flex_row() .items_center() From 897e0522bdb6cc9a5de7be8671d3e0a96b3195f3 Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Fri, 9 Oct 2026 01:52:38 -0400 Subject: [PATCH 5/7] Round the inner content corner beside the library --- crates/echo-desktop/src/views.rs | 76 ++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/echo-desktop/src/views.rs b/crates/echo-desktop/src/views.rs index 3a77e55..3ecb42f 100644 --- a/crates/echo-desktop/src/views.rs +++ b/crates/echo-desktop/src/views.rs @@ -769,8 +769,6 @@ pub fn sidebar(app: &mut EchoApp, cx: &mut Context) -> impl IntoElement .h_full() .flex() .flex_col() - .border_r_1() - .border_color(palette.border) .child(nav_row) .child(div().id("home-link").mx_2().mt_2().px_2().py_2().rounded_md().flex().items_center().gap_2().text_sm().text_color(if app.state.ui.active_view == ActiveView::Home { accent } else { muted }).when(app.state.ui.active_view == ActiveView::Home, |el| el.bg(palette.row_selected)).hover(move |style| style.bg(palette.row_hover)).cursor_pointer().on_click(cx.listener(|this: &mut EchoApp, _, window, cx| { this.open_home(cx); window.focus(&this.focus_handle, cx); })).child(svg().path("icons/home.svg").size(px(18.0)).text_color(muted)).child(tr(&app.state, "desktop.home"))) .child( @@ -1244,6 +1242,19 @@ pub fn main_area( } }; + let palette = DesktopPalette::resolve(&app.state.ui.active_theme); + let background = app + .state + .ui + .active_theme + .background + .gpui(crate::theme::WINDOW_BG()); + let body = if app.sidebar_collapsed { + body + } else { + rounded_content_frame(body, background, palette.border).into_any_element() + }; + div() .flex_grow(1.0) .h_full() @@ -1254,6 +1265,64 @@ pub fn main_area( .child(body) } +/// Join the library edge to the search header with an inner top-left corner. GPUI's +/// overflow mask is rectangular, so paint the corner cutout after the scrolling content. +/// The border belongs to this frame, not the sidebar/header, avoiding a square junction. +fn rounded_content_frame(body: AnyElement, background: Hsla, border: Hsla) -> impl IntoElement { + div() + .relative() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_hidden() + .pt(px(1.0)) + .pl(px(1.0)) + .child(body) + .child( + canvas( + |_, _, _| (), + move |bounds, _, window, _| { + let radius = px(12.0).min(bounds.size.width).min(bounds.size.height); + let point = |x, y| bounds.origin + gpui::point(x, y); + // A circular quarter-arc, approximated by a cubic Bezier. + let control = radius * (1.0 - 0.552_284_8); + let mut mask = gpui::PathBuilder::fill(); + mask.move_to(point(px(0.0), px(0.0))); + mask.line_to(point(radius, px(0.0))); + mask.cubic_bezier_to( + point(px(0.0), radius), + point(control, px(0.0)), + point(px(0.0), control), + ); + mask.close(); + if let Ok(path) = mask.build() { + window.paint_path(path, background); + } + + let half = px(0.5); + let control = radius - (radius - half) * 0.552_284_8; + let mut edge = gpui::PathBuilder::stroke(px(1.0)); + edge.move_to(point(bounds.size.width, half)); + edge.line_to(point(radius, half)); + edge.cubic_bezier_to( + point(half, radius), + point(control, half), + point(half, control), + ); + edge.line_to(point(half, bounds.size.height)); + if let Ok(path) = edge.build() { + window.paint_path(path, border); + } + }, + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) +} + /// First-run credentials: a BYOK (bring-your-own-key) card writing to the /// `state.ui.setup_*` fields and submitting through the setup intent. fn setup_view( @@ -1478,8 +1547,7 @@ fn search_bar( .pl_2() .pr_4() .py_2() - .border_b_1() - .border_color(palette.border) + .when(collapsed, |el| el.border_b_1().border_color(palette.border)) .flex() .flex_row() .items_center() From ae7d985a175362db7f2bbaf534374bba658b5fba Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Fri, 9 Oct 2026 01:59:15 -0400 Subject: [PATCH 6/7] Align window content corners with the outer frame --- crates/echo-desktop/src/views.rs | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/echo-desktop/src/views.rs b/crates/echo-desktop/src/views.rs index 3ecb42f..31e5462 100644 --- a/crates/echo-desktop/src/views.rs +++ b/crates/echo-desktop/src/views.rs @@ -289,7 +289,7 @@ pub fn titlebar( // The close button's hover fill reaches the window's top-right corner, so it rounds // itself too — DWM's radius on Windows 11, ours on a client-decorated Linux window. let close_radius = match corners { - Some(tiling) => (!tiling.top && !tiling.right).then(|| px(CLIENT_CORNER_RADIUS)), + Some(tiling) => (!tiling.top && !tiling.right).then(|| px(CLIENT_CONTENT_RADIUS)), None => (cfg!(target_os = "windows") && !maximized).then(|| px(WIN_CORNER_RADIUS)), }; @@ -462,6 +462,10 @@ fn caption_button( /// Corner radius and drop-shadow depth for a client-decorated window, matching what GNOME and /// Zed use so echo sits alongside them without looking off. const CLIENT_CORNER_RADIUS: f32 = 10.0; +const CLIENT_BORDER_WIDTH: f32 = 1.0; +// Content sits inside the frame border: reduce its radius by the same inset so both +// arcs share a center instead of leaving a sliver between their antialiased edges. +const CLIENT_CONTENT_RADIUS: f32 = CLIENT_CORNER_RADIUS - CLIENT_BORDER_WIDTH; /// The radius DWM clips an unmaximized window to on Windows 11. const WIN_CORNER_RADIUS: f32 = 8.0; const CLIENT_SHADOW: f32 = 10.0; @@ -518,17 +522,21 @@ pub(crate) fn round_client_corners( pub(crate) fn client_corner_radii( corners: Option, which: ClientCorners, +) -> gpui::Corners { + corner_radii(corners, which, CLIENT_CONTENT_RADIUS) +} + +fn corner_radii( + corners: Option, + which: ClientCorners, + radius: f32, ) -> gpui::Corners { let Some(tiling) = corners else { return gpui::Corners::default(); }; let all = matches!(which, ClientCorners::All); let radius = |rounded: bool| { - if rounded { - px(CLIENT_CORNER_RADIUS) - } else { - px(0.0) - } + if rounded { px(radius) } else { px(0.0) } }; gpui::Corners { top_left: radius(!tiling.top && !tiling.left), @@ -605,10 +613,17 @@ pub fn window_frame( .child( div() .size_full() - .map(|el| round_client_corners(el, Some(tiling), ClientCorners::All)) + .map(|el| { + let radii = + corner_radii(Some(tiling), ClientCorners::All, CLIENT_CORNER_RADIUS); + el.rounded_tl(radii.top_left) + .rounded_tr(radii.top_right) + .rounded_bl(radii.bottom_left) + .rounded_br(radii.bottom_right) + }) // A hairline outline stands in for the frame the compositor is not drawing, so // the window still reads as one against a same-coloured background behind it. - .border_1() + .border(px(CLIENT_BORDER_WIDTH)) .border_color(palette.border) .when(!tiling.is_tiled(), |el| { el.shadow(vec![ From c7fc541c5cd5353a6c46049b0a888e9105c72be3 Mon Sep 17 00:00:00 2001 From: Andre Sun Date: Fri, 9 Oct 2026 02:02:50 -0400 Subject: [PATCH 7/7] Document desktop refinements in release notes --- release.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/release.md b/release.md index 825c32f..a2b50f7 100644 --- a/release.md +++ b/release.md @@ -1 +1,9 @@ # Changelog + +### Desktop refinements + - Right-click menus stay inside the visible window, moving up or left near an edge; playlist submenus follow the repositioned menu + - Queue and Lyrics share a resizable right panel that remembers its width; drag its left edge to resize, with song durations disappearing first as the queue narrows + - Home and Search artwork scrolls cleanly to the content edge instead of cutting off behind a strip of padding + - The search bar has equal padding above and below, with a divider separating it from scrolling content + - Opening the library sidebar gives the main content a rounded top-left corner; collapsing it restores the straight top divider + - Client-drawn window corners align with the outer outline, including the titlebar and immersive backdrop