From 55129e85415ec5d14a47d6533ce914b05c4f975f Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Tue, 4 Aug 2026 14:48:37 +0800 Subject: [PATCH] feat(app): streamline Ribbon and unify macOS title bar --- crates/app/Cargo.toml | 4 +- crates/app/src/main.rs | 13 +- crates/app/src/ui/mod.rs | 17 +- crates/app/src/ui/ribbon.rs | 314 +++++++++++------- crates/app/src/ui/ribbon_chrome.rs | 228 +++++++++++++ .../docs/getting-started/quick-tour.md | 9 +- .../src/content/docs/reference/ui-overview.md | 10 +- .../docs/zh-cn/getting-started/quick-tour.md | 6 +- .../docs/zh-cn/reference/ui-overview.md | 8 +- 9 files changed, 448 insertions(+), 161 deletions(-) create mode 100644 crates/app/src/ui/ribbon_chrome.rs diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index baf5c70..2961423 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -34,9 +34,11 @@ rfd.workspace = true serde_json.workspace = true uuid.workspace = true +[target.'cfg(any(windows, target_os = "macos"))'.dependencies] +raw-window-handle.workspace = true + [target.'cfg(windows)'.dependencies] eframe = { workspace = true, features = ["glow"] } -raw-window-handle.workspace = true windows-sys.workspace = true [target.'cfg(not(windows))'.dependencies] diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index a47c307..952fce4 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -130,6 +130,7 @@ impl eframe::App for Shell { let ctx = ui.ctx().clone(); observability::show_pending_crash_dialog(); self.scale.drive(&mut self.app, &ctx, frame); + let ribbon_chrome = ui::current_ribbon_chrome(&ctx, frame); let recovery_blocked = self.pending_recovery.is_some(); #[cfg(target_os = "macos")] if !recovery_blocked { @@ -160,6 +161,7 @@ impl eframe::App for Shell { &mut self.batch_workflow, ui, recovery_blocked, + ribbon_chrome, ); // Apply save completion after every edit-producing poll. This makes the // generation check below cover compute results that arrived this frame. @@ -648,19 +650,12 @@ fn main() -> eframe::Result<()> { } else { DEFAULT_WINDOW_PT }; - #[allow(unused_mut)] - let mut viewport = egui::ViewportBuilder::default() + let viewport = egui::ViewportBuilder::default() .with_inner_size(inner) .with_min_inner_size([720.0, 460.0]) .with_title("PlotX") .with_icon(application_icon()); - // Windows and Linux draw a VS Code style title bar (logo + menus + window - // controls) inside the content area; macOS keeps the native title bar and - // system menu. - #[cfg(not(target_os = "macos"))] - { - viewport = viewport.with_decorations(false); - } + let viewport = ui::configure_ribbon_viewport(viewport); let mut wgpu_options = eframe::egui_wgpu::WgpuConfiguration { desired_maximum_frame_latency: desired_maximum_frame_latency(), ..Default::default() diff --git a/crates/app/src/ui/mod.rs b/crates/app/src/ui/mod.rs index 583d47f..f768ef7 100644 --- a/crates/app/src/ui/mod.rs +++ b/crates/app/src/ui/mod.rs @@ -25,6 +25,7 @@ mod primary_sidebar; pub(crate) mod processing_templates; pub(crate) mod properties; mod ribbon; +mod ribbon_chrome; mod secondary_sidebar; mod settings_dialog; mod shortcuts; @@ -42,6 +43,9 @@ use plotx_core::actions::Action; use plotx_core::export::{ExportPageScope, ExportScopeKind, ExportSettings}; use plotx_core::operation::OperationOutcome; use plotx_core::state::{Interaction, PlotxApp, Selection}; +pub(crate) use ribbon_chrome::{ + RibbonChrome, configure_viewport as configure_ribbon_viewport, current as current_ribbon_chrome, +}; pub(crate) use settings_dialog::apply_chrome_theme; use settings_dialog::{settings_window, sync_chrome_theme}; use shortcuts::*; @@ -53,6 +57,7 @@ pub fn render( batch_workflow: &mut batch_workflow::AutomationUi, ui: &mut Ui, input_blocked: bool, + ribbon_chrome: RibbonChrome, ) { let ctx = ui.ctx().clone(); ctx.send_viewport_cmd(egui::ViewportCommand::Title(project_window_title(app))); @@ -116,18 +121,10 @@ pub fn render( }); egui::Panel::top("ribbon") - .frame(card_frame( - dark, - egui::Margin { - left: 8, - right: 8, - top: 4, - bottom: 4, - }, - )) + .frame(ribbon_chrome::frame(dark)) .show_separator_line(false) .show_inside(ui, |ui| { - ribbon::render(app, clipboard_table_paste, ui); + ribbon::render(app, clipboard_table_paste, ui, ribbon_chrome); }); feedback_banner(app, ui, dark); diff --git a/crates/app/src/ui/ribbon.rs b/crates/app/src/ui/ribbon.rs index 55bd957..85095ef 100644 --- a/crates/app/src/ui/ribbon.rs +++ b/crates/app/src/ui/ribbon.rs @@ -3,7 +3,10 @@ //! idea borrowed from the supplied Office reference. use egui::text::LayoutJob; -use egui::{Align2, Button, Color32, FontId, Label, RichText, TextFormat, Ui, Vec2}; +use egui::{ + Align, Align2, Button, Color32, FontId, Label, Layout, PointerButton, RichText, Sense, + TextFormat, TextWrapMode, Ui, UiBuilder, Vec2, vec2, +}; use egui_phosphor::regular as icon; use plotx_core::actions::ZOrder; use plotx_core::export::ExportFormat; @@ -17,15 +20,23 @@ const AUTO_COLLAPSE_WIDTH: f32 = 760.0; /// command in a group visually equal-sized. const TILE_HEIGHT: f32 = 46.0; const ROW_HEIGHT: f32 = 26.0; +/// The native metric includes a little more bottom breathing room than the +/// tab highlight needs visually; trim it so the highlight has equal margins. +const MACOS_TITLE_ROW_BOTTOM_TRIM: f32 = 2.0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum RibbonDensity { +pub(super) enum RibbonDensity { Collapsed, Compact, Full, } -pub(crate) fn render(app: &mut PlotxApp, clipboard: &mut ClipboardTablePaste, ui: &mut Ui) { +pub(crate) fn render( + app: &mut PlotxApp, + clipboard: &mut ClipboardTablePaste, + ui: &mut Ui, + chrome: super::RibbonChrome, +) { let width = ui.available_width(); // Density is content-aware: measured against the active tab's groups, not a // fixed window-width breakpoint (which UI scaling would silently retune). @@ -36,13 +47,11 @@ pub(crate) fn render(app: &mut PlotxApp, clipboard: &mut ClipboardTablePaste, ui let groups = groups_for_tab(&catalog, app.session.ui.ribbon_tab); density(width, app.session.ui.ribbon_expanded, &groups) }; - task_row(app, clipboard, ui, density); + task_row(app, clipboard, ui, density, chrome); if density != RibbonDensity::Collapsed { ui.separator(); command_row(app, clipboard, ui, density); } - ui.separator(); - context_summary(app, ui); } fn task_row( @@ -50,69 +59,159 @@ fn task_row( clipboard: &mut ClipboardTablePaste, ui: &mut Ui, density: RibbonDensity, + chrome: super::RibbonChrome, ) { - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = if density == RibbonDensity::Full { - 8.0 - } else { - 3.0 - }; - for tab in WorkflowTab::ALL { - let selected = app.session.ui.ribbon_tab == tab; - let response = ui.selectable_label( - selected, - crate::typography::headline(tab.label()), - ); - if response.clicked() { - select_workflow_tab(app, tab); - // Picking a task re-opens a manually collapsed command area; - // width-driven auto-collapse still wins in `density()`. - app.session.ui.ribbon_expanded = true; - } + let Some(traffic_lights) = chrome.macos_traffic_lights else { + // Windows and Linux retain the original Ribbon task-row layout; their + // separate custom title bar owns all window chrome and dragging. + ui.horizontal(|ui| render_task_row_contents(app, clipboard, ui, density, false, None)); + return; + }; + + let row_height = + (traffic_lights.y - MACOS_TITLE_ROW_BOTTOM_TRIM).max(ui.spacing().interact_size.y); + let vertical_spacing = ui.spacing().item_spacing.y; + // The next widget is the rule below the unified title row. Suppress the + // normal inter-widget gap so the rule sits on the row boundary; otherwise + // the selected tab appears high despite being centered. + ui.spacing_mut().item_spacing.y = 0.0; + let (row_rect, _) = + ui.allocate_exact_size(vec2(ui.available_width(), row_height), Sense::hover()); + ui.spacing_mut().item_spacing.y = vertical_spacing; + // Register the background first; interactive children below take + // precedence while every remaining pixel continues to drag the window. + let drag = ui.interact( + row_rect, + ui.id().with("macos_unified_titlebar_drag"), + Sense::click_and_drag(), + ); + if drag.drag_started_by(PointerButton::Primary) { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + + let mut ui = ui.new_child( + UiBuilder::new() + .max_rect(row_rect) + .layout(Layout::left_to_right(Align::Center)), + ); + let leading = traffic_lights.x + 4.0; + ui.add_space(leading); + let compact_controls = super::ribbon_chrome::controls_need_compacting( + app, + &ui, + density, + leading, + row_rect.width(), + ); + let inline_title_width = super::ribbon_chrome::available_title_width( + app, + &ui, + density, + leading, + row_rect.width(), + compact_controls, + ); + render_task_row_contents( + app, + clipboard, + &mut ui, + density, + compact_controls, + inline_title_width, + ); +} + +fn render_task_row_contents( + app: &mut PlotxApp, + clipboard: &mut ClipboardTablePaste, + ui: &mut Ui, + density: RibbonDensity, + compact_controls: bool, + inline_title_width: Option, +) { + ui.spacing_mut().item_spacing.x = if density == RibbonDensity::Full { + 8.0 + } else { + 3.0 + }; + for tab in WorkflowTab::ALL { + let selected = app.session.ui.ribbon_tab == tab; + let response = ui.selectable_label(selected, crate::typography::headline(tab.label())); + if response.clicked() { + select_workflow_tab(app, tab); + // Picking a task re-opens a manually collapsed command area; + // width-driven auto-collapse still wins in `density()`. + app.session.ui.ribbon_expanded = true; } + } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let auto_collapsed = - density == RibbonDensity::Collapsed && app.session.ui.ribbon_expanded; - let 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) - }; - // 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", - ) + if let (Some(title), Some(width)) = ( + super::ribbon_chrome::inline_project_title(app), + inline_title_width, + ) { + ui.separator(); + let title = ui.add_sized( + [width, ui.spacing().interact_size.y], + Label::new(RichText::new(title).color(ui.visuals().weak_text_color())) + .truncate() + .sense(Sense::click_and_drag()), + ); + if title.drag_started_by(PointerButton::Primary) { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + } + + 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 { - 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); - let palette = commands::describe(app, CommandId::CommandPalette); - if ui - .add( - Button::new(format!("{} Search commands", icon::MAGNIFYING_GLASS)) - .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()); + } }); } @@ -158,7 +257,7 @@ fn command_row( let mut used = 0.0; let mut shown = vec![false; groups.len()]; for index in ranked { - let width = group_width(&groups[index].2, density) + 8.0; + let width = group_width(groups[index].0, &groups[index].2, density) + 8.0; if used + width <= budget { shown[index] = true; used += width; @@ -208,7 +307,7 @@ fn ribbon_group( entries: Vec<&CommandDescriptor>, density: RibbonDensity, ) { - let width = group_width(&entries, density); + let width = group_width(title, &entries, density); let tile = tile_width(&entries); ui.allocate_ui_with_layout( Vec2::new( @@ -232,7 +331,10 @@ fn ribbon_group( } }); ui.add_space(1.0); - ui.label(crate::typography::caption(title).color(ui.visuals().weak_text_color())); + ui.add( + Label::new(crate::typography::caption(title).color(ui.visuals().weak_text_color())) + .wrap_mode(TextWrapMode::Extend), + ); }, ); } @@ -246,13 +348,13 @@ fn required_width( ) -> f32 { groups .iter() - .map(|(_, _, entries)| group_width(entries, density) + 8.0) + .map(|(title, _, entries)| group_width(title, entries, density) + 8.0) .sum() } -fn group_width(entries: &[&CommandDescriptor], density: RibbonDensity) -> f32 { +fn group_width(title: &str, entries: &[&CommandDescriptor], density: RibbonDensity) -> f32 { let spacing = 4.0 * entries.len().saturating_sub(1) as f32; - if density == RibbonDensity::Full { + let commands = if density == RibbonDensity::Full { tile_width(entries) * entries.len() as f32 + spacing } else { entries @@ -260,7 +362,8 @@ fn group_width(entries: &[&CommandDescriptor], density: RibbonDensity) -> f32 { .map(|command| button_width(command)) .sum::() + spacing - } + }; + commands.max(title.chars().count() as f32 * 5.8 + 8.0) } /// All tiles in a group share the width of the widest short label, so a group @@ -527,54 +630,6 @@ fn group_order(tab: WorkflowTab, group: &str) -> u8 { } } -fn context_summary(app: &PlotxApp, ui: &mut Ui) { - let task = app.session.ui.ribbon_tab.label(); - let tool = app.session.tool.label(); - ui.horizontal(|ui| { - let summary = active_context(app); - let reserve = 150.0_f32.min(ui.available_width() * 0.4); - ui.add_sized( - [ui.available_width() - reserve, ui.spacing().interact_size.y], - Label::new( - RichText::new(summary) - .small() - .color(ui.visuals().weak_text_color()), - ) - .truncate(), - ); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(RichText::new(format!("{task} / {tool}")).small().strong()); - }); - }); -} - -fn active_context(app: &PlotxApp) -> String { - let Some(ci) = app - .session - .active_canvas - .filter(|&ci| ci < app.doc.canvases.len()) - else { - return "Canvas — no active canvas".to_owned(); - }; - let canvas = &app.doc.canvases[ci]; - let object_id = app - .session - .ui - .selection - .object() - .or_else(|| canvas.active_plot_object_id()); - let object = object_id - .and_then(|id| canvas.object(id)) - .map(|object| object.name.as_str()) - .unwrap_or("No object"); - let data = app - .active_dataset() - .filter(|&di| di < app.doc.datasets.len()) - .map(|di| app.doc.datasets[di].display_name()) - .unwrap_or_else(|| "no data".to_owned()); - format!("{} › {object} · {data}", canvas.name) -} - /// The richest density whose content actually fits `width`: full icon-and-text /// tiles whenever the active tab's groups all fit, otherwise the compact icon /// row (whose own overflow moves whole groups into More). Below the absolute @@ -593,12 +648,15 @@ fn density( } } -fn update_button(app: &mut PlotxApp, ui: &mut Ui) { +fn update_button(app: &mut PlotxApp, ui: &mut Ui, compact: bool) { use plotx_core::update::UpdateStatus; match app.session.updates.status().clone() { UpdateStatus::Downloading { percent, .. } => { - let text = - percent.map_or_else(|| "Updating…".to_owned(), |p| format!("Updating… {p}%")); + let text = if compact { + percent.map_or_else(|| icon::ARROW_CLOCKWISE.to_owned(), |p| format!("{p}%")) + } else { + percent.map_or_else(|| "Updating…".to_owned(), |p| format!("Updating… {p}%")) + }; ui.label( RichText::new(text) .small() @@ -607,7 +665,11 @@ fn update_button(app: &mut PlotxApp, ui: &mut Ui) { } UpdateStatus::Installed { version, .. } if ui - .button(format!("{} Restart to update", icon::ARROW_CLOCKWISE)) + .button(if compact { + icon::ARROW_CLOCKWISE.to_owned() + } else { + format!("{} Restart to update", icon::ARROW_CLOCKWISE) + }) .on_hover_text(format!( "PlotX {version} is installed and ready after restart" )) @@ -648,6 +710,12 @@ mod tests { ); } + #[test] + fn compact_groups_reserve_width_for_single_line_titles() { + assert!(group_width("Guides", &[], RibbonDensity::Compact) > ROW_HEIGHT); + assert!(group_width("Object", &[], RibbonDensity::Compact) > ROW_HEIGHT); + } + #[test] fn figure_tiles_use_short_labels() { let app = PlotxApp::new_with_settings(plotx_core::settings::Settings::default()); diff --git a/crates/app/src/ui/ribbon_chrome.rs b/crates/app/src/ui/ribbon_chrome.rs new file mode 100644 index 0000000..353f00a --- /dev/null +++ b/crates/app/src/ui/ribbon_chrome.rs @@ -0,0 +1,228 @@ +use egui::{TextStyle, TextWrapMode, Ui, Vec2, WidgetText}; +use egui_phosphor::regular as icon; +use plotx_core::state::{PlotxApp, WorkflowTab}; + +use super::ribbon::RibbonDensity; + +const INLINE_TITLE_MAX_WIDTH: f32 = 240.0; +const INLINE_TITLE_MIN_WIDTH: f32 = 72.0; + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct RibbonChrome { + pub(super) macos_traffic_lights: Option, +} + +impl RibbonChrome { + #[cfg(target_os = "macos")] + pub(crate) fn macos(traffic_lights_size: Vec2) -> Self { + Self { + macos_traffic_lights: Some(traffic_lights_size), + } + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn current(ctx: &egui::Context, frame: &eframe::Frame) -> RibbonChrome { + use raw_window_handle::HasWindowHandle; + + let Some(size) = frame + .window_handle() + .ok() + .and_then(|handle| eframe::WindowChromeMetrics::from_window_handle(&handle.as_raw())) + .map(|metrics| metrics.traffic_lights_size / ctx.zoom_factor()) + else { + return RibbonChrome::macos(egui::vec2(76.0, 28.0)); + }; + RibbonChrome::macos(size) +} + +#[cfg(not(target_os = "macos"))] +pub(crate) fn current(_ctx: &egui::Context, _frame: &eframe::Frame) -> RibbonChrome { + RibbonChrome::default() +} + +pub(crate) fn configure_viewport(viewport: egui::ViewportBuilder) -> egui::ViewportBuilder { + // Windows and Linux draw a VS Code style title bar inside the content area; + // macOS keeps the native traffic lights and system menu. + #[cfg(not(target_os = "macos"))] + let viewport = viewport.with_decorations(false); + #[cfg(target_os = "macos")] + let viewport = viewport + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false) + .with_titlebar_buttons_shown(true); + viewport +} + +pub(super) fn inline_project_title(app: &PlotxApp) -> Option { + let project = app + .doc + .project_path + .as_deref() + .and_then(std::path::Path::file_name) + .map(|name| name.to_string_lossy().into_owned()) + .or_else(|| app.session.project_present.then(|| "Untitled".to_owned()))?; + Some(if app.doc.dirty { + format!("* {project}") + } else { + project + }) +} + +pub(super) fn controls_need_compacting( + app: &PlotxApp, + ui: &Ui, + density: RibbonDensity, + leading: f32, + row_width: f32, +) -> bool { + leading + task_tabs_width(ui, density) + controls_width(app, ui, density, false) + 16.0 + > row_width +} + +pub(super) fn available_title_width( + app: &PlotxApp, + ui: &Ui, + density: RibbonDensity, + leading: f32, + row_width: f32, + compact_controls: bool, +) -> Option { + let remaining = row_width + - leading + - task_tabs_width(ui, density) + - controls_width(app, ui, density, compact_controls) + - 24.0; + (remaining >= INLINE_TITLE_MIN_WIDTH).then(|| remaining.min(INLINE_TITLE_MAX_WIDTH)) +} + +fn task_tabs_width(ui: &Ui, density: RibbonDensity) -> f32 { + let spacing = if density == RibbonDensity::Full { + 8.0 + } else { + 3.0 + }; + WorkflowTab::ALL + .iter() + .map(|tab| { + text_width( + ui, + crate::typography::headline(tab.label()), + TextStyle::Button, + ) + }) + .sum::() + + spacing * (WorkflowTab::ALL.len().saturating_sub(1) as f32) +} + +fn controls_width(app: &PlotxApp, ui: &Ui, density: RibbonDensity, compact: bool) -> f32 { + use plotx_core::update::UpdateStatus; + + let collapse = if compact { + icon::CARET_UP.to_owned() + } else if density == RibbonDensity::Collapsed && app.session.ui.ribbon_expanded { + 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 search = if compact { + icon::MAGNIFYING_GLASS.to_owned() + } else { + format!("{} Search commands", icon::MAGNIFYING_GLASS) + }; + let update = match app.session.updates.status() { + UpdateStatus::Downloading { percent, .. } if compact => { + percent.map_or_else(|| icon::ARROW_CLOCKWISE.to_owned(), |p| format!("{p}%")) + } + UpdateStatus::Downloading { percent, .. } => { + percent.map_or_else(|| "Updating…".to_owned(), |p| format!("Updating… {p}%")) + } + UpdateStatus::Installed { .. } if compact => icon::ARROW_CLOCKWISE.to_owned(), + UpdateStatus::Installed { .. } => { + format!("{} Restart to update", icon::ARROW_CLOCKWISE) + } + _ => String::new(), + }; + let spacing = ui.spacing().item_spacing.x; + [collapse, search, update] + .into_iter() + .filter(|text| !text.is_empty()) + .map(|text| text_width(ui, text, TextStyle::Button)) + .sum::() + + 2.0 * spacing +} + +fn text_width(ui: &Ui, text: impl Into, fallback: TextStyle) -> f32 { + text.into() + .into_galley(ui, Some(TextWrapMode::Extend), f32::INFINITY, fallback) + .size() + .x + + 2.0 * ui.spacing().button_padding.x +} + +pub(super) fn frame(dark: bool) -> egui::Frame { + #[cfg(target_os = "macos")] + { + // The task row occupies the native title-bar area, so its surface must + // meet the window edges instead of floating below them as a card. + super::card_frame( + dark, + egui::Margin { + left: 0, + right: 0, + top: 0, + bottom: 4, + }, + ) + .corner_radius(0) + .inner_margin(egui::Margin { + left: 8, + right: 8, + top: 0, + bottom: 8, + }) + .shadow(egui::epaint::Shadow::NONE) + } + #[cfg(not(target_os = "macos"))] + { + super::card_frame( + dark, + egui::Margin { + left: 8, + right: 8, + top: 4, + bottom: 4, + }, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn app() -> PlotxApp { + PlotxApp::new_with_settings(plotx_core::settings::Settings::default()) + } + + #[test] + fn inline_title_only_describes_a_present_project() { + let mut app = app(); + assert_eq!(inline_project_title(&app), None); + + app.session.project_present = true; + assert_eq!(inline_project_title(&app).as_deref(), Some("Untitled")); + + app.doc.dirty = true; + assert_eq!(inline_project_title(&app).as_deref(), Some("* Untitled")); + + app.doc.project_path = Some(std::path::PathBuf::from("/tmp/report.plotx")); + assert_eq!( + inline_project_title(&app).as_deref(), + Some("* report.plotx") + ); + } +} diff --git a/docs/src/content/docs/getting-started/quick-tour.md b/docs/src/content/docs/getting-started/quick-tour.md index 3d95523..81c30f1 100644 --- a/docs/src/content/docs/getting-started/quick-tour.md +++ b/docs/src/content/docs/getting-started/quick-tour.md @@ -28,20 +28,21 @@ On Windows and Linux, the title bar holds the app logo, the **File**, **Edit**, **View**, **Insert**, and **Help** menus, and the window controls in one row. Drag its empty area to move the window, or double-click to maximize. On macOS these commands use the system menu bar, including the standard PlotX -application and Window menus. +application and Window menus. The native traffic-light controls share the top +row with the Ribbon task tabs and project name, leaving more height for the +workspace. **File** keeps an **Open Recent** submenu with the files, folders, and projects you opened or saved most recently; the same entries are listed on the welcome screen while no data is loaded. **Help** contains **User Manual**, which opens this documentation in your browser. -The Ribbon below it is a focused shortcut surface, not a second complete menu. +The Ribbon is a focused shortcut surface, not a second complete menu. Choose **Data**, **Process**, **Analyze**, **Figure**, **Arrange**, or **View** to see grouped frequent commands for that stage. Use **Collapse ribbon** to collapse it to the task tabs. At narrower window widths, whole low-priority groups move into **More**; at the minimum width the command area folds automatically instead of shrinking text or -buttons. The context line below the Ribbon names the active canvas, object, -dataset, task, and tool. +buttons. **Search commands** opens the command palette. Menu items, Ribbon buttons, shortcuts, and palette rows share the same enabled and selected states. diff --git a/docs/src/content/docs/reference/ui-overview.md b/docs/src/content/docs/reference/ui-overview.md index bd87a53..b8b1f76 100644 --- a/docs/src/content/docs/reference/ui-overview.md +++ b/docs/src/content/docs/reference/ui-overview.md @@ -28,12 +28,10 @@ introduces the same regions in walkthrough form. and shows one page at a time. Its Processing page lists one pipeline for a 1D or pseudo-2D dataset and two, **F2 (direct)** then **F1 (indirect)**, for a true 2D spectrum. See [Processing](/guides/processing/). -- **Ribbon** — the command strip under the title bar, organized into task - tabs (**Data**, **Process**, **Analyze**, **Figure**, **Arrange**, - **View**). It is a shortcut surface: everything on it is also in the menus - or the command palette. -- **Context line** — the line below the Ribbon naming the active canvas, - object, dataset, task, and tool. +- **Ribbon** — the command strip organized into task tabs (**Data**, + **Process**, **Analyze**, **Figure**, **Arrange**, **View**). On macOS its + task row also holds the native window controls and project name. It is a + shortcut surface: everything on it is also in the menus or command palette. - **Status bar** — the bottom strip, showing hints, progress, and selection details. 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 09a65fe..6376e40 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 @@ -23,16 +23,16 @@ description: 五分钟了解 PlotX 的界面与典型工作流。 Windows 和 Linux 的标题栏把应用图标、**File**、**Edit**、**View**、**Insert**、 **Help** 菜单和窗口控制按钮合并为一行;拖动空白处可移动窗口,双击可最大化。 macOS 使用系统全局菜单栏,并包含符合平台习惯的 PlotX 应用菜单和 Window 菜单。 +原生红绿灯按钮与 Ribbon 任务页签和项目名共用窗口最上方一行,为工作区留出更多高度。 **File** 菜单中的 **Open Recent** 子菜单列出最近打开或保存过的文件、文件夹和 项目;尚未加载数据时,欢迎页也会显示同一份最近列表。**Help** 菜单中的 **User Manual** 会在浏览器中打开本手册。 -菜单下方的 Ribbon 是高频命令入口,不是完整菜单的重复。选择 **Data**、**Process**、 +Ribbon 是高频命令入口,不是完整菜单的重复。选择 **Data**、**Process**、 **Analyze**、**Figure**、**Arrange** 或 **View** 可查看对应工作阶段的分组命令;使用 **Collapse ribbon** 可折叠为 仅显示任务页签。窗口变窄时,低优先级分组会整体移入 **More**;达到最小宽度时命令区会 -自动折叠,而不会缩小文字或点击目标。Ribbon 下方的上下文摘要显示当前画布、对象、数据集、 -任务和工具。 +自动折叠,而不会缩小文字或点击目标。 点击 **Search commands** 可打开命令面板。菜单、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 85d66a2..d624df9 100644 --- a/docs/src/content/docs/zh-cn/reference/ui-overview.md +++ b/docs/src/content/docs/zh-cn/reference/ui-overview.md @@ -25,11 +25,9 @@ PlotX 的界面为英文;手册中加粗的英文词即界面上的原文标 **Process**、**Regions**、**Fit**、**Stats** 标签,一次显示一页。其 Processing 页对 1D 与伪 2D 数据集显示一条管线,对真 2D 谱显示两条: **F2 (direct)** 与 **F1 (indirect)**。参见[数据处理](/zh-cn/guides/processing/)。 -- **Ribbon**——标题栏下方的命令条,按任务页签组织(**Data**、 - **Process**、**Analyze**、**Figure**、**Arrange**、**View**)。它是 - 快捷入口:其上的一切也都能在菜单或命令面板中找到。 -- **上下文行**——Ribbon 下方的一行,显示当前画布、对象、数据集、任务 - 和工具。 +- **Ribbon**——按任务页签组织的命令条(**Data**、**Process**、 + **Analyze**、**Figure**、**Arrange**、**View**)。在 macOS 上,其任务行 + 还承载原生窗口按钮和项目名。它是快捷入口:其上的一切也都能在菜单或命令面板中找到。 - **状态栏**——底部条带,显示提示、进度和选择详情。 ## 常见元素