Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions crates/app/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,9 +371,25 @@ fn show_resizable_sidebar<R>(
ui: &mut Ui,
panel_id: Id,
edge: SidebarEdge,
width_range: std::ops::RangeInclusive<f32>,
add_contents: impl FnOnce(&mut Ui) -> R,
) -> InnerResponse<R> {
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;
Expand Down
159 changes: 156 additions & 3 deletions crates/app/src/ui/sidebar_tests.rs
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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(),
Expand Down
60 changes: 31 additions & 29 deletions crates/app/src/ui/sidebars.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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"),
Expand All @@ -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");
}
Expand Down Expand Up @@ -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"),
Expand All @@ -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");
}
Expand All @@ -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
Expand All @@ -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) {
Expand All @@ -157,13 +158,14 @@ fn show_sidebar(
ui: &mut Ui,
dark: bool,
primary: bool,
width_range: std::ops::RangeInclusive<f32>,
) -> InnerResponse<Rect> {
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
Expand Down
7 changes: 5 additions & 2 deletions docs/src/content/docs/getting-started/quick-tour.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Ctrl</kbd>+<kbd>B</kbd>
(left) or <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>B</kbd> (right;
<kbd>Cmd</kbd> 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.
<kbd>Cmd</kbd> 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

Expand Down
7 changes: 5 additions & 2 deletions docs/src/content/docs/reference/ui-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<kbd>Ctrl</kbd>+<kbd>B</kbd> (left) or <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>B</kbd>
(right; <kbd>Cmd</kbd> on macOS), or drag a Side Bar's inner edge past its
minimum width to hide it.
(right; <kbd>Cmd</kbd> 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
Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/zh-cn/getting-started/quick-tour.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ description: 五分钟了解 PlotX 的界面与典型工作流。

隐藏任意一侧侧栏可为画布腾出空间:点击 Ribbon 任务行右端对应的布局按钮,按
<kbd>Ctrl</kbd>+<kbd>B</kbd>(左)或 <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>B</kbd>
(右;macOS 上为 <kbd>Cmd</kbd>),或把侧栏内缘拖过其最小宽度。同样的命令也在
(右;macOS 上为 <kbd>Cmd</kbd>),或将侧栏内缘拖向同一侧的窗口边缘,看到
**Release to hide sidebar**(松开以隐藏侧栏)提示后松手。
只想把侧栏缩到最窄时,不必一直拖到窗口边缘;如果看到提示后改变主意,松手前拖回即可取消隐藏。同样的命令也在
**View** 菜单和 **View** Ribbon 中。

## 菜单与任务 Ribbon
Expand Down
4 changes: 3 additions & 1 deletion docs/src/content/docs/zh-cn/reference/ui-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ PlotX 的界面为英文;手册中加粗的英文词即界面上的原文标

两个侧栏随时可以显示或隐藏:点击 Ribbon 任务行右端的一对布局按钮,按
<kbd>Ctrl</kbd>+<kbd>B</kbd>(左)或 <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>B</kbd>
(右;macOS 上为 <kbd>Cmd</kbd>),或把侧栏内缘拖过其最小宽度即可将其隐藏。
(右;macOS 上为 <kbd>Cmd</kbd>),或将侧栏内缘拖向同一侧的窗口边缘,看到
**Release to hide sidebar**(松开以隐藏侧栏)提示后松手。
只想把侧栏缩到最窄时,不必一直拖到窗口边缘;如果看到提示后改变主意,松手前拖回即可取消隐藏。

Ribbon 的显示/隐藏按钮沿用相同的窗口轮廓样式,顶部条带表示命令区。
任务卡片使用底部条带表示内容区;实心条带表示该区域可见。
Expand Down
Loading