diff --git a/HANDOFF.md b/HANDOFF.md index fbbfce8..33d5277 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -3,9 +3,35 @@ > Working notes for continuing this work in another session/agent (Copilot, Codex, etc.). > Updated 2026-07-08. Delete or update freely; this is not a tracked spec. -## Current work (2026-07-08): desktop row virtualization — DONE, ready to commit +## Current work (2026-07-09): narrow-window layout fix — DONE, committed -- `main` is at `d4bed01` (perf pass phase 1: WAL pragmas, prepare_cached, +- `23ed80a` (desktop row virtualization, below) is committed + pushed. +- **Narrow-window layout fix** (the follow-up noted below, now RESOLVED): the + real root cause was NOT the top bar shoving the central panel — the sort + bar's `right_to_left` chip group inside `video_list`'s toolbar row overflows + LEFT when the panel is narrow, and egui advances the parent's vertical + cursor from the overflowed rect, shifting every row below it under the + sidebar. Fix in `src/app.rs`: + - `video_list`: sort chips are defined once in `SORT_CHIPS` (visual order); + `sort_chip_row_width(ui)` measures the group via font metrics and the RTL + right-aligned layout is used only when it fits (`sort_inline`), otherwise + the chips render on their own `horizontal_wrapped` row below. Wide-window + look unchanged. + - Top bar: `ui.horizontal` → `ui.horizontal_wrapped` (nav buttons wrap to a + second line instead of going off-screen); the right-aligned status label + is width-guarded the same way with a truncating-label fallback (an + overflowing RTL layout spills left over the buttons). + - `SortMode` now derives `Copy`. + - Verified by live GUI screenshots at 1000×700 and 1400×800 (scratchpad + `final2-1000.png` / `final-1400.png`); 134+12 tests pass. + - Screenshot-harness gotcha: egui renders on events; after `xdotool + windowsize` you must `windowactivate` + `windowraise` + a couple of + `mousemove --window` nudges or the capture races a stale frame (the + "empty second toolbar line" ghost). + +## Previous work (2026-07-08): desktop row virtualization — committed as `23ed80a` + +- `main` was at `d4bed01` (perf pass phase 1: WAL pragmas, prepare_cached, batched scan writes, sort_by_cached_key, panic=abort) — committed + pushed. - **`src/app.rs`: desktop row virtualization** (the follow-up the perf spec deferred; spec at @@ -31,13 +57,10 @@ never overcommits/clips off-screen). - `cargo build --release` clean; `cargo test --release` = 134 unit + 12 integration, 0 failures. -- **Known pre-existing issue (NOT this change):** at the ~1000px window - minimum width, the central panel's content origin shifts left and underlaps - the ~290px sidebar (column 1 / card left edge clipped), because the top - header/toolbar row overflows and shoves the layout. Confirmed **identical on - the pre-virtualization binary** (git-stash test), so it's not a regression. - Root cause is the non-wrapping header `ui.horizontal`; a real fix would make - that row wrap or clip. Left as a separate follow-up. +- **Known pre-existing issue — FIXED in the 2026-07-09 session (see top):** at + the ~1000px window minimum width the central panel content underlapped the + sidebar. Root cause was the sort bar's overflowing `right_to_left` group, + not the top header as originally guessed. - **GUI screenshot recipe on this box (Wayland/KWin):** winit defaults to a native Wayland surface that xdotool/`import` can't see. Launch with `env -u WAYLAND_DISPLAY WINIT_UNIX_BACKEND=x11 DISPLAY=:0` to force XWayland, diff --git a/src/app.rs b/src/app.rs index 776963d..8dfcc68 100644 --- a/src/app.rs +++ b/src/app.rs @@ -62,7 +62,7 @@ struct SimGroup { videos: Vec, } -#[derive(Clone, PartialEq)] +#[derive(Clone, Copy, PartialEq)] enum SortMode { Title, DurationAsc, @@ -1517,7 +1517,10 @@ impl App { fn top_bar(&mut self, ctx: &egui::Context) { egui::TopBottomPanel::top("top_bar").show(ctx, |ui| { ui.add_space(2.0); - ui.horizontal(|ui| { + // Wrapped so narrow windows push buttons onto a second line + // instead of off-screen (the row is wider than the ~1000px + // window minimum). + ui.horizontal_wrapped(|ui| { ui.heading("Catacomb"); ui.separator(); ui.label("🔍"); @@ -1629,9 +1632,26 @@ impl App { self.current_screen = Screen::Settings; } } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(egui::RichText::new(&self.status).weak()); - }); + // Right-align the status only when it fits in the remaining + // row width — an overflowing right_to_left layout spills LEFT + // over the nav buttons. Otherwise show it truncated in flow. + let status = egui::RichText::new(&self.status).weak(); + let status_w = egui::WidgetText::from(status.clone()) + .into_galley( + ui, + Some(egui::TextWrapMode::Extend), + f32::INFINITY, + egui::TextStyle::Body, + ) + .size() + .x; + if ui.available_width() >= status_w + 8.0 { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(status); + }); + } else { + ui.add(egui::Label::new(status).truncate()); + } }); ui.add_space(2.0); }); @@ -4169,6 +4189,35 @@ impl App { } fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { + // Toolbar sort chips in left-to-right visual order. + const SORT_CHIPS: [(SortMode, &str); 10] = [ + (SortMode::DownloadDesc, "Recent DL"), + (SortMode::DownloadAsc, "Oldest DL"), + (SortMode::DateDesc, "Newest"), + (SortMode::DateAsc, "Oldest"), + (SortMode::Title, "Title"), + (SortMode::ChannelAsc, "Channel"), + (SortMode::DurationAsc, "Shortest"), + (SortMode::DurationDesc, "Longest"), + (SortMode::SizeAsc, "Smallest"), + (SortMode::SizeDesc, "Largest"), + ]; + /// Measured width the inline sort group needs on the toolbar row. + fn sort_chip_row_width(ui: &egui::Ui) -> f32 { + let spacing = ui.spacing().item_spacing.x; + let pad = ui.spacing().button_padding.x * 2.0; + let text_w = |s: &str, style: egui::TextStyle| { + egui::WidgetText::from(s) + .into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, style) + .size() + .x + }; + let chips: f32 = SORT_CHIPS + .iter() + .map(|(_, label)| text_w(label, egui::TextStyle::Button) + pad + spacing) + .sum(); + chips + text_w("Sort:", egui::TextStyle::Body) + spacing + 8.0 + } if self.sidebar_view == SidebarView::Channels { self.channel_grid(ctx, ui); return; @@ -4205,6 +4254,7 @@ impl App { } // Bulk mode toolbar + let mut sort_inline = true; ui.horizontal(|ui| { let label_text = if self.bulk_mode { format!("{} videos", cards.len()) @@ -4254,20 +4304,31 @@ impl App { } } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.selectable_value(&mut self.sort_mode, SortMode::SizeDesc, "Largest"); - ui.selectable_value(&mut self.sort_mode, SortMode::SizeAsc, "Smallest"); - ui.selectable_value(&mut self.sort_mode, SortMode::DurationDesc, "Longest"); - ui.selectable_value(&mut self.sort_mode, SortMode::DurationAsc, "Shortest"); - ui.selectable_value(&mut self.sort_mode, SortMode::ChannelAsc, "Channel"); - ui.selectable_value(&mut self.sort_mode, SortMode::Title, "Title"); - ui.selectable_value(&mut self.sort_mode, SortMode::DateAsc, "Oldest"); - ui.selectable_value(&mut self.sort_mode, SortMode::DateDesc, "Newest"); - ui.selectable_value(&mut self.sort_mode, SortMode::DownloadAsc, "Oldest DL"); - ui.selectable_value(&mut self.sort_mode, SortMode::DownloadDesc, "Recent DL"); - ui.label(egui::RichText::new("Sort:").weak()); - }); + // Right-align the sort chips only when they actually fit in the + // remaining row width. A right_to_left layout that runs out of + // room overflows LEFT past the panel edge, and egui then advances + // the vertical cursor from that overflowed rect — shifting every + // row below it under the sidebar. So measure first; when narrow, + // the chips move to their own wrapped row below instead. + let needed = sort_chip_row_width(ui); + sort_inline = ui.available_width() >= needed; + if sort_inline { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + for (mode, label) in SORT_CHIPS.iter().rev() { + ui.selectable_value(&mut self.sort_mode, *mode, *label); + } + ui.label(egui::RichText::new("Sort:").weak()); + }); + } }); + if !sort_inline { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new("Sort:").weak()); + for (mode, label) in SORT_CHIPS { + ui.selectable_value(&mut self.sort_mode, mode, label); + } + }); + } ui.separator();