feat(viewer): directory-tree browser + plain-text viewer

Explorer drawer was a flat list of all 353 disc files and only textures had a
preview. Two additions:

1. Directory tree — the left drawer is now a collapsible `📁` tree, rebuilt each
   frame from the path list (ui.rs: build_tree / split_for_tree / render_dir).
   Filtering narrows leaves and auto-expands matches; `.p00…p04` data segments
   nest under a virtual `<stem>.pak` node. Selection still keys off the original
   file index, so the loader pipeline is untouched.

2. Plain-text viewer — config.ini and any file that sniffs as text now show
   read-only, selectable, monospace content with a Wrap toggle and an encoding
   label. Detection/decoding live in the Bevy-free formats crate:
   vfs::is_probably_text + vfs::decode_text (BOM-aware UTF-8/UTF-16LE/BE, BOM-less
   UTF-16LE heuristic, NUL-reject + 95%-printable fallback) — pure std, WASM-safe,
   unit-tested (6 new tests), and reused by the CLI sniffer (new cyan `txt`).

New TextPreview resource (registered unconditionally so it exists on wasm);
populated in apply_loaded_texture with a 2 MiB char-boundary-safe cap. Also fixes
a latent bug: switching from a texture to a non-texture left stale preview state —
both texture and text state now reset on every selection.

ViewerState gains text_wrap. Formats tests: 22 pass; workspace compiles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-10 23:05:58 +02:00
parent 854fd8dfd3
commit 2658dd20a6
5 changed files with 342 additions and 26 deletions

View File

