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.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,
}

View File

@@ -28,35 +28,15 @@ 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 ──────────────────────────────────────────────────────────────

View File

@@ -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<String>,
pub selected: Option<usize>,
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>, String) {
let mut comps: Vec<String> = 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 "<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)
}
/// 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<usize>,
loading: &mut bool,
events: &mut EventWriter<FileSelected>,
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 ──