Skip to main content

wisp_storybook/
app.rs

1//! eframe `App` for the storybook.
2//!
3//! Layout: top bar (story picker) + 4/5 center canvas + 1/5 right sidebar.
4//! wisp renders to a `RenderTexture` matching the canvas size; the texture is
5//! registered with egui via `register_native_texture` and shown as an
6//! `egui::Image` — zero-copy, GPU-side.
7
8use 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
20/// The eframe app that hosts the storybook gallery.
21pub 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    /// Construct a new app, sharing eframe's wgpu device with `wisp`.
40    #[must_use]
41    pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
42        // Borrow eframe's wgpu device so wisp shares the same GPU context.
43        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        // We don't use the instance after construction, but Application owns one.
51        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            // Drop old egui registration first so the texture id is freed.
119            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        // Tick + render each frame.
141        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        // Top bar — category-grouped story picker.
161        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        // Right sidebar — 1/5 width — write-up.
189        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        // Center 4/5 — live canvas.
208        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}