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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
Reference in New Issue
Block a user