refactor(viewer): explorer UX cleanups (menu, loading, tree, status bar)

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 <name>…" 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 "<stem>.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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 13:21:38 +02:00
parent fbd4550d62
commit 765e4a573b
3 changed files with 59 additions and 69 deletions

View File

@@ -597,20 +597,24 @@ fn poll_loader_channel(
pending.path = path; pending.path = path;
pending.bytes = bytes; pending.bytes = bytes;
pending.ready = true; pending.ready = true;
browser.loading = false;
} }
Ok(IsoLoaderMsg::PakLoaded { name, rows, error }) => { Ok(IsoLoaderMsg::PakLoaded { name, rows, error }) => {
pending_pak.name = name; pending_pak.name = name;
pending_pak.rows = rows; pending_pak.rows = rows;
pending_pak.error = error; pending_pak.error = error;
pending_pak.ready = true; pending_pak.ready = true;
browser.loading = false;
} }
Ok(IsoLoaderMsg::Cancelled) => { Ok(IsoLoaderMsg::Cancelled) => {
iso_state.loading = false; iso_state.loading = false;
browser.loading = false;
} }
Ok(IsoLoaderMsg::Error(msg)) => { Ok(IsoLoaderMsg::Error(msg)) => {
error!("ISO loader: {}", msg); error!("ISO loader: {}", msg);
iso_state.loading = false; iso_state.loading = false;
iso_state.error = Some(msg); iso_state.error = Some(msg);
browser.loading = false;
} }
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break, Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
} }

View File

@@ -28,36 +28,16 @@ pub mod ui;
/// Global viewer state, shared across all UI and rendering systems. /// Global viewer state, shared across all UI and rendering systems.
#[derive(Resource)] #[derive(Resource)]
pub struct ViewerState { 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. /// Whether the plain-text viewer wraps long lines.
pub text_wrap: bool, pub text_wrap: bool,
} }
impl Default for ViewerState { impl Default for ViewerState {
fn default() -> Self { fn default() -> Self {
Self { Self { text_wrap: true }
current_mode: ViewMode::Textures,
wireframe: false,
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 ────────────────────────────────────────────────────────────── // ── App builder ──────────────────────────────────────────────────────────────
/// Build and run the viewer application. /// Build and run the viewer application.

View File

@@ -13,7 +13,7 @@ use crate::iso_loader::{
FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, TextPreview, FileInfo, FileSelected, IsoState, PakView, RequestOpenDir, RequestOpenIso, TextPreview,
TexturePreview, IsoLoaderSystemSet, TexturePreview, IsoLoaderSystemSet,
}; };
use crate::{ViewerState, ViewMode}; use crate::ViewerState;
pub struct ViewerUiPlugin; pub struct ViewerUiPlugin;
@@ -31,6 +31,9 @@ pub struct FileBrowserState {
pub files: Vec<String>, pub files: Vec<String>,
pub selected: Option<usize>, pub selected: Option<usize>,
pub filter: String, 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 ──────────────────────────────────────────────────────────── // ── Directory tree ────────────────────────────────────────────────────────────
@@ -46,24 +49,27 @@ struct TreeDir {
files: Vec<(String, usize)>, files: Vec<(String, usize)>,
} }
/// Split a full path into (directory components, leaf name), grouping data /// True for an IPFB data segment (`foo.p00`, `foo.p01`, …). These are the pack's
/// segments (`foo.p00`, `foo.p01`, …) under a virtual `foo.pak` directory so a /// data body, not standalone files — the `.pak` index is what the user opens, so
/// pack and its segments sit together. Purely cosmetic — indices are untouched. /// 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>, String) { fn split_for_tree(full: &str) -> (Vec<String>, String) {
let mut comps: Vec<String> = full.split('/').map(str::to_string).collect(); let mut comps: Vec<String> = full.split('/').map(str::to_string).collect();
let leaf = comps.pop().unwrap_or_default(); 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 "<stem>.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) (comps, leaf)
} }
/// Build the directory tree, keeping only leaves whose full path contains /// 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 { fn build_tree(files: &[String], filter: &str) -> TreeDir {
let filter = filter.to_lowercase(); let filter = filter.to_lowercase();
let mut root = TreeDir::default(); let mut root = TreeDir::default();
@@ -72,6 +78,9 @@ fn build_tree(files: &[String], filter: &str) -> TreeDir {
continue; continue;
} }
let (dirs, leaf) = split_for_tree(full); let (dirs, leaf) = split_for_tree(full);
if is_pak_segment(&leaf) {
continue;
}
let mut node = &mut root; let mut node = &mut root;
for comp in dirs { for comp in dirs {
node = node.dirs.entry(comp).or_default(); 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, /// Render a directory node recursively: sub-directories (collapsible) first,
/// then leaf files as selectable labels. /// then leaf files as selectable labels.
#[allow(clippy::too_many_arguments)]
fn render_dir( fn render_dir(
ui: &mut egui::Ui, ui: &mut egui::Ui,
node: &TreeDir, node: &TreeDir,
force_open: bool, force_open: bool,
selected: &mut Option<usize>, selected: &mut Option<usize>,
loading: &mut bool,
events: &mut EventWriter<FileSelected>, events: &mut EventWriter<FileSelected>,
files: &[String], files: &[String],
) { ) {
@@ -96,7 +107,7 @@ fn render_dir(
.id_salt(name) .id_salt(name)
.default_open(force_open) .default_open(force_open)
.show(ui, |ui| { .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 { for (leaf, idx) in &node.files {
@@ -106,6 +117,7 @@ fn render_dir(
.clicked() .clicked()
{ {
*selected = Some(*idx); *selected = Some(*idx);
*loading = true; // show the spinner until the content is applied
events.send(FileSelected(files[*idx].clone())); 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| { ui.menu_button("Help", |ui| {
if ui.button("Controls…").clicked() { if ui.button("Controls…").clicked() {
// TODO: controls popup // TODO: controls popup
@@ -225,11 +214,15 @@ fn draw_viewer_ui(
let files = browser.files.clone(); let files = browser.files.clone();
let tree = build_tree(&files, &browser.filter); let tree = build_tree(&files, &browser.filter);
let force_open = !browser.filter.is_empty(); let force_open = !browser.filter.is_empty();
let FileBrowserState {
selected, loading, ..
} = &mut *browser;
render_dir( render_dir(
ui, ui,
&tree, &tree,
force_open, force_open,
&mut browser.selected, selected,
loading,
&mut file_selected_events, &mut file_selected_events,
&files, &files,
); );
@@ -239,27 +232,40 @@ fn draw_viewer_ui(
// ── Bottom panel: status bar ────────────────────────────────────────── // ── Bottom panel: status bar ──────────────────────────────────────────
egui::TopBottomPanel::bottom("status_bar").show(ctx, |ui| { 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 { 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(); 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.label(format!("{} files", browser.files.len()));
ui.separator(); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label("LMB: orbit | RMB: pan | Scroll: zoom | R: reset"); ui.label("LMB orbit · RMB pan · scroll zoom · R reset");
});
}); });
}); });
// ── Central area ────────────────────────────────────────────────────── // ── Central area ──────────────────────────────────────────────────────
if browser.selected.is_some() { if browser.selected.is_some() {
egui::CentralPanel::default().show(ctx, |ui| { 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); draw_pak_browser(ui, &mut pak_view);
} else if let Some(text) = &text_preview.content { } else if let Some(text) = &text_preview.content {
// ── Plain-text viewer ── // ── Plain-text viewer ──