diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index cf61020..c8cfcc9 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -44,7 +44,9 @@ mod workspace_geometry; use data_sheet::*; use diagnostics::*; -use egui::{Color32, Id, InnerResponse, Mesh, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2}; +use egui::{ + Color32, Id, InnerResponse, Mesh, Pos2, Rect, Response, Sense, Stroke, Ui, UiBuilder, Vec2, +}; use export_dialog::*; use plotx_core::actions::Action; use plotx_core::export::{ExportPageScope, ExportScopeKind, ExportSettings}; @@ -162,12 +164,21 @@ pub fn render( ), )) .show_inside(ui, |ui| { - canvas::render_central(app, ui); - tools::render_processing_task(app, ui); - tools::render_region_task(app, ui); - tools::render_curve_fit_task(app, ui); - tools::render_statistics_task(app, ui); - tools::render_craft_task(app, ui); + // Same stable id scope as the sidebars: the central panel's ids + // must not shift when a sidebar toggles (see `show_sidebar`). + ui.scope_builder( + UiBuilder::new() + .id_salt(Id::new("central_workspace_stable_scope")) + .global_scope(true), + |ui| { + canvas::render_central(app, ui); + tools::render_processing_task(app, ui); + tools::render_region_task(app, ui); + tools::render_curve_fit_task(app, ui); + tools::render_statistics_task(app, ui); + tools::render_craft_task(app, ui); + }, + ); }); canvas_settings_window(app, &ctx); diff --git a/crates/app/src/ui/ribbon.rs b/crates/app/src/ui/ribbon.rs index ae7636d..782a012 100644 --- a/crates/app/src/ui/ribbon.rs +++ b/crates/app/src/ui/ribbon.rs @@ -162,57 +162,91 @@ fn render_task_row_contents( } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let auto_collapsed = density == RibbonDensity::Collapsed && app.session.ui.ribbon_expanded; - let full_collapse_label = if auto_collapsed { - format!("{} Ribbon auto-collapsed", icon::CARET_DOWN) - } else if app.session.ui.ribbon_expanded { - format!("{} Collapse ribbon", icon::CARET_UP) - } else { - format!("{} Expand ribbon", icon::CARET_DOWN) - }; - let collapse_label = if compact_controls { - if app.session.ui.ribbon_expanded { - icon::CARET_UP.to_owned() - } else { - icon::CARET_DOWN.to_owned() - } - } else { - full_collapse_label - }; - // The strip next to the task tabs stays quiet: chrome buttons show - // their frame only on hover so they read no heavier than the tabs. - let collapse = ui.add_enabled( - !auto_collapsed, - Button::new(collapse_label).frame_when_inactive(false), - ); - let collapse = if auto_collapsed { - collapse.on_disabled_hover_text( - "The ribbon collapses automatically at this width; use menus or Search commands", - ) - } else { - collapse.on_hover_text("Collapse or expand the ribbon command area") - }; - if collapse.clicked() { - app.session.ui.ribbon_expanded = !app.session.ui.ribbon_expanded; - } - update_button(app, ui, compact_controls); - let palette = commands::describe(app, CommandId::CommandPalette); - let search_label = if compact_controls { - icon::MAGNIFYING_GLASS.to_owned() + // Stable id scope: siblings earlier in this row (the inline project + // title, tab labels) come and go with app state; without this every + // control in the strip changes id when they do, which drops focus + // and trips egui's rect-changed-id debug overlay. + let scope = UiBuilder::new() + .id_salt(egui::Id::new("ribbon_chrome_controls")) + .global_scope(true); + ui.scope_builder(scope, |ui| { + render_chrome_controls(app, clipboard, ui, density, compact_controls) + }); + }); +} + +fn render_chrome_controls( + app: &mut PlotxApp, + clipboard: &mut ClipboardTablePaste, + ui: &mut Ui, + density: RibbonDensity, + compact_controls: bool, +) { + let auto_collapsed = density == RibbonDensity::Collapsed && app.session.ui.ribbon_expanded; + let full_collapse_label = if auto_collapsed { + format!("{} Ribbon auto-collapsed", icon::CARET_DOWN) + } else if app.session.ui.ribbon_expanded { + format!("{} Collapse ribbon", icon::CARET_UP) + } else { + format!("{} Expand ribbon", icon::CARET_DOWN) + }; + let collapse_label = if compact_controls { + if app.session.ui.ribbon_expanded { + icon::CARET_UP.to_owned() } else { - format!("{} Search commands", icon::MAGNIFYING_GLASS) - }; - if ui - .add(Button::new(search_label).frame_when_inactive(false)) - .on_hover_text(format!( - "Search every command ({})", - palette.shortcut.as_deref().unwrap_or("Ctrl+K") - )) - .clicked() - { - commands::execute(CommandId::CommandPalette, app, clipboard, ui.ctx()); + icon::CARET_DOWN.to_owned() } - }); + } else { + full_collapse_label + }; + // The strip next to the task tabs stays quiet: chrome buttons show + // their frame only on hover so they read no heavier than the tabs. + let collapse = ui.add_enabled( + !auto_collapsed, + Button::new(collapse_label).frame_when_inactive(false), + ); + let collapse = if auto_collapsed { + collapse.on_disabled_hover_text( + "The ribbon collapses automatically at this width; use menus or Search commands", + ) + } else { + collapse.on_hover_text("Collapse or expand the ribbon command area") + }; + if collapse.clicked() { + app.session.ui.ribbon_expanded = !app.session.ui.ribbon_expanded; + } + update_button(app, ui, compact_controls); + let palette = commands::describe(app, CommandId::CommandPalette); + let search_label = if compact_controls { + icon::MAGNIFYING_GLASS.to_owned() + } else { + format!("{} Search commands", icon::MAGNIFYING_GLASS) + }; + if ui + .add(Button::new(search_label).frame_when_inactive(false)) + .on_hover_text(format!( + "Search every command ({})", + palette.shortcut.as_deref().unwrap_or("Ctrl+K") + )) + .clicked() + { + commands::execute(CommandId::CommandPalette, app, clipboard, ui.ctx()); + } + ui.separator(); + // Right-to-left layout: added secondary-first so the pair reads + // [left sidebar][right sidebar], mirroring the window. + super::ribbon_chrome::sidebar_toggle_button( + app, + clipboard, + ui, + CommandId::ToggleSecondarySidebar, + ); + super::ribbon_chrome::sidebar_toggle_button( + app, + clipboard, + ui, + CommandId::TogglePrimarySidebar, + ); } fn select_workflow_tab(app: &mut PlotxApp, tab: WorkflowTab) { diff --git a/crates/app/src/ui/ribbon_chrome.rs b/crates/app/src/ui/ribbon_chrome.rs index 353f00a..dddfbb1 100644 --- a/crates/app/src/ui/ribbon_chrome.rs +++ b/crates/app/src/ui/ribbon_chrome.rs @@ -152,7 +152,94 @@ fn controls_width(app: &PlotxApp, ui: &Ui, density: RibbonDensity, compact: bool .filter(|text| !text.is_empty()) .map(|text| text_width(ui, text, TextStyle::Button)) .sum::() - + 2.0 * spacing + // The two sidebar layout toggles, the separator before them, and + // their share of the item spacing. + + 2.0 * SIDEBAR_TOGGLE_WIDTH + + 6.0 + + 5.0 * spacing +} + +/// Fixed width of one sidebar layout toggle; shared with the width estimate +/// in `controls_width` so compaction accounts for the pair. +pub(super) const SIDEBAR_TOGGLE_WIDTH: f32 = 30.0; + +/// Always-visible sidebar toggle for the task row. The glyph mirrors the +/// window layout: the band marks which side the command controls and is +/// filled while that sidebar is visible, so the pair doubles as a live +/// layout indicator. +pub(super) fn sidebar_toggle_button( + app: &mut PlotxApp, + clipboard: &mut super::clipboard_table::ClipboardTablePaste, + ui: &mut Ui, + id: super::commands::CommandId, +) { + use super::commands; + + let command = commands::describe(app, id); + let sidebar_visible = command.checked == Some(true); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(SIDEBAR_TOGGLE_WIDTH, ui.spacing().interact_size.y), + egui::Sense::click(), + ); + if response.clicked() { + commands::execute(id, app, clipboard, ui.ctx()); + } + let visuals = ui.style().interact(&response); + if response.hovered() || response.is_pointer_button_down_on() { + // Match the neighbouring frameless chrome buttons: a quiet fill that + // appears only under the pointer. + ui.painter() + .rect_filled(rect, visuals.corner_radius, visuals.weak_bg_fill); + } + let color = if sidebar_visible { + visuals.text_color() + } else { + ui.visuals().weak_text_color() + }; + paint_sidebar_glyph( + ui, + rect, + id == commands::CommandId::TogglePrimarySidebar, + sidebar_visible, + color, + ); + let tip = match &command.shortcut { + Some(shortcut) => format!("{} ({shortcut})", command.label), + None => command.label.clone(), + }; + response.on_hover_text(tip); +} + +fn paint_sidebar_glyph(ui: &Ui, rect: egui::Rect, left: bool, filled: bool, color: egui::Color32) { + let painter = ui.painter(); + let outer = egui::Rect::from_center_size(rect.center(), egui::vec2(16.0, 12.0)); + painter.rect_stroke( + outer, + 3.0, + egui::Stroke::new(1.2_f32, color), + egui::StrokeKind::Inside, + ); + let band = if left { + egui::Rect::from_min_max( + outer.min + egui::vec2(2.0, 2.0), + egui::pos2(outer.min.x + 7.0, outer.max.y - 2.0), + ) + } else { + egui::Rect::from_min_max( + egui::pos2(outer.max.x - 7.0, outer.min.y + 2.0), + outer.max - egui::vec2(2.0, 2.0), + ) + }; + if filled { + painter.rect_filled(band, 1.5, color); + } else { + painter.rect_stroke( + band, + 1.5, + egui::Stroke::new(1.0_f32, color), + egui::StrokeKind::Inside, + ); + } } fn text_width(ui: &Ui, text: impl Into, fallback: TextStyle) -> f32 { diff --git a/crates/app/src/ui/shortcuts.rs b/crates/app/src/ui/shortcuts.rs index 18dafd7..fc1cbfa 100644 --- a/crates/app/src/ui/shortcuts.rs +++ b/crates/app/src/ui/shortcuts.rs @@ -111,6 +111,13 @@ static BINDINGS: &[CommandBinding] = &[ bound(commands::CommandId::Group, cmd(egui::Key::G)), bound(commands::CommandId::Ungroup, cmd_shift(egui::Key::G)), bound(commands::CommandId::Preferences, cmd(egui::Key::Comma)), + // The VS Code sidebar chords. The layout buttons in the Ribbon task row + // are the discoverable surface; these are the fast path. + bound(commands::CommandId::TogglePrimarySidebar, cmd(egui::Key::B)), + bound( + commands::CommandId::ToggleSecondarySidebar, + cmd_shift(egui::Key::B), + ), CommandBinding { id: commands::CommandId::UiScaleUp, primary: cmd(egui::Key::Plus), diff --git a/crates/app/src/ui/sidebars.rs b/crates/app/src/ui/sidebars.rs index 59cb609..4313883 100644 --- a/crates/app/src/ui/sidebars.rs +++ b/crates/app/src/ui/sidebars.rs @@ -1,6 +1,10 @@ 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; pub(super) fn render(app: &mut PlotxApp, ui: &mut Ui, dark: bool, workspace_width: f32) { let mut primary_rect = None; @@ -45,6 +49,20 @@ 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, + ) { + app.session.primary_sidebar_visible = false; + sidebar_hidden_status(app, commands::CommandId::TogglePrimarySidebar, "Left"); + } + } else { + // A shown panel consumes one auto-id slot from this ui. Burn the same + // slot while hidden so the later siblings' container ids (secondary + // panel, central panel) do not shift when a sidebar toggles. + ui.skip_ahead_auto_ids(1); } if app.session.secondary_sidebar_visible { @@ -81,10 +99,58 @@ 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, + ) { + app.session.secondary_sidebar_visible = false; + sidebar_hidden_status(app, commands::CommandId::ToggleSecondarySidebar, "Right"); + } + } else { + // See the primary branch: keep the sibling auto-id sequence stable. + ui.skip_ahead_auto_ids(1); } 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( + 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() { + return false; + } + let Some(pointer) = resize + .interact_pointer_pos() + .or_else(|| ctx.pointer_interact_pos()) + 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, + } +} + +fn sidebar_hidden_status(app: &mut PlotxApp, id: commands::CommandId, side: &str) { + let recovery = match shortcuts::shortcut_label(id) { + Some(chord) => format!("the layout buttons in the title row or {chord}"), + None => "the layout buttons in the title row".to_owned(), + }; + app.session.status = format!("{side} sidebar hidden. Show it again with {recovery}."); +} + fn show_sidebar( panel: egui::Panel, app: &mut PlotxApp, @@ -98,19 +164,32 @@ fn show_sidebar( (Id::new("secondary_sidebar"), SidebarEdge::Left) }; show_resizable_sidebar(panel, ui, id, edge, |ui| { - let size = ui.available_size(); - let frame = card_frame(dark, egui::Margin::ZERO); - let inset = frame.total_margin().sum(); - frame - .show(ui, |ui| { - ui.set_min_size((size - inset).max(Vec2::ZERO)); - if primary { - primary_sidebar::render(app, ui); - } else { - secondary_sidebar::render(app, ui); - } - }) - .response - .rect + // 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 + // dropped focus mid-edit and tripped egui's rect-changed-id debug + // overlay (one-frame red boxes) on the unmoved sidebar. + ui.scope_builder( + UiBuilder::new() + .id_salt(id.with("stable_scope")) + .global_scope(true), + |ui| { + let size = ui.available_size(); + let frame = card_frame(dark, egui::Margin::ZERO); + let inset = frame.total_margin().sum(); + frame + .show(ui, |ui| { + ui.set_min_size((size - inset).max(Vec2::ZERO)); + if primary { + primary_sidebar::render(app, ui); + } else { + secondary_sidebar::render(app, ui); + } + }) + .response + .rect + }, + ) + .inner }) } diff --git a/docs/src/content/docs/getting-started/quick-tour.md b/docs/src/content/docs/getting-started/quick-tour.md index 81c30f1..be827a9 100644 --- a/docs/src/content/docs/getting-started/quick-tour.md +++ b/docs/src/content/docs/getting-started/quick-tour.md @@ -19,8 +19,11 @@ flow from raw data to a finished figure. tasks: Processing, Regions, Curve Fit, and Statistics. Open two or more and they become tabs on the same card, one page shown at a time. -Hide either side bar from **View** or the **View** Ribbon to give the canvas -more room. +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. ## Menus and task Ribbon diff --git a/docs/src/content/docs/reference/shortcuts.md b/docs/src/content/docs/reference/shortcuts.md index 470caed..7ceb5a0 100644 --- a/docs/src/content/docs/reference/shortcuts.md +++ b/docs/src/content/docs/reference/shortcuts.md @@ -97,6 +97,8 @@ layer you want from the Object inspector. | `Ctrl` + `C` | Copy the single selected page frame (or the active canvas) to the clipboard as bitmap + vector | | `Ctrl` + `Shift` + `V` | Paste a delimited table (comma, tab, or semicolon) from the clipboard as a new data table | | `Ctrl` + `,` | Open Preferences | +| `Ctrl` + `B` | Show or hide the left Side Bar | +| `Ctrl` + `Shift` + `B` | Show or hide the right Side Bar | | `Ctrl` + `K` or `Ctrl` + `Shift` + `P` | Open the [command palette](/reference/command-palette/) | `+` and `-` act on the plot you have selected, or on the active plot when diff --git a/docs/src/content/docs/reference/ui-overview.md b/docs/src/content/docs/reference/ui-overview.md index fe0c5ab..ec3b149 100644 --- a/docs/src/content/docs/reference/ui-overview.md +++ b/docs/src/content/docs/reference/ui-overview.md @@ -35,6 +35,12 @@ introduces the same regions in walkthrough form. - **Status bar** — the bottom strip, showing hints, progress, and selection details. +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. + ## Recurring elements - **Figure panel (Panel)** — a labelled section of a figure that can contain 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 6376e40..53fa23c 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 @@ -16,7 +16,10 @@ description: 五分钟了解 PlotX 的界面与典型工作流。 Curve Fit 与 Statistics。同时打开两个及以上时,它们成为同一张卡片上的标签, 一次显示一页。 -在 **View** 菜单或 **View** Ribbon 中可隐藏任意一侧侧栏,为画布腾出空间。 +隐藏任意一侧侧栏可为画布腾出空间:点击 Ribbon 任务行右端对应的布局按钮,按 +Ctrl+B(左)或 Ctrl+Shift+B +(右;macOS 上为 Cmd),或把侧栏内缘拖过其最小宽度。同样的命令也在 +**View** 菜单和 **View** Ribbon 中。 ## 菜单与任务 Ribbon diff --git a/docs/src/content/docs/zh-cn/reference/shortcuts.md b/docs/src/content/docs/zh-cn/reference/shortcuts.md index d32ef9a..a4a2e65 100644 --- a/docs/src/content/docs/zh-cn/reference/shortcuts.md +++ b/docs/src/content/docs/zh-cn/reference/shortcuts.md @@ -91,6 +91,8 @@ description: 键盘与鼠标快捷操作。 | `Ctrl` + `C` | 把唯一选中的页面图框(未选中时为活动画布)以位图 + 矢量格式复制到剪贴板 | | `Ctrl` + `Shift` + `V` | 把剪贴板中的分隔文本(逗号、制表符或分号)粘贴为新数据表 | | `Ctrl` + `,` | 打开首选项 | +| `Ctrl` + `B` | 显示或隐藏左侧栏 | +| `Ctrl` + `Shift` + `B` | 显示或隐藏右侧栏 | | `Ctrl` + `K` 或 `Ctrl` + `Shift` + `P` | 打开[命令面板](/zh-cn/reference/command-palette/) | `+` 与 `-` 作用于所选的图;未选中时作用于当前活动的图,且仅在该图绘制等高线时 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 48f632e..fc50023 100644 --- a/docs/src/content/docs/zh-cn/reference/ui-overview.md +++ b/docs/src/content/docs/zh-cn/reference/ui-overview.md @@ -30,6 +30,10 @@ PlotX 的界面为英文;手册中加粗的英文词即界面上的原文标 还承载原生窗口按钮和项目名。它是快捷入口:其上的一切也都能在菜单或命令面板中找到。 - **状态栏**——底部条带,显示提示、进度和选择详情。 +两个侧栏随时可以显示或隐藏:点击 Ribbon 任务行右端的一对布局按钮,按 +Ctrl+B(左)或 Ctrl+Shift+B +(右;macOS 上为 Cmd),或把侧栏内缘拖过其最小宽度即可将其隐藏。 + ## 常见元素 - **图版面板(Panel)**——图版中带标签的分区,可以包含图表、图片、文本与形状。