Start card: every sample as a card with its thumbnail, flagship first, scrolling (#245)

The Start card and File › Samples show a grid of sample cards (thumbnail, name,
one-line description, the full text on hover) in a scroll area with a solid
scrollbar; Help › Samples keeps rows in the same order. The card stays clear of
the view cube and the navigation bar.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-15 00:06:20 -04:00
1 parent 072139353f
commit d3ed8ffb05
7 files changed
+265 -27

No files matched your search

Generated
+1
View File
@@ -1436,6 +1436,7 @@ dependencies = [
"fieldcraft-model",
"fieldcraft-render",
"log",
"png",
"resvg",
"serde",
"serde_json",
+1
View File
@@ -22,4 +22,5 @@ serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
resvg = { workspace = true }
png = { workspace = true }
+1 -1
View File
@@ -330,7 +330,7 @@ fn help_menu(app: &mut ModelApp, ui: &mut egui::Ui) {
ui.set_max_width(340.0);
crate::frame::menu_heading(ui, &t, "SAMPLES");
let mut open = None;
crate::files::sample_rows(ui, &t, &mut open);
crate::samples::rows(ui, &t, &mut open);
ui.separator();
crate::frame::menu_heading(ui, &t, "NAVIGATION");
ui.label("Left drag orbits (or pans or zooms: the navigation bar), right or middle drag pans, the wheel zooms, F or a double click fits.");
+3 -24
View File
@@ -976,8 +976,9 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
}
Some(Prompt::Samples) => {
let (mut open, mut close) = (None, false);
crate::frame::window(ctx, "Samples", crate::frame::Width::Normal).id(egui::Id::new("fc_samples")).show(ctx, |ui| {
sample_rows(ui, &t, &mut open);
crate::frame::window(ctx, "Samples", crate::frame::Width::Wide).id(egui::Id::new("fc_samples")).show(ctx, |ui| {
ui.set_width(crate::frame::WINDOW_WIDE_MAX_W.min(ctx.content_rect().width() - 48.0));
crate::samples::grid(ui, &t, (ctx.content_rect().height() * 0.6).max(160.0), &mut open);
ui.add_space(6.0);
close = ui.button("Close").clicked();
});
@@ -992,28 +993,6 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) {
}
}
/// One row per sample: its name, what it shows and an Open button; `open` gets the one clicked.
pub fn sample_rows(ui: &mut egui::Ui, t: &crate::theme::Tokens, open: &mut Option<&'static str>) {
let width = ui.available_width().clamp(280.0, 420.0);
egui::ScrollArea::vertical().auto_shrink([false, true]).show(ui, |ui| sample_list(ui, t, open, width));
}
fn sample_list(ui: &mut egui::Ui, t: &crate::theme::Tokens, open: &mut Option<&'static str>, width: f32) {
for sample in fieldcraft_engine::sample::SAMPLES {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(width - 70.0);
ui.label(egui::RichText::new(sample.label).strong());
ui.add(egui::Label::new(egui::RichText::new(sample.about).small().color(t.text_dim)).wrap());
});
if ui.button("Open").clicked() {
*open = Some(sample.name);
}
});
ui.add_space(4.0);
}
}
impl ModelApp {
/// The recover window's entries, read from disk once while it is open.
fn recover_list(&mut self, ctx: &egui::Context) -> Vec<Value> {
+67
View File
@@ -42,6 +42,7 @@ pub mod palette;
pub mod reply;
pub mod results;
pub mod results_panel;
pub mod samples;
pub mod scene;
pub mod selection;
pub mod theme;
@@ -1962,6 +1963,72 @@ mod tests {
(cam.yaw - yaw).abs() < 1e-9 && (cam.pitch - pitch).abs() < 1e-9
}
/// Every registered sample is on the Start card and opens with a click, at 1280 × 800 and in a
/// small window where the grid scrolls (#245). Unsaved work (a material, no geometry) keeps the
/// Start card up and turns the click into the question naming that sample, so nothing loads.
#[test]
fn every_sample_is_reachable_from_the_start_card() {
for (w, h) in [(1280.0, 800.0), (900.0, 560.0)] {
let mut scrolled = false;
for sample in fieldcraft_engine::sample::SAMPLES {
let mut app = ModelApp::new(Session::default());
app.run("model.material.create", json!({"name": "Steel", "elastic": {"type": "isotropic", "young": 210000, "poisson": 0.3}}))
.unwrap();
let ctx = egui::Context::default();
let mut time = 0.0;
let mut frame = |app: &mut ModelApp, events: Vec<egui::Event>| {
time += 0.1;
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, h))),
time: Some(time),
events,
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| app.ui(ui));
out.textures_delta.clear();
};
frame(&mut app, vec![]);
frame(&mut app, vec![]);
let card = |ctx: &egui::Context| ctx.read_response(samples::card_id(sample.name)).map(|r| (r.rect, r.interact_rect));
// Wheel down over the grid until the card is in view: wholly, or (when the grid is
// shorter than a card) most of it.
let in_view = |c: Option<(egui::Rect, egui::Rect)>| {
c.is_some_and(|(r, i)| i.is_positive() && i.height() >= (r.height() * 0.6).min(r.height() - 0.5))
};
// Over the first card, which is in view before any scrolling.
let first = ctx
.read_response(samples::card_id(samples::ordered()[0].name))
.map_or(egui::pos2(w / 2.0, h / 2.0), |r| r.interact_rect.center());
for _ in 0..40 {
if in_view(card(&ctx)) {
break;
}
let wheel = egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: egui::vec2(0.0, -60.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
};
frame(&mut app, vec![egui::Event::PointerMoved(first), wheel]);
frame(&mut app, vec![]);
scrolled = true;
}
assert!(in_view(card(&ctx)), "{w}×{h}: {} is never in view: {:?}", sample.name, card(&ctx));
let at = card(&ctx).map_or(egui::Pos2::ZERO, |(_, i)| i.center());
let button =
|pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
frame(&mut app, vec![egui::Event::PointerMoved(at)]);
frame(&mut app, vec![button(true)]);
frame(&mut app, vec![button(false)]);
assert_eq!(app.prompt, Some(files::Prompt::Unsaved(files::Replace::Sample(sample.name))), "{w}×{h}: clicking {}", sample.name);
}
// The small window cannot show them all at once.
if w < 1000.0 {
assert!(scrolled, "{w}×{h}");
}
}
}
/// Opening a sample, then another, frames each in the home view; an edit keeps the camera
/// (#233, #223).
#[test]
+179
View File
@@ -0,0 +1,179 @@
//! The samples as a grid of cards (the Start card, File › Samples) and as rows (Help › Samples):
//! every sample of `fieldcraft_engine::sample::SAMPLES`, the flagship ones first, each with its
//! thumbnail where one exists. Thumbnails are the gallery captures (docs/media/examples) cropped
//! and scaled to 320 px, embedded at build time so they work on the web too.
use egui::{Rect, Sense, TextureHandle, vec2};
use fieldcraft_engine::sample::{SAMPLES, Sample};
use crate::theme::Tokens;
/// The order the samples are offered in: these first, then any other in the registry's order.
const FLAGSHIP: &[&str] = &["crank-arm", "drop-test", "rubber-seal", "heated-bracket", "bracket-hex", "flow-past", "cantilever", "impact", "parts"];
/// The embedded thumbnails (PNG, 320 px wide), by sample name.
const THUMBNAILS: &[(&str, &[u8])] = &[
("crank-arm", include_bytes!("../thumbnails/crank-arm.png")),
("crank-mirror", include_bytes!("../thumbnails/crank-mirror.png")),
("drop-test", include_bytes!("../thumbnails/drop-test.png")),
("rubber-seal", include_bytes!("../thumbnails/rubber-seal.png")),
("heated-bracket", include_bytes!("../thumbnails/heated-bracket.png")),
("bracket-hex", include_bytes!("../thumbnails/bracket-hex.png")),
];
/// The thumbnails' aspect ratio (height over width).
const THUMB_ASPECT: f32 = 213.0 / 320.0;
/// The narrowest card; the grid fits as many columns as this allows.
const MIN_CARD_W: f32 = 180.0;
const MAX_COLUMNS: usize = 4;
const GAP: f32 = 10.0;
/// Every sample, flagship first.
pub fn ordered() -> Vec<&'static Sample> {
let rank = |s: &Sample| FLAGSHIP.iter().position(|n| *n == s.name).unwrap_or(FLAGSHIP.len());
let mut out: Vec<&'static Sample> = SAMPLES.iter().collect();
// Stable: samples outside the flagship list keep the registry's order.
out.sort_by_key(|s| rank(s));
out
}
/// The id of a sample's card in the grid (tests and the control channel read its response).
pub fn card_id(name: &str) -> egui::Id {
egui::Id::new(("fc_sample_card", name))
}
/// A PNG decoded to an egui image; `None` when it does not decode.
fn decode(bytes: &[u8]) -> Option<egui::ColorImage> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(png::Transformations::normalize_to_color8());
let mut reader = decoder.read_info().ok()?;
let mut buf = vec![0; reader.output_buffer_size()?];
let info = reader.next_frame(&mut buf).ok()?;
let size = [info.width as usize, info.height as usize];
let pixels = buf.get(..info.buffer_size())?;
match info.color_type {
png::ColorType::Rgba => Some(egui::ColorImage::from_rgba_unmultiplied(size, pixels)),
png::ColorType::Rgb => Some(egui::ColorImage::from_rgb(size, pixels)),
_ => None,
}
}
/// The thumbnail of sample `name`, uploaded once; `None` when it has none.
fn thumbnail(ctx: &egui::Context, name: &'static str) -> Option<TextureHandle> {
let key = egui::Id::new(("fc_sample_thumbnail", name));
if let Some(cached) = ctx.data(|d| d.get_temp::<Option<TextureHandle>>(key)) {
return cached;
}
let texture = THUMBNAILS.iter().find(|(n, _)| *n == name).and_then(|(_, bytes)| {
let image = decode(bytes);
if image.is_none() {
log::warn!("sample thumbnail {name}: does not decode");
}
image.map(|i| ctx.load_texture(format!("sample:{name}"), i, egui::TextureOptions::LINEAR))
});
ctx.data_mut(|d| d.insert_temp(key, texture.clone()));
texture
}
/// The samples as a grid of cards in a vertical scroll area with a solid (always drawn)
/// scrollbar; `open` gets the one clicked.
pub fn grid(ui: &mut egui::Ui, t: &Tokens, max_height: f32, open: &mut Option<&'static str>) {
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
egui::ScrollArea::vertical().id_salt("fc_sample_grid").max_height(max_height).auto_shrink([false, true]).show(ui, |ui| {
let width = ui.available_width();
let columns = (((width + GAP) / (MIN_CARD_W + GAP)).floor() as usize).clamp(1, MAX_COLUMNS);
let card_w = ((width - GAP * (columns as f32 - 1.0)) / columns as f32).max(1.0);
for row in ordered().chunks(columns) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = GAP;
for sample in row {
if card(ui, t, sample, card_w) {
*open = Some(sample.name);
}
}
});
ui.add_space(GAP);
}
});
}
/// One sample's card: thumbnail, name and a one-line description (in full on hover). True when
/// clicked.
fn card(ui: &mut egui::Ui, t: &Tokens, sample: &'static Sample, w: f32) -> bool {
let thumb_h = (w * THUMB_ASPECT).round();
let text_h = 40.0;
let (rect, _) = ui.allocate_exact_size(vec2(w, thumb_h + text_h), Sense::hover());
let response = ui.interact(rect, card_id(sample.name), Sense::click()).on_hover_text(sample.about);
let painter = ui.painter_at(rect);
let fill = if response.hovered() { t.hover } else { t.panel };
painter.rect_filled(rect, 6.0, fill);
let image_rect = Rect::from_min_size(rect.min, vec2(w, thumb_h)).shrink(4.0);
match thumbnail(ui.ctx(), sample.name) {
Some(texture) => {
egui::Image::new(&texture).corner_radius(4.0).paint_at(ui, image_rect);
}
None => {
// No capture yet: the name's initial on a plain tile.
painter.rect_filled(image_rect, 4.0, t.accent_soft);
let initial = sample.label.chars().next().map(String::from).unwrap_or_default();
painter.text(image_rect.center(), egui::Align2::CENTER_CENTER, initial, egui::FontId::proportional(thumb_h * 0.4), t.accent);
}
}
let stroke = if response.hovered() { t.accent } else { t.border };
painter.rect_stroke(rect, 6.0, egui::Stroke::new(1.0, stroke), egui::StrokeKind::Inside);
let text_rect = Rect::from_min_max(egui::pos2(rect.left() + 6.0, rect.top() + thumb_h + 2.0), rect.max - vec2(6.0, 2.0));
let mut text_ui = ui.new_child(egui::UiBuilder::new().max_rect(text_rect).layout(egui::Layout::top_down(egui::Align::Min)));
text_ui.add(egui::Label::new(egui::RichText::new(sample.label).strong().color(t.overlay_text)).truncate().selectable(false));
text_ui.add(egui::Label::new(egui::RichText::new(sample.about).small().color(t.text_dim)).truncate().selectable(false));
response.clicked()
}
/// The samples as rows (name, what it shows, Open) for a narrow menu; `open` gets the one clicked.
pub fn rows(ui: &mut egui::Ui, t: &Tokens, open: &mut Option<&'static str>) {
let width = ui.available_width().clamp(280.0, 420.0);
ui.spacing_mut().scroll = egui::style::ScrollStyle::solid();
egui::ScrollArea::vertical().id_salt("fc_sample_rows").auto_shrink([false, true]).show(ui, |ui| {
for sample in ordered() {
ui.horizontal(|ui| {
ui.vertical(|ui| {
ui.set_width(width - 70.0);
ui.label(egui::RichText::new(sample.label).strong());
ui.add(egui::Label::new(egui::RichText::new(sample.about).small().color(t.text_dim)).wrap());
});
if ui.button("Open").clicked() {
*open = Some(sample.name);
}
});
ui.add_space(4.0);
}
});
}
#[cfg(test)]
mod tests {
use super::*;
/// Every registered sample is offered exactly once, flagship first.
#[test]
fn every_sample_is_offered_once_flagship_first() {
let names: Vec<&str> = ordered().iter().map(|s| s.name).collect();
assert_eq!(names.len(), SAMPLES.len());
for s in SAMPLES {
assert_eq!(names.iter().filter(|n| **n == s.name).count(), 1, "{}", s.name);
}
assert_eq!(names.first(), Some(&"crank-arm"));
}
/// Every flagship name and every thumbnail names a registered sample, and every thumbnail
/// decodes at 320 px wide.
#[test]
fn thumbnails_and_flagship_names_are_registered_samples() {
for name in FLAGSHIP.iter().chain(THUMBNAILS.iter().map(|(n, _)| n)) {
assert!(fieldcraft_engine::sample::find(name).is_some(), "{name}");
}
for (name, bytes) in THUMBNAILS {
let image = decode(bytes).unwrap_or_else(|| panic!("{name} does not decode"));
assert_eq!(image.size[0], 320, "{name}");
}
}
}
+13 -2
View File
@@ -92,11 +92,20 @@ fn overlays(app: &mut ModelApp, ui: &egui::Ui, rect: Rect, t: &Tokens) -> Vec<Re
out
}
/// The Start card's largest size; a smaller viewport shrinks it and its samples scroll.
const START_W: f32 = 820.0;
const START_H: f32 = 620.0;
/// The margins the view cube and the navigation bar keep.
const START_RIGHT: f32 = 130.0;
const START_BOTTOM: f32 = 56.0;
/// The empty viewport: open a file or a sample (the samples are ready to solve).
fn start_screen(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
let mut open = None;
let mut pick = false;
let area = Rect::from_center_size(rect.center(), vec2(440.0_f32.min(rect.width() - 24.0), 300.0_f32.min(rect.height() - 24.0)));
// Clear of the view cube (top right) and the navigation bar (bottom centre).
let free = Rect::from_min_max(rect.min + vec2(12.0, 12.0), rect.max - vec2(START_RIGHT, START_BOTTOM));
let area = Rect::from_center_size(free.center(), vec2(START_W.min(free.width()), START_H.min(free.height())));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(area).layout(egui::Layout::top_down(egui::Align::Min)));
egui::Frame::NONE.fill(t.overlay).corner_radius(6.0).inner_margin(egui::Margin::same(14)).show(&mut child, |ui| {
ui.label(egui::RichText::new("Start").size(16.0).color(t.overlay_text));
@@ -105,7 +114,9 @@ fn start_screen(app: &mut ModelApp, ui: &mut egui::Ui, rect: Rect, t: &Tokens) {
pick = ui.button("Open…").clicked();
ui.add_space(8.0);
ui.label(egui::RichText::new("Samples").color(t.overlay_text));
crate::files::sample_rows(ui, t, &mut open);
// The grid takes what the card has left and scrolls the rest.
let left = (area.bottom() - ui.cursor().top() - 14.0).max(60.0);
crate::samples::grid(ui, t, left, &mut open);
});
if pick {
app.open_dialog(None);