Skip to main content

yog_book/
renderer.rs

1//! Book renderer — ties together yog-ui layout, yog-gfx GPU pipeline,
2//! SVG icon rasterization, and custom font rendering.
3
4use std::collections::HashMap;
5use yog_gfx::{GfxContext, gl, core::{DrawMode, DataType, blend}};
6use yog_ui::{widget, FlexDir, Align, UiRoot};
7
8use crate::{Book, BookPage};
9use crate::state::BookViewState;
10use crate::theme::BookTheme;
11use crate::font::{BookFontRegistry, FontAtlas};
12use crate::svg;
13
14// ── Vertex for the custom 2D shader (pos + uv + color) ───────────────────────
15
16#[repr(C)]
17#[derive(Copy, Clone)]
18struct Vert { x: f32, y: f32, u: f32, v: f32, r: f32, g: f32, b: f32, a: f32 }
19
20fn rgba_f(c: u32) -> (f32, f32, f32, f32) {
21    let a = ((c >> 24) & 0xFF) as f32 / 255.0;
22    let r = ((c >> 16) & 0xFF) as f32 / 255.0;
23    let g = ((c >>  8) & 0xFF) as f32 / 255.0;
24    let b = ( c        & 0xFF) as f32 / 255.0;
25    (r, g, b, a)
26}
27
28fn quad(verts: &mut Vec<Vert>, x: f32, y: f32, w: f32, h: f32,
29        u0: f32, v0: f32, u1: f32, v1: f32, color: u32) {
30    let (r, g, b, a) = rgba_f(color);
31    let p = [
32        Vert { x,        y,        u: u0, v: v0, r, g, b, a },
33        Vert { x: x+w,   y,        u: u1, v: v0, r, g, b, a },
34        Vert { x,        y: y+h,   u: u0, v: v1, r, g, b, a },
35        Vert { x: x+w,   y: y+h,   u: u1, v: v1, r, g, b, a },
36    ];
37    // two triangles: 0,1,2  1,3,2
38    verts.extend_from_slice(&[p[0], p[1], p[2], p[1], p[3], p[2]]);
39}
40
41// ── GLSL shaders ─────────────────────────────────────────────────────────────
42
43const VERT: &str = r#"
44#version 150 core
45in vec2 aPos;
46in vec2 aUv;
47in vec4 aCol;
48out vec2 fUv;
49out vec4 fCol;
50uniform vec2 uScreen;
51void main() {
52    fUv  = aUv;
53    fCol = aCol;
54    vec2 ndc = aPos / uScreen * 2.0 - vec2(1.0);
55    gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
56}
57"#;
58
59const FRAG: &str = r#"
60#version 150 core
61in vec2 fUv;
62in vec4 fCol;
63out vec4 outColor;
64uniform sampler2D uTex;
65uniform int uMode;  // 0 = solid color, 1 = RGBA texture, 2 = alpha-only (font)
66void main() {
67    if (uMode == 0) {
68        outColor = fCol;
69    } else if (uMode == 1) {
70        outColor = texture(uTex, fUv) * fCol;
71    } else {
72        float alpha = texture(uTex, fUv).a;
73        outColor = vec4(fCol.rgb, fCol.a * alpha);
74    }
75}
76"#;
77
78// ── GL resource cache ─────────────────────────────────────────────────────────
79
80struct BookGl {
81    prog: gl::ShaderProgram,
82    vao:  gl::VertexArray,
83    vbo:  gl::Buffer,
84    // SVG texture cache: hash → (Texture, pixel_w, pixel_h)
85    svg_tex: HashMap<u64, (gl::Texture, u32, u32)>,
86    // Font atlas cache: hash → (Texture, FontAtlas)
87    font_atlas: HashMap<u64, (gl::Texture, FontAtlas)>,
88}
89
90impl BookGl {
91    fn init(ctx: &GfxContext) -> Option<Self> {
92        let prog = ctx.create_shader(VERT, FRAG).ok()?;
93        let vbo  = ctx.create_buffer();
94        let vao  = ctx.create_vao();
95
96        const STRIDE: u32 = 32; // 8 × f32
97        vao.attrib(ctx, &vbo, 0, 2, DataType::F32, false, STRIDE, 0);  // pos
98        vao.attrib(ctx, &vbo, 1, 2, DataType::F32, false, STRIDE, 8);  // uv
99        vao.attrib(ctx, &vbo, 2, 4, DataType::F32, false, STRIDE, 16); // col
100
101        Some(BookGl { prog, vao, vbo, svg_tex: HashMap::new(), font_atlas: HashMap::new() })
102    }
103
104    fn svg_tex(&mut self, ctx: &GfxContext, hash: u64, data: &str, w: u32, h: u32)
105        -> Option<&(gl::Texture, u32, u32)>
106    {
107        if !self.svg_tex.contains_key(&hash) {
108            let pixels = svg::rasterize(data, w, h)?;
109            let tex = ctx.create_texture_rgba(w, h, &pixels, true);
110            self.svg_tex.insert(hash, (tex, w, h));
111        }
112        self.svg_tex.get(&hash)
113    }
114
115    fn flush(&self, ctx: &GfxContext, verts: &[Vert]) {
116        if verts.is_empty() { return; }
117        unsafe { self.vbo.upload(ctx, verts, true); }
118        ctx.draw_arrays(&self.vao, &self.prog, DrawMode::Triangles, 0, verts.len() as u32);
119    }
120}
121
122// ── Draw helpers ──────────────────────────────────────────────────────────────
123
124impl BookGl {
125    fn begin_frame(&self, ctx: &GfxContext, sw: f32, sh: f32) {
126        ctx.set_blend(true, blend::SRC_ALPHA, blend::ONE_MINUS_SRC_ALPHA);
127        ctx.set_depth(false, false);
128        self.prog.uniform_2f(ctx, "uScreen", sw, sh);
129        self.prog.uniform_1i(ctx, "uTex", 0);
130    }
131
132    fn draw_svg(&mut self, ctx: &GfxContext, data: &str, x: f32, y: f32, w: f32, h: f32) {
133        let hash = svg::svg_hash(data);
134        let tw = w as u32; let th = h as u32;
135        if let Some(&(tex, _, _)) = self.svg_tex(ctx, hash, data, tw, th) {
136            self.prog.uniform_1i(ctx, "uMode", 1);
137            ctx.bind_texture(0, &tex);
138            let mut v = Vec::with_capacity(6);
139            quad(&mut v, x, y, w, h, 0.0, 0.0, 1.0, 1.0, 0xFF_FFFFFF);
140            self.flush(ctx, &v);
141        }
142    }
143
144    fn draw_text_custom(&mut self, ctx: &GfxContext, ttf: &[u8], size_px: f32,
145                        text: &str, mut x: f32, y: f32, color: u32) {
146        let hash = font_hash(ttf);
147        if !self.font_atlas.contains_key(&hash) {
148            if let Some(atlas) = FontAtlas::build(ttf, size_px) {
149                let tex = ctx.create_texture_rgba(
150                    atlas.atlas_size, atlas.atlas_size, &atlas.pixels, true);
151                self.font_atlas.insert(hash, (tex, atlas));
152            }
153        }
154        // Get raw pointers so the borrow of self.font_atlas ends before we call
155        // self.prog / self.flush, which also borrow fields of self.
156        let ptrs = self.font_atlas.get(&hash)
157            .map(|(t, a)| (t as *const gl::Texture, a as *const FontAtlas));
158        if let Some((tex_ptr, atlas_ptr)) = ptrs {
159            // SAFETY: we hold &mut self; font_atlas is not mutated below.
160            let tex   = unsafe { &*tex_ptr   };
161            let atlas = unsafe { &*atlas_ptr };
162            self.prog.uniform_1i(ctx, "uMode", 2);
163            ctx.bind_texture(0, tex);
164            let baseline = y + size_px;
165            let mut verts = Vec::new();
166            for ch in text.chars() {
167                if let Some(g) = atlas.glyphs.get(&ch) {
168                    if g.width > 0 {
169                        let gx = x + g.xoff;
170                        let gy = baseline - g.yoff - g.height as f32;
171                        quad(&mut verts, gx, gy, g.width as f32, g.height as f32,
172                             g.u0, g.v0, g.u1, g.v1, color);
173                    }
174                    x += g.advance;
175                }
176            }
177            self.flush(ctx, &verts);
178        }
179    }
180}
181
182fn font_hash(data: &[u8]) -> u64 {
183    use std::hash::{Hash, Hasher};
184    let mut h = std::collections::hash_map::DefaultHasher::new();
185    data.hash(&mut h);
186    h.finish()
187}
188
189// ── Pending overlay draw commands ─────────────────────────────────────────────
190
191#[derive(Clone)]
192pub(crate) enum OverlayCmd {
193    Svg  { data: String, x: f32, y: f32, w: f32, h: f32 },
194    Text { text: String, font: crate::font::BookFont, x: f32, y: f32, color: u32 },
195}
196
197// ── BookRenderer ──────────────────────────────────────────────────────────────
198
199pub struct BookRenderer {
200    pub book:  Book,
201    pub state: BookViewState,
202    pub theme: BookTheme,
203    gl:         Option<BookGl>,
204    pub ui:     Option<UiRoot>,
205    overlays:   Vec<OverlayCmd>,
206    dirty:     bool,
207    last_sw:   f32,
208    last_sh:   f32,
209}
210
211impl BookRenderer {
212    pub fn new(book: Book) -> Self {
213        let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
214        Self {
215            book,
216            state: BookViewState::default(),
217            theme,
218            gl: None,
219            ui: None,
220            overlays: Vec::new(),
221            dirty: true,
222            last_sw: 0.0,
223            last_sh: 0.0,
224        }
225    }
226
227    pub fn handle_event(&mut self, ev: &str) {
228        if self.state.handle(ev, &self.book) {
229            self.dirty = true;
230        }
231    }
232
233    pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32, fonts: &BookFontRegistry) {
234        // Lazy GL init.
235        if self.gl.is_none() {
236            self.gl = BookGl::init(ctx);
237        }
238
239        // Rebuild widget tree if dirty or screen resized.
240        if self.dirty || sw != self.last_sw || sh != self.last_sh {
241            let (root, overlays) = build_ui(&self.book, &self.state, &self.theme, sw, sh);
242            self.ui = Some(root);
243            self.overlays = overlays;
244            self.dirty = false;
245            self.last_sw = sw;
246            self.last_sh = sh;
247        }
248
249        // Layout + yog-ui render (backgrounds, text, buttons).
250        if let Some(ui) = &mut self.ui {
251            if ui.needs_layout { ui.layout(sw, sh); }
252            ui.render(ctx);
253        }
254
255        // Custom GL overlays (SVG icons, custom font text).
256        if let Some(gl) = &mut self.gl {
257            gl.begin_frame(ctx, sw, sh);
258            for ov in self.overlays.clone() {
259                match ov {
260                    OverlayCmd::Svg  { data, x, y, w, h } =>
261                        gl.draw_svg(ctx, &data, x, y, w, h),
262                    OverlayCmd::Text { text, font, x, y, color } => {
263                        if let Some(ttf) = fonts.get(&font.font_id) {
264                            gl.draw_text_custom(ctx, ttf, font.size_px, &text, x, y, color);
265                        }
266                    }
267                }
268            }
269        }
270    }
271}
272
273// ── UI builder ────────────────────────────────────────────────────────────────
274
275/// Build the yog-ui widget tree + overlay commands for the current book state.
276fn build_ui(book: &Book, state: &BookViewState, theme: &BookTheme,
277             sw: f32, sh: f32) -> (UiRoot, Vec<OverlayCmd>) {
278    let mut overlays: Vec<OverlayCmd> = Vec::new();
279
280    // Overall book proportions (centered, fixed size).
281    let bw = (sw * 0.75).min(600.0);
282    let bh = (sh * 0.80).min(400.0);
283    let bx = (sw - bw) / 2.0;
284    let by = (sh - bh) / 2.0;
285    let sidebar_w = 130.0;
286    let page_w    = bw - sidebar_w - 6.0;
287
288    // ── Sidebar: categories ──────────────────────────────────────────────────
289    let mut cats_col = widget::panel(FlexDir::Column).gap(1.0).padding(4.0, 4.0, 4.0, 4.0);
290    for (i, cat) in book.categories.iter().enumerate() {
291        let selected = i == state.cat;
292        let color = if selected { theme.nav_selected } else { theme.nav };
293        let bg    = if selected { 0x30_FFFFFF } else { 0 };
294        let btn = widget::button(&cat.name)
295            .color(color).bg(bg).h(16.0)
296            .on_click(format!("cat:{}", i));
297
298        // Schedule SVG icon overlay if category has one.
299        if let Some(svg_data) = &cat.icon_svg {
300            // Icon is drawn at indent position; we'll track it by cat index.
301            // For layout, just add a small spacer to reserve horizontal space.
302            overlays.push(OverlayCmd::Svg {
303                data: svg_data.clone(),
304                x: bx + 4.0,
305                y: by + 20.0 + i as f32 * 18.0,
306                w: 14.0, h: 14.0,
307            });
308        }
309        cats_col = cats_col.child(btn);
310    }
311
312    // ── Sidebar: entry list ──────────────────────────────────────────────────
313    let entries = state.entries_in_cat(book);
314    let mut entries_col = widget::panel(FlexDir::Column).gap(1.0).padding(0.0, 4.0, 4.0, 4.0);
315    entries_col = entries_col.child(
316        widget::label("─────────").color(theme.divider).h(8.0)
317    );
318    for (i, entry) in entries.iter().enumerate() {
319        let selected = i == state.entry;
320        let color = if selected { theme.nav_selected } else { theme.nav };
321        let bg    = if selected { 0x30_FFFFFF } else { 0 };
322        let label = if entry.name.len() > 16 { &entry.name[..16] } else { &entry.name };
323        let btn = widget::button(label)
324            .color(color).bg(bg).h(14.0)
325            .on_click(format!("entry:{}", i));
326        entries_col = entries_col.child(btn);
327    }
328
329    let sidebar = widget::panel(FlexDir::Column)
330        .w(sidebar_w).h(bh)
331        .bg(theme.sidebar_bg)
332        .child(
333            widget::label(&book.name)
334                .color(theme.nameplate).h(18.0)
335                .padding(3.0, 4.0, 3.0, 4.0)
336        )
337        .child(cats_col)
338        .child(entries_col);
339
340    // ── Page area ────────────────────────────────────────────────────────────
341    let entry = state.current_entry(book);
342    let page  = entry.and_then(|e| e.pages.get(state.page));
343    let page_count = state.page_count(book);
344
345    let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
346    let title_widget = widget::label(title_text)
347        .color(theme.title).h(16.0)
348        .padding(4.0, 4.0, 2.0, 6.0);
349
350    let page_body = build_page(page, theme, &mut overlays,
351                               bx + sidebar_w + 6.0, by + 32.0);
352
353    // ── Nav buttons ──────────────────────────────────────────────────────────
354    let page_label = format!("{}/{}", state.page + 1, page_count);
355    let nav = widget::panel(FlexDir::Row).h(20.0).gap(4.0)
356        .padding(2.0, 6.0, 2.0, 6.0)
357        .child(widget::button("◀").w(22.0).h(16.0).color(theme.nav).on_click("prev_page"))
358        .child(widget::label(&page_label).color(theme.nav).flex(1.0).align(Align::Center))
359        .child(widget::button("▶").w(22.0).h(16.0).color(theme.nav).on_click("next_page"));
360
361    let page_panel = widget::panel(FlexDir::Column)
362        .w(page_w).h(bh)
363        .bg(theme.page_bg)
364        .child(title_widget)
365        .child(
366            widget::label("").h(1.0).bg(theme.border)  // divider
367        )
368        .child(page_body)
369        .child(nav);
370
371    // ── Root: outer book frame ───────────────────────────────────────────────
372    let root_widget = widget::panel(FlexDir::Row)
373        .w(bw).h(bh).gap(2.0)
374        .bg(theme.bg)
375        .padding(3.0, 3.0, 3.0, 3.0)
376        .margin(by, 0.0, 0.0, bx)
377        .child(sidebar)
378        .child(page_panel);
379
380    let ui = UiRoot::new(&book.id, root_widget);
381    (ui, overlays)
382}
383
384/// Build the content widget for a single page. Appends overlay commands for
385/// SVG images and custom-font text.
386fn build_page(
387    page: Option<&BookPage>,
388    theme: &BookTheme,
389    overlays: &mut Vec<OverlayCmd>,
390    _content_x: f32,
391    _content_y: f32,
392) -> widget::Widget {
393    let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0)
394        .padding(6.0, 8.0, 6.0, 8.0);
395
396    let Some(page) = page else {
397        return col.child(
398            widget::label("No entries yet.").color(theme.nav)
399        );
400    };
401
402    match page {
403        BookPage::Text { text } => {
404            // Wrap long text into paragraphs at line breaks.
405            for para in text.split('\n') {
406                col = col.child(widget::label(para).color(theme.text));
407            }
408        }
409
410        BookPage::Spotlight { item, title, text } => {
411            if let Some(t) = title {
412                col = col.child(widget::label(t).color(theme.title));
413            }
414            col = col.child(widget::item_slot(&item.id));
415            if let Some(t) = text {
416                col = col.child(widget::label(t).color(theme.text));
417            }
418        }
419
420        BookPage::Crafting { recipe_id, text } => {
421            col = col.child(
422                widget::label(format!("[Crafting: {}]", recipe_id)).color(theme.nav)
423            );
424            if let Some(t) = text {
425                col = col.child(widget::label(t).color(theme.text));
426            }
427        }
428
429        BookPage::Smelting { recipe_id, text } => {
430            col = col.child(
431                widget::label(format!("[Smelting: {}]", recipe_id)).color(theme.nav)
432            );
433            if let Some(t) = text {
434                col = col.child(widget::label(t).color(theme.text));
435            }
436        }
437
438        BookPage::Image { texture, title, text, .. } => {
439            if let Some(t) = title {
440                col = col.child(widget::label(t).color(theme.title));
441            }
442            // Use MC texture blitter via draw2d_mc_tex — rendered by yog-ui render pass.
443            col = col.child(
444                widget::mc_image(texture, 80.0, 80.0)
445            );
446            if let Some(t) = text {
447                col = col.child(widget::label(t).color(theme.text));
448            }
449        }
450
451        BookPage::Svg { data, title, text } => {
452            if let Some(t) = title {
453                col = col.child(widget::label(t).color(theme.title));
454            }
455            // Reserve space via a spacer; SVG drawn as overlay.
456            // Overlay position is approximate — refined at render time.
457            overlays.push(OverlayCmd::Svg {
458                data:  data.clone(),
459                x:     _content_x,
460                y:     _content_y,
461                w:     64.0, h: 64.0,
462            });
463            col = col.child(widget::spacer().h(68.0));
464            if let Some(t) = text {
465                col = col.child(widget::label(t).color(theme.text));
466            }
467        }
468
469        BookPage::CustomText { text, font, color } => {
470            overlays.push(OverlayCmd::Text {
471                text:  text.clone(),
472                font:  font.clone(),
473                x:     _content_x,
474                y:     _content_y,
475                color: *color,
476            });
477            col = col.child(widget::spacer().h(font.size_px * 1.5));
478        }
479
480        BookPage::Relations { entries, text } => {
481            if let Some(t) = text {
482                col = col.child(widget::label(t).color(theme.text));
483            }
484            col = col.child(widget::label("See also:").color(theme.title));
485            for e in entries {
486                col = col.child(widget::label(format!("• {}", e)).color(theme.nav));
487            }
488        }
489
490        BookPage::Entity { entity_type, name, text } => {
491            if let Some(n) = name {
492                col = col.child(widget::label(n).color(theme.title));
493            } else {
494                col = col.child(widget::label(entity_type).color(theme.title));
495            }
496            if let Some(t) = text {
497                col = col.child(widget::label(t).color(theme.text));
498            }
499        }
500
501        BookPage::Pattern { op_id, input, output, text, .. } => {
502            col = col.child(widget::label(op_id).color(theme.title));
503            col = col.child(
504                widget::label(format!("{} → {}", input, output)).color(theme.nav)
505            );
506            if !text.is_empty() {
507                col = col.child(widget::label(text).color(theme.text));
508            }
509        }
510
511        BookPage::Empty => {}
512    }
513
514    col
515}