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