fix: stop narrow windows shoving the library under the sidebar

At the ~1000px window minimum, the toolbar's right-aligned sort group
ran out of row width and overflowed leftward past the panel edge; egui
then advances the vertical cursor from the overflowed rect, so every
row below (view toggle, the whole list) shifted left and was clipped
under the sidebar.

The sort chips now right-align only when a font-metrics measurement
says they fit, and otherwise drop to their own wrapped row. The top
bar likewise wraps instead of pushing nav buttons off-screen, with the
right-aligned status label width-guarded the same way (truncating
fallback).

Verified via live GUI screenshots at 1000x700 and 1400x800; the wide
layout is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-07-09 03:31:56 -07:00
parent 23ed80a3b7
commit 2c9031bcf3
No known key found for this signature in database
2 changed files with 111 additions and 27 deletions

View file

@ -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,

View file

@ -62,7 +62,7 @@ struct SimGroup {
videos: Vec<SimVideo>,
}
#[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;
}
}
// 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(egui::RichText::new(&self.status).weak());
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 {
}
}
// 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| {
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");
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();