diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index d9ad998ff..2f9260530 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -6,11 +6,35 @@ use crate::canvas::{DocView, Fit, PageLayout}; use crate::theme::{self, ThemePreference, Tokens}; use crate::{Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, icons, widgets}; +/// Height of the tab strip when it doubles as the macOS title bar (#789). AppKit draws the traffic +/// lights itself, centred in the standard 28 pt title-bar band at the top of the window, and moving +/// them takes AppKit calls the workspace forbids (`unsafe`). So on macOS the strip is that band: +/// the lights sit vertically centred in it, and the controls beside them shrink to fit, as in +/// WordCraft (storytold/wordcraft#255). +pub const MAC_TITLE_BAR: f32 = 28.0; +/// The traffic lights as AppKit lays them out: button diameter, centre-to-centre pitch and the +/// first button's left edge (8–10 pt depending on the macOS version; the larger value keeps the +/// gap after them from shrinking). +const MAC_LIGHT: f32 = 12.0; +const MAC_LIGHT_PITCH: f32 = 20.0; +const MAC_LIGHTS_LEFT: f32 = 10.0; +/// Where the strip's content starts to the right of the traffic lights: the end of the three +/// lights plus the same gap the lights keep from the top of the window. +pub const MAC_CONTENT_LEFT: f32 = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT + (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; +/// The strip and its controls everywhere else. +const TAB_STRIP: f32 = 38.0; +const TAB_HEIGHT: f32 = 30.0; +const STRIP_BUTTON: f32 = 28.0; + pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let left = if cfg!(target_os = "macos") && app.integrated_titlebar { 80 } else { 8 }; + // macOS: the strip is the 28 pt title bar, so the traffic lights are centred in it; tabs and + // buttons shrink to keep a margin above and below. + let mac = cfg!(target_os = "macos") && app.integrated_titlebar; + let left = if mac { MAC_CONTENT_LEFT as i8 } else { 8 }; + let (tab_h, button) = if mac { (MAC_TITLE_BAR - 2.0, 24.0) } else { (TAB_HEIGHT, STRIP_BUTTON) }; egui::Panel::top("tab_strip") - .exact_size(38.0) + .exact_size(if mac { MAC_TITLE_BAR } else { TAB_STRIP }) .frame(egui::Frame::NONE.fill(t.titlebar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); @@ -24,12 +48,12 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; - if icons::button(ui, "house", 28.0, app.active.is_none() && !app.combine_showing(), tl!("Home")).clicked() { + if icons::button(ui, "house", button, app.active.is_none() && !app.combine_showing(), tl!("Home")).clicked() { app.active = None; app.combine_tab.focused = false; } - // Keep the two 28-point buttons, Discord's text/padding and three gaps outside the scrolling tabs. - let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x) + 106.0; + // Keep the two icon buttons, Discord's text/padding and three gaps outside the scrolling tabs. + let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x) + 2.0 * button + 50.0; // Open stays in sight after the tabs: its icon, text and padding, and the gaps around it. let open_width = ui.fonts_mut(|f| f.layout_no_wrap(tl!("Open").into(), theme::medium(13.0), t.text).size().x) + 38.0 + 12.0; let mut tabs_width = (ui.available_width() - controls_width - open_width).max(0.0); @@ -90,6 +114,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { &mut close, i, cap, + tab_h, ); if changed && app.active == Some(i) && !app.combine_showing() { response.scroll_to_me(Some(Align::Center)); @@ -116,6 +141,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { &mut close, usize::MAX, cap, + tab_h, ); if changed && app.combine_showing() { response.scroll_to_me(Some(Align::Center)); @@ -141,7 +167,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } ui.add_space(4.0); let tip = crate::commands::command_tip(ui.ctx(), tl!("Open a PDF ({key})"), "file.open"); - if widgets::ghost_button(ui, "plus", tl!("Open")).on_hover_text(tip).clicked() { + if widgets::ghost_button_sized(ui, "plus", tl!("Open"), tab_h).on_hover_text(tip).clicked() { app.open_dialog(); } ui.with_layout(Layout::right_to_left(Align::Center), |ui| { @@ -151,13 +177,13 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ThemePreference::Dark => ("moon", tl!("Dark gray")), }; let tip = format!("{}: {label}", tl!("Display theme")); - let response = icons::button(ui, icon, 28.0, false, &tip); + let response = icons::button(ui, icon, button, false, &tip); egui::Popup::menu(&response).show(|ui| theme_menu(app, ui)); - if icons::button(ui, "circle-help", 28.0, false, tl!("Keyboard shortcuts")).clicked() { + if icons::button(ui, "circle-help", button, false, tl!("Keyboard shortcuts")).clicked() { app.dialog = Some(Dialog::Shortcuts); } // One click to the community, from anywhere in the app. - if widgets::ghost_button(ui, "messages-square", "Discord").on_hover_text(pdfcraft_engine::links::DISCORD).clicked() { + if widgets::ghost_button_sized(ui, "messages-square", "Discord", tab_h).on_hover_text(pdfcraft_engine::links::DISCORD).clicked() { app.execute("help.discord"); } }); @@ -245,6 +271,7 @@ fn tab( close: &mut Option, index: usize, cap: Option, + height: f32, ) -> egui::Response { let font = theme::regular(13.0); let mut label = tab_name(name); @@ -259,7 +286,7 @@ fn tab( let label = crate::bidi::visual(&label).into_owned(); let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.clone(), font.clone(), t.text).size().x); let width = cap.map_or(text_w + TAB_CHROME, |cap| (text_w + TAB_CHROME).min(cap)); - let (rect, _) = ui.allocate_exact_size(vec2(width, 30.0), Sense::hover()); + let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover()); let a11y = if dirty { format!("{name} (edited)") } else { name.to_string() }; let resp = craft_ui::tabs::Tab::new(id, &a11y, active).focus_stroke(Stroke::new(1.0, t.accent)).show_at(ui, rect, |ui, resp| { let bg = if active { @@ -270,7 +297,13 @@ fn tab( Color32::TRANSPARENT }; ui.painter().rect_filled(rect, CornerRadius { nw: 7, ne: 7, sw: 0, se: 0 }, bg); - icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, 7.0), vec2(16.0, 16.0)), icon, 15.0, if active { t.accent } else { t.text_muted }); + icons::paint( + ui, + Rect::from_min_size(rect.min + vec2(6.0, (height - 16.0) / 2.0), vec2(16.0, 16.0)), + icon, + 15.0, + if active { t.accent } else { t.text_muted }, + ); ui.painter().text(rect.min + vec2(28.0, rect.height() / 2.0), Align2::LEFT_CENTER, label, font, if active { t.text } else { t.text_muted }); }); let x_rect = Rect::from_center_size(rect.right_center() - vec2(16.0, 0.0), vec2(20.0, 20.0)); @@ -592,7 +625,7 @@ fn rail_view_menu(ui: &mut egui::Ui, view: &mut DocView) -> Option<&'static str> #[cfg(test)] mod tab_widths { - use super::tab_cap; + use super::{MAC_CONTENT_LEFT, MAC_LIGHT, MAC_LIGHT_PITCH, MAC_LIGHTS_LEFT, MAC_TITLE_BAR, tab_cap}; /// Tabs that fit keep their own widths. #[test] @@ -628,4 +661,14 @@ mod tab_widths { assert_eq!(tab_cap(&[250.0; 12], 600.0, 4.0, 110.0), Some(110.0)); assert_eq!(tab_cap(&[250.0; 3], 0.0, 4.0, 110.0), Some(110.0)); } + + #[test] + fn the_mac_title_bar_centres_and_clears_the_traffic_lights() { + // The lights are centred in the bar, and content keeps the same gap after them (#789). + let margin = (MAC_TITLE_BAR - MAC_LIGHT) / 2.0; + let lights_end = MAC_LIGHTS_LEFT + 2.0 * MAC_LIGHT_PITCH + MAC_LIGHT; + assert_eq!(MAC_CONTENT_LEFT - lights_end, margin); + // `egui::Margin` is in `i8`. + assert!(MAC_CONTENT_LEFT <= f32::from(i8::MAX)); + } } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 21448bdc8..203427ead 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -42,15 +42,20 @@ pub fn pill_button(ui: &mut egui::Ui, label: &str, primary: bool) -> Response { /// Icon + label, transparent until hovered. pub fn ghost_button(ui: &mut egui::Ui, icon: &str, label: &str) -> Response { + ghost_button_sized(ui, icon, label, 30.0) +} + +/// [`ghost_button`] at a given height (the macOS title bar is shorter than the tab strip elsewhere). +pub fn ghost_button_sized(ui: &mut egui::Ui, icon: &str, label: &str, height: f32) -> Response { let t = Tokens::get(ui.ctx()); let font = theme::medium(13.0); let w = ui.fonts_mut(|f| f.layout_no_wrap(label.to_owned(), font.clone(), t.text).size().x); - let (rect, resp) = ui.allocate_exact_size(vec2(w + 38.0, 30.0), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(w + 38.0, height), Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); if resp.hovered() { ui.painter().rect_filled(rect, CornerRadius::same(6), t.hover); } - icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, 6.0), vec2(18.0, 18.0)), icon, 17.0, t.icon); + icons::paint(ui, Rect::from_min_size(rect.min + vec2(6.0, (height - 18.0) / 2.0), vec2(18.0, 18.0)), icon, 17.0, t.icon); ui.painter().text(rect.left_center() + vec2(30.0, 0.0), Align2::LEFT_CENTER, label, font, t.text); resp } diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index a10ad230f..2a94e78d0 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -225,6 +225,26 @@ fn tabs_shrink_to_fit_then_arrows_step_through_them_and_open_stays_in_sight() { assert!(open.left() > 0.0 && open.right() <= discord.left(), "Open {open:?} stays in sight left of Discord {discord:?}"); } +/// macOS (#789): with the integrated title bar, the strip is the 28 pt band AppKit centres the +/// traffic lights in, and everything in it fits that band and clears the lights. +#[cfg(target_os = "macos")] +#[test] +fn the_integrated_title_bar_is_the_traffic_light_band() { + let h = harness(|app| { + app.integrated_titlebar = true; + app.open_bytes("band.pdf", None, FIXTURE.to_vec()).unwrap(); + }); + let band = 28.0; + for label in ["Home", "band.pdf", "Open", "Discord", "Keyboard shortcuts"] { + let r = h.get_by_label(label).rect(); + assert!(r.top() >= 0.0 && r.bottom() <= band, "{label}: {r:?} inside the {band} pt title bar"); + assert!(r.height() >= 24.0, "{label}: {r:?} stays clickable"); + } + // Content starts after the three lights (the last one ends 62 pt in) with a gap. + let home = h.get_by_label("Home").rect(); + assert!(home.left() >= 62.0 + 4.0, "Home {home:?} clears the traffic lights"); +} + #[test] fn narrow_tab_strips_accept_vertical_wheel_scrolling() { for width in [600.0, 900.0] {