@@ -90,6 +90,22 @@ pub struct FileInfo {
pub detected_format: Option<FileFormat>,
}
/// Decoded contents of the currently-selected text file, shown in the central
/// panel. `content` is `None` whenever the selection isn't text.
#[derive(Resource, Default)]
pub struct TextPreview {
/// Decoded UTF-8 (any BOM / UTF-16 already resolved), or `None`.
pub content: Option<String>,
/// Encoding label for the header, e.g. "UTF-16LE (BOM)".
pub encoding: String,
/// True when the file was larger than `MAX_TEXT_BYTES` and got clipped.
pub truncated: bool,
}
/// Cap on the decoded text we hand to egui — its text layout cost grows
/// super-linearly, and disc config files are only a few KiB anyway.
const MAX_TEXT_BYTES: usize = 2 * 1024 * 1024;
// ── SystemSet label ───────────────────────────────────────────────────────────
/// `draw_viewer_ui` runs `.after(IsoLoaderSystemSet)` to see the frame's
@@ -157,7 +173,10 @@ impl Plugin for IsoLoaderPlugin {
.add_event::<FileSelected>()
.init_resource::<IsoState>()
.init_resource::<TexturePreview>()
.init_resource::<FileInfo>();
.init_resource::<FileInfo>()
// Registered unconditionally (even on wasm, where the load systems
// below are cfg'd out) so the UI can always read it.
.init_resource::<TextPreview>();
#[cfg(not(target_arch = "wasm32"))]
{
@@ -392,6 +411,7 @@ fn poll_loader_channel(
fn apply_loaded_texture(
mut pending: ResMut<PendingFileBytes>,
mut preview: ResMut<TexturePreview>,
mut text_preview: ResMut<TextPreview>,
mut file_info: ResMut<FileInfo>,
mut images: ResMut<Assets<Image>>,
mut contexts: bevy_egui::EguiContexts,
@@ -412,6 +432,9 @@ fn apply_loaded_texture(
images.remove(handle.id());
}
*preview = TexturePreview::default();
// Clear stale text too, so switching files never shows a previous file's
// content in the wrong panel.
*text_preview = TextPreview::default();
// Populate FileInfo for the status / info panel.
file_info.name = path
@@ -422,6 +445,25 @@ fn apply_loaded_texture(
file_info.size_bytes = bytes.len();
file_info.detected_format = Some(fmt);
if fmt == FileFormat::Text {
let (mut text, encoding) = sylpheed_formats::vfs::decode_text(&bytes);
let truncated = text.len() > MAX_TEXT_BYTES;
if truncated {
// Cut on a char boundary so we never split a codepoint.
let end = text
.char_indices()
.take_while(|(i, _)| *i < MAX_TEXT_BYTES)
.last()
.map(|(i, c)| i + c.len_utf8())
.unwrap_or(0);
text.truncate(end);
}
text_preview.content = Some(text);
text_preview.encoding = encoding.to_string();
text_preview.truncated = truncated;
return; // FileInfo + TextPreview are sufficient for text files.
}
if fmt != FileFormat::Xpr2Texture {
return; // Not a texture — FileInfo is sufficient
}

View File

@@ -32,6 +32,8 @@ pub struct ViewerState {
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 {
@@ -39,6 +41,7 @@ impl Default for ViewerState {
Self {
current_mode: ViewMode::Textures,
wireframe: false,
text_wrap: true,
}
}
}

View File

@@ -10,7 +10,7 @@ use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use crate::iso_loader::{
FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TexturePreview,
FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TextPreview, TexturePreview,
IsoLoaderSystemSet,
};
use crate::{ViewerState, ViewMode};
@@ -33,12 +33,91 @@ pub struct FileBrowserState {
pub filter: String,
}
// ── Directory tree ────────────────────────────────────────────────────────────
/// One directory node in the ephemeral file tree. Rebuilt from the flat path
/// list each frame; leaves carry their original index into `browser.files` so
/// selection stays stable regardless of tree shape or filtering.
#[derive(Default)]
struct TreeDir {
/// Child directories, keyed by name (`BTreeMap` = stable alphabetical order).
dirs: std::collections::BTreeMap<String, TreeDir>,
/// Leaf files: (display name, original index into `browser.files`).
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.
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).
fn build_tree(files: &[String], filter: &str) -> TreeDir {
let filter = filter.to_lowercase();
let mut root = TreeDir::default();
for (idx, full) in files.iter().enumerate() {
if !filter.is_empty() && !full.to_lowercase().contains(&filter) {
continue;
}
let (dirs, leaf) = split_for_tree(full);
let mut node = &mut root;
for comp in dirs {
node = node.dirs.entry(comp).or_default();
}
node.files.push((leaf, idx));
}
root
}
/// Render a directory node recursively: sub-directories (collapsible) first,
/// then leaf files as selectable labels.
fn render_dir(
ui: &mut egui::Ui,
node: &TreeDir,
force_open: bool,
selected: &mut Option<usize>,
events: &mut EventWriter<FileSelected>,
files: &[String],
) {
for (name, child) in &node.dirs {
egui::CollapsingHeader::new(format!("📁 {name}"))
.id_salt(name)
.default_open(force_open)
.show(ui, |ui| {
render_dir(ui, child, force_open, selected, events, files);
});
}
for (leaf, idx) in &node.files {
let is_selected = *selected == Some(*idx);
if ui
.add(egui::SelectableLabel::new(is_selected, leaf.as_str()))
.clicked()
{
*selected = Some(*idx);
events.send(FileSelected(files[*idx].clone()));
}
}
}
fn draw_viewer_ui(
mut contexts: EguiContexts,
mut viewer: ResMut<ViewerState>,
mut browser: ResMut<FileBrowserState>,
iso_state: Res<IsoState>,
preview: Res<TexturePreview>,
text_preview: Res<TextPreview>,
file_info: Res<FileInfo>,
mut open_iso_events: EventWriter<RequestOpenIso>,
mut open_dir_events: EventWriter<RequestOpenDir>,
@@ -138,26 +217,21 @@ fn draw_viewer_ui(
or File → Open extracted folder.",
);
} else {
let filter = browser.filter.to_lowercase();
let filtered: Vec<(usize, String)> = browser
.files
.iter()
.enumerate()
.filter(|(_, f)| {
filter.is_empty() || f.to_lowercase().contains(&filter)
})
.map(|(i, f)| (i, f.clone()))
.collect();
for (idx, file) in &filtered {
let is_selected = browser.selected == Some(*idx);
let label =
egui::SelectableLabel::new(is_selected, file.as_str());
if ui.add(label).clicked() {
browser.selected = Some(*idx);
file_selected_events.send(FileSelected(file.clone()));
}
}
// Build a directory tree from the flat path list. Cheap to
// rebuild every frame (a few hundred short paths); cloning
// `files` first sidesteps borrowing `browser` immutably and
// mutably (selected) at the same time.
let files = browser.files.clone();
let tree = build_tree(&files, &browser.filter);
let force_open = !browser.filter.is_empty();
render_dir(
ui,
&tree,
force_open,
&mut browser.selected,
&mut file_selected_events,
&files,
);
}
});
});
@@ -184,7 +258,37 @@ fn draw_viewer_ui(
// ── Central area ──────────────────────────────────────────────────────
if browser.selected.is_some() {
egui::CentralPanel::default().show(ctx, |ui| {
if let Some(egui_id) = preview.egui_id {
if let Some(text) = &text_preview.content {
// ── Plain-text viewer ──
ui.horizontal(|ui| {
ui.heading(&file_info.name);
ui.separator();
ui.label(&text_preview.encoding);
ui.separator();
ui.checkbox(&mut viewer.text_wrap, "Wrap");
if text_preview.truncated {
ui.separator();
ui.colored_label(egui::Color32::YELLOW, "(truncated)");
}
});
ui.separator();
egui::ScrollArea::both().show(ui, |ui| {
// Read-only: `&str` is a non-editable TextBuffer, and
// `.interactive(false)` keeps it selectable/copyable.
let mut buf = text.as_str();
let mut edit = egui::TextEdit::multiline(&mut buf)
.font(egui::TextStyle::Monospace)
.code_editor()
.desired_width(f32::INFINITY)
.interactive(false);
if !viewer.text_wrap {
// Don't wrap: let ScrollArea scroll horizontally.
edit = edit.clip_text(false);
}
ui.add(edit);
});
} else if let Some(egui_id) = preview.egui_id {
// Texture preview
ui.heading(&file_info.name);
ui.label(&preview.format_info);