diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4bc316a..ee789cf4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -541,8 +541,10 @@ jobs: tools/roosttest/test_notifications.py tools/roosttest/test_provider.py tools/roosttest/test_sidebar_pixels.py + tools/roosttest/test_tab_strip_pixels.py tools/roosttest/test_focus.py tools/roosttest/test_palette.py + tools/roosttest/test_z_typography.py tools/roosttest/test_project_lifecycle.py tools/roosttest/test_sidebar_resize.py tools/roosttest/test_selection.py @@ -584,8 +586,10 @@ jobs: tools/roosttest/test_notifications.py tools/roosttest/test_provider.py tools/roosttest/test_sidebar_pixels.py + tools/roosttest/test_tab_strip_pixels.py tools/roosttest/test_focus.py tools/roosttest/test_palette.py + tools/roosttest/test_z_typography.py tools/roosttest/test_project_lifecycle.py tools/roosttest/test_sidebar_resize.py --roost-target iced --roost-fresh -v @@ -606,8 +610,10 @@ jobs: tools/roosttest/test_notifications.py tools/roosttest/test_provider.py tools/roosttest/test_sidebar_pixels.py + tools/roosttest/test_tab_strip_pixels.py tools/roosttest/test_focus.py tools/roosttest/test_palette.py + tools/roosttest/test_z_typography.py tools/roosttest/test_project_lifecycle.py tools/roosttest/test_sidebar_resize.py tools/roosttest/test_selection.py diff --git a/CLAUDE.md b/CLAUDE.md index a469bc2e..28ac68e0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,7 @@ on the main thread. | swash (vendored patch) | `third_party/swash` via `[patch.crates-io]` | Pristine 0.2.10 pinned, plus a one-line zero-long-metrics guard (issue #292 — debug SIGABRT when iced/cosmic-text shapes such a font). `README.roost.md` has the delta + removal condition. | | notify-rust | iced-side Linux desktop notifications (`crates/roost-iced`) | Target-scoped to Linux; `z-with-tokio` = zbus 5 riding the app's existing tokio runtime. macOS backend deliberately absent — issue #303. | | arboard | iced-side clipboard image read on paste (`crates/roost-iced`) | `image-data` + `wayland-data-control` with X11 fallback; PNG encoding stays on the existing `png` crate. | +| Inter (bundled font) | `third_party/inter` (`include_bytes!` via `roost-iced`) | v4.1 static Regular/Medium/SemiBold, SIL OFL 1.1; iced chrome font only (terminal cells keep the configured monospace); single `chrome_font()` seam so a future config swap is small. `README.roost.md` has provenance + removal condition. | If you need a new dependency, prefer Sendable-safe / pure-Rust / pure-Swift options. cgo via `roost-vt` is permitted because there's diff --git a/Cargo.lock b/Cargo.lock index 8b94aa5c..860b48c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3206,6 +3206,7 @@ dependencies = [ "serde_json", "tempfile", "tracing", + "unicode-segmentation", ] [[package]] diff --git a/crates/roost-iced/src/app.rs b/crates/roost-iced/src/app.rs index 27cf0058..df1574dd 100644 --- a/crates/roost-iced/src/app.rs +++ b/crates/roost-iced/src/app.rs @@ -43,6 +43,7 @@ use roost_ui_model::{ keybind::{self, Accel, AccelMods, KeybindAction}, notification_inbox, palette, provider, rollup::project_rollup, + window_title, }; use roost_url::HoverUrl; use roost_vt::{ @@ -496,6 +497,16 @@ fn drag_width_is_actionable(collapsed: bool, live_width: f32, width: f32) -> boo !collapsed && width != live_width } +/// Closing the sidebar from a drag past the collapse threshold is the one +/// collapse that must *not* commit the live drag: the widths that gesture +/// published all sat at the clamp floor on its way down, so committing would +/// remember 160 where the user had, say, 300. Dropping the drag leaves the +/// persisted width untouched and a reopen restores the pre-drag sidebar. +fn drop_drag_and_collapse(drag_width: &mut Option, workspace: &Workspace) { + *drag_width = None; + workspace.set_sidebar_collapsed(true); +} + /// The core half of every focus change: the two ops the UI owes the /// workspace, in order. `focus_tab`'s error is also the "is this tab still /// there?" guard — a route holding only a `&Workspace` (the notification @@ -1455,10 +1466,9 @@ impl App { }; let panel = container( column![ - text("Delete project?").size(15).font(Font { - weight: font::Weight::Bold, - ..Font::default() - }), + text("Delete project?") + .size(15) + .font(chrome::chrome_font(font::Weight::Semibold)), text(format!( "“{}” and all of its tabs will be deleted. This cannot be undone.", confirm.name @@ -1530,10 +1540,11 @@ impl App { .iter() .map(|tab| agent::effective_lifecycle(&tab.agent_state())), ); + let notifying = project.tabs.iter().any(|tab| tab.has_notification); let stripe = container( iced::widget::Space::new() .width(chrome::PROJECT_STRIPE_WIDTH) - .height(chrome::ROW_HEIGHT), + .height(chrome::ROW_HEIGHT - 2.0 * chrome::PROJECT_STRIPE_INSET_Y), ) .style(move |_| { let color = if rollup == roost_ipc::agent::AgentLifecycle::Inactive { @@ -1552,26 +1563,66 @@ impl App { .on_input(Message::RenameDraftChanged) .on_submit(Message::RenameSubmit) .size(13) + // Absolute line height so the editor's total height is + // integral: the default relative line height makes it + // ~18.9px, and centering that inside the pill puts the + // 1px focus border on half-pixels — tiny-skia at scale 1 + // then blends the border away (fuzzy on screen, and the + // real-input harness counts exact border pixels). + .line_height(iced::widget::text::LineHeight::Absolute(18.0.into())) .padding([1, 3]) .style(chrome::inline_rename_input) .into() } - _ => text(&project.name).size(13).into(), + // Label leading, notification-dot slot trailing: the spacer + // holds that slot open against the pill's trailing inset + // (`PROJECT_DOT_INSET`, matched by the pill container's own + // right padding — chrome::badge lands 8px from the pill's + // right edge for free). The rename editor keeps the whole + // pill instead — a fill spacer beside it would halve the + // field, and no dot renders beside the editor. + _ => { + let mut label_row = row![ + text(&project.name).size(13), + iced::widget::Space::new().width(Fill) + ] + .align_y(Alignment::Center); + if notifying { + label_row = label_row.push( + container( + iced::widget::Space::new() + .width(chrome::NOTIFICATION_DOT_SIZE) + .height(chrome::NOTIFICATION_DOT_SIZE), + ) + .style(chrome::badge), + ); + } + label_row.width(Fill).into() + } }; let project_pill = container(project_label) .width(Fill) - .height(chrome::ROW_HEIGHT) - .padding([3.0, chrome::PROJECT_LABEL_INSET]) + .center_y(chrome::ROW_HEIGHT - 2.0 * chrome::PROJECT_PILL_INSET_Y) + .padding(iced::Padding { + top: 0.0, + right: chrome::PROJECT_DOT_INSET, + bottom: 0.0, + left: chrome::PROJECT_LABEL_INSET, + }) .style(chrome::project_pill( project.id == active_project, dragged_project == Some(project.id), )); + // The rail sits at the row's leading edge, inside the pill's own + // 6px inset — the two never overlap, so a plain row places both + // without a stack layer between the strip and its rows. let project_row = container( row![ stripe, - iced::widget::Space::new().width(chrome::PROJECT_STRIPE_GAP), + iced::widget::Space::new() + .width(chrome::PROJECT_PILL_INSET_X - chrome::PROJECT_STRIPE_WIDTH), project_pill, - iced::widget::Space::new().width(chrome::PROJECT_RIGHT_INSET) + iced::widget::Space::new().width(chrome::PROJECT_PILL_INSET_X) ] .align_y(Alignment::Center), ) @@ -1613,23 +1664,28 @@ impl App { } sidebar_body = sidebar_body.push(project_group); } - let sidebar_header = container(text("PROJECTS").size(11).color(chrome::MUTED_TEXT)) - .height(chrome::BAND_HEIGHT) - .width(Fill) - .padding([10, 12]) - .style(chrome::surface); + let sidebar_header = container( + text("PROJECTS") + .size(11) + .color(chrome::MUTED_TEXT) + .font(chrome::chrome_font(font::Weight::Semibold)), + ) + .center_y(chrome::BAND_HEIGHT) + .width(Fill) + .padding([0, 12]) + .style(chrome::band); let sidebar_footer = container( - button(text("+ New Project").size(11)) + button(text("+ New Project").size(13)) .height(chrome::PILL_HEIGHT) - .padding([4, 12]) + .padding([3, 12]) .style(chrome::footer_chip_button) .on_press(Message::NewProject), ) .height(chrome::BAND_HEIGHT) .width(Fill) .center_x(Fill) - .padding([5, 8]) - .style(chrome::surface); + .padding([chrome::BAND_PILL_PADDING_Y, 8.0]) + .style(chrome::band); // The strip delegates layout to its content, so its layout node is the // column's: one child per project group, which is what the gesture's // hit-testing and target index walk. @@ -1639,14 +1695,22 @@ impl App { self.project_strip_generation, self.strip_gestures_enabled(), ); + // The hairline lives inside the sidebar's own width — the outer + // container paints it and pads the three region fills off it — so the + // terminal grid keeps every pixel `sidebar_width` leaves it and the + // resize grip's seam still lands on the sidebar's right edge. let sidebar = container(column![ sidebar_header, - scrollable(project_strip).height(Fill), + container(scrollable(project_strip).height(Fill)) + .width(Fill) + .height(Fill) + .style(chrome::list), sidebar_footer ]) .width(self.live_sidebar_width()) .height(Fill) - .style(chrome::surface); + .padding(iced::Padding::default().right(chrome::DIVIDER_WIDTH)) + .style(chrome::divider); let active_project_model = self .projects @@ -1707,6 +1771,9 @@ impl App { .on_submit(Message::RenameSubmit) .width(140) .size(12) + // Same integral-height rationale as the project + // rename editor above. + .line_height(iced::widget::text::LineHeight::Absolute(18.0.into(),)) .padding([1, 3]) .style(chrome::inline_rename_input) ] @@ -1720,11 +1787,18 @@ impl App { container( row![ dot, - text(title).size(12).color(if active { - chrome::TEXT - } else { - chrome::MUTED_TEXT - }) + text(title) + .size(12) + .color(if active { + chrome::TEXT + } else { + chrome::MUTED_TEXT + }) + .font(chrome::chrome_font(if active { + font::Weight::Medium + } else { + font::Weight::Normal + })) ] .spacing(6) .align_y(Alignment::Center), @@ -1773,62 +1847,36 @@ impl App { self.tab_strip_generation, self.strip_gestures_enabled(), ); + let add_tab_button = button(text("+").size(16).color(chrome::MUTED_TEXT)) + .width(chrome::PILL_HEIGHT) + .height(chrome::PILL_HEIGHT) + .padding(1) + .style(chrome::transparent_button) + .on_press(Message::NewTab); + // The `+` is a sibling of the strip — never inside its content, since + // the strip walks its own layout children for reorder hit-testing and + // an extra child there would corrupt the drag target index. As a + // sibling row inside the scrollable it hugs the last pill and scrolls + // with overflow (Mac parity: the Mac's trailing + scrolls with the + // strip too; under overflow it scrolls offscreen — accepted, #281). + let tab_strip_row = row![tab_strip, add_tab_button] + .spacing(6) + .align_y(Alignment::Center); // A zero-width scrollbar: any visible indicator overlays the 24px // pills themselves and reads as a band across the tab row (#281) — // the stock 10px filled rail, and even a 2px hover sliver, both did. // Wheel/trackpad scrolling is independent of the scrollbar's size. - let tab_scroller = scrollable(tab_strip) + let tab_scroller = scrollable(tab_strip_row) .direction(scrollable::Direction::Horizontal( scrollable::Scrollbar::hidden(), )) .width(Fill) .height(chrome::PILL_HEIGHT); - let mut tabs = row![].spacing(5).align_y(Alignment::Center); - if collapsed { - tabs = tabs.push( - button(text("☰").size(13)) - .width(chrome::PILL_HEIGHT) - .height(chrome::PILL_HEIGHT) - .padding(2) - .style(chrome::transparent_button) - .on_press(Message::ToggleSidebar), - ); - } - tabs = tabs.push(tab_scroller).push( - button(text("+").size(15)) - .width(chrome::PILL_HEIGHT) - .height(chrome::PILL_HEIGHT) - .padding(1) - .style(chrome::transparent_button) - .on_press(Message::NewTab), - ); - let notification_count = self.notification_inbox.count(); - let notification_label = if notification_count == 0 { - "○".to_string() - } else { - format!("•{}", notification_count.min(99)) - }; - tabs = tabs.push( - button( - text(notification_label) - .size(11) - .color(if notification_count == 0 { - chrome::MUTED_TEXT - } else { - chrome::NOTIFICATION - }), - ) - .width(chrome::PILL_HEIGHT) - .height(chrome::PILL_HEIGHT) - .padding(2) - .style(chrome::transparent_button) - .on_press(Message::OpenNotifications), - ); - let tab_bar = container(tabs) + let tab_bar = container(tab_scroller) .height(chrome::BAND_HEIGHT) .width(Fill) - .padding([5, 8]) - .style(chrome::dark_surface); + .padding([chrome::BAND_PILL_PADDING_Y, 8.0]) + .style(chrome::band); let terminal: Element<'_, Message> = match self.tabs.get(&active_tab) { Some(tab) if tab.applied_metrics.is_some() => TerminalWidget { @@ -1918,10 +1966,7 @@ impl App { container( text(project) .size(14) - .font(Font { - weight: font::Weight::Bold, - ..Font::default() - }) + .font(chrome::chrome_font(font::Weight::Semibold)) .color(primary_color) .wrapping(iced::widget::text::Wrapping::None) ) @@ -1961,10 +2006,7 @@ impl App { }, ); if run.matched && actionable { - span = span.font(Font { - weight: font::Weight::Semibold, - ..Font::default() - }); + span = span.font(chrome::chrome_font(font::Weight::Semibold)); } span }) @@ -2069,13 +2111,6 @@ impl App { let _ = self.focus_tab_and_clear(tab_id, true); } - pub fn open_notifications(&mut self) -> UiTask { - if let Err(error) = self.open_palette("notifications") { - self.set_status(error); - } - self.take_palette_focus_task() - } - pub fn toggle_sidebar(&mut self) { self.cancel_drags(); self.cancel_editor_for_interaction(); @@ -2098,6 +2133,20 @@ impl App { self.commit_sidebar_drag(); } + /// The grip dragged the seam past the collapse threshold. Same discipline + /// as `toggle_sidebar` — the sidebar leaves the tree, so anything anchored + /// in it is stranded — but the drag itself is dropped, not committed, and + /// dropped *first*: both `cancel_drags` and `set_sidebar_collapsed` commit + /// a live drag, and committing here would pin the remembered width at the + /// clamp floor the gesture last published instead of the width the sidebar + /// had before it started. + pub fn sidebar_drag_collapsed(&mut self) { + drop_drag_and_collapse(&mut self.sidebar_drag_width, &self.workspace); + self.cancel_drags(); + self.cancel_editor_for_interaction(); + self.resize(self.window_size); + } + fn commit_sidebar_drag(&mut self) { if let Some(width) = self.sidebar_drag_width.take() { self.workspace.set_sidebar_width(f64::from(width)); @@ -2109,6 +2158,9 @@ impl App { // still live never publishes its end. Commit first so the width the // session shows is the width a relaunch restores. Expanding keeps the // grip, so a drag in flight there is still the widget's to finish. + // The one collapse that does not come through here is the grip's own + // drag-to-collapse (`sidebar_drag_collapsed`), which drops that drag + // rather than committing a width the user dragged away from. if collapsed { self.commit_sidebar_drag(); } @@ -2323,6 +2375,31 @@ impl App { Ok(()) } + /// The window title, recomposed from live state on every update batch + /// (iced's `window::State::synchronize` re-calls the title fn and only + /// touches the OS window when the string changed). + /// + /// Mirrors the Mac's priority: the active tab's cwd — which OSC 7 keeps + /// current through `Workspace::set_tab_cwd` — falling back to the + /// project's static cwd before a tab has reported one. The native + /// foreground-process lookup `launch_cwd` uses is deliberately not + /// consulted here: this runs every batch, and the Mac subtitle tracks + /// OSC 7 only. + pub fn window_title(&self, home: &str) -> String { + let (project_id, tab_id) = self.workspace.active(); + let Some(project) = self.projects.iter().find(|p| p.id == project_id) else { + return window_title::DEFAULT_WINDOW_TITLE.to_string(); + }; + let cwd = project + .tabs + .iter() + .find(|tab| tab.id == tab_id) + .map(|tab| tab.cwd.as_str()) + .filter(|cwd| !cwd.is_empty()) + .unwrap_or(project.cwd.as_str()); + window_title::window_title(&project.name, cwd, home) + } + fn launch_cwd(&self, project_id: i64) -> String { let active_tab = self.workspace.active().1; if let Some(native) = self.supervisor.foreground_cwd(active_tab) { @@ -2474,10 +2551,8 @@ impl Message { Self::CloseTab(tab_id) => return app.close_tab(tab_id), Self::NewTab => return app.new_tab(), Self::NewProject => return app.new_project(), - Self::ToggleSidebar => app.toggle_sidebar(), Self::ConfirmDeleteCancel => app.cancel_confirm_delete(), Self::ConfirmDeleteConfirm => return app.execute_confirmed_delete(), - Self::OpenNotifications => return app.open_notifications(), _ => {} } UiTask::None @@ -2513,6 +2588,37 @@ mod tests { assert!(!drag_width_is_actionable(false, 320.0, 320.0)); } + #[test] + fn a_drag_collapse_drops_the_live_width_so_a_reopen_restores_the_pre_drag_one() { + let workspace = Workspace::new(); + workspace.set_sidebar_width(300.0); + + // What the app holds when the grip publishes `Collapse`: the drag's + // last actionable width, pinned at the clamp floor on its way down. + let mut drag_width = Some(160.0); + drop_drag_and_collapse(&mut drag_width, &workspace); + + assert_eq!(drag_width, None, "the live drag is dropped, not committed"); + assert!(workspace.sidebar_collapsed()); + assert_eq!( + workspace.sidebar_width(), + 300.0, + "reopening restores the width the sidebar had before the drag" + ); + assert_eq!( + effective_sidebar_width(workspace.sidebar_collapsed(), 300.0), + 0.0 + ); + + // The contrast that motivates the separate path: committing first — + // what every other collapse does — would remember the floor. + let committing = Workspace::new(); + committing.set_sidebar_width(300.0); + committing.set_sidebar_width(160.0); + committing.set_sidebar_collapsed(true); + assert_eq!(committing.sidebar_width(), 160.0); + } + #[test] fn a_wider_sidebar_leaves_the_terminal_fewer_columns() { let size = Size::new(1100.0, 720.0); diff --git a/crates/roost-iced/src/chrome.rs b/crates/roost-iced/src/chrome.rs index 0a038260..836c30a7 100644 --- a/crates/roost-iced/src/chrome.rs +++ b/crates/roost-iced/src/chrome.rs @@ -1,23 +1,55 @@ use iced::widget::{button, container, scrollable, text_input}; -use iced::{Background, Border, Color, Shadow, Theme, Vector}; +use iced::{Background, Border, Color, Font, Shadow, Theme, Vector}; /// One application-owned band height keeps the sidebar header and tab strip /// on the same seam. Native window decorations remain outside this geometry. -pub const BAND_HEIGHT: f32 = 34.0; -pub const ROW_HEIGHT: f32 = 28.0; +pub const BAND_HEIGHT: f32 = 32.0; +pub const ROW_HEIGHT: f32 = 32.0; pub const PILL_HEIGHT: f32 = 24.0; +/// Vertical padding that centers a `PILL_HEIGHT` pill inside a `BAND_HEIGHT` +/// band — shared by the sidebar footer and the tab strip so both bands stay +/// in sync if either height changes. +pub const BAND_PILL_PADDING_Y: f32 = (BAND_HEIGHT - PILL_HEIGHT) / 2.0; +/// The agent-rollup rail: 3px at the project row's leading edge, gapped 5px +/// top and bottom (Mac `SidebarRowView.drawBackground`, App.swift:5402-5405) +/// so adjacent active projects read as discrete segments rather than one +/// merged bar. It lives entirely inside the selection pill's leading inset, +/// so rail and pill never overlap. pub const PROJECT_STRIPE_WIDTH: f32 = 3.0; -pub const PROJECT_STRIPE_GAP: f32 = 11.0; -pub const PROJECT_LABEL_INSET: f32 = 10.0; -pub const PROJECT_RIGHT_INSET: f32 = 8.0; +pub const PROJECT_STRIPE_INSET_Y: f32 = 5.0; +/// The selection pill is inset from the row's bounds on all four sides +/// (Mac `bounds.insetBy(dx: 6, dy: 1)`, App.swift:5419). +pub const PROJECT_PILL_INSET_X: f32 = 6.0; +pub const PROJECT_PILL_INSET_Y: f32 = 1.0; +/// Label leading inset and notification-dot trailing inset, both measured +/// from the pill's own edges. The label inset is what puts the project name +/// on the same left edge as the agent rows nested under it. +pub const PROJECT_LABEL_INSET: f32 = 18.0; +pub const PROJECT_DOT_INSET: f32 = 8.0; pub const AGENT_DOT_INSET: f32 = 25.0; pub const TAB_STATUS_SIZE: f32 = 7.0; pub const NOTIFICATION_DOT_SIZE: f32 = 8.0; pub const PALETTE_WIDTH: f32 = 660.0; pub const PALETTE_MAX_HEIGHT: f32 = 500.0; -pub const SURFACE: Color = Color::from_rgb8(0x28, 0x28, 0x28); -pub const SURFACE_DARK: Color = Color::from_rgb8(0x21, 0x21, 0x21); +/// The chrome's bundled sans (`third_party/inter/`, loaded via +/// `include_bytes!` in `main.rs`). This is the exact name-table family +/// cosmic-text reports for all three static weights it registers — Regular, +/// Medium, and SemiBold group under one "Inter" family, so a `Weight` alone +/// selects the right instance. +pub const CHROME_FONT_FAMILY: &str = "Inter"; + +/// The one solid chrome band: sidebar header, sidebar footer, tab band. +pub const BAND: Color = Color::from_rgb8(0x24, 0x29, 0x2c); +/// The sidebar's scrollable project list, one step lighter than the bands. +pub const LIST: Color = Color::from_rgb8(0x2d, 0x32, 0x35); +/// Hairline between the sidebar and the terminal, drawn inside the sidebar's +/// own width so the terminal grid keeps every pixel it is sized for. +pub const DIVIDER: Color = Color::from_rgb8(0x1a, 0x1d, 0x1e); +pub const DIVIDER_WIDTH: f32 = 1.0; +pub const FOOTER_CHIP: Color = Color::from_rgb8(0x34, 0x39, 0x3c); +pub const FOOTER_CHIP_HOVER: Color = Color::from_rgb8(0x3e, 0x44, 0x47); +pub const FOOTER_CHIP_PRESSED: Color = Color::from_rgb8(0x2a, 0x2f, 0x32); pub const ACTIVE_BLUE: Color = Color::from_rgb8(0x13, 0x50, 0x9d); pub const ACTIVE_TAB: Color = Color::from_rgb8(0x24, 0x37, 0x51); pub const HOVER: Color = Color::from_rgb8(0x39, 0x39, 0x39); @@ -35,12 +67,24 @@ pub const ERROR_TEXT: Color = Color::from_rgb8(0xee, 0x78, 0x78); pub const DANGER: Color = Color::from_rgb8(0x8a, 0x2a, 0x2a); pub const DANGER_ACCENT: Color = Color::from_rgb8(0xa8, 0x33, 0x33); -pub fn surface(_: &Theme) -> container::Style { - container::Style::default().background(SURFACE) +pub fn chrome_font(weight: iced::font::Weight) -> Font { + Font { + family: iced::font::Family::Name(CHROME_FONT_FAMILY), + weight, + ..Font::default() + } +} + +pub fn band(_: &Theme) -> container::Style { + container::Style::default().background(BAND) } -pub fn dark_surface(_: &Theme) -> container::Style { - container::Style::default().background(SURFACE_DARK) +pub fn list(_: &Theme) -> container::Style { + container::Style::default().background(LIST) +} + +pub fn divider(_: &Theme) -> container::Style { + container::Style::default().background(DIVIDER) } /// Both strips share one drag affordance, so the dragged row and the dragged @@ -75,7 +119,7 @@ pub fn badge(_: &Theme) -> container::Style { } pub fn project_pill(active: bool, dragging: bool) -> impl Fn(&Theme) -> container::Style { - move |_| pill(ACTIVE_BLUE, 5.0, active, dragging) + move |_| pill(ACTIVE_BLUE, 6.0, active, dragging) } pub fn agent_button(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style { @@ -91,10 +135,10 @@ pub fn transparent_button(_: &Theme, status: button::Status) -> button::Style { /// rather than the flat text buttons used elsewhere in the chrome. pub fn footer_chip_button(_: &Theme, status: button::Status) -> button::Style { let background = match status { - button::Status::Hovered => PALETTE_SELECTION, - button::Status::Pressed => ACTIVE_TAB, - button::Status::Active => ACTIVE_AGENT, - button::Status::Disabled => ACTIVE_AGENT.scale_alpha(0.5), + button::Status::Hovered => FOOTER_CHIP_HOVER, + button::Status::Pressed => FOOTER_CHIP_PRESSED, + button::Status::Active => FOOTER_CHIP, + button::Status::Disabled => FOOTER_CHIP.scale_alpha(0.5), }; button::Style { background: Some(Background::Color(background)), @@ -277,6 +321,41 @@ mod tests { ); } + #[test] + fn chrome_regions_split_into_one_band_color_and_one_list_color() { + let theme = Theme::Dark; + assert_eq!(band(&theme).background, Some(Background::Color(BAND))); + assert_eq!(list(&theme).background, Some(Background::Color(LIST))); + assert_eq!(divider(&theme).background, Some(Background::Color(DIVIDER))); + assert_ne!(BAND, LIST, "the list region reads lighter than the bands"); + assert_eq!(DIVIDER_WIDTH, 1.0); + } + + #[test] + fn footer_chip_rests_on_a_filled_bezel_and_dims_when_disabled() { + let theme = Theme::Dark; + assert_eq!( + footer_chip_button(&theme, button::Status::Active).background, + Some(Background::Color(FOOTER_CHIP)) + ); + assert_eq!( + footer_chip_button(&theme, button::Status::Hovered).background, + Some(Background::Color(FOOTER_CHIP_HOVER)) + ); + assert_eq!( + footer_chip_button(&theme, button::Status::Pressed).background, + Some(Background::Color(FOOTER_CHIP_PRESSED)) + ); + assert_eq!( + footer_chip_button(&theme, button::Status::Disabled).background, + Some(Background::Color(FOOTER_CHIP.scale_alpha(0.5))) + ); + assert_eq!( + footer_chip_button(&theme, button::Status::Active).text_color, + TEXT + ); + } + #[test] fn inactive_controls_are_transparent_until_hovered() { let theme = Theme::Dark; @@ -292,12 +371,26 @@ mod tests { #[test] fn project_and_agent_content_share_the_reference_left_edge() { - let project_text = PROJECT_STRIPE_WIDTH + PROJECT_STRIPE_GAP + PROJECT_LABEL_INSET; + let project_text = PROJECT_PILL_INSET_X + PROJECT_LABEL_INSET; assert!((project_text - AGENT_DOT_INSET).abs() <= 1.0); assert_eq!(TAB_STATUS_SIZE, 7.0); assert_eq!(NOTIFICATION_DOT_SIZE, 8.0); } + #[test] + fn row_and_band_metrics_center_their_content() { + assert_eq!(BAND_PILL_PADDING_Y, 4.0, "4px above and below a pill"); + // The pill is inset inside the row, the rail is gapped inside it, and + // the rail stops short of where the pill begins. + assert_eq!(ROW_HEIGHT - 2.0 * PROJECT_PILL_INSET_Y, 30.0); + assert_eq!(ROW_HEIGHT - 2.0 * PROJECT_STRIPE_INSET_Y, 22.0); + assert_eq!( + PROJECT_PILL_INSET_X - PROJECT_STRIPE_WIDTH, + 3.0, + "the rail clears the pill's leading edge" + ); + } + #[test] fn overlay_scrollable_never_fills_a_rail() { let theme = Theme::Dark; diff --git a/crates/roost-iced/src/main.rs b/crates/roost-iced/src/main.rs index 821082d3..1e9d25a8 100644 --- a/crates/roost-iced/src/main.rs +++ b/crates/roost-iced/src/main.rs @@ -14,7 +14,7 @@ mod terminal_widget; mod url_launcher; use std::fs::OpenOptions; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, LazyLock, Mutex}; use std::time::Duration; use anyhow::Context; @@ -31,7 +31,14 @@ use tracing_subscriber::EnvFilter; use crate::app::App; -const WINDOW_TITLE: &str = "Roost — Iced POC"; +/// `$HOME` for the cwd segment of the window title. Read once: the title fn +/// runs on every update batch, and the value cannot change under a live +/// process. +static HOME_DIR: LazyLock = LazyLock::new(|| { + std::env::var_os("HOME") + .map(|home| home.to_string_lossy().into_owned()) + .unwrap_or_default() +}); #[derive(Debug, Clone)] enum Message { @@ -92,12 +99,11 @@ enum Message { ConfirmDeleteCancel, ConfirmDeleteConfirm, ConfirmDeleteCardPressed, - ToggleSidebar, SidebarResizeDragged { width: f32, }, SidebarResizeEnded, - OpenNotifications, + SidebarDragCollapsed, PaletteQueryChanged(String), PaletteActivate(String), PaletteConfirm, @@ -139,18 +145,48 @@ fn main() -> anyhow::Result<()> { }; iced::application(boot, update, view) - .title(WINDOW_TITLE) + .title(title) .theme(theme) .subscription(subscription) - .window(window::Settings { - size: Size::new(1100.0, 720.0), - min_size: Some(Size::new(640.0, 360.0)), - ..window::Settings::default() - }) + .font(include_bytes!("../../../third_party/inter/Inter-Regular.ttf").as_slice()) + .font(include_bytes!("../../../third_party/inter/Inter-Medium.ttf").as_slice()) + .font(include_bytes!("../../../third_party/inter/Inter-SemiBold.ttf").as_slice()) + .default_font(chrome::chrome_font(iced::font::Weight::Normal)) + .window(window_settings()) .run() .context("run Iced application") } +/// macOS `titlebar_transparent` drops the standard titlebar material so the +/// bar takes the window's own background, landing much closer to the Swift +/// app's solid `#24292C` band than the stock dark chrome. `title_hidden` and +/// `fullsize_content_view` stay off deliberately: the latter would slide +/// content under the titlebar, and `servicing.rs` reports +/// `terminal_top = BAND_HEIGHT` (the macOS pixel lane scans those rows), so +/// it is not a window-settings change alone. Linux `PlatformSpecific` is a +/// disjoint struct: `application_id` fills WM_CLASS (X11) / app_id (Wayland), +/// which winit otherwise leaves empty — the dynamic window title made an +/// empty class unfindable for tooling, and the id matches the notification +/// adapter's `desktop-entry` hint so shells group both under one identity. +fn window_settings() -> window::Settings { + window::Settings { + size: Size::new(1100.0, 720.0), + min_size: Some(Size::new(640.0, 360.0)), + #[cfg(target_os = "macos")] + platform_specific: window::settings::PlatformSpecific { + titlebar_transparent: true, + title_hidden: false, + fullsize_content_view: false, + }, + #[cfg(target_os = "linux")] + platform_specific: window::settings::PlatformSpecific { + application_id: "ai.stridelabs.Roost.iced".to_owned(), + ..window::settings::PlatformSpecific::default() + }, + ..window::Settings::default() + } +} + fn update(app: &mut App, message: Message) -> Task { match message { Message::EngineReady => app.service_engine_ready().map_task(), @@ -225,6 +261,10 @@ fn update(app: &mut App, message: Message) -> Task { app.sidebar_resize_ended(); Task::none() } + Message::SidebarDragCollapsed => { + app.sidebar_drag_collapsed(); + Task::none() + } Message::RenameSubmit => app.submit_rename_editor().map_task(), Message::RenamePointerDismiss => { app.rename_pointer_dismiss(); @@ -267,9 +307,7 @@ fn update(app: &mut App, message: Message) -> Task { | Message::NewTab | Message::NewProject | Message::ConfirmDeleteCancel - | Message::ConfirmDeleteConfirm - | Message::ToggleSidebar - | Message::OpenNotifications) => message.apply(app).map_task(), + | Message::ConfirmDeleteConfirm) => message.apply(app).map_task(), } } @@ -386,6 +424,10 @@ fn is_enter_release(event: &keyboard::Event) -> bool { ) } +fn title(app: &App) -> String { + app.window_title(&HOME_DIR) +} + fn theme(_app: &App) -> Theme { Theme::Dark } diff --git a/crates/roost-iced/src/sidebar_resize.rs b/crates/roost-iced/src/sidebar_resize.rs index b05661bc..92ebb2c0 100644 --- a/crates/roost-iced/src/sidebar_resize.rs +++ b/crates/roost-iced/src/sidebar_resize.rs @@ -18,6 +18,10 @@ const GRIP_HALF_WIDTH: f32 = 3.0; const MIN_WIDTH: f32 = SIDEBAR_MIN_WIDTH as f32; const MAX_WIDTH: f32 = SIDEBAR_MAX_WIDTH as f32; +/// Unclamped width at which a drag stops being a resize and becomes a +/// collapse — NSSplitView snaps closed well past its floor rather than at it. +const COLLAPSE_THRESHOLD: f32 = MIN_WIDTH / 2.0; + pub(crate) struct SidebarResizeGrip<'a> { content: Element<'a, Message>, current_width: f32, @@ -46,8 +50,13 @@ struct State { #[derive(Clone, Copy, Debug, PartialEq)] enum GripEvent { - Dragged { width: f32 }, + Dragged { + width: f32, + }, Ended, + /// The pointer went far enough past the floor to close the sidebar. Ends + /// the gesture too — the grip leaves the tree with the sidebar. + Collapse, } /// `capture_event` alone does not stop delegation, so ownership has to be @@ -84,8 +93,15 @@ fn over_seam(layout: Layout<'_>, cursor: mouse::Cursor) -> bool { .is_some_and(|position| over_seam_at(layout, position)) } +/// Where the pointer actually is, before the engine's bounds are applied. +/// The clamped width hides everything below the floor, so the collapse +/// decision has to read this instead. +fn unclamped_width(drag: Drag, x: f32) -> f32 { + drag.start_width + (x - drag.start_x) +} + fn dragged_width(drag: Drag, x: f32) -> f32 { - (drag.start_width + (x - drag.start_x)).clamp(MIN_WIDTH, MAX_WIDTH) + unclamped_width(drag, x).clamp(MIN_WIDTH, MAX_WIDTH) } fn owns_event( @@ -140,6 +156,15 @@ fn owns_event( // the window, where `mouse::Cursor` reports nothing. Event::Mouse(mouse::Event::CursorMoved { position, .. }) => match state.drag { Some(drag) => { + // Ordering is the contract: the collapse is decided before the + // clamped width is published, so no `Dragged { 160 }` can + // follow the close, and the drag is dropped in the same arm so + // no later move in this batch (or the next) publishes a width + // for a sidebar that is already gone. + if unclamped_width(drag, position.x) < COLLAPSE_THRESHOLD { + state.drag = None; + return Ownership::Own(Some(GripEvent::Collapse)); + } // A pointer travelling past a clamp bound recomputes the same // width every move; publishing it would request a redraw per // move for a frame that cannot differ. @@ -171,6 +196,7 @@ fn grip_message(event: GripEvent) -> Message { match event { GripEvent::Dragged { width } => Message::SidebarResizeDragged { width }, GripEvent::Ended => Message::SidebarResizeEnded, + GripEvent::Collapse => Message::SidebarDragCollapsed, } } @@ -411,6 +437,11 @@ mod tests { assert_eq!(dragged_width(drag, 100.0), MIN_WIDTH); assert_eq!(dragged_width(drag, 900.0), MAX_WIDTH); + // The clamp is what hides sub-floor travel; the collapse decision + // reads the raw width instead. + assert_eq!(unclamped_width(drag, 100.0), 100.0); + assert_eq!(unclamped_width(drag, 900.0), 900.0); + // A drag started off-center keeps the grab offset. let offset = Drag { start_x: 222.0, @@ -419,6 +450,69 @@ mod tests { assert_eq!(dragged_width(offset, 262.0), 260.0); } + #[test] + fn a_drag_past_the_collapse_threshold_publishes_collapse_and_ends_the_gesture() { + // start_width 220 at start_x 220 → the pointer's x *is* the unclamped + // width. 79 is the first position below the 80px threshold. + let mut live = dragging(); + assert_eq!( + owns(&mut live, &moved(79.0), 220.0, Some(79.0), MIN_WIDTH), + Ownership::Own(Some(GripEvent::Collapse)) + ); + assert_eq!( + live.drag, None, + "the collapse ends the gesture — the grip leaves the tree with the sidebar" + ); + } + + #[test] + fn a_drag_at_or_above_the_threshold_still_clamps_to_the_floor() { + for x in [80.0, 81.0, 120.0] { + let mut live = dragging(); + assert_eq!( + owns(&mut live, &moved(x), 220.0, Some(x), 220.0), + Ownership::Own(Some(GripEvent::Dragged { width: MIN_WIDTH })), + "an unclamped width of {x} is still a resize, not a collapse" + ); + assert!(live.drag.is_some(), "the gesture stays live at {x}"); + } + } + + #[test] + fn no_width_is_published_after_a_collapse_in_the_same_batch() { + let mut live = dragging(); + assert_eq!( + owns(&mut live, &moved(40.0), 220.0, Some(40.0), MIN_WIDTH), + Ownership::Own(Some(GripEvent::Collapse)) + ); + // Whatever the rest of the batch does — further travel, or a swing + // back over the old seam — the drag is gone, so nothing publishes. + assert_eq!( + owns(&mut live, &moved(20.0), 220.0, Some(20.0), MIN_WIDTH), + Ownership::Delegate + ); + assert_eq!( + owns(&mut live, &moved(300.0), 220.0, Some(300.0), MIN_WIDTH), + Ownership::Delegate + ); + } + + #[test] + fn the_release_left_dangling_by_a_collapse_is_delegated() { + // The real tree drops the grip on collapse, so this release never + // reaches it; in a tree that still has one it must behave like any + // release with no live drag — no `Ended`, content's to handle. + let mut live = dragging(); + assert_eq!( + owns(&mut live, &moved(40.0), 220.0, Some(40.0), MIN_WIDTH), + Ownership::Own(Some(GripEvent::Collapse)) + ); + assert_eq!( + owns(&mut live, &release(), 220.0, Some(40.0), MIN_WIDTH), + Ownership::Delegate + ); + } + #[test] fn seam_press_is_owned_and_a_press_elsewhere_is_delegated() { let mut state = State::default(); @@ -482,7 +576,9 @@ mod tests { owns(&mut live, &moved(220.0), 220.0, Some(220.0), 220.0), Ownership::Own(None) ); - // Past the clamp bound the width stops moving, so neither does the app. + // Between the clamp bound and the collapse threshold the width stops + // moving, so neither does the app. (Below the threshold it is a + // collapse instead — see the drag-past-the-threshold tests.) let mut clamped = State { drag: Some(Drag { start_x: 220.0, @@ -491,7 +587,7 @@ mod tests { ..State::default() }; assert_eq!( - owns(&mut clamped, &moved(100.0), 220.0, Some(100.0), MIN_WIDTH), + owns(&mut clamped, &moved(190.0), 220.0, Some(190.0), MIN_WIDTH), Ownership::Own(None) ); } diff --git a/crates/roost-iced/src/terminal_widget.rs b/crates/roost-iced/src/terminal_widget.rs index a4415796..a7fc8b12 100644 --- a/crates/roost-iced/src/terminal_widget.rs +++ b/crates/roost-iced/src/terminal_widget.rs @@ -14,7 +14,11 @@ use roost_vt::{ColorRgb, CursorInfo, CursorVisualStyle, SelectionSpan}; use std::time::{Duration, Instant}; use unicode_width::UnicodeWidthStr; -pub const TERMINAL_PADDING: f32 = 12.0; +/// The grid is edge-pinned: it starts at the widget's own origin and keeps +/// every pixel the layout gives it (Mac parity — `app.window_metrics` puts +/// the terminal flush under the tab band). Kept as a named seam so the cell +/// math below stays symbolic. +pub const TERMINAL_PADDING: f32 = 0.0; const POINT_TO_LOGICAL_PIXEL: f64 = 96.0 / 72.0; const TERMINAL_LINE_HEIGHT: f32 = 1.2; const MULTI_CLICK_INTERVAL: Duration = Duration::from_millis(500); @@ -879,7 +883,36 @@ mod tests { ), Some((5, 3)) ); - assert_eq!(cell_at(Point::new(1.0, 1.0), 80, 24, metrics()), None); + // Edge-pinned grid: the widget's own origin is cell (0, 0) — there is + // no inset gutter to reject — so only points before the origin or past + // the last cell fall outside. + assert_eq!( + cell_at( + Point::new(TERMINAL_PADDING, TERMINAL_PADDING), + 80, + 24, + metrics() + ), + Some((0, 0)) + ); + assert_eq!( + cell_at( + Point::new(TERMINAL_PADDING - 1.0, TERMINAL_PADDING), + 80, + 24, + metrics() + ), + None + ); + assert_eq!( + cell_at( + Point::new(TERMINAL_PADDING + 80.0 * CELL_WIDTH, TERMINAL_PADDING), + 80, + 24, + metrics() + ), + None + ); assert_eq!( cell_at_clamped(Point::new(-50.0, 9_000.0), 80, 24, metrics()), Some((0, 23)) diff --git a/crates/roost-ui-model/Cargo.toml b/crates/roost-ui-model/Cargo.toml index 77a80a10..d121eb71 100644 --- a/crates/roost-ui-model/Cargo.toml +++ b/crates/roost-ui-model/Cargo.toml @@ -15,6 +15,7 @@ bitflags = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tracing = { workspace = true } +unicode-segmentation = "1" [dev-dependencies] tempfile = "3" diff --git a/crates/roost-ui-model/src/lib.rs b/crates/roost-ui-model/src/lib.rs index 561893e7..ceaa3605 100644 --- a/crates/roost-ui-model/src/lib.rs +++ b/crates/roost-ui-model/src/lib.rs @@ -17,4 +17,5 @@ pub mod rollup; pub mod shell_escape; pub mod theme; pub mod typography; +pub mod window_title; pub mod word_selection; diff --git a/crates/roost-ui-model/src/window_title.rs b/crates/roost-ui-model/src/window_title.rs new file mode 100644 index 00000000..76bfaf07 --- /dev/null +++ b/crates/roost-ui-model/src/window_title.rs @@ -0,0 +1,194 @@ +//! Window-chrome title composition shared by the Rust UI adapters. +//! +//! Ports `mac/Sources/Roost/PathDisplay.swift` and the Mac's +//! `updateWindowTitle` (App.swift) so the iced window title reads the same +//! as the shipped Swift app. The Mac splits the string across +//! `NSWindow.title` + `.subtitle`; toolkits with a single title string +//! join them with a spaced en dash, which is how AppKit renders the pair. +//! +//! Pure functions — the caller supplies `$HOME` so these stay testable and +//! free of process state. GTK's `tilde_abbreviate` is home-collapse-only and +//! could adopt [`abbreviate_path`] later. + +use unicode_segmentation::UnicodeSegmentation; + +/// Fallback title when no project is active or its name is empty. +pub const DEFAULT_WINDOW_TITLE: &str = "Roost"; + +/// Separator between the project name and the abbreviated cwd. Matches the +/// spaced en dash AppKit puts between a window's title and subtitle. +const TITLE_SEPARATOR: &str = " – "; + +/// Grapheme budget for the cwd segment (Mac subtitle budget, App.swift:4219). +pub const TITLE_CWD_MAX_GRAPHEMES: usize = 48; + +/// Collapse a `$HOME` prefix to `~` and left-truncate with a leading `…` +/// when the result exceeds `max` grapheme clusters. +/// +/// Trailing path segments are what users recognize at a glance, so the tail +/// is kept. Counts extended grapheme clusters — the same unit as Swift's +/// `Character` — so emoji, flags, and combining marks are never sliced. +/// +/// `max == 0` renders zero characters rather than panicking; the function is +/// exported for testing and the truncation branch below would otherwise +/// underflow. +pub fn abbreviate_path(path: &str, home: &str, max: usize) -> String { + if max == 0 { + return String::new(); + } + let collapsed = collapse_home(path, home); + let count = collapsed.graphemes(true).count(); + if count <= max { + return collapsed; + } + let tail: String = collapsed + .graphemes(true) + .skip(count - (max - 1)) + .collect::(); + format!("…{tail}") +} + +fn collapse_home(path: &str, home: &str) -> String { + if home.is_empty() { + return path.to_string(); + } + if path == home { + return "~".to_string(); + } + if let Some(rest) = path.strip_prefix(home) { + if rest.starts_with('/') { + return format!("~{rest}"); + } + } + path.to_string() +} + +/// Compose the window title from the active project's name and the cwd that +/// should be shown beside it. +/// +/// Mirrors the Mac: the project name (or `Roost` when there is no active +/// project, or its name is empty) plus the home-collapsed, ≤48-grapheme cwd. +/// An empty `cwd` drops the separator segment entirely. The caller decides +/// which cwd wins — the active tab's live OSC 7 cwd, falling back to the +/// project's static cwd. +/// +/// Emptiness follows the Swift check exactly (no trimming), so a +/// whitespace-only project name renders as-is rather than falling back. +pub fn window_title(project_name: &str, cwd: &str, home: &str) -> String { + let name = if project_name.is_empty() { + DEFAULT_WINDOW_TITLE + } else { + project_name + }; + if cwd.is_empty() { + return name.to_string(); + } + let shown = abbreviate_path(cwd, home, TITLE_CWD_MAX_GRAPHEMES); + if shown.is_empty() { + return name.to_string(); + } + format!("{name}{TITLE_SEPARATOR}{shown}") +} + +#[cfg(test)] +mod tests { + use super::*; + + const HOME: &str = "/Users/me"; + + #[test] + fn collapses_home_prefix() { + assert_eq!( + abbreviate_path("/Users/me/projects/roost", HOME, 48), + "~/projects/roost" + ); + } + + #[test] + fn collapses_bare_home() { + assert_eq!(abbreviate_path(HOME, HOME, 48), "~"); + } + + #[test] + fn does_not_collapse_a_sibling_of_home() { + assert_eq!( + abbreviate_path("/Users/melissa/src", HOME, 48), + "/Users/melissa/src" + ); + } + + #[test] + fn leaves_a_path_outside_home_alone() { + assert_eq!(abbreviate_path("/etc/nginx", HOME, 48), "/etc/nginx"); + } + + #[test] + fn empty_home_disables_collapsing() { + assert_eq!(abbreviate_path("/Users/me/x", "", 48), "/Users/me/x"); + } + + #[test] + fn exactly_max_graphemes_is_not_truncated() { + let path = format!("/{}", "a".repeat(47)); + assert_eq!(path.graphemes(true).count(), 48); + assert_eq!(abbreviate_path(&path, HOME, 48), path); + } + + #[test] + fn one_over_max_truncates_to_max() { + let path = format!("/{}", "a".repeat(48)); + let out = abbreviate_path(&path, HOME, 48); + assert_eq!(out.graphemes(true).count(), 48); + assert!(out.starts_with('…')); + assert!(out.ends_with(&"a".repeat(47))); + } + + /// The truncation unit is the extended grapheme cluster, not the scalar: + /// a ZWJ family emoji and a flag each count as one and are never split. + #[test] + fn truncation_counts_grapheme_clusters() { + let family = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}"; + let flag = "\u{1F1FA}\u{1F1F8}"; + let path = format!("/{}{family}{flag}", "b".repeat(50)); + let out = abbreviate_path(&path, HOME, 48); + assert_eq!(out.graphemes(true).count(), 48); + assert!(out.starts_with('…')); + assert!(out.ends_with(&format!("{family}{flag}"))); + // 47 kept clusters: the family + the flag + 45 'b's. + assert!(out.ends_with(&format!("{}{family}{flag}", "b".repeat(45)))); + } + + #[test] + fn zero_max_renders_nothing() { + assert_eq!(abbreviate_path("/Users/me/x", HOME, 0), ""); + } + + #[test] + fn title_joins_project_and_cwd() { + assert_eq!( + window_title("Untitled 2", "/Users/me/projects", HOME), + "Untitled 2 – ~/projects" + ); + } + + #[test] + fn title_without_cwd_drops_the_separator() { + assert_eq!(window_title("roost", "", HOME), "roost"); + } + + #[test] + fn empty_project_name_falls_back_to_roost() { + assert_eq!(window_title("", "", HOME), "Roost"); + assert_eq!(window_title("", "/tmp", HOME), "Roost – /tmp"); + } + + #[test] + fn title_abbreviates_a_long_cwd() { + let deep = format!("{HOME}/{}", "segment/".repeat(12)); + let title = window_title("roost", &deep, HOME); + let (name, shown) = title.split_once(TITLE_SEPARATOR).expect("separator"); + assert_eq!(name, "roost"); + assert_eq!(shown.graphemes(true).count(), TITLE_CWD_MAX_GRAPHEMES); + assert!(shown.starts_with('…')); + } +} diff --git a/docs/development/iced-migration-roadmap.md b/docs/development/iced-migration-roadmap.md index 9f3175d5..555674db 100644 --- a/docs/development/iced-migration-roadmap.md +++ b/docs/development/iced-migration-roadmap.md @@ -153,28 +153,53 @@ Slices, each sized for one gauntlet pass: are async engine ops with op-id-guarded rename/reorder state machines and op-id-keyed deferred `palette.activate` replies; idle CPU measured ~73% → 0.0% in the shed. -* **3e. Polish parity:** notification bell/badge, hover-close, offscreen-tab - reveal, empty/loading/error states, cursor/selection/link pixel geometry — - per the P1 rows in the [parity inventory](iced-parity-inventory.md). - Also sidebar/chrome visual polish (subtle color differences, separator - lines, the footer chip's exact bezel, and a spike on the Mac app's - frosted/translucent look — likely `window-vibrancy` over a transparent - iced window on macOS; compositor-dependent on Linux). **Plan this slice - with the user in the loop — they want to give detailed direction here.** - The tab-strip artifact bug ([#281]) that was folded in here is **fixed** - (PR #291, merged 2026-08-04): the strip scrollbar is `Scrollbar::hidden()` - — live testing rejected both a resting and a hover-revealed sliver, so any - visible indicator over the pills is a regression, and - `tools/roosttest/test_tab_strip_pixels.py` guards it (its allowlist of - wide-run band colors must be updated by any 3e band-background change). - Two 3e items named during that testing: the band background under the - tabs should match the Swift look, and tab-band metrics differ (iced - 34px band / 5px above pills vs Swift `tabBarHeight` 32 / centerY ≈4px). - Carried from 3b (plan 010): designing the empty state after the last - project is deleted — iced lands in the engine's empty workspace state - while both shipped UIs close their window — and tightening the confirm - overlay's pointer modality (it blocks presses but, like the palette, - passes wheel/hover through) are 3e items. +* **3e. Polish parity batch 1 (complete — plan 016).** Shipped: Inter + bundled as the chrome font (`third_party/inter/`, static Regular/ + Medium/SemiBold registered under one cosmic-text family, single + `chrome_font()` seam); the Mac color system (`BAND #24292c` for the + sidebar header/footer + tab band, `LIST #2d3235` for the scrollable + project list, a `DIVIDER` hairline drawn *inside* the sidebar's own + width so `terminal_grid` keeps every pixel at zero inset); metrics + matched to the measured Mac reference (`BAND_HEIGHT` 32, `ROW_HEIGHT` + 32 — the plan's draft value of 24 was corrected to 32 against the + frozen reference mid-implementation; `TERMINAL_PADDING` 0 on all + sides); one unified project-row layout serving both the metrics and + the notification dots (a 3px rollup rail at the row's leading edge + gapped 5px top/bottom, a selection pill inset 6px horizontal/1px + vertical with radius 6, label aligned with the agent rows' left + edge); the header bell removed in favor of Mac-parity sidebar + notification dots (any project with a notifying tab, active project + included) alongside the existing pill badges — the palette/keybind + remain the inbox entry; the ☰ collapsed-sidebar button removed with + no replacement in-window affordance (Mac parity — reopen via + keybind/palette); the in-strip `+` now scrolls with the pills and + can go offscreen under overflow (Mac-parity accepted behavior — the + Mac's own + scrolls with the strip too); drag-to-collapse below + half the 160pt floor (80px, unclamped) collapses the sidebar and + reopens at its pre-drag committed width (recorded parity divergence: + NSSplitView lets a drag continue past the floor and re-expand within + the same gesture — here the grip leaves the tree at collapse, so + drag-back-to-reopen within one gesture is impossible); a dynamic + window title (`{project} – {abbreviated cwd}`, pure helper in + `roost-ui-model/src/window_title.rs`) plus a transparent titlebar on + macOS rendering the band color; and the tab-strip pixel guard + updated for the new chrome plus a new `test_z_typography.py`, both + wired into all three ci.yml iced lanes (closing a CI gap — they were + in Makefile `ICED_E2E_TESTS` but no ci.yml list, so the scrollbar/ + band pixel guard never ran on CI for iced before this). Two items + flagged for Charlie's review rather than resolved by the plan: the + divider hairline shipped as `#1a1d1e`, sampled during implementation + rather than matched to the Mac's literal black divider (a + one-constant change if he wants it darker); and the zero + left-terminal-inset against the divider may read cramped (his call, + not reworked speculatively). The tab-strip artifact bug ([#281]) + that was folded into 3e's original scope is **fixed** (PR #291, + merged 2026-08-04): the strip scrollbar is `Scrollbar::hidden()` and + `tools/roosttest/test_tab_strip_pixels.py` guards it. The remainder + of 3e's original scope — hover-close, offscreen-tab reveal, empty/ + loading/error states, cursor/selection/link pixel geometry, and the + frosted/translucent spike — split out as **3h** below (plan 016, + W8); do not read those as still-3e. * **3f. Native desktop notifications (complete — plan 015).** Shipped: a Linux D-Bus adapter via notify-rust 4.18 (`z-with-tokio`: zbus 5 rides the app's existing tokio runtime), a per-OS backend seam (non-Linux @@ -196,13 +221,32 @@ Slices, each sized for one gauntlet pass: `e2e-iced`/`e2e-iced-ci` gate on `WAYLAND_DISPLAY`, documented in `ci.yml`). Exit condition: an iced/winit release that delivers any of these becomes its own adoption slice. +* **3h. Polish parity II (user-directed).** The remainder of 3e's + original scope, split out by plan 016 (W8) once batch 1 (chrome + parity) shipped: typography glyph-baseline comparison; cursor/ + selection/link pixel geometry; empty/loading/error states, including + the empty-workspace state after the last project is deleted (today + iced lands in the engine's empty workspace state while both shipped + UIs close their window instead); confirm-overlay pointer modality + tightening (it blocks presses but, like the palette, passes wheel/ + hover through); hover/focus/disabled state styles; the frosted/ + translucent window-vibrancy spike (likely `window-vibrancy` over a + transparent iced window on macOS, compositor-dependent on Linux — + decision is Charlie's); agent-row height/typography distinctness; + tab status dot/label geometry; the hover-close decision (note: the + shipped Mac shows × only on the active pill with no hover reveal — + App.swift:4747 — so adding hover-close to iced would be a *product* + decision, not a parity port); and offscreen-tab reveal after + programmatic selection. **Plan this slice with the user in the + loop — they want to give detailed direction here,** as with 3e. Slice order is deliberate: 3b closed the honest `Err("… not available in Iced yet")` stubs — the grep now has zero hits — and 3c closed the last functional gap blocking M4; 3d closed the architecture cleanup that everything real-time depends on; 3f is done and -3g is documentation-complete via [#302] — only 3e (polish, user-directed) -remains open on this track. +3g is documentation-complete via [#302]; 3e closed chrome parity batch 1 +(plan 016) — only 3h (polish parity II, user-directed) remains open on +this track. ### Maintenance backlog (filed, not scheduled) diff --git a/docs/development/iced-parity-inventory.md b/docs/development/iced-parity-inventory.md index 3e3e4c24..fbf578f9 100644 --- a/docs/development/iced-parity-inventory.md +++ b/docs/development/iced-parity-inventory.md @@ -11,6 +11,12 @@ desktop integration are toolkit-specific. A difference is acceptable only when this document names the reference, reason, affected platforms, and user-visible impact. +**GTK-divergence note (per Charlie, 2026-08-05):** GTK is expected to be +retired rather than restyled to match the Mac-parity chrome plan 016 +introduced on iced — iced's chrome now leads, GTK does not chase it. +Rows below that name a GTK/iced difference record it as documented +divergence, not as GTK-restyle work to schedule. + ## Baseline method and evidence The first audit used the existing lifecycle sidebar fixture at a requested @@ -124,21 +130,21 @@ product polish, and P2 is an optional native/toolkit refinement. | Area | Reference behavior | Current Iced behavior | Priority | Acceptance evidence | |---|---|---|---:|---| -| Shell hierarchy | Sidebar and tab strip are compact chrome bands around a darker terminal | Closed in chrome slice: compact 34 pt seam and explicit dark surfaces | closed | Same named fixture; focused band-height/background assertions; side-by-side capture | +| Shell hierarchy | Sidebar and tab strip are compact chrome bands around a darker terminal | Closed in chrome slice, band height trued up to 32 pt in plan 016: compact seam and explicit dark surfaces | closed | Same named fixture; focused band-height/background assertions; side-by-side capture | | Sidebar surface | 220 pt default, `#282828` GTK chrome; Swift material resolves near `#3a3a3a`; header reads `PROJECTS` | Closed: 220 pt `#282828` surface and `PROJECTS` header | closed | Metrics remain 220 pt; background sample and header-content assertion | | Project rows | 28 pt compact rows; active project is an inset deep-blue rounded pill; lifecycle rollup is a narrow leading stripe | Closed: compact transparent rows, active `#13509d` pill, shared rollup stripe | closed | Selected/unselected geometry and color assertions; lifecycle stripe fixture; click E2E | | Agent rows | Transparent compact nested rows; only active agent has a faint wash; lifecycle dot, name, status, and time have distinct roles | Shared lifecycle colors/alignment and a gray active wash are implemented; current one-line rows remain taller and typographically less distinct than GTK/AppKit | P1 | Add active-row background and row-height bounds to the existing four-state capture; retain click E2E | | Sidebar footer | Centered compact `+ New Project` action in a separated footer | `Hide Sidebar` full-width action occupies list content; no visible project creation | P0 | Real directory-selection/create path plus capture and functional test | | Sidebar overflow | Project and agent lists scroll vertically without moving the header/footer | Closed: body scrolls independently and final row activates in a constrained real-pointer fixture | closed | Small-window many-row fixture, wheel/drag navigation, final-row activation | -| Sidebar collapse/resize | Both references expose a toolbar toggle. Swift persists a 160–400 pt user width; GTK uses a 160 pt minimum/default 220 pt `GtkPaned` without persisting a 400 pt cap | Resize shipped in slice 3c (plan 011): grip drag with exclusive event ownership, persisted 160–400 pt width, 220 pt default, `sidebar.set_width` test op on all three UIs. Collapse works through button/command but still has no reference-like chrome affordance — a « button was tried and removed by request; the affordance design is a 3e item under the user's direction | P1 | Resize: functional e2e + real-input grip segment (shipped, plan 011). Collapse affordance: capture + click e2e once designed | -| Tab strip | About 24 pt pills in a compact band with 6 pt gaps and horizontal overflow | Closed for active/manual reachability: 24 pt dark pills in a 34 pt band with independent horizontal overflow | closed | Band/pill geometry assertions under both renderers; overflow test | +| Sidebar collapse/resize | Both references expose a toolbar toggle. Swift persists a 160–400 pt user width; GTK uses a 160 pt minimum/default 220 pt `GtkPaned` without persisting a 400 pt cap | Resolved (plan 016): no in-window collapse affordance, matching the Mac — reopen via keybind/palette only, the ☰ button is removed. Dragging the grip past half the 160 pt floor (below 80 px, unclamped) collapses the sidebar and drops the live drag width without committing, so reopening restores the pre-drag committed width rather than the 160 pt floor. Recorded parity divergence: NSSplitView lets a drag continue past the floor and re-expand within the same gesture; here the grip leaves the tree at collapse, so drag-back-to-reopen within one gesture is impossible | closed | Resize: functional e2e + real-input grip segment (shipped, plan 011). Collapse: unit matrix (threshold crossing, no-Dragged-after-Collapse, released-event no-op, reopen-width invariant) plus capture (shipped, plan 016) | +| Tab strip | About 24 pt pills in a compact band with 6 pt gaps and horizontal overflow | Closed for active/manual reachability: 24 pt dark pills in a 32 pt band (trued up from 34 pt in plan 016) with independent horizontal overflow | closed | Band/pill geometry assertions under both renderers; overflow test | | Tab status | Shared lifecycle dot at leading edge, white active label, muted inactive label | Implemented with shared lifecycle derivation; inactive slots are transparent, but the current parity fixture does not yet pin dot/label geometry | P1 | Add focused status-slot geometry/color capture; retain the semantic color unit test | | Tab close/badge | Active or hovered pill exposes close; inactive notification uses a distinct blue trailing badge | Active exact-ID close and blue badge implemented; hover-close remains deferred | P1 | Real click-close test, badge color/position assertion, notification clear test | | Tab rename | Inline rename through double-click or the configured command, with authoritative persistence | Closed: compact inline editor uses stable IDs, select-all focus, Enter commit, Escape/click-away cancel, and shared GTK/Iced trim/no-op policy | closed | X11 physical shortcut/double-click/Enter/Escape/click-away gate, zero PTY leakage, relaunch persistence, and named GTK/Iced captures | | Tab reorder | Pointer drag reorder with visible insertion feedback | Closed: stable-ID drag preview, insertion feedback, exact authoritative commit, cancellation, overflow, and relaunch persistence work under both renderers | closed | Bidirectional physical X11/Wayland input, outside-release/palette cancellation, zero PTY leakage, and named product captures | -| New-tab affordance | Compact plus control following the pills | Closed: compact fixed plus remains reachable outside overflow and opens a PTY tab | closed | Click opens one PTY-backed tab; compact geometry assertion | -| Notification entry | Header bell with count badge opens the inbox palette | Text button in the tab band | P1 | Bell/count capture and click-to-palette E2E | -| Terminal padding | Compact consistent inset around the grid | 12 pt inset, visibly close but not yet measured against both references | P1 | Cell-origin and viewport-edge assertions at fixed size | +| New-tab affordance | Compact plus control following the pills | Closed (re-shaped by plan 016): the plus sits inside the scrolling strip 6px after the last pill and scrolls with overflow — Mac parity, where + is an arranged strip subview. Under overflow it scrolls offscreen (accepted; keybind/palette still create tabs) | closed | Real-input click computed from the last pill's rendered right edge opens one PTY-backed tab (plan 016 harness rework) | +| Notification entry | Header bell with count badge opens the inbox palette (original framing) — the Mac reference actually has no bell at all | Resolved (plan 016): the bell is removed entirely — a deliberate divergence from this row's original framing, brought into line with the Mac reference, which has no bell. Shipped shape: sidebar project-row dots (any project with a notifying tab, active project included) plus the existing pill badges; the palette/keybind own the inbox entry | closed | Sidebar-dot + pill-badge capture; `roostctl notify` demo comparison against the Mac; inbox-via-palette/keybind e2e (shipped, plan 016) | +| Terminal padding | Compact consistent inset around the grid — the Mac reference is edge-pinned at zero | Resolved (plan 016): `TERMINAL_PADDING` 0 on all sides, Mac-parity — the measurement half of this row is done. Typography/glyph-baseline comparison against the reference remains open (3h) | P1 | Cell-origin/viewport-edge assertions at padding 0 (shipped, plan 016); glyph-baseline comparison tracked under 3h | | Terminal scrollback | Wheel/page navigation scrolls retained history locally when mouse reporting is off; alternate-screen behavior follows terminal modes | Closed: wheel and bare PageUp/PageDown page navigation both route through the shared GTK/Iced `roost-vt` policy — retained history, exact bottom state, next-terminal-key snap, mouse-report precedence, alternate-screen arrows/forwarding, and a full-viewport local page move that preserves selection and bypasses snap only on the local route. Swift Mac has no PageUp/PageDown scrollback route (deliberate Rust-UI-first divergence; no prior reference behavior existed) | closed | Physical X11 wheel under both renderers; `roost-vt::route_page` unit/fixture coverage plus both-UI (GTK/Iced) adapter fixtures (selection preserved, zero local PTY bytes, byte-identical Forward path); physical PageUp/PageDown segment in `iced_clipboard_check.py` | | Terminal typography | Configured family/size, baseline and cell metrics stable across styles and graphemes | Renderer-measured size and installed-family selection now reflow every live tab atomically, persist through the shared config policy, and reach new/restored tabs; focused glyph baseline/style comparison remains | P1 | Latin/wide/combined/style fixture under wgpu and tiny-skia; shared GTK/Iced font selection E2E | | Terminal cursor/selection/link | Shared colors and modes with reference-like cursor, selection, and link feedback | Functional coverage exists; geometry/color comparison remains incomplete | P1 | Focused cursor/selection/link screenshots plus existing real-input gates | @@ -147,7 +153,7 @@ product polish, and P2 is an optional native/toolkit refinement. | Hover/focus/disabled states | Subtle per-control hover and visible focus without global blue fills | Mostly inherited stock theme states | P1 | Renderer-neutral state-style unit tests plus real pointer/keyboard capture | | File/image drops | Swift and GTK accept text/file URI drops and image-paste paths using terminal-attached native adapters | Local file drops anywhere in the owned Iced window target the active terminal when no palette/editor owns input, using the shared GTK/Iced resolver and bracketed-paste path on macOS/X11. Clipboard image paste is also shipped: a System-clipboard read materializes to a GTK-parity temp PNG (same cap/naming/0600 policy) on paste, wrapped through the shared bracketed-paste path. Documented divergences: the pixel cap runs post-decode (arboard decodes internally), uri-list-only file copies stay GTK-only, and macOS is compiled but not live-verified. Exact hit-testing, raw text/URI drops, and native Wayland DnD are upstream-blocked (issue #302) | P1 | Shared payload/PTY-byte tests, real Finder evidence, and a reusable shed XDND guard proven under wgpu/tiny-skia; clipboard image materialization unit/paste-path tests (cap, naming, permissions, no-shell-escaping charset pin); retain the upstream-tracked coordinate/Wayland gaps (#302) | | Native chrome | Platform-appropriate window controls and title/subtitle behavior | Native winit decorations; renderer screenshot cannot compare them directly | P2 | Platform launch artifacts and manual checklist, separate from content pixels | -| Renderer consistency | The terminal surface fills the available right pane under every supported renderer/backend | Closed for the current shell: renderer-neutral widget begins at x=220/y=34 with the sidebar and x=0/y=34 collapsed under wgpu/tiny-skia on macOS, X11, and Wayland | closed | Focused product screenshot regression runs in the existing renderer matrix; repeatable parity captures remain available for human review | +| Renderer consistency | The terminal surface fills the available right pane under every supported renderer/backend | Closed for the current shell: renderer-neutral widget begins at x=220/y=32 with the sidebar and x=0/y=32 collapsed under wgpu/tiny-skia on macOS, X11, and Wayland (band height trued up to 32 pt in plan 016) | closed | Focused product screenshot regression runs in the existing renderer matrix; repeatable parity captures remain available for human review | ## Functional interaction gap register @@ -167,9 +173,9 @@ does not justify a second state machine. | Close tab | implemented | active-pill close implemented | Keep exact rendered tab IDs and last-tab/project cascade coverage; add hover-close polish separately | | Rename tabs | implemented | implemented | Double-click/configured command uses the authoritative operation; physical input proves focus, cancel, commit, zero PTY leakage, and restoration | | Reorder tabs | implemented | implemented | Stable-ID pointer preview commits through the authoritative operation and persists; hover-close and automatic offscreen reveal remain polish | -| Sidebar collapse | implemented/persisted | implemented | Move affordance into chrome; retain command and shortcut convergence | +| Sidebar collapse | implemented/persisted | implemented | Resolved (plan 016): no in-window affordance is the decided shape (Mac parity), not a gap — reopen via command/shortcut only, plus drag-to-collapse below the half-floor threshold | | Sidebar resize | UI-owned geometry | implemented | Shipped in slice 3c (plan 011): grip drag adapter + engine-persisted 160–400 pt width policy | -| Notifications inbox | shared model/UI port | implemented | Replace text control with bell/badge without changing model | +| Notifications inbox | shared model/UI port | implemented | Resolved (plan 016): bell removed; sidebar project-row dots + existing pill badges replace it, palette/keybind own the inbox entry | | Command/agent/provider palettes | shared model/UI port | implemented | Visual/focus polish; provider activation behavior remains shared | | New Project command | shared command ID | reports unimplemented | Route through the same Iced directory-picker port as the footer | | Select Font command | shared command ID | implemented | Shared ordering/resolution/confirmation policy drives toolkit discovery adapters; preview/cancel/confirm and config persistence are covered by the target-neutral Rust-UI E2E | diff --git a/third_party/inter/Inter-Medium.ttf b/third_party/inter/Inter-Medium.ttf new file mode 100644 index 00000000..458cd060 Binary files /dev/null and b/third_party/inter/Inter-Medium.ttf differ diff --git a/third_party/inter/Inter-Regular.ttf b/third_party/inter/Inter-Regular.ttf new file mode 100644 index 00000000..b7aaca8d Binary files /dev/null and b/third_party/inter/Inter-Regular.ttf differ diff --git a/third_party/inter/Inter-SemiBold.ttf b/third_party/inter/Inter-SemiBold.ttf new file mode 100644 index 00000000..47f8ab1d Binary files /dev/null and b/third_party/inter/Inter-SemiBold.ttf differ diff --git a/third_party/inter/LICENSE.txt b/third_party/inter/LICENSE.txt new file mode 100644 index 00000000..9b2ca37b --- /dev/null +++ b/third_party/inter/LICENSE.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/third_party/inter/README.roost.md b/third_party/inter/README.roost.md new file mode 100644 index 00000000..119f2c9d --- /dev/null +++ b/third_party/inter/README.roost.md @@ -0,0 +1,31 @@ +# Vendored Inter (roost) + +Static instances from the official [Inter](https://rsms.me/inter/) v4.1 +release, SIL Open Font License 1.1 (`LICENSE.txt`, unmodified): + +* `Inter-Regular.ttf` +* `Inter-Medium.ttf` +* `Inter-SemiBold.ttf` + +This is the repo's **first bundled binary asset**. `third_party/swash`'s +README/license pattern transfers (provenance + removal condition, license +file alongside), but its layout does not — swash is a patched source crate +wired through `[patch.crates-io]`; Inter is three font files loaded +directly by the application. + +Consumed via `include_bytes!` in `crates/roost-iced/src/main.rs`'s +`iced::application(...)` builder (`.font(...)` per weight, +`.default_font(...)` naming the family) — the iced UI's chrome font only. +Terminal cells keep the user-configured monospace font +(`font_registry.rs`'s system scan and picker are untouched; Inter is not +monospace and is excluded from the terminal font picker). The family name +and the `chrome_font(weight)` helper live in `crates/roost-iced/src/ +chrome.rs` next to the other chrome constants — one seam, so a future +config-driven chrome font would touch only that helper. + +**Removal condition.** Delete `third_party/inter/` and the builder wiring +in `main.rs` if the chrome font becomes user-configurable (a config key +would likely replace the bundled bytes with a system-font lookup through +`font_registry.rs`) or if the iced UI is retired. + +Authoritative rationale: `CLAUDE.md` § Library preferences. diff --git a/tools/input/linux/iced_clipboard_check.py b/tools/input/linux/iced_clipboard_check.py index 4176efa7..b283b587 100755 --- a/tools/input/linux/iced_clipboard_check.py +++ b/tools/input/linux/iced_clipboard_check.py @@ -51,9 +51,13 @@ # Sidebar project rows: chrome::ROW_HEIGHT, the sidebar body column's spacing, # and its `padding([4, 0])` top inset (crates/roost-iced/src/app.rs). The band # above them is read from the product instead of copied. -SIDEBAR_ROW_HEIGHT = 28 +SIDEBAR_ROW_HEIGHT = 32 SIDEBAR_ROW_SPACING = 2 SIDEBAR_BODY_TOP_PADDING = 4 +# crates/roost-iced/src/terminal_widget.rs: the grid is edge-pinned, so a cell +# offset is measured straight off the terminal widget's origin. Named rather +# than folded away so the next inset change stays a one-line edit here. +TERMINAL_PADDING = 0 def _skip(message: str) -> NoReturn: @@ -121,8 +125,11 @@ def _wait_window(display: str) -> str: holder: list[str] = [] def find() -> bool: + # By class, not name: the window title is dynamic since plan 016 + # ("{project} – {cwd}"), but WM_CLASS carries the fixed + # application_id the app sets on Linux. result = subprocess.run( - ["xdotool", "search", "--name", "Roost"], + ["xdotool", "search", "--class", "Roost"], env=env, capture_output=True, text=True, @@ -275,8 +282,8 @@ def capture() -> bool: image = pngtool.load(str(path)) width, height, bpp, pixels = image metrics = launch.client.window_metrics() - x0 = round(float(metrics["sidebar_width"])) + 12 - y0 = round(launch.client.terminal_top(metrics)) + 12 + x0 = round(float(metrics["sidebar_width"])) + TERMINAL_PADDING + y0 = round(launch.client.terminal_top(metrics)) + TERMINAL_PADDING def pixel(x: int, y: int) -> tuple[int, int, int]: offset = (y * width + x) * bpp @@ -550,6 +557,19 @@ def _active_pill_close_point( return right - 11, round(launch.client.terminal_top()) // 2 +# The add-tab `+` is a sibling of the tab strip inside the scrollable (plan +# 016 C4) — never a fixed right-pinned control — so it hugs the last pill +# with the strip's 6px spacing and is itself PILL_HEIGHT (24px) wide. Its +# click center sits 6 + 24/2 = 18px past whichever pill currently renders +# last, not at a window-relative constant. +_ADD_TAB_OFFSET = 18 + + +def _add_tab_point(pill_right: int, band_center_y: int) -> tuple[int, int]: + """Compute the `+` control's click point from the last pill's right edge.""" + return pill_right + _ADD_TAB_OFFSET, band_center_y + + def _stable_rollup_stripe_point(launch: Launch) -> tuple[int, int]: """Locate one stable waiting-agent project stripe after focus settles.""" path = launch.root / "chrome-project-rollup-stripe.png" @@ -685,12 +705,16 @@ def _palette_pointer_routing(launch: Launch) -> None: ) launch.client.palette_dismiss() - # The fixed add-tab control is deliberately outside the card. The first - # click dismisses exactly once and cannot also activate that control. + # The add-tab control is deliberately outside the card. The first click + # dismisses exactly once and cannot also activate that control. The + # window still has its one launch-default tab here (a known tab set), so + # its pill is both the active AND last one — the `+` sits right after it. before = len(launch.client.project_tab_ids(project)) - launch.client.palette_open("commands") terminal_top = round(launch.client.terminal_top()) - _click_window_control(launch, width - 49, terminal_top // 2) + _left, _top, pill_right, _bottom = _active_pill_bounds(launch, (width, 360)) + add_x, add_y = _add_tab_point(pill_right, terminal_top // 2) + launch.client.palette_open("commands") + _click_window_control(launch, add_x, add_y) _wait_until( lambda: launch.client.palette_state().get("open") is False, "outside click palette dismissal", @@ -905,7 +929,7 @@ def _keybind_dispatch(launch: Launch) -> tuple[int, int]: _double_click_window_control( launch, round(float(metrics["sidebar_width"])) + 45, - 17, + round(launch.client.terminal_top(metrics) / 2), ) _wait_until( lambda: launch.client.identify()["active_tab_id"] == sibling, @@ -1805,38 +1829,40 @@ def _chrome_overflow_navigation(launch: Launch) -> None: _click_window_control(launch, x, y) _wait_until(lambda: launch.client.tab(last_tab) is None, "scrolled active tab close") + # The `+` is a scrolling sibling of the strip now, not a window-pinned + # control (Mac parity, plan 016 C4) — closing the scrolled-into-view tab + # can leave it anywhere, so re-focus the true last remaining tab and + # re-scroll to the strip's tail (a no-op past the end) before deriving + # the click point from that pill's actual rendered right edge. before = len(launch.client.project_tab_ids(home_project)) - launch.terminal_pointer( - [ - "mousemove", - "--window", - launch.window, - str(width - 49), - str(terminal_top // 2), - "click", - "1", - ] - ) + remaining_last_tab = launch.client.project_tab_ids(home_project)[-1] + launch.client.focus(remaining_last_tab) _wait_until( - lambda: len(launch.client.project_tab_ids(home_project)) == before + 1, - "fixed add-tab control outside horizontal overflow", + lambda: launch.client.identify()["active_tab_id"] == remaining_last_tab, + "remaining last tab selection before add-tab click", ) launch.terminal_pointer( [ "mousemove", "--window", launch.window, - str(width - 20), + str(sidebar + 120), str(terminal_top // 2), "click", - "1", + "--repeat", + "35", + "--delay", + "15", + "7", ] ) + _left, _top, pill_right, _bottom = _active_pill_bounds(launch, (width, height)) + add_x, add_y = _add_tab_point(pill_right, terminal_top // 2) + _click_window_control(launch, add_x, add_y) _wait_until( - lambda: launch.client.palette_state().get("frame") == "notifications", - "fixed notification control outside horizontal overflow", + lambda: len(launch.client.project_tab_ids(home_project)) == before + 1, + "add-tab click still lands after the strip scrolls to its tail", ) - launch.client.palette_dismiss() last_project = 0 last_project_tab = 0 @@ -1890,7 +1916,7 @@ def _chrome_overflow_navigation(launch: Launch) -> None: "--window", launch.window, "110", - str(height - 17), + str(height - terminal_top // 2), "click", "1", ] @@ -1913,29 +1939,20 @@ def _chrome_overflow_navigation(launch: Launch) -> None: ) # The sidebar has no pointer collapse control (the header « was removed - # after user testing; parity with Mac, where collapse is keybind/menu - # only) — collapse via the ToggleSidebar default so the collapsed-state - # ☰ restore control below is still exercised by a real click. + # after user testing) and, as of plan 016 C4, no pointer restore control + # either — the collapsed-band ☰ affordance is gone too. Collapse and + # restore are both keybind/palette only now (Mac parity: no in-window + # affordance when collapsed). launch.key("alt+b") _wait_until( lambda: launch.client.window_metrics()["sidebar_collapsed"], "keybind sidebar collapse after body scroll", ) assert launch.client.identify()["active_tab_id"] == last_project_tab - launch.terminal_pointer( - [ - "mousemove", - "--window", - launch.window, - "20", - str(terminal_top // 2), - "click", - "1", - ] - ) + launch.key("alt+b") _wait_until( lambda: not launch.client.window_metrics()["sidebar_collapsed"], - "fixed collapsed-sidebar control", + "keybind sidebar restore (no in-window ☰ affordance, plan 016 C4)", ) @@ -2002,8 +2019,12 @@ def _terminal_scrollback_routing(launch: Launch) -> None: in "\n".join(launch.client.dump(launch.tab).get("rows_text", [])), "terminal history fixture at live bottom", ) - terminal_x = 220 + 12 + 4 - terminal_y = round(launch.client.terminal_top()) + 12 + launch.cell_height // 2 + terminal_x = 220 + TERMINAL_PADDING + 4 + terminal_y = ( + round(launch.client.terminal_top()) + + TERMINAL_PADDING + + launch.cell_height // 2 + ) def wheel(button: int) -> None: launch.terminal_pointer( @@ -2134,12 +2155,13 @@ def _drag_copy_and_middle_paste(launch: Launch) -> None: launch.client.clipboard_write("system", baseline) launch.client.clipboard_write("selection", baseline) - # Window-relative client coordinates: sidebar + terminal padding and the - # live application-owned terminal origin. End on the last marker cell because - # TerminalSelection's committed range is inclusive at pointer release. - x0 = 220 + 12 + launch.cell_width // 2 - x1 = 220 + 12 + int((len(marker) - 0.5) * launch.cell_width) - y = round(launch.client.terminal_top()) + 12 + launch.cell_height // 2 + # Window-relative client coordinates: sidebar + the terminal inset (zero — + # the grid is edge-pinned) and the live application-owned terminal origin. + # End on the last marker cell because TerminalSelection's committed range + # is inclusive at pointer release. + x0 = 220 + TERMINAL_PADDING + launch.cell_width // 2 + x1 = 220 + TERMINAL_PADDING + int((len(marker) - 0.5) * launch.cell_width) + y = round(launch.client.terminal_top()) + TERMINAL_PADDING + launch.cell_height // 2 # Keep the press, motion, and release as separate XTEST submissions. The # tiny-skia event loop can process a single batched xdotool sequence only # after its release, coalescing away the drag motion. IPC observation while @@ -2249,8 +2271,8 @@ def committed_selection_is_exact() -> bool: def _multi_click_and_link_hover(launch: Launch) -> None: row = "alpha/beta tail" _set_row(launch, row) - x = 220 + 12 + int(2.5 * launch.cell_width) - y = round(launch.client.terminal_top()) + 12 + launch.cell_height // 2 + x = 220 + TERMINAL_PADDING + int(2.5 * launch.cell_width) + y = round(launch.client.terminal_top()) + TERMINAL_PADDING + launch.cell_height // 2 launch.terminal_pointer( [ "mousemove", @@ -2303,7 +2325,7 @@ def _multi_click_and_link_hover(launch: Launch) -> None: lambda: launch.client.app_cursor_shape() == "crosshair", "OSC 22 baseline cursor", ) - url_x = 220 + 12 + int(8.5 * launch.cell_width) + url_x = 220 + TERMINAL_PADDING + int(8.5 * launch.cell_width) launch.terminal_pointer( [ "keydown", diff --git a/tools/input/linux/iced_wayland_clipboard_check.py b/tools/input/linux/iced_wayland_clipboard_check.py index 17235047..c9d5d1e3 100755 --- a/tools/input/linux/iced_wayland_clipboard_check.py +++ b/tools/input/linux/iced_wayland_clipboard_check.py @@ -35,6 +35,7 @@ sys.path.insert(0, str(REPO / "tools" / "screenshot")) import pngtool # noqa: E402 +from iced_clipboard_check import TERMINAL_PADDING # noqa: E402 ICED_BIN = Path( os.environ.get("ROOST_ICED_BIN") or REPO / "target" / "debug" / "roost-iced" @@ -183,8 +184,8 @@ def capture() -> bool: path.write_bytes(png) width, height, bpp, pixels = pngtool.load(str(path)) metrics = client.window_metrics() - x0 = round(float(metrics["sidebar_width"])) + 12 - y0 = round(client.terminal_top(metrics)) + 12 + x0 = round(float(metrics["sidebar_width"])) + TERMINAL_PADDING + y0 = round(client.terminal_top(metrics)) + TERMINAL_PADDING def pixel(x: int, y: int) -> tuple[int, int, int]: offset = (y * width + x) * bpp @@ -408,9 +409,9 @@ def main() -> int: _set_row(client, tab, dragged) client.selection_clear(tab) client.tab_capture_pty_input(tab, drain=True) - x0 = sidebar + 12 + cell_width // 2 - x1 = sidebar + 12 + int((len(dragged) - 0.5) * cell_width) - y = round(client.terminal_top(metrics)) + 12 + cell_height // 2 + x0 = sidebar + TERMINAL_PADDING + cell_width // 2 + x1 = sidebar + TERMINAL_PADDING + int((len(dragged) - 0.5) * cell_width) + y = round(client.terminal_top(metrics)) + TERMINAL_PADDING + cell_height // 2 _inject_drag(width, height, x0, y, x1) _wait_for_selection( client, tab, dragged, "real-seat Wayland drag selection" @@ -423,7 +424,7 @@ def main() -> int: multi = "alpha/beta tail" _set_row(client, tab, multi) - click_x = sidebar + 12 + int(2.5 * cell_width) + click_x = sidebar + TERMINAL_PADDING + int(2.5 * cell_width) _inject_clicks(width, height, click_x, y, 2) _wait_until( lambda: client.selection_dump(tab).get("text") == "alpha/beta", @@ -443,7 +444,7 @@ def main() -> int: lambda: client.app_cursor_shape() == "crosshair", "real-seat Wayland OSC cursor baseline", ) - hover_x = sidebar + 12 + int(8.5 * cell_width) + hover_x = sidebar + TERMINAL_PADDING + int(8.5 * cell_width) _inject_link_hover(client, width, height, hover_x, y) _wait_until( lambda: client.app_cursor_shape() == "crosshair", diff --git a/tools/roosttest/test_iced_walking_skeleton.py b/tools/roosttest/test_iced_walking_skeleton.py index 6df9825e..6b5ef0d0 100644 --- a/tools/roosttest/test_iced_walking_skeleton.py +++ b/tools/roosttest/test_iced_walking_skeleton.py @@ -25,7 +25,9 @@ import pngtool # noqa: E402 — pure stdlib PNG decoder, imported not shelled out -TERMINAL_PADDING = 12 +# crates/roost-iced/src/terminal_widget.rs — the grid is edge-pinned, so the +# origin cell paints AT the widget origin and there is no gutter to skip. +TERMINAL_PADDING = 0 ORIGIN_MARKER = (17, 201, 93) @@ -186,7 +188,12 @@ def painted() -> bool: ) shot = latest["shot"] width, height, _bpp, _pixels = shot - assert pixel(shot, expected_sidebar + 1, terminal_top + 1) == default_rgb + # Edge-pinned grid: the origin cell paints AT the widget origin, so the + # pixel one in from the sidebar seam and one below the band — formerly + # inset gutter — is the marker. The exact corner is what pins the inset + # at zero: a 1px gutter would still satisfy the +1/+1 probe above. + assert pixel(shot, expected_sidebar, terminal_top) == ORIGIN_MARKER + assert pixel(shot, expected_sidebar + 1, terminal_top + 1) == ORIGIN_MARKER assert pixel(shot, width - 2, terminal_top + 1) == default_rgb assert pixel(shot, width - 2, height - 2) == default_rgb diff --git a/tools/roosttest/test_sidebar_pixels.py b/tools/roosttest/test_sidebar_pixels.py index a13c79db..8d50325f 100644 --- a/tools/roosttest/test_sidebar_pixels.py +++ b/tools/roosttest/test_sidebar_pixels.py @@ -294,20 +294,24 @@ def _all_four_painted() -> bool: ) if target == "iced": - # The active project's failed rollup is a 3x28 strip at the true - # sidebar edge. Rounded ends may shorten the exact-color centreline, - # so pin the edge and require the bulk of the reference-height run. + # The active project's failed rollup is a 3x22 rail at the true sidebar + # edge: `chrome::ROW_HEIGHT` (32) less the 5px gap top and bottom that + # keeps adjacent active projects discrete. Its 2px corner radius eats + # the ends of the exact-color centreline, so require the bulk of it. stripe_run = _longest_vertical_run(shot, 0, LIFECYCLE_COLORS["failed"]) - assert stripe_run >= 24, ( + assert stripe_run >= 18, ( f"Iced project rollup must occupy the true sidebar edge at x=0 " - f"for approximately 28px; longest failed-color run was {stripe_run}px " + f"for approximately 22px; longest failed-color run was {stripe_run}px " f"(screenshot: {shot_path})" ) sidebar_w = round(float(roost.window_metrics()["sidebar_width"])) + # The pill spans the row less its 6px horizontal inset, and stands 30px + # tall in the 32px row — both far larger than any dot or badge, which + # is all this filter has to exclude. selections = [ bounds for bounds in _color_components(shot, sidebar_w, ICED_ACTIVE_PROJECT) - if bounds[2] - bounds[0] >= 150 and bounds[3] - bounds[1] >= 20 + if bounds[2] - bounds[0] >= 100 and bounds[3] - bounds[1] >= 15 ] assert len(selections) == 1, ( f"expected one Iced active-project selection pill, got {selections} " @@ -315,8 +319,11 @@ def _all_four_painted() -> bool: ) left, _top, right, _bottom = selections[0] right_inset = sidebar_w - 1 - right - assert abs(left - 14) <= 1 and abs(right_inset - 8) <= 1, ( - f"Iced project selection must be independently inset from the rollup " - f"and divider; got left={left}, right inset={right_inset} " - f"(screenshot: {shot_path})" + # Mac parity: `bounds.insetBy(dx: 6, dy: 1)`. The right inset is read + # against the divider column the sidebar reserves inside its own width, + # so it measures one larger than the geometric 6. + assert abs(left - 6) <= 1 and abs(right_inset - 7) <= 1, ( + f"Iced project selection must be inset 6px inside the row, clear of " + f"the rollup rail and the divider; got left={left}, right " + f"inset={right_inset} (screenshot: {shot_path})" ) diff --git a/tools/roosttest/test_tab_strip_pixels.py b/tools/roosttest/test_tab_strip_pixels.py index 81ba55e3..9741e4bc 100644 --- a/tools/roosttest/test_tab_strip_pixels.py +++ b/tools/roosttest/test_tab_strip_pixels.py @@ -1,27 +1,38 @@ -"""Tab-strip band pixel guard — issue #281, iced only. +"""Tab-strip band + sidebar-divider pixel guards — iced only. -With enough wide tabs the strip's horizontal scrollable overflows, and -iced's stock scrollbar painted a 10px filled rail + scroller straight -across the 24px pills — a gray band across the tab row. The fix is a -zero-width scrollbar (any visible indicator overlays the pills), which -`tab.dump`/`tab.list` cannot see; only pixels can. +Two chrome invariants that no dump op can see, both asserted off one +`app.screenshot`: + +1. Issue #281 — with enough wide tabs the strip's horizontal scrollable + overflows, and iced's stock scrollbar painted a 10px filled rail + + scroller straight across the 24px pills, a gray band across the tab + row. The fix is a zero-width scrollbar (any visible indicator + overlays the pills), which `tab.dump`/`tab.list` cannot see. +2. Plan 016 W1.3 — the 1px sidebar/content divider, which lives INSIDE + the sidebar's own width (so `terminal_grid` keeps every pixel + `sidebar_width` leaves it) and is not drawn at all when the sidebar + is collapsed, matching the Mac's NSSplitView divider. *Asserted* — with the strip overflowing, no row of the tab band contains a long horizontal run of a single color other than the band's own chrome -fills (`SURFACE_DARK` band background, `ACTIVE_TAB` pill fill — our own +fills (`BAND` band background, `ACTIVE_TAB` pill fill — our own constants in `chrome.rs`). The stock rail (~870px), its scroller (~350px), and even the interim 2px hover sliver all form such runs; tab title glyphs, status dots, and antialiasing never do. Color-agnostic on purpose: a scrollbar reintroduced in ANY theme color is caught, not just -the iced-Dark grays observed in #281. +the iced-Dark grays observed in #281. Then: the sidebar's rightmost +column reads `DIVIDER` from the band rows down while expanded, and the +window's leading column carries no divider once collapsed. *Not asserted* — text position or metrics (font-dependent), the clipped right-most pill's exact edge, and hover states (headless capture has no pointer inside the window; the zero-width scrollbar draws nothing in any -state anyway). +state anyway). The divider is sampled at a handful of rows, not scanned: +it is one container fill, so a full scan buys no signal. Iced-only: the mac/gtk strips are native scroll views with overlay -scrollers and have never shown this artifact; their band colors differ. +scrollers and have never shown this artifact, mac's divider is +NSSplitView's own and gtk has none; their band colors differ. `window.resize` is test-mode-gated, so the file skips without `ROOST_TEST_MODE=1` — same convention as `test_sidebar_pixels.py`. @@ -36,6 +47,7 @@ import pytest from client import Timeout +from test_sidebar_collapse_persistence import _toggle_to_collapsed, _toggle_to_visible from test_sidebar_pixels import _capture from util import BARE_SHELL_ARGV @@ -46,12 +58,15 @@ WINDOW_W, WINDOW_H = 1100.0, 700.0 -# Verbatim from `crates/roost-iced/src/chrome.rs`: BAND_HEIGHT bounds the -# scan; the two fills are the only colors allowed to run wide in the band. -BAND_HEIGHT = 34 -SURFACE_DARK = (0x21, 0x21, 0x21) +# Verbatim from `crates/roost-iced/src/chrome.rs`: `BAND_HEIGHT` bounds the +# scan; `BAND` (every chrome band — header, footer, tab strip) and +# `ACTIVE_TAB` are the only fills allowed to run wide inside the band, and +# `DIVIDER` is the sidebar's own trailing hairline. +BAND_HEIGHT = 32 +BAND = (0x24, 0x29, 0x2C) ACTIVE_TAB = (0x24, 0x37, 0x51) -ALLOWED_WIDE = {SURFACE_DARK, ACTIVE_TAB} +DIVIDER = (0x1A, 0x1D, 0x1E) +ALLOWED_WIDE = {BAND, ACTIVE_TAB} # The tab title color, verbatim from `chrome.rs` MUTED_TEXT — used only as # the readiness/overflow probe (glyph pixels render exactly this color). @@ -95,6 +110,25 @@ def _wide_foreign_runs(shot, x0: int, x1: int, y1: int) -> list[tuple[int, int, return bad +def _sample_rows(height: int) -> list[int]: + """Rows spanning the band, the list region and the footer band — the + three sidebar regions the one divider fill has to cover.""" + return [1, BAND_HEIGHT // 2, BAND_HEIGHT + 8, height // 2, height - 8] + + +def _column_samples(shot, x: int, ys: list[int]) -> list[tuple[int, tuple[int, int, int]]]: + width, _height, bpp, px = shot + out = [] + for y in ys: + o = (y * width + x) * bpp + out.append((y, (px[o], px[o + 1], px[o + 2]))) + return out + + +def _fmt(samples) -> str: + return ", ".join(f"y={y}:#{c[0]:02x}{c[1]:02x}{c[2]:02x}" for y, c in samples) + + def _rightmost_title_pixel(shot, x0: int, x1: int, y1: int) -> int: """Rightmost MUTED_TEXT pixel in columns x0..x1, rows 0..y1, found by scanning each row back-to-front and stopping at its first match.""" @@ -147,10 +181,10 @@ def _strip_full_of_titles() -> bool: if shot is None: return False width = shot[0] - # The +/notification buttons occupy the last ~70px of the band, and - # the empty-inbox glyph is itself MUTED_TEXT (codex review finding) — - # exclude that cluster so only tab-title glyphs can satisfy the probe. - rightmost = _rightmost_title_pixel(shot, sidebar_w + 2, width - 70, BAND_HEIGHT) + # Nothing is pinned at the band's right edge anymore (bell removed, + # `+` moved in-strip — plan 016 C4), so the probe scans the full + # band width; no carve-out needed. + rightmost = _rightmost_title_pixel(shot, sidebar_w + 2, width, BAND_HEIGHT) state["shot"], state["sidebar_w"], state["rightmost"] = shot, sidebar_w, rightmost # A title glyph within 150px of the right edge proves the pills run # to the strip's clip edge, i.e. the scrollable is overflowing and @@ -180,3 +214,81 @@ def _strip_full_of_titles() -> bool: ) + f" (screenshot: {shot_path})" ) + + +@pytest.mark.skipif( + not TEST_MODE, + reason="window.resize requires ROOST_TEST_MODE=1 in the UI's launch env", +) +def test_sidebar_divider_hairline_only_while_expanded(roost, target, tmp_path): + """The divider is one 1px column at the sidebar's trailing edge, drawn + inside `sidebar_width` (plan 016 W1.3) so the terminal grid keeps every + pixel the sidebar leaves it — a divider that grew its own layout column + would still look right but would steal a cell. It is absent when the + sidebar is collapsed, like the Mac's NSSplitView divider. + + Pixel oracle by design: like the rest of this module (#281/#291), the + subject is renderer-only chrome with no textual IPC surface — the + narrow, documented exception to roosttest's text-not-pixels rule.""" + if target != "iced": + pytest.skip("iced chrome hairline; mac uses NSSplitView's divider and gtk has none") + + roost.window_resize(WINDOW_W, WINDOW_H) + _toggle_to_visible(roost) + + artifact_dir = os.environ.get("ROOST_E2E_ARTIFACT_DIR") + base = Path(artifact_dir) if artifact_dir else tmp_path + base.mkdir(parents=True, exist_ok=True) + expanded_path, collapsed_path = base / "divider.png", base / "divider_collapsed.png" + state: dict = {"samples": [], "collapsed_samples": []} + + def _divider_painted() -> bool: + metrics = roost.window_metrics() + sidebar_w = int(metrics["sidebar_width"]) + if metrics["sidebar_collapsed"] or sidebar_w <= 0: + return False + shot = _capture(roost, expanded_path) + if shot is None: + return False + state["x"] = sidebar_w - 1 + state["samples"] = _column_samples(shot, sidebar_w - 1, _sample_rows(shot[1])) + return all(color == DIVIDER for _y, color in state["samples"]) + + try: + roost._wait(_divider_painted, 10.0, "sidebar divider hairline painted") + except Timeout as exc: + raise AssertionError( + f"the sidebar's trailing column (x={state.get('x')}) must render " + f"DIVIDER #{DIVIDER[0]:02x}{DIVIDER[1]:02x}{DIVIDER[2]:02x} from the " + f"band rows down; got {_fmt(state['samples'])} " + f"(screenshot: {expanded_path})" + ) from exc + + try: + _toggle_to_collapsed(roost) + + def _collapsed_repainted() -> bool: + shot = _capture(roost, collapsed_path) + if shot is None: + return False + state["collapsed_samples"] = _column_samples(shot, 0, _sample_rows(shot[1])) + # The leading column is the tab band once the sidebar is gone, so + # a band-colored top row proves the relayout landed and the + # samples below it are worth asserting on. + return all(c == BAND for y, c in state["collapsed_samples"] if y < BAND_HEIGHT) + + try: + roost._wait(_collapsed_repainted, 10.0, "collapsed layout repainted to the window edge") + except Timeout as exc: + raise AssertionError( + f"with the sidebar collapsed the tab band must reach x=0; got " + f"{_fmt(state['collapsed_samples'])} (screenshot: {collapsed_path})" + ) from exc + + assert all(color != DIVIDER for _y, color in state["collapsed_samples"]), ( + f"no divider may be drawn while the sidebar is collapsed (Mac hides " + f"its own); leading column reads {_fmt(state['collapsed_samples'])} " + f"(screenshot: {collapsed_path})" + ) + finally: + _toggle_to_visible(roost)