From 765e4a573bff5165e4b859c968b4410ce4706879 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 11 Jul 2026 13:21:38 +0200 Subject: [PATCH] refactor(viewer): explorer UX cleanups (menu, loading, tree, status bar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small UX fixes to the asset explorer: 1. Remove the no-op "View" menu (Textures/Meshes/Audio toggles + Wireframe checkbox) — none were wired to anything. Dropped ViewMode + the current_mode/ wireframe fields from ViewerState (nothing read them); the central panel already dispatches on which preview resource is populated. 2. Loading indicator — clicking a drawer item now flips FileBrowserState.loading and the central panel shows a centered spinner + "Loading …" until the background read/decode applies, instead of leaving the previous item on screen with no feedback. Cleared in poll_loader_channel on FileLoaded/PakLoaded/ Error/Cancelled. 3. Drawer shows only the .pak index, not its .pNN data segments — the segments are the pack's body (loaded when the .pak is opened), so listing them (with no preview) was noise. Dropped the virtual ".pak" folder too; the .pak now sits as a normal leaf in its real directory. 4. Bottom status bar no longer overflows — horizontal_wrapped, only the source's final path component (full path on hover), file count, and a right-aligned, shortened controls hint. Removed the mode label. Viewer + formats build; all tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/sylpheed-viewer/src/iso_loader.rs | 4 + crates/sylpheed-viewer/src/lib.rs | 22 +---- crates/sylpheed-viewer/src/ui.rs | 102 ++++++++++++----------- 3 files changed, 59 insertions(+), 69 deletions(-) diff --git a/crates/sylpheed-viewer/src/iso_loader.rs b/crates/sylpheed-viewer/src/iso_loader.rs index a910203..e147102 100644 --- a/crates/sylpheed-viewer/src/iso_loader.rs +++ b/crates/sylpheed-viewer/src/iso_loader.rs @@ -597,20 +597,24 @@ fn poll_loader_channel( pending.path = path; pending.bytes = bytes; pending.ready = true; + browser.loading = false; } Ok(IsoLoaderMsg::PakLoaded { name, rows, error }) => { pending_pak.name = name; pending_pak.rows = rows; pending_pak.error = error; pending_pak.ready = true; + browser.loading = false; } Ok(IsoLoaderMsg::Cancelled) => { iso_state.loading = false; + browser.loading = false; } Ok(IsoLoaderMsg::Error(msg)) => { error!("ISO loader: {}", msg); iso_state.loading = false; iso_state.error = Some(msg); + browser.loading = false; } Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break, } diff --git a/crates/sylpheed-viewer/src/lib.rs b/crates/sylpheed-viewer/src/lib.rs index 191de1d..b187f5c 100644 --- a/crates/sylpheed-viewer/src/lib.rs +++ b/crates/sylpheed-viewer/src/lib.rs @@ -28,36 +28,16 @@ pub mod ui; /// Global viewer state, shared across all UI and rendering systems. #[derive(Resource)] pub struct ViewerState { - /// Which type of asset is currently being browsed / previewed. - pub current_mode: ViewMode, - /// Whether to render meshes in wireframe mode (toggle with F2). - pub wireframe: bool, /// Whether the plain-text viewer wraps long lines. pub text_wrap: bool, } impl Default for ViewerState { fn default() -> Self { - Self { - current_mode: ViewMode::Textures, - wireframe: false, - text_wrap: true, - } + Self { text_wrap: true } } } -/// The active view / asset type being inspected. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ViewMode { - /// XPR2 texture preview (Milestone 1). - #[default] - Textures, - /// 3D mesh viewer (Milestone 2+). - Mesh, - /// Audio waveform / playback (Milestone 2+). - Audio, -} - // ── App builder ────────────────────────────────────────────────────────────── /// Build and run the viewer application. diff --git a/crates/sylpheed-viewer/src/ui.rs b/crates/sylpheed-viewer/src/ui.rs index 3adfb3e..5f711cc 100644 --- a/crates/sylpheed-viewer/src/ui.rs +++ b/crates/sylpheed-viewer/src/ui.rs @@ -13,7 +13,7 @@ use crate::iso_loader::{ FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, TextPreview, TexturePreview, IsoLoaderSystemSet, }; -use crate::{ViewerState, ViewMode}; +use crate::ViewerState; pub struct ViewerUiPlugin; @@ -31,6 +31,9 @@ pub struct FileBrowserState { pub files: Vec, pub selected: Option, pub filter: String, + /// True from the moment a file is clicked until its content is applied — + /// drives the "Loading…" indicator so the click registers immediately. + pub loading: bool, } // ── Directory tree ──────────────────────────────────────────────────────────── @@ -46,24 +49,27 @@ struct TreeDir { files: Vec<(String, usize)>, } -/// Split a full path into (directory components, leaf name), grouping data -/// segments (`foo.p00`, `foo.p01`, …) under a virtual `foo.pak` directory so a -/// pack and its segments sit together. Purely cosmetic — indices are untouched. +/// True for an IPFB data segment (`foo.p00`, `foo.p01`, …). These are the pack's +/// data body, not standalone files — the `.pak` index is what the user opens, so +/// they're hidden from the tree (their content loads when the `.pak` is opened). +fn is_pak_segment(leaf: &str) -> bool { + matches!(leaf.rsplit_once('.'), Some((_, ext)) + if ext.len() == 3 + && ext.as_bytes()[0] == b'p' + && ext.as_bytes()[1].is_ascii_digit() + && ext.as_bytes()[2].is_ascii_digit()) +} + +/// Split a full path into (directory components, leaf name). fn split_for_tree(full: &str) -> (Vec, String) { let mut comps: Vec = full.split('/').map(str::to_string).collect(); let leaf = comps.pop().unwrap_or_default(); - if let Some((stem, ext)) = leaf.rsplit_once('.') { - let e = ext.as_bytes(); - // `.pNN` where NN are two digits → nest under ".pak". - if e.len() == 3 && e[0] == b'p' && e[1].is_ascii_digit() && e[2].is_ascii_digit() { - comps.push(format!("{stem}.pak")); - } - } (comps, leaf) } /// Build the directory tree, keeping only leaves whose full path contains -/// `filter` (case-insensitive; empty filter keeps everything). +/// `filter` (case-insensitive; empty filter keeps everything). `.pNN` data +/// segments are skipped — only their `.pak` index is shown. fn build_tree(files: &[String], filter: &str) -> TreeDir { let filter = filter.to_lowercase(); let mut root = TreeDir::default(); @@ -72,6 +78,9 @@ fn build_tree(files: &[String], filter: &str) -> TreeDir { continue; } let (dirs, leaf) = split_for_tree(full); + if is_pak_segment(&leaf) { + continue; + } let mut node = &mut root; for comp in dirs { node = node.dirs.entry(comp).or_default(); @@ -83,11 +92,13 @@ fn build_tree(files: &[String], filter: &str) -> TreeDir { /// Render a directory node recursively: sub-directories (collapsible) first, /// then leaf files as selectable labels. +#[allow(clippy::too_many_arguments)] fn render_dir( ui: &mut egui::Ui, node: &TreeDir, force_open: bool, selected: &mut Option, + loading: &mut bool, events: &mut EventWriter, files: &[String], ) { @@ -96,7 +107,7 @@ fn render_dir( .id_salt(name) .default_open(force_open) .show(ui, |ui| { - render_dir(ui, child, force_open, selected, events, files); + render_dir(ui, child, force_open, selected, loading, events, files); }); } for (leaf, idx) in &node.files { @@ -106,6 +117,7 @@ fn render_dir( .clicked() { *selected = Some(*idx); + *loading = true; // show the spinner until the content is applied events.send(FileSelected(files[*idx].clone())); } } @@ -152,29 +164,6 @@ fn draw_viewer_ui( ); }); - ui.menu_button("View", |ui| { - if ui - .selectable_label(viewer.current_mode == ViewMode::Textures, "Textures") - .clicked() - { - viewer.current_mode = ViewMode::Textures; - } - if ui - .selectable_label(viewer.current_mode == ViewMode::Mesh, "Meshes") - .clicked() - { - viewer.current_mode = ViewMode::Mesh; - } - if ui - .selectable_label(viewer.current_mode == ViewMode::Audio, "Audio") - .clicked() - { - viewer.current_mode = ViewMode::Audio; - } - ui.separator(); - ui.checkbox(&mut viewer.wireframe, "Wireframe (F2)"); - }); - ui.menu_button("Help", |ui| { if ui.button("Controls…").clicked() { // TODO: controls popup @@ -225,11 +214,15 @@ fn draw_viewer_ui( let files = browser.files.clone(); let tree = build_tree(&files, &browser.filter); let force_open = !browser.filter.is_empty(); + let FileBrowserState { + selected, loading, .. + } = &mut *browser; render_dir( ui, &tree, force_open, - &mut browser.selected, + selected, + loading, &mut file_selected_events, &files, ); @@ -239,27 +232,40 @@ fn draw_viewer_ui( // ── Bottom panel: status bar ────────────────────────────────────────── egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| { - ui.horizontal(|ui| { + // `horizontal_wrapped` so the bar wraps instead of overflowing the + // window on narrow widths; the controls hint is right-aligned and only + // the source's final path component is shown (full path on hover). + ui.horizontal_wrapped(|ui| { if let Some(label) = &iso_state.source_label { - ui.label(label); + let short = label.rsplit(['/', '\\']).next().unwrap_or(label); + ui.label(short).on_hover_text(label); ui.separator(); } - match viewer.current_mode { - ViewMode::Textures => ui.label("Mode: Texture Viewer"), - ViewMode::Mesh => ui.label("Mode: Mesh Viewer"), - ViewMode::Audio => ui.label("Mode: Audio Viewer"), - }; - ui.separator(); ui.label(format!("{} files", browser.files.len())); - ui.separator(); - ui.label("LMB: orbit | RMB: pan | Scroll: zoom | R: reset"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label("LMB orbit · RMB pan · scroll zoom · R reset"); + }); }); }); // ── Central area ────────────────────────────────────────────────────── if browser.selected.is_some() { egui::CentralPanel::default().show(ctx, |ui| { - if pak_view.loaded { + if browser.loading { + // A file was just clicked — show immediate feedback while the + // background read + decode runs, instead of the previous item. + let name = browser + .selected + .and_then(|i| browser.files.get(i)) + .map(|p| p.rsplit('/').next().unwrap_or(p)) + .unwrap_or(""); + ui.centered_and_justified(|ui| { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(format!("Loading {name}…")); + }); + }); + } else if pak_view.loaded { draw_pak_browser(ui, &mut pak_view); } else if let Some(text) = &text_preview.content { // ── Plain-text viewer ──