Skip to content

Commit 9b92fe3

Browse files
jabberwockclaude
andcommitted
feat: port preview panel + bundle design-system fonts
Finishes the variant 02 design port: - Preview panel: panel_header w/ extension pill, frost-variant meta_row (rune-blue keys, primary mono values), section_label mastheads in place of nine bare ui.separator() calls. - Bundle Cinzel Decorative (display), Inter (body), JetBrains Mono (mono), OpenDyslexic Regular+Bold (a11y) — all OFL, baked via include_bytes!. engraved() and section_label() now render in Cinzel. - View → Dyslexia-friendly type swaps Proportional+Monospace to OpenDyslexic at runtime per the design system's a11y opt-in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 18c1f0b commit 9b92fe3

9 files changed

Lines changed: 161 additions & 45 deletions

File tree

60.8 KB
Binary file not shown.
856 KB
Binary file not shown.
183 KB
Binary file not shown.
180 KB
Binary file not shown.
172 KB
Binary file not shown.

rustydemon/src/app.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,11 @@ pub struct CascExplorerApp {
175175
// ── 3D viewport spike ─────────────────────────────────────────────────────
176176
pub viewport3d_open: bool,
177177

178+
// ── Accessibility ─────────────────────────────────────────────────────────
179+
/// Swap Inter / JetBrains Mono for OpenDyslexic per the design
180+
/// system's a11y opt-in. Toggled from View → Dyslexia-friendly type.
181+
pub dyslexia_friendly: bool,
182+
178183
// ── Deferred actions ──────────────────────────────────────────────────────
179184
/// A viewer-override change requested on the previous frame. Applied
180185
/// at the top of the next `update` so the old `PreviewOutput`'s GPU
@@ -237,6 +242,7 @@ impl CascExplorerApp {
237242
cancel: Arc::new(AtomicBool::new(false)),
238243
loading: false,
239244
viewport3d_open: false,
245+
dyslexia_friendly: false,
240246
pending_preview_override: None,
241247
audio_player: None,
242248
pending_audio_action: None,

rustydemon/src/ui/menu.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ pub fn draw_menu(ctx: &Context, app: &mut CascExplorerApp) {
128128
}
129129
ui.separator();
130130
ui.checkbox(&mut app.viewport3d_open, "3D Test Viewport");
131+
let mut dys = app.dyslexia_friendly;
132+
if ui.checkbox(&mut dys, "Dyslexia-friendly type").changed() {
133+
app.dyslexia_friendly = dys;
134+
crate::ui::theme::set_fonts(ctx, dys);
135+
}
131136
});
132137

133138
// ── Tools ─────────────────────────────────────────────────────────

rustydemon/src/ui/preview.rs

Lines changed: 42 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,17 @@ use crate::ui::theme::{self, rd};
33

44
/// Draw the right-panel details/preview area.
55
pub fn draw_preview(ui: &mut egui::Ui, app: &mut CascExplorerApp) {
6-
ui.add_space(2.0);
7-
ui.label(theme::engraved("Details / Preview"));
8-
ui.separator();
6+
let pill = app
7+
.selected
8+
.as_ref()
9+
.and_then(|s| s.result.filename.as_deref())
10+
.and_then(|n| n.rsplit('.').next())
11+
.map(str::to_uppercase);
12+
theme::panel_header(ui, "Details / Preview", pill.as_deref());
913

1014
if app.selected.is_none() {
11-
ui.label("Select a file to preview it.");
15+
ui.add_space(8.0);
16+
ui.label(egui::RichText::new("Select a file to preview it.").color(rd::FG_MUTED));
1217
return;
1318
}
1419

@@ -53,18 +58,20 @@ fn draw_preview_body(ui: &mut egui::Ui, app: &mut CascExplorerApp) {
5358
}
5459
});
5560

56-
ui.separator();
57-
5861
// ── Error ──────────────────────────────────────────────────────────────────
5962
if let Some(err) = &sel.load_error {
63+
theme::section_label(ui, "Error");
6064
ui.colored_label(rd::DANGER, format!("⚠ {err}"));
6165
return;
6266
}
6367

6468
// ── Loading spinner while the background task is running ──────────────────
6569
if sel.data.is_none() && sel.load_error.is_none() && app.loading {
66-
ui.spinner();
67-
ui.label("Loading file data…");
70+
ui.add_space(8.0);
71+
ui.horizontal(|ui| {
72+
ui.spinner();
73+
ui.label(egui::RichText::new("Loading file data…").color(rd::FG_MUTED));
74+
});
6875
return;
6976
}
7077

