1use std::collections::BTreeMap;
9use std::time::Instant;
10
11use wisp::application::{AppConfig, Application};
12use wisp::render::Renderer;
13use wisp::{Color, RenderTexture, Stage};
14
15use crate::stories::all_stories;
16use crate::story::Story;
17
18const CANVAS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
19
20pub struct StorybookApp {
22 wisp: Application,
23 renderer: Renderer,
24 stories: Vec<Story>,
25 current: usize,
26 stage: Stage,
27 rt: Option<CanvasRender>,
28 started: Instant,
29}
30
31struct CanvasRender {
32 texture: RenderTexture,
33 egui_id: egui::TextureId,
34 width: u32,
35 height: u32,
36}
37
38impl StorybookApp {
39 #[must_use]
41 pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
42 let render_state = cc
44 .wgpu_render_state
45 .as_ref()
46 .expect("storybook requires wgpu renderer");
47 let device = render_state.device.clone();
48 let queue = render_state.queue.clone();
49 let adapter = render_state.adapter.clone();
50 let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
52
53 let wisp = Application::from_wgpu(
54 instance,
55 adapter,
56 device,
57 queue,
58 AppConfig {
59 width: 1024,
60 height: 768,
61 ..Default::default()
62 },
63 );
64
65 let renderer = Renderer::new(&wisp, CANVAS_FORMAT).expect("renderer");
66 let stories = all_stories();
67 let mut stage = Stage::new();
68 if let Some(first) = stories.first() {
69 (first.build)(&wisp, &mut stage);
70 }
71
72 Self {
73 wisp,
74 renderer,
75 stories,
76 current: 0,
77 stage,
78 rt: None,
79 started: Instant::now(),
80 }
81 }
82
83 fn select_story(&mut self, idx: usize) {
84 if idx >= self.stories.len() || idx == self.current {
85 return;
86 }
87 self.current = idx;
88 self.stage = Stage::new();
89 let story = &self.stories[idx];
90 (story.build)(&self.wisp, &mut self.stage);
91 self.started = Instant::now();
92 }
93
94 #[allow(
95 clippy::cast_possible_truncation,
96 clippy::cast_sign_loss,
97 reason = "size clamped to [1, 8192] before cast"
98 )]
99 fn ensure_canvas(
100 &mut self,
101 ctx: &egui::Context,
102 frame: &mut eframe::Frame,
103 size: egui::Vec2,
104 ) -> Option<egui::TextureId> {
105 let render_state = frame.wgpu_render_state()?;
106
107 let w = size.x.clamp(1.0, 8192.0).round() as u32;
108 let h = size.y.clamp(1.0, 8192.0).round() as u32;
109 let w = w.max(1);
110 let h = h.max(1);
111
112 let needs_realloc = match &self.rt {
113 Some(c) => c.width != w || c.height != h,
114 None => true,
115 };
116
117 if needs_realloc {
118 if let Some(old) = self.rt.take() {
120 let mut renderer = render_state.renderer.write();
121 renderer.free_texture(&old.egui_id);
122 }
123 let texture = RenderTexture::with_format(&self.wisp, w, h, CANVAS_FORMAT);
124 let id = {
125 let mut renderer = render_state.renderer.write();
126 renderer.register_native_texture(
127 &render_state.device,
128 texture.view(),
129 wgpu::FilterMode::Linear,
130 )
131 };
132 self.rt = Some(CanvasRender {
133 texture,
134 egui_id: id,
135 width: w,
136 height: h,
137 });
138 }
139
140 let t = self.started.elapsed().as_secs_f32();
142 let story = &self.stories[self.current];
143 story.tick(&mut self.stage, t);
144
145 let canvas = self.rt.as_ref()?;
146 let _stats = self.renderer.render_stage(
147 &self.wisp,
148 canvas.texture.view(),
149 Color::rgba_u8(18, 18, 22, 255),
150 &self.stage,
151 );
152
153 ctx.request_repaint();
154 Some(canvas.egui_id)
155 }
156}
157
158impl eframe::App for StorybookApp {
159 fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
160 egui::TopBottomPanel::top("nav").show(ctx, |ui| {
162 ui.horizontal(|ui| {
163 ui.heading("wisp · storybook");
164 ui.separator();
165
166 let by_category = group_by_category(&self.stories);
167 for (cat, idxs) in &by_category {
168 ui.menu_button(*cat, |ui| {
169 for idx in idxs {
170 let story = &self.stories[*idx];
171 let label = format!("{} {}", story.milestone, story.title);
172 if ui.selectable_label(*idx == self.current, label).clicked() {
173 self.select_story(*idx);
174 ui.close_menu();
175 }
176 }
177 });
178 }
179 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
180 let s = &self.stories[self.current];
181 ui.label(
182 egui::RichText::new(format!("{} · {}", s.milestone, s.title)).strong(),
183 );
184 });
185 });
186 });
187
188 egui::SidePanel::right("writeup")
190 .resizable(false)
191 .exact_width(ctx.screen_rect().width() * 0.2)
192 .show(ctx, |ui| {
193 let story = &self.stories[self.current];
194 ui.add_space(8.0);
195 ui.heading(story.title);
196 ui.label(
197 egui::RichText::new(format!("{} · {}", story.category, story.milestone))
198 .small()
199 .weak(),
200 );
201 ui.separator();
202 egui::ScrollArea::vertical().show(ui, |ui| {
203 ui.label(story.writeup);
204 });
205 });
206
207 egui::CentralPanel::default().show(ctx, |ui| {
209 let avail = ui.available_size();
210 if let Some(id) = self.ensure_canvas(ctx, frame, avail) {
211 ui.image((id, avail));
212 } else {
213 ui.label("(canvas unavailable — wgpu render state missing)");
214 }
215 });
216 }
217}
218
219fn group_by_category(stories: &[Story]) -> BTreeMap<&'static str, Vec<usize>> {
220 let mut out: BTreeMap<&'static str, Vec<usize>> = BTreeMap::new();
221 for (i, s) in stories.iter().enumerate() {
222 out.entry(s.category).or_default().push(i);
223 }
224 out
225}