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:
@@ -272,6 +272,7 @@ fn cmd_sniff(dir: &Path, unknown_only: bool) -> Result<()> {
|
|||||||
"bin" => label.red().to_string(),
|
"bin" => label.red().to_string(),
|
||||||
"xpr" => label.green().to_string(),
|
"xpr" => label.green().to_string(),
|
||||||
"dds" => label.green().to_string(),
|
"dds" => label.green().to_string(),
|
||||||
|
"txt" => label.cyan().to_string(),
|
||||||
_ => label.yellow().to_string(),
|
_ => label.yellow().to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -108,14 +108,23 @@ impl GameAssets {
|
|||||||
/// Use this to identify unknown file types during reverse engineering.
|
/// Use this to identify unknown file types during reverse engineering.
|
||||||
pub fn identify_format(bytes: &[u8]) -> FileFormat {
|
pub fn identify_format(bytes: &[u8]) -> FileFormat {
|
||||||
if bytes.len() < 4 {
|
if bytes.len() < 4 {
|
||||||
return FileFormat::Unknown;
|
// Very short files can still be text (e.g. a one-line config).
|
||||||
|
return if is_probably_text(bytes) {
|
||||||
|
FileFormat::Text
|
||||||
|
} else {
|
||||||
|
FileFormat::Unknown
|
||||||
|
};
|
||||||
}
|
}
|
||||||
match &bytes[..4] {
|
match &bytes[..4] {
|
||||||
b"XPR2" => FileFormat::Xpr2Texture,
|
b"XPR2" => FileFormat::Xpr2Texture,
|
||||||
b"RIFF" => FileFormat::Riff, // could be WAV or XWB
|
b"RIFF" => FileFormat::Riff, // could be WAV or XWB
|
||||||
b"XWB\0" => FileFormat::XwbAudio, // Xbox Wave Bank
|
b"XWB\0" => FileFormat::XwbAudio, // Xbox Wave Bank
|
||||||
[0x89, b'P', b'N', b'G'] => FileFormat::Png,
|
[0x89, b'P', b'N', b'G'] => FileFormat::Png,
|
||||||
b"DDS " => FileFormat::Dds,
|
b"DDS " => FileFormat::Dds,
|
||||||
|
// No binary magic matched — fall back to a content heuristic so text
|
||||||
|
// configs (`config.ini`, etc.) are recognised even though they have no
|
||||||
|
// signature.
|
||||||
|
_ if is_probably_text(bytes) => FileFormat::Text,
|
||||||
_ => FileFormat::Unknown,
|
_ => FileFormat::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,6 +136,8 @@ pub enum FileFormat {
|
|||||||
XwbAudio,
|
XwbAudio,
|
||||||
Png,
|
Png,
|
||||||
Dds,
|
Dds,
|
||||||
|
/// Plain text (ASCII / UTF-8 / UTF-16) — `.ini` and similar.
|
||||||
|
Text,
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +149,162 @@ impl FileFormat {
|
|||||||
Self::XwbAudio => "xwb",
|
Self::XwbAudio => "xwb",
|
||||||
Self::Png => "png",
|
Self::Png => "png",
|
||||||
Self::Dds => "dds",
|
Self::Dds => "dds",
|
||||||
|
Self::Text => "txt",
|
||||||
Self::Unknown => "bin",
|
Self::Unknown => "bin",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Text detection & decoding (pure std, WASM-safe) ───────────────────────────
|
||||||
|
|
||||||
|
/// True if `b` is a byte we'd expect inside a text file: common whitespace,
|
||||||
|
/// printable ASCII, or any high byte (UTF-8 continuation / Latin-1). Mirrors the
|
||||||
|
/// printable convention used by the IDXD string-pool extractor.
|
||||||
|
#[inline]
|
||||||
|
fn is_text_byte(b: u8) -> bool {
|
||||||
|
matches!(b, b'\t' | b'\n' | b'\r') || (0x20..0x7f).contains(&b) || b >= 0x80
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Heuristic sniff for whether `bytes` is human-readable text.
|
||||||
|
///
|
||||||
|
/// Recognises UTF-8 / UTF-16 BOMs outright, BOM-less UTF-16LE by its
|
||||||
|
/// characteristic odd-position NUL bytes, and otherwise requires a byte sample
|
||||||
|
/// to be almost entirely text bytes with no embedded NUL (NUL is the strongest
|
||||||
|
/// binary signal). Decoding is left to [`decode_text`].
|
||||||
|
pub fn is_probably_text(bytes: &[u8]) -> bool {
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Byte-order marks are unambiguous.
|
||||||
|
if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) // UTF-8
|
||||||
|
|| bytes.starts_with(&[0xFF, 0xFE]) // UTF-16LE
|
||||||
|
|| bytes.starts_with(&[0xFE, 0xFF])
|
||||||
|
// UTF-16BE
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap the scan so huge files stay cheap.
|
||||||
|
let sample = &bytes[..bytes.len().min(4096)];
|
||||||
|
|
||||||
|
// BOM-less UTF-16LE: ASCII text encodes as `<char> 0x00 <char> 0x00 …`, so
|
||||||
|
// roughly half the bytes (the odd positions) are NUL and the even bytes are
|
||||||
|
// printable.
|
||||||
|
if sample.len() >= 16 {
|
||||||
|
let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count();
|
||||||
|
if nul_odd * 2 >= sample.len() / 2 {
|
||||||
|
let printable_even = sample.iter().step_by(2).filter(|&&b| is_text_byte(b)).count();
|
||||||
|
if printable_even * 2 >= sample.len() / 2 - 2 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-byte (ASCII / UTF-8): an embedded NUL means binary; otherwise
|
||||||
|
// require the overwhelming majority to be text bytes.
|
||||||
|
if sample.contains(&0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let text = sample.iter().filter(|&&b| is_text_byte(b)).count();
|
||||||
|
text * 100 >= sample.len() * 95
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode `bytes` to a `String`, returning `(text, encoding_label)`.
|
||||||
|
///
|
||||||
|
/// Honours UTF-8 / UTF-16LE / UTF-16BE BOMs and BOM-less UTF-16LE, falling back
|
||||||
|
/// to lossy UTF-8. Never fails — undecodable sequences become U+FFFD. Pure std,
|
||||||
|
/// so both the viewer and CLI can share it.
|
||||||
|
pub fn decode_text(bytes: &[u8]) -> (String, &'static str) {
|
||||||
|
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
|
||||||
|
return (String::from_utf8_lossy(rest).into_owned(), "UTF-8 (BOM)");
|
||||||
|
}
|
||||||
|
if let Some(rest) = bytes.strip_prefix(&[0xFF, 0xFE]) {
|
||||||
|
return (decode_utf16(rest, false), "UTF-16LE (BOM)");
|
||||||
|
}
|
||||||
|
if let Some(rest) = bytes.strip_prefix(&[0xFE, 0xFF]) {
|
||||||
|
return (decode_utf16(rest, true), "UTF-16BE (BOM)");
|
||||||
|
}
|
||||||
|
// BOM-less UTF-16LE detection (same signal as `is_probably_text`).
|
||||||
|
let sample = &bytes[..bytes.len().min(4096)];
|
||||||
|
if sample.len() >= 16 {
|
||||||
|
let nul_odd = sample.iter().skip(1).step_by(2).filter(|&&b| b == 0).count();
|
||||||
|
if nul_odd * 2 >= sample.len() / 2 {
|
||||||
|
return (decode_utf16(bytes, false), "UTF-16LE");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(String::from_utf8_lossy(bytes).into_owned(), "UTF-8 / ASCII")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_utf16(bytes: &[u8], big_endian: bool) -> String {
|
||||||
|
let units: Vec<u16> = bytes
|
||||||
|
.chunks_exact(2)
|
||||||
|
.map(|c| {
|
||||||
|
if big_endian {
|
||||||
|
u16::from_be_bytes([c[0], c[1]])
|
||||||
|
} else {
|
||||||
|
u16::from_le_bytes([c[0], c[1]])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
String::from_utf16_lossy(&units)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ascii_config_is_text() {
|
||||||
|
let ini = b"[Video]\r\nWidth=1280\r\nHeight=720\r\n";
|
||||||
|
assert!(is_probably_text(ini));
|
||||||
|
assert_eq!(identify_format(ini), FileFormat::Text);
|
||||||
|
let (text, enc) = decode_text(ini);
|
||||||
|
assert!(text.contains("Width=1280"));
|
||||||
|
assert_eq!(enc, "UTF-8 / ASCII");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn magic_takes_precedence_over_text() {
|
||||||
|
// An XPR2 header happens to start with printable bytes; the magic match
|
||||||
|
// must win so it's never misfiled as text.
|
||||||
|
let mut xpr = b"XPR2".to_vec();
|
||||||
|
xpr.extend_from_slice(&[0u8; 32]);
|
||||||
|
assert_eq!(identify_format(&xpr), FileFormat::Xpr2Texture);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn binary_with_nul_is_not_text() {
|
||||||
|
let bin = [0u8, 1, 2, 0xFF, 0x00, 0x89, 0x10, 0x00, 0x42];
|
||||||
|
assert!(!is_probably_text(&bin));
|
||||||
|
assert_eq!(identify_format(&bin), FileFormat::Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn utf16le_bom_is_text_and_decodes() {
|
||||||
|
let data = [0xFF, 0xFE, b'H', 0x00, b'i', 0x00];
|
||||||
|
assert!(is_probably_text(&data));
|
||||||
|
assert_eq!(identify_format(&data), FileFormat::Text);
|
||||||
|
let (text, enc) = decode_text(&data);
|
||||||
|
assert_eq!(text, "Hi");
|
||||||
|
assert_eq!(enc, "UTF-16LE (BOM)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bomless_utf16le_detected() {
|
||||||
|
// "Hello, world!!!!" as UTF-16LE, no BOM (>=16 bytes so the heuristic runs).
|
||||||
|
let mut data = Vec::new();
|
||||||
|
for &b in b"Hello, world!!!!" {
|
||||||
|
data.push(b);
|
||||||
|
data.push(0);
|
||||||
|
}
|
||||||
|
assert!(is_probably_text(&data));
|
||||||
|
let (text, enc) = decode_text(&data);
|
||||||
|
assert_eq!(text, "Hello, world!!!!");
|
||||||
|
assert_eq!(enc, "UTF-16LE");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_is_not_text() {
|
||||||
|
assert!(!is_probably_text(&[]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -90,6 +90,22 @@ pub struct FileInfo {
|
|||||||
pub detected_format: Option<FileFormat>,
|
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 ───────────────────────────────────────────────────────────
|
// ── SystemSet label ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// `draw_viewer_ui` runs `.after(IsoLoaderSystemSet)` to see the frame's
|
/// `draw_viewer_ui` runs `.after(IsoLoaderSystemSet)` to see the frame's
|
||||||
@@ -157,7 +173,10 @@ impl Plugin for IsoLoaderPlugin {
|
|||||||
.add_event::<FileSelected>()
|
.add_event::<FileSelected>()
|
||||||
.init_resource::<IsoState>()
|
.init_resource::<IsoState>()
|
||||||
.init_resource::<TexturePreview>()
|
.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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
@@ -392,6 +411,7 @@ fn poll_loader_channel(
|
|||||||
fn apply_loaded_texture(
|
fn apply_loaded_texture(
|
||||||
mut pending: ResMut<PendingFileBytes>,
|
mut pending: ResMut<PendingFileBytes>,
|
||||||
mut preview: ResMut<TexturePreview>,
|
mut preview: ResMut<TexturePreview>,
|
||||||
|
mut text_preview: ResMut<TextPreview>,
|
||||||
mut file_info: ResMut<FileInfo>,
|
mut file_info: ResMut<FileInfo>,
|
||||||
mut images: ResMut<Assets<Image>>,
|
mut images: ResMut<Assets<Image>>,
|
||||||
mut contexts: bevy_egui::EguiContexts,
|
mut contexts: bevy_egui::EguiContexts,
|
||||||
@@ -412,6 +432,9 @@ fn apply_loaded_texture(
|
|||||||
images.remove(handle.id());
|
images.remove(handle.id());
|
||||||
}
|
}
|
||||||
*preview = TexturePreview::default();
|
*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.
|
// Populate FileInfo for the status / info panel.
|
||||||
file_info.name = path
|
file_info.name = path
|
||||||
@@ -422,6 +445,25 @@ fn apply_loaded_texture(
|
|||||||
file_info.size_bytes = bytes.len();
|
file_info.size_bytes = bytes.len();
|
||||||
file_info.detected_format = Some(fmt);
|
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 {
|
if fmt != FileFormat::Xpr2Texture {
|
||||||
return; // Not a texture — FileInfo is sufficient
|
return; // Not a texture — FileInfo is sufficient
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ pub struct ViewerState {
|
|||||||
pub current_mode: ViewMode,
|
pub current_mode: ViewMode,
|
||||||
/// Whether to render meshes in wireframe mode (toggle with F2).
|
/// Whether to render meshes in wireframe mode (toggle with F2).
|
||||||
pub wireframe: bool,
|
pub wireframe: bool,
|
||||||
|
/// Whether the plain-text viewer wraps long lines.
|
||||||
|
pub text_wrap: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ViewerState {
|
impl Default for ViewerState {
|
||||||
@@ -39,6 +41,7 @@ impl Default for ViewerState {
|
|||||||
Self {
|
Self {
|
||||||
current_mode: ViewMode::Textures,
|
current_mode: ViewMode::Textures,
|
||||||
wireframe: false,
|
wireframe: false,
|
||||||
|
text_wrap: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use bevy::prelude::*;
|
|||||||
use bevy_egui::{egui, EguiContexts};
|
use bevy_egui::{egui, EguiContexts};
|
||||||
|
|
||||||
use crate::iso_loader::{
|
use crate::iso_loader::{
|
||||||
FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TexturePreview,
|
FileInfo, FileSelected, IsoState, RequestOpenDir, RequestOpenIso, TextPreview, TexturePreview,
|
||||||
IsoLoaderSystemSet,
|
IsoLoaderSystemSet,
|
||||||
};
|
};
|
||||||
use crate::{ViewerState, ViewMode};
|
use crate::{ViewerState, ViewMode};
|
||||||
@@ -33,12 +33,91 @@ pub struct FileBrowserState {
|
|||||||
pub filter: String,
|
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(
|
fn draw_viewer_ui(
|
||||||
mut contexts: EguiContexts,
|
mut contexts: EguiContexts,
|
||||||
mut viewer: ResMut<ViewerState>,
|
mut viewer: ResMut<ViewerState>,
|
||||||
mut browser: ResMut<FileBrowserState>,
|
mut browser: ResMut<FileBrowserState>,
|
||||||
iso_state: Res<IsoState>,
|
iso_state: Res<IsoState>,
|
||||||
preview: Res<TexturePreview>,
|
preview: Res<TexturePreview>,
|
||||||
|
text_preview: Res<TextPreview>,
|
||||||
file_info: Res<FileInfo>,
|
file_info: Res<FileInfo>,
|
||||||
mut open_iso_events: EventWriter<RequestOpenIso>,
|
mut open_iso_events: EventWriter<RequestOpenIso>,
|
||||||
mut open_dir_events: EventWriter<RequestOpenDir>,
|
mut open_dir_events: EventWriter<RequestOpenDir>,
|
||||||
@@ -138,26 +217,21 @@ fn draw_viewer_ui(
|
|||||||
or File → Open extracted folder.",
|
or File → Open extracted folder.",
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let filter = browser.filter.to_lowercase();
|
// Build a directory tree from the flat path list. Cheap to
|
||||||
let filtered: Vec<(usize, String)> = browser
|
// rebuild every frame (a few hundred short paths); cloning
|
||||||
.files
|
// `files` first sidesteps borrowing `browser` immutably and
|
||||||
.iter()
|
// mutably (selected) at the same time.
|
||||||
.enumerate()
|
let files = browser.files.clone();
|
||||||
.filter(|(_, f)| {
|
let tree = build_tree(&files, &browser.filter);
|
||||||
filter.is_empty() || f.to_lowercase().contains(&filter)
|
let force_open = !browser.filter.is_empty();
|
||||||
})
|
render_dir(
|
||||||
.map(|(i, f)| (i, f.clone()))
|
ui,
|
||||||
.collect();
|
&tree,
|
||||||
|
force_open,
|
||||||
for (idx, file) in &filtered {
|
&mut browser.selected,
|
||||||
let is_selected = browser.selected == Some(*idx);
|
&mut file_selected_events,
|
||||||
let label =
|
&files,
|
||||||
egui::SelectableLabel::new(is_selected, file.as_str());
|
);
|
||||||
if ui.add(label).clicked() {
|
|
||||||
browser.selected = Some(*idx);
|
|
||||||
file_selected_events.send(FileSelected(file.clone()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -184,7 +258,37 @@ fn draw_viewer_ui(
|
|||||||
// ── 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 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
|
// Texture preview
|
||||||
ui.heading(&file_info.name);
|
ui.heading(&file_info.name);
|
||||||
ui.label(&preview.format_info);
|
ui.label(&preview.format_info);
|
||||||
|
|||||||
Reference in New Issue
Block a user