@@ -81,8 +88,8 @@ fn draw_preview_body(ui: &mut egui::Ui, app: &mut CascExplorerApp) {
8188
None => "Auto".to_string(),
8289
Some(i) => names.get(i).cloned().unwrap_or_else(|| format!("#{i}")),
8390
};
91+
theme::section_label(ui, "Viewer");
8492
ui.horizontal(|ui| {
85-
ui.label("Viewer:");
8693
egui::ComboBox::from_id_salt("preview_override")
8794
.selected_text(current_label)
8895
.show_ui(ui, |ui| {
@@ -102,41 +109,48 @@ fn draw_preview_body(ui: &mut egui::Ui, app: &mut CascExplorerApp) {
102109
}
103110
});
104111
});
105-
ui.separator();
106112
}
107113

108114
// ── Plugin-provided preview ───────────────────────────────────────────────
109115
if let Some(preview) = &sel.preview {
110116
// Texture (inline image).
111117
if let Some(tex) = &preview.texture {
112118
let tex_size = tex.size_vec2();
119+
theme::section_label(
120+
ui,
121+
&format!(
122+
"Texture · {}×{}",
123+
tex_size.x.round() as u32,
124+
tex_size.y.round() as u32
125+
),
126+
);
113127
let max_w = ui.available_width();
114128
let scale = (max_w / tex_size.x).min(1.0);
115129
let display_size = tex_size * scale;
116130
ui.image((tex.id(), display_size));
117-
ui.separator();
118131
}
119132

120133
// 3D mesh viewport (currently only WMO group geometry).
121134
if let Some(mesh) = preview.mesh3d.clone() {
135+
theme::section_label(ui, "Mesh");
122136
crate::viewport3d::paint_mesh(ui, mesh);
123-
ui.separator();
124137
}
125138

126139
// Text block (formatted summary or full text file). The outer
127140
// ScrollArea handles overflow; this widget just lays out as tall
128141
// as its contents.
129142
if let Some(text) = &preview.text {
143+
theme::section_label(ui, "Text");
130144
ui.add(
131145
egui::TextEdit::multiline(&mut text.as_str())
132146
.font(egui::TextStyle::Monospace)
133147
.desired_width(f32::INFINITY),
134148
);
135-
ui.separator();
136149
}
137150
} else if let Some(data) = &sel.data {
138151
// ── Hex-dump fallback (no plugin claimed this file) ───────────────────
139152
let preview_len = data.len().min(256);
153+
theme::section_label(ui, &format!("Hex · first {preview_len} bytes"));
140154
let hex: String = data[..preview_len]
141155
.chunks(16)
142156
.enumerate()
@@ -166,22 +180,20 @@ fn draw_preview_body(ui: &mut egui::Ui, app: &mut CascExplorerApp) {
166180

167181
// ── Deep-search content matches ────────────────────────────────────────────
168182
if !sel.content_matches.is_empty() {
169-
ui.separator();
170-
ui.label(format!(
171-
"Deep search: {} entries",
172-
sel.content_matches.len()
173-
));
183+
theme::section_label(
184+
ui,
185+
&format!("Deep search · {} entries", sel.content_matches.len()),
186+
);
174187
for m in &sel.content_matches {
175188
ui.label(
176189
egui::RichText::new(format!("[{}] {}", m.kind, m.inner_path))
177190
.small()
178-
.monospace(),
191+
.monospace()
192+
.color(rd::FG_PRIMARY),
179193
);
180194
}
181195
}
182196

183-
ui.separator();
184-
185197
// ── PCX palette picker (for SC1 assets with external palettes) ───────────
186198
let is_pcx = sel
187199
.result
@@ -199,6 +211,7 @@ fn draw_preview_body(ui: &mut egui::Ui, app: &mut CascExplorerApp) {
199211
let mut audio_action: Option<crate::app::AudioAction> = None;
200212

201213
if is_pcx {
214+
theme::section_label(ui, "PCX palette");
202215
ui.horizontal(|ui| {
203216
if ui
204217
.button("Load Palette…")
@@ -218,7 +231,6 @@ fn draw_preview_body(ui: &mut egui::Ui, app: &mut CascExplorerApp) {
218231
);
219232
}
220233
});
221-
ui.separator();
222234
}
223235

224236
// ── Audio playback controls ───────────────────────────────────────────────
@@ -234,6 +246,7 @@ fn draw_preview_body(ui: &mut egui::Ui, app: &mut CascExplorerApp) {
234246
.map(crate::audio::is_audio_filename)
235247
.unwrap_or(false);
236248
if is_audio && sel.data.is_some() {
249+
theme::section_label(ui, "Audio");
237250
// Look at the live player (if any) to decide button states.
238251
// We can't borrow `app` mutably here, but the immutable peek
239252
// at `audio_player` is fine because we only read is_playing /
@@ -320,12 +333,11 @@ fn draw_preview_body(ui: &mut egui::Ui, app: &mut CascExplorerApp) {
320333
ui.ctx()
321334
.request_repaint_after(std::time::Duration::from_millis(200));
322335
}
323-
324-
ui.separator();
325336
}
326337

327338
// ── Export buttons ─────────────────────────────────────────────────────────
328339
if sel.data.is_some() {
340+
theme::section_label(ui, "Export");
329341
ui.horizontal(|ui| {
330342
// Plugin-provided exports (e.g. "Export As PNG", "Export As BK2").
331343
if let Some(preview) = &sel.preview {
@@ -548,25 +560,20 @@ fn export_raw(app: &CascExplorerApp) {
548560
}
549561

550562
fn meta_row(ui: &mut egui::Ui, label: &str, value: &str) {
551-
// Muted key, primary/rune value — technical data (FDID, Hash, CKey,
552-
// Size) gets monospace + rune-blue per the design system; display
553-
// fields (Name, Type, Locale) stay in the primary body color.
554-
ui.label(
555-
egui::RichText::new(label)
556-
.small()
557-
.color(rd::FG_MUTED)
558-
.strong(),
559-
);
563+
// Frost-variant `.v-meta`: rune-blue bold keys, primary values.
564+
// Technical fields (FDID, Hash, CKey, Size) render mono; display
565+
// fields (Name, Type, Locale) stay proportional.
566+
ui.label(egui::RichText::new(label).strong().color(rd::RUNE_400));
560567
let is_technical = matches!(label, "FDID:" | "Hash:" | "CKey:" | "Size:");
561568
let max_chars = 24;
562569
let (display, tip): (String, Option<&str>) = if value.chars().count() > max_chars {
563570
(format!("{}…", &value[..max_chars]), Some(value))
564571
} else {
565572
(value.to_string(), None)
566573
};
567-
let mut rt = egui::RichText::new(&display);
574+
let mut rt = egui::RichText::new(&display).color(rd::FG_PRIMARY);
568575
if is_technical {
569-
rt = rt.monospace().color(rd::RUNE_400);
576+
rt = rt.monospace();
570577
}
571578
let resp = ui.label(rt);
572579
if let Some(full) = tip {

rustydemon/src/ui/theme.rs

Lines changed: 108 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,31 @@
44
//! into a single `apply(ctx)` call. Cold gunmetal surfaces, rune-blue for
55
//! technical data, ember fills for selection / focus / active state.
66
//!
7-
//! Fonts: the design system names Cinzel Decorative / Inter / JetBrains
8-
//! Mono as the display/body/mono faces, but none of those are shipped
9-
//! locally (only OpenDyslexic, an a11y alternate, is on disk). We keep
10-
//! eframe's `default_fonts` for now and only reshape colors, strokes,
11-
//! radii, and the type scale. Dropping the three Google Fonts TTFs into
12-
//! `assets/fonts/` and wiring them through `FontDefinitions` is a clean
13-
//! follow-up — no other theme code needs to change.
7+
//! Fonts: bundled OFL faces baked into the binary via `include_bytes!` —
8+
//! Cinzel Decorative for engraved mastheads, Inter for body/UI, JetBrains
9+
//! Mono for hashes/hex. OpenDyslexic Regular/Bold ship as the a11y
10+
//! alternate the design system explicitly calls for; toggle via
11+
//! View → Dyslexia-friendly type, which calls `set_fonts(ctx, true)`.
1412
15-
use egui::{Color32, Context, FontId, Rounding, Stroke, Style, TextStyle, Visuals};
13+
use egui::{
14+
Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Rounding, Stroke, Style,
15+
TextStyle, Visuals,
16+
};
17+
use std::sync::Arc;
18+
19+
// ── Bundled font data (baked into the binary) ──────────────────────────────
20+
// All four faces are OFL-licensed; safe to ship with this AGPL/FOSS tool.
21+
const FONT_INTER: &[u8] = include_bytes!("../../assets/fonts/Inter-Regular.ttf");
22+
const FONT_JETBRAINS: &[u8] = include_bytes!("../../assets/fonts/JetBrainsMono-Regular.ttf");
23+
const FONT_CINZEL: &[u8] = include_bytes!("../../assets/fonts/CinzelDecorative-Bold.ttf");
24+
const FONT_DYSLEXIA_REG: &[u8] = include_bytes!("../../assets/fonts/OpenDyslexic-Regular.otf");
25+
const FONT_DYSLEXIA_BOLD: &[u8] = include_bytes!("../../assets/fonts/OpenDyslexic-Bold.otf");
26+
27+
/// Custom font family used by `engraved()` / `section_label()` — Cinzel
28+
/// Decorative, the medieval-Roman display face from the design system.
29+
fn display_family() -> FontFamily {
30+
FontFamily::Name(Arc::from("display"))
31+
}
1632

1733
/// RustyDemon design tokens, transcribed from
1834
/// `RustyDemon Design System/colors_and_type.css`. Keep in sync if the
@@ -68,9 +84,64 @@ pub mod rd {
6884
pub const SUCCESS: Color32 = Color32::from_rgb(0x4a, 0xa5, 0x64);
6985
}
7086

87+
/// Install the bundled design-system fonts on `ctx`.
88+
///
89+
/// `dyslexia_friendly = false` (default): Inter for body/UI, JetBrains
90+
/// Mono for hashes/hex, Cinzel Decorative as a custom `display` family
91+
/// for engraved mastheads.
92+
///
93+
/// `dyslexia_friendly = true`: swaps Inter and JetBrains Mono out for
94+
/// OpenDyslexic Regular / Bold — the a11y alternate the design system
95+
/// ships specifically for this toggle. Cinzel stays put (the engraved
96+
/// look is brand identity, not body type).
97+
///
98+
/// Safe to call any time; egui re-shapes affected text on the next frame.
99+
pub fn set_fonts(ctx: &Context, dyslexia_friendly: bool) {
100+
let mut fonts = FontDefinitions::default();
101+
102+
fonts
103+
.font_data
104+
.insert("rd_cinzel".into(), FontData::from_static(FONT_CINZEL));
105+
106+
if dyslexia_friendly {
107+
fonts.font_data.insert(
108+
"rd_proportional".into(),
109+
FontData::from_static(FONT_DYSLEXIA_REG),
110+
);
111+
fonts.font_data.insert(
112+
"rd_monospace".into(),
113+
FontData::from_static(FONT_DYSLEXIA_BOLD),
114+
);
115+
} else {
116+
fonts
117+
.font_data
118+
.insert("rd_proportional".into(), FontData::from_static(FONT_INTER));
119+
fonts
120+
.font_data
121+
.insert("rd_monospace".into(), FontData::from_static(FONT_JETBRAINS));
122+
}
123+
124+
fonts
125+
.families
126+
.entry(FontFamily::Proportional)
127+
.or_default()
128+
.insert(0, "rd_proportional".into());
129+
fonts
130+
.families
131+
.entry(FontFamily::Monospace)
132+
.or_default()
133+
.insert(0, "rd_monospace".into());
134+
fonts
135+
.families
136+
.insert(display_family(), vec!["rd_cinzel".into()]);
137+
138+
ctx.set_fonts(fonts);
139+
}
140+
71141
/// Install the RustyDemon visual theme on `ctx`. Safe to call once at
72142
/// startup — takes immediate effect and persists across frames.
73143
pub fn apply(ctx: &Context) {
144+
set_fonts(ctx, false);
74145
let mut visuals = Visuals::dark();
75146

76147
visuals.dark_mode = true;
@@ -188,8 +259,7 @@ pub fn engraved(text: &str) -> egui::RichText {
188259
.join("\u{2009}");
189260
egui::RichText::new(spaced)
190261
.color(rd::FROST_700)
191-
.size(13.0)
192-
.strong()
262+
.font(FontId::new(13.0, display_family()))
193263
}
194264

195265
// ── v2 additions: panel chrome, row frames, meta grid helpers ────────────
@@ -252,3 +322,31 @@ pub fn filepath_line(text: &str) -> egui::RichText {
252322
.size(10.5)
253323
.color(rd::FG_MUTED)
254324
}
325+
326+
/// Engraved section label inside a panel — the `.v-sectlabel` from the
327+
/// frost variant (10.5px, uppercase, wide tracking, rune-blue, hairline
328+
/// underneath). Use to introduce sub-sections of the preview panel
329+
/// (`Hex · first 256 bytes`, `Export`, `Audio`, etc.) instead of a bare
330+
/// `ui.separator()`.
331+
pub fn section_label(ui: &mut egui::Ui, text: &str) {
332+
ui.add_space(8.0);
333+
let spaced: String = text
334+
.to_uppercase()
335+
.chars()
336+
.map(|c| c.to_string())
337+
.collect::<Vec<_>>()
338+
.join("\u{2009}");
339+
ui.label(
340+
egui::RichText::new(spaced)
341+
.font(FontId::new(10.5, display_family()))
342+
.color(rd::RUNE_400),
343+
);
344+
let avail = ui.available_width();
345+
let (rect, _) = ui.allocate_exact_size(egui::vec2(avail, 1.0), egui::Sense::hover());
346+
ui.painter().hline(
347+
rect.x_range(),
348+
rect.center().y,
349+
egui::Stroke::new(1.0, egui::Color32::from_rgba_premultiplied(29, 50, 71, 153)),
350+
);
351+
ui.add_space(4.0);
352+
}

0 commit comments

Comments
 (0)