macOS: the tab strip is the 28 pt band the traffic lights are centred in (#789) (#1055)

AppKit centres the window buttons in its standard 28 pt title-bar band and
nothing in winit/eframe moves them (AppKit calls would need unsafe, which the
workspace forbids). The integrated title bar was 38 pt with 30 pt tabs, so the
lights sat 5 pt above its centre. On macOS the strip is now 28 pt, tabs 26 pt,
icon buttons 24 pt, and content starts after the lights plus the gap they keep
from the top (MAC_CONTENT_LEFT), as storytold/wordcraft#255 did for WordCraft.
Other platforms are unchanged. Unit test for the constants, macOS kittest for
the strip's contents staying inside the band.

Co-authored-by: Tristan von Kries <tristan@MacBook-Pro-von-Tristan.local>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Tristan von Kries
2026-10-11 12:54:47 -04:00
committed by GitHub
co-authored by Tristan von Kries Claude Fable 5.1
parent f22b5d424b
commit 10f801e752
3 changed files with 82 additions and 14 deletions
+55 -12
View File
@@ -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<usize>,
index: usize,
cap: Option<f32>,
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));
}
}
+7 -2
View File
@@ -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
}
+20
View File
@@ -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] {