From 8b3b357de7d0d52cccf5043eb45e2812c5c16b36 Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Wed, 9 Sep 2026 17:40:21 +0800 Subject: [PATCH] fix(ui): make sidebar hide threshold edge-based --- crates/app/src/ui/mod.rs | 16 ++ crates/app/src/ui/sidebar_tests.rs | 159 +++++++++++++++++- crates/app/src/ui/sidebars.rs | 60 +++---- .../docs/getting-started/quick-tour.md | 7 +- .../src/content/docs/reference/ui-overview.md | 7 +- .../docs/zh-cn/getting-started/quick-tour.md | 4 +- .../docs/zh-cn/reference/ui-overview.md | 4 +- 7 files changed, 219 insertions(+), 38 deletions(-) diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index d9a4cd5..4ddefd4 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -371,9 +371,25 @@ fn show_resizable_sidebar( ui: &mut Ui, panel_id: Id, edge: SidebarEdge, + width_range: std::ops::RangeInclusive, add_contents: impl FnOnce(&mut Ui) -> R, ) -> InnerResponse { let resizable = sidebar_resize_enabled(ui, panel_id, edge); + // egui 0.34 uses an absolute distance from the fixed edge. Preserve the + // direction before clamping so dragging through that edge cannot grow it. + let panel = if let Some(resize) = ui.ctx().read_response(panel_id.with("__resize")) + && resize.dragged() + && let Some(pointer) = resize.interact_pointer_pos() + { + let available = ui.available_rect_before_wrap(); + let width = match edge { + SidebarEdge::Left => available.right() - pointer.x, + SidebarEdge::Right => pointer.x - available.left(), + }; + panel.exact_size(width.clamp(*width_range.start(), *width_range.end())) + } else { + panel + }; let normal_style = ui.style().clone(); ui.style_mut().visuals.widgets.hovered.fg_stroke = Stroke::NONE; ui.style_mut().visuals.widgets.active.fg_stroke = Stroke::NONE; diff --git a/crates/app/src/ui/sidebar_tests.rs b/crates/app/src/ui/sidebar_tests.rs index 9a0fd03..cecf952 100644 --- a/crates/app/src/ui/sidebar_tests.rs +++ b/crates/app/src/ui/sidebar_tests.rs @@ -1,6 +1,153 @@ use super::*; use std::cell::Cell; +#[test] +fn sidebar_hides_only_when_released_near_its_window_edge() { + for edge in [SidebarEdge::Left, SidebarEdge::Right] { + // Include the old hide threshold, the exact new boundary, outside the + // window, and returning from the hide zone before releasing. + for (distance, cancel, expected) in [ + (180.0, false, false), + (100.0, false, false), + (25.0, false, false), + (24.0, false, true), + (0.0, false, true), + (-100.0, false, true), + (10.0, true, false), + ] { + let ctx = egui::Context::default(); + let hidden = Cell::new(false); + let window = Rect::from_min_max(Pos2::new(50.0, 30.0), Pos2::new(850.0, 530.0)); + let position = |distance: f32| { + Pos2::new( + match edge { + SidebarEdge::Right => window.left() + distance, + SidebarEdge::Left => window.right() - distance, + }, + 250.0, + ) + }; + let panel_id = Id::new("hide_test"); + let render = |events| { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(window), + events, + ..Default::default() + }, + |ui| { + ui.interact( + Rect::from_center_size(position(180.0), egui::vec2(4.0, 200.0)), + panel_id.with("__resize"), + egui::Sense::drag(), + ); + hidden.set(sidebars::resize_released_at_window_edge( + &ctx, panel_id, edge, + )); + }, + ); + }; + let button = |pos, pressed| egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + render(vec![]); + render(vec![egui::Event::PointerMoved(position(180.0))]); + render(vec![button(position(180.0), true)]); + render(vec![egui::Event::PointerMoved(position(160.0))]); + render(vec![egui::Event::PointerMoved(position(distance))]); + assert!(!hidden.get(), "must not hide while dragging"); + let release = position(if cancel { 100.0 } else { distance }); + if cancel { + render(vec![egui::Event::PointerMoved(release)]); + assert!(!hidden.get()); + } + render(vec![button(release, false)]); + assert_eq!( + hidden.get(), + expected, + "distance={distance}, cancel={cancel}" + ); + render(vec![]); + assert!(!hidden.get(), "release is a one-shot action"); + } + } +} + +#[test] +fn sidebar_drag_past_fixed_edge_stays_at_minimum_and_recovers() { + for edge in [SidebarEdge::Left, SidebarEdge::Right] { + let ctx = egui::Context::default(); + let rect = Cell::new(Rect::NOTHING); + let render = |events| { + let _ = ctx.run_ui( + egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, egui::vec2(600.0, 300.0))), + events, + ..Default::default() + }, + |ui| { + let panel = match edge { + SidebarEdge::Left => egui::Panel::right("drag_sidebar"), + SidebarEdge::Right => egui::Panel::left("drag_sidebar"), + } + .frame(egui::Frame::NONE) + .default_size(150.0) + .size_range(50.0..=250.0); + let response = show_resizable_sidebar( + panel, + ui, + Id::new("drag_sidebar"), + edge, + 50.0..=250.0, + |ui| ui.set_min_size(ui.available_size()), + ); + rect.set(response.response.rect); + paint_sidebar_resize_edge(ui, Id::new("drag_sidebar"), rect.get(), edge, true); + }, + ); + }; + render(vec![]); + let (fixed, direction) = match edge { + SidebarEdge::Left => (rect.get().right(), -1.0), + SidebarEdge::Right => (rect.get().left(), 1.0), + }; + let position = |width: f32| Pos2::new(fixed + direction * width, 150.0); + render(vec![egui::Event::PointerMoved(position(150.0))]); + render(vec![egui::Event::PointerButton { + pos: position(150.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }]); + for (requested, expected) in [ + (100.0, 100.0), + (20.0, 50.0), + (-100.0, 50.0), + (-400.0, 50.0), + (120.0, 120.0), + (400.0, 250.0), + ] { + render(vec![egui::Event::PointerMoved(position(requested))]); + assert!( + (rect.get().width() - expected).abs() < 0.1, + "requested {requested}, expected {expected}, got {}", + rect.get().width() + ); + } + render(vec![egui::Event::PointerButton { + pos: position(400.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }]); + render(vec![]); + assert!((rect.get().width() - 250.0).abs() < 0.1); + } +} + #[test] fn highlight_falls_off_symmetrically_to_zero() { assert_eq!(sidebar_highlight_alpha(0.0), 1.0); @@ -66,10 +213,16 @@ fn cursor_at_edge_offset(edge: SidebarEdge, offset: f32) -> egui::CursorIcon { .frame(egui::Frame::NONE) .default_size(100.0) .size_range(50.0..=200.0); - let response = - show_resizable_sidebar(panel, ui, Id::new("test_sidebar"), edge, |ui| { + let response = show_resizable_sidebar( + panel, + ui, + Id::new("test_sidebar"), + edge, + 50.0..=200.0, + |ui| { ui.set_min_size(ui.available_size()); - }); + }, + ); boundary.set(match edge { SidebarEdge::Left => response.response.rect.left(), SidebarEdge::Right => response.response.rect.right(), diff --git a/crates/app/src/ui/sidebars.rs b/crates/app/src/ui/sidebars.rs index 4313883..38eb8ef 100644 --- a/crates/app/src/ui/sidebars.rs +++ b/crates/app/src/ui/sidebars.rs @@ -1,10 +1,8 @@ use super::*; const MIN_WORKSPACE_WIDTH: f32 = 320.0; -/// Releasing a resize drag with the pointer this far past the sidebar's -/// minimum width hides the sidebar. Wide enough that overshooting a fast -/// resize does not hide it by accident. -const HIDE_DRAG_SLACK: f32 = 60.0; +/// Logical pixels from the window's outer edge, independent of sidebar width. +const HIDE_EDGE_DISTANCE: f32 = 24.0; pub(super) fn render(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_width: f32) { let mut primary_rect = None; @@ -39,7 +37,7 @@ pub(super) fn render(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_widt .clamp(min_width, max_width), ) .size_range(min_width..=max_width); - let response = show_sidebar(panel, app, ui, dark, true); + let response = show_sidebar(panel, app, ui, dark, true, min_width..=max_width); paint_sidebar_resize_edge( ui, Id::new("primary_sidebar"), @@ -49,12 +47,8 @@ pub(super) fn render(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_widt ); app.session.primary_sidebar_width = response.response.rect.width(); primary_rect = Some(response.inner); - if resize_dragged_past_min( - ui.ctx(), - Id::new("primary_sidebar"), - response.inner, - SidebarEdge::Right, - ) { + if resize_released_at_window_edge(ui.ctx(), Id::new("primary_sidebar"), SidebarEdge::Right) + { app.session.primary_sidebar_visible = false; sidebar_hidden_status(app, commands::CommandId::TogglePrimarySidebar, "Left"); } @@ -89,7 +83,7 @@ pub(super) fn render(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_widt .clamp(min_width, max_width), ) .size_range(min_width..=max_width); - let response = show_sidebar(panel, app, ui, dark, false); + let response = show_sidebar(panel, app, ui, dark, false, min_width..=max_width); paint_sidebar_resize_edge( ui, Id::new("secondary_sidebar"), @@ -99,12 +93,8 @@ pub(super) fn render(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_widt ); app.session.secondary_sidebar_width = response.response.rect.width(); secondary_rect = Some(response.inner); - if resize_dragged_past_min( - ui.ctx(), - Id::new("secondary_sidebar"), - response.inner, - SidebarEdge::Left, - ) { + if resize_released_at_window_edge(ui.ctx(), Id::new("secondary_sidebar"), SidebarEdge::Left) + { app.session.secondary_sidebar_visible = false; sidebar_hidden_status(app, commands::CommandId::ToggleSecondarySidebar, "Right"); } @@ -115,20 +105,17 @@ pub(super) fn render(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_widt super::workspace_geometry::set_sidebar_rects(ui.ctx(), primary_rect, secondary_rect); } -/// True when a resize drag on `panel_id` just ended with the pointer well past -/// the sidebar's minimum width — the user pulled the edge "through" the -/// sidebar. egui clamps the panel at its minimum during the drag, so the -/// overshoot is the pointer-to-edge distance on release. -fn resize_dragged_past_min( +/// Preview and commit use the same current pointer position: dragging back +/// from the window edge cancels hiding without any latched state. +pub(super) fn resize_released_at_window_edge( ctx: &egui::Context, panel_id: Id, - card_rect: Rect, edge: SidebarEdge, ) -> bool { let Some(resize) = ctx.read_response(panel_id.with("__resize")) else { return false; }; - if !resize.drag_stopped() { + if !resize.dragged() && !resize.drag_stopped() { return false; } let Some(pointer) = resize @@ -137,10 +124,24 @@ fn resize_dragged_past_min( else { return false; }; - match edge { - SidebarEdge::Right => pointer.x < card_rect.right() - HIDE_DRAG_SLACK, - SidebarEdge::Left => pointer.x > card_rect.left() + HIDE_DRAG_SLACK, + let window = ctx.content_rect(); + let near_edge = match edge { + SidebarEdge::Right => pointer.x <= window.left() + HIDE_EDGE_DISTANCE, + SidebarEdge::Left => pointer.x >= window.right() - HIDE_EDGE_DISTANCE, + }; + if near_edge && resize.dragged() { + // Keep the hint visible even when the pointer is outside the window. + egui::Tooltip::always_open( + ctx.clone(), + resize.layer_id, + panel_id.with("hide_hint"), + window.shrink(12.0).clamp(pointer), + ) + .show(|ui| { + ui.label("Release to hide sidebar"); + }); } + near_edge && resize.drag_stopped() } fn sidebar_hidden_status(app: &mut PlotxApp, id: commands::CommandId, side: &str) { @@ -157,13 +158,14 @@ fn show_sidebar( ui: &mut Ui, dark: bool, primary: bool, + width_range: std::ops::RangeInclusive, ) -> InnerResponse { let (id, edge) = if primary { (Id::new("primary_sidebar"), SidebarEdge::Right) } else { (Id::new("secondary_sidebar"), SidebarEdge::Left) }; - show_resizable_sidebar(panel, ui, id, edge, |ui| { + show_resizable_sidebar(panel, ui, id, edge, width_range, |ui| { // Anchor the content ids globally: a Ui's per-pass unique id folds in // the parent's auto-id counter, so without this every widget in this // sidebar changes id whenever an earlier sibling panel toggles. That diff --git a/docs/src/content/docs/getting-started/quick-tour.md b/docs/src/content/docs/getting-started/quick-tour.md index a45429e..ffa0661 100644 --- a/docs/src/content/docs/getting-started/quick-tour.md +++ b/docs/src/content/docs/getting-started/quick-tour.md @@ -22,8 +22,11 @@ flow from raw data to a finished figure. Hide either side bar to give the canvas more room: click its layout button at the right end of the Ribbon's task row, press Ctrl+B (left) or Ctrl+Shift+B (right; -Cmd on macOS), or drag the side bar's inner edge past its minimum -width. The same commands live in the **View** menu and the **View** Ribbon. +Cmd on macOS), or drag the side bar's inner edge toward the nearest +window edge. When **Release to hide sidebar** appears, release to hide it. +You can make the side bar as narrow as possible without hiding it; just stop +before reaching the window edge. To cancel hiding, drag back before releasing. +The same commands live in the **View** menu and the **View** Ribbon. ## Menus and task Ribbon diff --git a/docs/src/content/docs/reference/ui-overview.md b/docs/src/content/docs/reference/ui-overview.md index b83909c..857efc1 100644 --- a/docs/src/content/docs/reference/ui-overview.md +++ b/docs/src/content/docs/reference/ui-overview.md @@ -45,8 +45,11 @@ introduces the same regions in walkthrough form. Both Side Bars can be shown or hidden at any time: click the pair of layout buttons at the right end of the Ribbon's task row, press Ctrl+B (left) or Ctrl+Shift+B -(right; Cmd on macOS), or drag a Side Bar's inner edge past its -minimum width to hide it. +(right; Cmd on macOS), or drag a Side Bar's inner edge toward the +nearest window edge. When **Release to hide sidebar** appears, release to +hide it. You can make the Side Bar as narrow as possible without hiding it; +just stop before reaching the window edge. To cancel hiding, drag back +before releasing. The Ribbon's show/hide button uses the same window-outline style, with a band at the top. Task cards use a bottom band for their body; a filled band means diff --git a/docs/src/content/docs/zh-cn/getting-started/quick-tour.md b/docs/src/content/docs/zh-cn/getting-started/quick-tour.md index 2374082..0b43bb0 100644 --- a/docs/src/content/docs/zh-cn/getting-started/quick-tour.md +++ b/docs/src/content/docs/zh-cn/getting-started/quick-tour.md @@ -18,7 +18,9 @@ description: 五分钟了解 PlotX 的界面与典型工作流。 隐藏任意一侧侧栏可为画布腾出空间:点击 Ribbon 任务行右端对应的布局按钮,按 Ctrl+B(左)或 Ctrl+Shift+B -(右;macOS 上为 Cmd),或把侧栏内缘拖过其最小宽度。同样的命令也在 +(右;macOS 上为 Cmd),或将侧栏内缘拖向同一侧的窗口边缘,看到 +**Release to hide sidebar**(松开以隐藏侧栏)提示后松手。 +只想把侧栏缩到最窄时,不必一直拖到窗口边缘;如果看到提示后改变主意,松手前拖回即可取消隐藏。同样的命令也在 **View** 菜单和 **View** Ribbon 中。 ## 菜单与任务 Ribbon diff --git a/docs/src/content/docs/zh-cn/reference/ui-overview.md b/docs/src/content/docs/zh-cn/reference/ui-overview.md index a0fa966..5f41646 100644 --- a/docs/src/content/docs/zh-cn/reference/ui-overview.md +++ b/docs/src/content/docs/zh-cn/reference/ui-overview.md @@ -38,7 +38,9 @@ PlotX 的界面为英文;手册中加粗的英文词即界面上的原文标 两个侧栏随时可以显示或隐藏:点击 Ribbon 任务行右端的一对布局按钮,按 Ctrl+B(左)或 Ctrl+Shift+B -(右;macOS 上为 Cmd),或把侧栏内缘拖过其最小宽度即可将其隐藏。 +(右;macOS 上为 Cmd),或将侧栏内缘拖向同一侧的窗口边缘,看到 +**Release to hide sidebar**(松开以隐藏侧栏)提示后松手。 +只想把侧栏缩到最窄时,不必一直拖到窗口边缘;如果看到提示后改变主意,松手前拖回即可取消隐藏。 Ribbon 的显示/隐藏按钮沿用相同的窗口轮廓样式,顶部条带表示命令区。 任务卡片使用底部条带表示内容区;实心条带表示该区域可见。