1use 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
14pub const BOOK_TEX_W: f32 = 512.0;
18pub const BOOK_TEX_H: f32 = 256.0;
19pub const BK_W: f32 = 272.0;
21pub const BK_H: f32 = 180.0;
22pub const PAGE_W: f32 = 116.0;
24pub const PAGE_H: f32 = 156.0;
25pub const TOP_PAD: f32 = 18.0; pub const LEFT_X: f32 = 15.0; pub const RIGHT_X: f32 = 141.0; pub const SEP_U: f32 = 140.0;
30pub const SEP_V: f32 = 180.0;
31pub const SEP_W: f32 = 110.0;
32pub const SEP_H: f32 = 3.0;
33
34#[repr(C)]
37#[derive(Copy, Clone)]
38struct Vert { x: f32, y: f32, u: f32, v: f32, r: f32, g: f32, b: f32, a: f32 }
39
40fn rgba_f(c: u32) -> (f32, f32, f32, f32) {
41 let a = ((c >> 24) & 0xFF) as f32 / 255.0;
42 let r = ((c >> 16) & 0xFF) as f32 / 255.0;
43 let g = ((c >> 8) & 0xFF) as f32 / 255.0;
44 let b = ( c & 0xFF) as f32 / 255.0;
45 (r, g, b, a)
46}
47
48fn quad(verts: &mut Vec<Vert>, x: f32, y: f32, w: f32, h: f32,
49 u0: f32, v0: f32, u1: f32, v1: f32, color: u32) {
50 let (r, g, b, a) = rgba_f(color);
51 let p = [
52 Vert { x, y, u: u0, v: v0, r, g, b, a },
53 Vert { x: x+w, y, u: u1, v: v0, r, g, b, a },
54 Vert { x, y: y+h, u: u0, v: v1, r, g, b, a },
55 Vert { x: x+w, y: y+h, u: u1, v: v1, r, g, b, a },
56 ];
57 verts.extend_from_slice(&[p[0], p[1], p[2], p[1], p[3], p[2]]);
59}
60
61const VERT: &str = r#"
64#version 330 core
65layout(location = 0) in vec2 aPos;
66layout(location = 1) in vec2 aUv;
67layout(location = 2) in vec4 aCol;
68out vec2 fUv;
69out vec4 fCol;
70uniform vec2 uScreen;
71void main() {
72 fUv = aUv;
73 fCol = aCol;
74 vec2 ndc = aPos / uScreen * 2.0 - vec2(1.0);
75 gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
76}
77"#;
78
79const FRAG: &str = r#"
80#version 330 core
81in vec2 fUv;
82in vec4 fCol;
83out vec4 outColor;
84uniform sampler2D uTex;
85uniform int uMode; // 0 = solid color, 1 = RGBA texture, 2 = alpha-only (font)
86void main() {
87 if (uMode == 0) {
88 outColor = fCol;
89 } else if (uMode == 1) {
90 outColor = texture(uTex, fUv) * fCol;
91 } else {
92 float alpha = texture(uTex, fUv).a;
93 outColor = vec4(fCol.rgb, fCol.a * alpha);
94 }
95}
96"#;
97
98static BOOK_PNG: &[u8] = include_bytes!("../assets/book_brown.png");
101static CRAFTING_PNG: &[u8] = include_bytes!("../assets/crafting.png");
102
103fn decode_png(data: &[u8]) -> Option<(Vec<u8>, u32, u32)> {
104 use png::Decoder;
105 let decoder = Decoder::new(std::io::Cursor::new(data));
106 let mut reader = decoder.read_info().ok()?;
107 let mut buf = vec![0u8; reader.output_buffer_size()];
108 let info = reader.next_frame(&mut buf).ok()?;
109 let rgba = match info.color_type {
110 png::ColorType::Rgba => buf[..info.buffer_size()].to_vec(),
111 png::ColorType::Rgb => {
112 let rgb = &buf[..info.buffer_size()];
113 let mut out = Vec::with_capacity(rgb.len() / 3 * 4);
114 for px in rgb.chunks(3) { out.extend_from_slice(px); out.push(255); }
115 out
116 }
117 _ => return None,
118 };
119 Some((rgba, info.width, info.height))
120}
121
122struct BookGl {
125 prog: gl::ShaderProgram,
126 vao: gl::VertexArray,
127 vbo: gl::Buffer,
128 book_tex: Option<(gl::Texture, u32, u32)>,
129 crafting_tex: Option<(gl::Texture, u32, u32)>,
130 svg_tex: HashMap<u64, (gl::Texture, u32, u32)>,
131 font_atlas: HashMap<u64, (gl::Texture, FontAtlas)>,
132}
133
134impl BookGl {
135 fn init(ctx: &GfxContext) -> Option<Self> {
136 let prog = ctx.create_shader(VERT, FRAG).ok()?;
137 let vbo = ctx.create_buffer();
138 let vao = ctx.create_vao();
139
140 const STRIDE: u32 = 32; vao.attrib(ctx, &vbo, 0, 2, DataType::F32, false, STRIDE, 0); vao.attrib(ctx, &vbo, 1, 2, DataType::F32, false, STRIDE, 8); vao.attrib(ctx, &vbo, 2, 4, DataType::F32, false, STRIDE, 16); let load = |data: &[u8]| decode_png(data).map(|(rgba, w, h)| {
146 let tex = ctx.create_texture_rgba(w, h, &rgba, true);
147 (tex, w, h)
148 });
149 let book_tex = load(BOOK_PNG);
150 let crafting_tex = load(CRAFTING_PNG);
151
152 Some(BookGl { prog, vao, vbo, book_tex, crafting_tex, svg_tex: HashMap::new(), font_atlas: HashMap::new() })
153 }
154
155 fn svg_tex(&mut self, ctx: &GfxContext, hash: u64, data: &str, w: u32, h: u32)
156 -> Option<&(gl::Texture, u32, u32)>
157 {
158 if !self.svg_tex.contains_key(&hash) {
159 let pixels = svg::rasterize(data, w, h)?;
160 let tex = ctx.create_texture_rgba(w, h, &pixels, true);
161 self.svg_tex.insert(hash, (tex, w, h));
162 }
163 self.svg_tex.get(&hash)
164 }
165
166 fn flush(&self, ctx: &GfxContext, verts: &[Vert]) {
167 if verts.is_empty() { return; }
168 unsafe { self.vbo.upload(ctx, verts, true); }
169 ctx.draw_arrays(&self.vao, &self.prog, DrawMode::Triangles, 0, verts.len() as u32);
170 }
171}
172
173impl BookGl {
176 fn begin_frame(&self, ctx: &GfxContext, sw: f32, sh: f32) {
177 ctx.set_blend(true, blend::SRC_ALPHA, blend::ONE_MINUS_SRC_ALPHA);
178 ctx.set_depth(false, false);
179 self.prog.uniform_2f(ctx, "uScreen", sw, sh);
180 self.prog.uniform_1i(ctx, "uTex", 0);
181 }
182
183 fn draw_book_sprite(&self, ctx: &GfxContext, spr: &BgSprite) {
185 let sheet = match spr.sheet {
186 SpriteSheet::Book => &self.book_tex,
187 SpriteSheet::Crafting => &self.crafting_tex,
188 };
189 let Some((tex, tw, th)) = sheet else { return };
190 let u0 = spr.u / *tw as f32;
191 let v0 = spr.v / *th as f32;
192 let u1 = (spr.u + spr.uw) / *tw as f32;
193 let v1 = (spr.v + spr.uh) / *th as f32;
194 self.prog.uniform_1i(ctx, "uMode", 1);
195 ctx.bind_texture(0, tex);
196 let mut v = Vec::with_capacity(6);
197 quad(&mut v, spr.x, spr.y, spr.w, spr.h, u0, v0, u1, v1, 0xFF_FFFFFF);
198 self.flush(ctx, &v);
199 }
200
201 fn draw_book_bg(&self, ctx: &GfxContext, bx: f32, by: f32, bw: f32, bh: f32) {
203 let Some((tex, tw, th)) = &self.book_tex else { return };
204 let su = BK_W / *tw as f32;
205 let sv = BK_H / *th as f32;
206 self.prog.uniform_1i(ctx, "uMode", 1);
207 ctx.bind_texture(0, tex);
208 let mut v = Vec::with_capacity(6);
209 quad(&mut v, bx, by, bw, bh, 0.0, 0.0, su, sv, 0xFF_FFFFFF);
210 self.flush(ctx, &v);
211 }
212
213 fn draw_svg(&mut self, ctx: &GfxContext, data: &str, x: f32, y: f32, w: f32, h: f32) {
214 let hash = svg::svg_hash(data);
215 let tw = w as u32; let th = h as u32;
216 if let Some(&(tex, _, _)) = self.svg_tex(ctx, hash, data, tw, th) {
217 self.prog.uniform_1i(ctx, "uMode", 1);
218 ctx.bind_texture(0, &tex);
219 let mut v = Vec::with_capacity(6);
220 quad(&mut v, x, y, w, h, 0.0, 0.0, 1.0, 1.0, 0xFF_FFFFFF);
221 self.flush(ctx, &v);
222 }
223 }
224
225 fn draw_text_custom(&mut self, ctx: &GfxContext, ttf: &[u8], size_px: f32,
226 text: &str, mut x: f32, y: f32, color: u32) {
227 let hash = font_hash(ttf);
228 if !self.font_atlas.contains_key(&hash) {
229 if let Some(atlas) = FontAtlas::build(ttf, size_px) {
230 let tex = ctx.create_texture_rgba(
231 atlas.atlas_size, atlas.atlas_size, &atlas.pixels, true);
232 self.font_atlas.insert(hash, (tex, atlas));
233 }
234 }
235 let ptrs = self.font_atlas.get(&hash)
238 .map(|(t, a)| (t as *const gl::Texture, a as *const FontAtlas));
239 if let Some((tex_ptr, atlas_ptr)) = ptrs {
240 let tex = unsafe { &*tex_ptr };
242 let atlas = unsafe { &*atlas_ptr };
243 self.prog.uniform_1i(ctx, "uMode", 2);
244 ctx.bind_texture(0, tex);
245 let baseline = y + size_px;
246 let mut verts = Vec::new();
247 for ch in text.chars() {
248 if let Some(g) = atlas.glyphs.get(&ch) {
249 if g.width > 0 {
250 let gx = x + g.xoff;
251 let gy = baseline - g.yoff - g.height as f32;
252 quad(&mut verts, gx, gy, g.width as f32, g.height as f32,
253 g.u0, g.v0, g.u1, g.v1, color);
254 }
255 x += g.advance;
256 }
257 }
258 self.flush(ctx, &verts);
259 }
260 }
261}
262
263fn font_hash(data: &[u8]) -> u64 {
264 use std::hash::{Hash, Hasher};
265 let mut h = std::collections::hash_map::DefaultHasher::new();
266 data.hash(&mut h);
267 h.finish()
268}
269
270#[derive(Clone, Copy, PartialEq)]
273pub(crate) enum SpriteSheet { Book, Crafting }
274
275#[derive(Clone)]
277pub(crate) struct BgSprite {
278 pub sheet: SpriteSheet,
279 pub u: f32, pub v: f32, pub uw: f32, pub uh: f32,
281 pub x: f32, pub y: f32, pub w: f32, pub h: f32,
283}
284
285#[derive(Clone)]
288pub(crate) enum OverlayCmd {
289 Svg { data: String, x: f32, y: f32, w: f32, h: f32 },
290 Text { text: String, font: crate::font::BookFont, x: f32, y: f32, color: u32 },
291 McText { text: String, x: f32, y: f32, color: u32 },
293}
294
295pub struct BookRenderer {
298 pub book: Book,
299 pub state: BookViewState,
300 pub theme: BookTheme,
301 fonts: BookFontRegistry,
302 gl: Option<BookGl>,
303 pub ui: Option<UiRoot>,
304 bg_sprites: Vec<BgSprite>,
305 overlays: Vec<OverlayCmd>,
306 dirty: bool,
307 last_sw: f32,
308 last_sh: f32,
309}
310
311impl BookRenderer {
312 pub fn new(book: Book) -> Self {
313 let theme = BookTheme::default().with_nameplate(&book.nameplate_color);
314 Self {
315 book,
316 state: BookViewState::default(),
317 theme,
318 fonts: BookFontRegistry::default(),
319 gl: None,
320 ui: None,
321 bg_sprites: Vec::new(),
322 overlays: Vec::new(),
323 dirty: true,
324 last_sw: 0.0,
325 last_sh: 0.0,
326 }
327 }
328
329 pub fn handle_event(&mut self, ev: &str) {
330 if self.state.handle(ev, &self.book) {
331 self.dirty = true;
332 }
333 }
334
335 pub fn register_font(&mut self, id: impl Into<String>, ttf: Vec<u8>) {
337 self.fonts.register(id, ttf);
338 }
339
340 pub fn render(&mut self, ctx: &GfxContext, sw: f32, sh: f32) {
341 if self.gl.is_none() {
343 self.gl = BookGl::init(ctx);
344 }
345
346 let scale = ((sw - 16.0) / BK_W).min((sh - 16.0) / BK_H).min(1.0).max(0.4);
349 let bw = BK_W * scale;
350 let bh = BK_H * scale;
351 let bx = (sw - bw) / 2.0;
352 let by = (sh - bh) / 2.0;
353
354 if self.dirty || sw != self.last_sw || sh != self.last_sh {
356 let (root, bg_sprites, overlays) = build_ui(&self.book, &self.state, &self.theme, sw, sh, bx, by, bw, bh);
357 self.ui = Some(root);
358 self.bg_sprites = bg_sprites;
359 self.overlays = overlays;
360 self.dirty = false;
361 self.last_sw = sw;
362 self.last_sh = sh;
363 }
364
365 if let Some(gl) = &mut self.gl {
367 gl.begin_frame(ctx, sw, sh);
368 gl.draw_book_bg(ctx, bx, by, bw, bh);
369 }
370
371 if let Some(gl) = &mut self.gl {
373 for spr in &self.bg_sprites {
374 gl.draw_book_sprite(ctx, spr);
375 }
376 }
377
378 if let Some(ui) = &mut self.ui {
380 if ui.needs_layout { ui.layout(sw, sh); }
381 ui.render(ctx);
382 }
383
384 if let Some(gl) = &mut self.gl {
386 gl.begin_frame(ctx, sw, sh);
387 for ov in self.overlays.clone() {
388 match ov {
389 OverlayCmd::Svg { data, x, y, w, h } =>
390 gl.draw_svg(ctx, &data, x, y, w, h),
391 OverlayCmd::Text { text, font, x, y, color } => {
392 if let Some(ttf) = self.fonts.get(&font.font_id) {
393 gl.draw_text_custom(ctx, ttf, font.size_px, &text, x, y, color);
394 }
395 }
396 OverlayCmd::McText { text, x, y, color } => {
397 ctx.draw2d().text(&text, x, y, color, false);
398 }
399 }
400 }
401 }
402 }
403}
404
405fn build_ui(book: &Book, state: &BookViewState, theme: &BookTheme,
410 sw: f32, sh: f32,
411 bx: f32, by: f32, bw: f32, bh: f32) -> (UiRoot, Vec<BgSprite>, Vec<OverlayCmd>) {
412 let _ = (sw, sh);
413 let mut bg_sprites: Vec<BgSprite> = Vec::new();
414 let mut overlays: Vec<OverlayCmd> = Vec::new();
415
416 let sx = bw / BK_W;
418 let sy = bh / BK_H;
419
420 let pw = PAGE_W * sx;
422 let ph = PAGE_H * sy;
423 let spine_gap = (RIGHT_X - LEFT_X - PAGE_W) * sx; let lx = bx + LEFT_X * sx;
427 let rx = bx + RIGHT_X * sx;
428 let py = by + TOP_PAD * sy;
429
430 let sep_cx = (PAGE_W / 2.0 - SEP_W / 2.0) * sx; if state.at_home {
434 bg_sprites.push(BgSprite {
436 sheet: SpriteSheet::Book,
437 u: 0.0, v: 180.0, uw: 140.0, uh: 31.0,
438 x: bx + (LEFT_X - 8.0) * sx,
439 y: by + 12.0 * sy,
440 w: 140.0 * sx,
441 h: 31.0 * sy,
442 });
443 bg_sprites.push(BgSprite {
445 sheet: SpriteSheet::Book,
446 u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
447 x: rx + sep_cx,
448 y: by + (TOP_PAD + 12.0) * sy,
449 w: SEP_W * sx,
450 h: (SEP_H * sy).max(1.0),
451 });
452 } else {
453 bg_sprites.push(BgSprite {
455 sheet: SpriteSheet::Book,
456 u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
457 x: rx + sep_cx,
458 y: by + (TOP_PAD + 12.0) * sy,
459 w: SEP_W * sx,
460 h: (SEP_H * sy).max(1.0),
461 });
462 }
463
464 let (left_page, right_page) = if state.at_home {
465 let l = build_landing_left(book, theme, pw, ph, lx, py, sx, sy, bx, by, &mut overlays);
466 let r = build_categories_right(book, state, theme, pw, ph, rx, py, sx, sy, &mut overlays);
467 (l, r)
468 } else {
469 let l = build_entry_left(book, state, theme, pw, ph, lx, py, sx, sy, sep_cx, &mut bg_sprites, &mut overlays);
470 let r = build_entries_right(book, state, theme, pw, ph, rx, py, sx, sy);
471 (l, r)
472 };
473
474 let root_widget = widget::panel(FlexDir::Row)
479 .w(bw).h(bh)
480 .padding(TOP_PAD * sy, 0.0, 0.0, LEFT_X * sx)
481 .child(left_page)
482 .child(widget::spacer().w(spine_gap)) .child(right_page);
484
485 let outer = widget::panel(FlexDir::Column)
488 .w(bw).h(bh)
489 .child(root_widget);
490
491 let screen_root = widget::panel(FlexDir::Row)
496 .padding(by, 0.0, 0.0, bx)
497 .child(outer);
498
499 let ui = UiRoot::new(&book.id, screen_root);
500 (ui, bg_sprites, overlays)
501}
502
503fn build_landing_left(
506 book: &Book, theme: &BookTheme,
507 page_w: f32, page_h: f32,
508 _lx: f32, _py: f32,
509 sx: f32, sy: f32,
510 bx: f32, by: f32,
511 overlays: &mut Vec<OverlayCmd>,
512) -> widget::Widget {
513 overlays.push(OverlayCmd::McText {
516 text: book.name.clone(),
517 x: bx + 13.0 * sx,
518 y: by + 16.0 * sy,
519 color: theme.nameplate,
520 });
521 if let Some(author) = &book.author {
522 overlays.push(OverlayCmd::McText {
523 text: format!("by {}", author),
524 x: bx + 24.0 * sx,
525 y: by + 24.0 * sy,
526 color: theme.nameplate,
527 });
528 }
529
530 let mut col = widget::panel(FlexDir::Column)
534 .w(page_w).h(page_h)
535 .padding(25.0 * sy, 6.0, 4.0, 4.0)
536 .gap(0.0);
537
538 for para in book.landing_text.split('\n') {
539 if para.is_empty() {
540 col = col.child(widget::spacer().h(4.0 * sy));
541 } else {
542 col = col.child(widget::label(para).color(theme.text));
543 }
544 }
545
546 let total = book.entries.len();
547 col = col.child(widget::spacer().flex(1.0));
548 col = col.child(widget::spacer().h((SEP_H * sy).max(1.0)).bg(theme.border));
549 col = col.child(
550 widget::label(format!("{} entries", total))
551 .color(theme.nav).h(9.0 * sy).align(Align::Center)
552 );
553 col
554}
555
556fn build_categories_right(
559 book: &Book, state: &BookViewState, theme: &BookTheme,
560 page_w: f32, page_h: f32,
561 _rx: f32, _py: f32,
562 sx: f32, sy: f32,
563 overlays: &mut Vec<OverlayCmd>,
564) -> widget::Widget {
565 let _ = overlays;
566
567 let cell_w = 24.0 * sx;
573 let cell_h = 24.0 * sy;
574 let grid_pad_left = 10.0 * sx;
575 let grid_top = 25.0 * sy;
578 let header_h = 12.0 * sy; let sep_gap = (grid_top - header_h).max(0.0); let mut col = widget::panel(FlexDir::Column)
583 .w(page_w).h(page_h)
584 .padding(0.0, 4.0, 4.0, 0.0)
585 .gap(0.0);
586
587 col = col.child(
589 widget::label("Categories").color(theme.divider).h(header_h)
590 );
591 col = col.child(widget::spacer().h(sep_gap));
593
594 let cats = &book.categories;
596 let mut row_i = 0usize;
597 loop {
598 let row_start = row_i * 4;
599 if row_start >= cats.len() { break; }
600 let mut row = widget::panel(FlexDir::Row)
601 .h(cell_h)
602 .gap(0.0)
603 .padding(0.0, 0.0, 0.0, grid_pad_left);
604 for col_i in 0..4 {
605 let idx = row_start + col_i;
606 if idx >= cats.len() { break; }
607 let cat = &cats[idx];
608 let selected = !state.at_home && idx == state.cat;
609 let bg = if selected { theme.nav_selected_bg } else { 0 };
610
611 let icon_w = 16.0 * sx;
612 let icon_h = 16.0 * sy;
613 let pad_xy = (cell_w - icon_w) / 2.0;
614
615 let mut cell = widget::panel(FlexDir::Column)
616 .w(cell_w).h(cell_h).bg(bg)
617 .on_click(format!("cat:{}", idx))
618 .id(format!("book_cat_{}", idx))
619 .padding(pad_xy, pad_xy, pad_xy, pad_xy);
620 cell = if let Some(icon_id) = &cat.icon {
621 cell.child(item_icon_widget(icon_id).w(icon_w).h(icon_h))
622 } else {
623 cell.child(widget::spacer().w(icon_w).h(icon_h))
624 };
625 row = row.child(cell);
626 }
627 col = col.child(row);
628 row_i += 1;
629 }
630 col
631}
632
633fn build_entry_left(
636 book: &Book, state: &BookViewState, theme: &BookTheme,
637 page_w: f32, page_h: f32,
638 ox: f32, oy: f32,
639 sx: f32, sy: f32,
640 sep_cx: f32,
641 bg_sprites: &mut Vec<BgSprite>,
642 overlays: &mut Vec<OverlayCmd>,
643) -> widget::Widget {
644 let entry = state.current_entry(book);
645 let page = entry.and_then(|e| e.pages.get(state.page));
646 let page_count = state.page_count(book);
647 let title_text = entry.map(|e| e.name.as_str()).unwrap_or("");
648
649 let title_h = 9.0 * sy;
654 let sep_h_px = (SEP_H * sy).max(1.0);
655 let sep_y = oy + 12.0 * sy;
656 let body_oy = oy + 22.0 * sy;
657
658 bg_sprites.push(BgSprite {
659 sheet: SpriteSheet::Book,
660 u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
661 x: ox + sep_cx, y: sep_y,
662 w: SEP_W * sx, h: sep_h_px,
663 });
664
665 let page_label = format!("{}/{}", state.page + 1, page_count);
666 let nav = widget::panel(FlexDir::Row)
667 .h(12.0 * sy).gap(4.0)
668 .padding(0.0, 2.0, 0.0, 2.0)
669 .child(widget::button("◀").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
670 .on_click("prev_page").id("prev_page"))
671 .child(widget::label(&page_label).color(theme.nav).flex(1.0).align(Align::Center))
672 .child(widget::button("⌂").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
673 .on_click("home").id("book_home"))
674 .child(widget::button("▶").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
675 .on_click("next_page").id("next_page"));
676
677 let page_body = build_page(page, state.page, theme, bg_sprites, overlays, ox, body_oy, sx, sy);
678
679 widget::panel(FlexDir::Column)
680 .w(page_w).h(page_h)
681 .padding(0.0, 6.0, 4.0, 4.0)
682 .gap(0.0)
683 .child(widget::label(title_text).color(theme.title).h(title_h).align(Align::Center))
684 .child(widget::spacer().h((12.0 - 9.0) * sy)) .child(widget::spacer().h(sep_h_px)) .child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy)) .child(page_body)
688 .child(nav)
689}
690
691fn build_entries_right(
694 book: &Book, state: &BookViewState, theme: &BookTheme,
695 page_w: f32, page_h: f32, _rx: f32, _py: f32,
696 sx: f32, sy: f32,
697) -> widget::Widget {
698 let entries = state.entries_visible(book);
699 let cat_name = book.categories.get(state.cat).map(|c| c.name.as_str()).unwrap_or("Entries");
700 let spread_count = state.list_spread_count(book);
701
702 let header_h = 9.0 * sy;
707 let row_h = 11.0 * sy;
708 let icon_size = 9.0 * sx.min(sy);
709
710 let mut col = widget::panel(FlexDir::Column)
711 .w(page_w).h(page_h)
712 .padding(0.0, 4.0, 4.0, 0.0)
713 .gap(0.0);
714
715 col = col.child(widget::label(cat_name).color(theme.divider).h(header_h).align(Align::Center));
716 col = col.child(widget::spacer().h((12.0 - 9.0) * sy)); col = col.child(widget::spacer().h((SEP_H * sy).max(1.0))); col = col.child(widget::spacer().h((20.0 - 12.0 - SEP_H) * sy)); let abs_start = state.list_spread_start();
722 for (i, entry) in entries.iter().enumerate() {
723 let abs_i = abs_start + i;
724 let selected = abs_i == state.entry;
725 let bg = if selected { theme.nav_selected_bg } else { 0 };
726 let color = if selected { theme.nav_selected } else { theme.nav };
727
728 let mut row = widget::panel(FlexDir::Row)
729 .h(row_h).gap(2.0).bg(bg)
730 .on_click(format!("entry:{}", abs_i))
731 .id(format!("book_entry_{}", abs_i))
732 .padding(1.0, 2.0, 1.0, 2.0);
733
734 if let Some(icon_id) = &entry.icon {
735 row = row.child(item_icon_widget(icon_id).w(icon_size).h(icon_size));
736 } else {
737 row = row.child(widget::spacer().w(icon_size));
738 }
739 row = row.child(widget::label(&entry.name).color(color).flex(1.0));
740 col = col.child(row);
741 }
742
743 if spread_count > 1 {
744 col = col.child(widget::spacer().flex(1.0));
745 let spread_label = format!("{}/{}", state.list_spread + 1, spread_count);
746 col = col.child(
747 widget::panel(FlexDir::Row).h(12.0 * sy).gap(2.0)
748 .child(widget::button("◀").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
749 .on_click("prev_list").id("prev_list"))
750 .child(widget::label(&spread_label).color(theme.nav)
751 .flex(1.0).align(Align::Center))
752 .child(widget::button("▶").w(12.0 * sx).h(12.0 * sy).color(theme.nav)
753 .on_click("next_list").id("next_list"))
754 );
755 }
756 col
757}
758
759fn item_icon_widget(item_id: &str) -> widget::Widget {
761 let tex = if let Some((ns, path)) = item_id.split_once(':') {
764 if path.starts_with("item/") || path.starts_with("block/") {
765 format!("{ns}:textures/{path}.png")
766 } else {
767 format!("{ns}:textures/item/{path}.png")
768 }
769 } else {
770 format!("minecraft:textures/item/{item_id}.png")
771 };
772 widget::mc_image(&tex, 16.0, 16.0)
773}
774
775fn build_page(
781 page: Option<&BookPage>,
782 page_num: usize,
783 theme: &BookTheme,
784 bg_sprites: &mut Vec<BgSprite>,
785 overlays: &mut Vec<OverlayCmd>,
786 ox: f32, oy: f32,
787 sx: f32, sy: f32,
788) -> widget::Widget {
789 let mut col = widget::panel(FlexDir::Column).flex(1.0).gap(4.0);
790
791 let Some(page) = page else {
792 return col.child(widget::label("No entries yet.").color(theme.nav));
793 };
794
795 match page {
796 BookPage::Text { text, title } => {
797 if page_num > 0 {
799 if let Some(t) = title {
800 let sep_h_px = (SEP_H * sy).max(1.0);
801 let title_h = 9.0 * sy;
802 col = col.child(widget::label(t.as_str()).color(theme.title)
803 .h(title_h).align(Align::Center));
804 bg_sprites.push(BgSprite {
806 sheet: SpriteSheet::Book,
807 u: SEP_U, v: SEP_V, uw: SEP_W, uh: SEP_H,
808 x: ox + (PAGE_W / 2.0 - SEP_W / 2.0) * sx,
809 y: oy + 12.0 * sy,
810 w: SEP_W * sx, h: sep_h_px,
811 });
812 col = col.child(widget::spacer().h((12.0 - 9.0) * sy));
813 col = col.child(widget::spacer().h(sep_h_px));
814 col = col.child(widget::spacer().h((22.0 - 12.0 - SEP_H) * sy));
815 }
816 }
817 for para in text.split('\n') {
818 col = col.child(widget::label(para).color(theme.text));
819 }
820 }
821
822 BookPage::Spotlight { item, title, text } => {
823 let box_w = 66.0 * sx;
827 let box_h = 26.0 * sy;
828 let box_x = ox + (PAGE_W / 2.0 - 33.0) * sx;
829 let box_y = oy + 10.0 * sy;
830 bg_sprites.push(BgSprite {
831 sheet: SpriteSheet::Crafting,
832 u: 0.0, v: 102.0, uw: 66.0, uh: 26.0,
833 x: box_x, y: box_y, w: box_w, h: box_h,
834 });
835
836 let item_name = title.as_deref()
838 .or(item.name.as_deref())
839 .unwrap_or(item.id.as_str());
840 col = col.child(widget::label(item_name).color(theme.title)
841 .h(10.0).align(Align::Center));
842
843 col = col.child(widget::spacer().h((10.0 * sy - 10.0 - 4.0).max(0.0)));
845
846 let icon_size = 16.0 * sx.min(sy);
848 col = col.child(
849 widget::panel(FlexDir::Row).h(icon_size)
850 .child(widget::spacer().flex(1.0))
851 .child(item_icon_widget(&item.id).w(icon_size).h(icon_size))
852 .child(widget::spacer().flex(1.0))
853 );
854
855 let icon_end = 15.0 * sy + icon_size;
857 let box_end = 10.0 * sy + box_h;
858 col = col.child(widget::spacer().h((box_end - icon_end + 4.0).max(0.0)));
859
860 if let Some(t) = text {
861 col = col.child(widget::label(t.as_str()).color(theme.text));
862 }
863 }
864
865 BookPage::Crafting { recipe_id, text } => {
866 col = col.child(
867 widget::label(format!("[Crafting: {}]", recipe_id)).color(theme.nav)
868 );
869 if let Some(t) = text {
870 col = col.child(widget::label(t.as_str()).color(theme.text));
871 }
872 }
873
874 BookPage::Smelting { recipe_id, text } => {
875 col = col.child(
876 widget::label(format!("[Smelting: {}]", recipe_id)).color(theme.nav)
877 );
878 if let Some(t) = text {
879 col = col.child(widget::label(t.as_str()).color(theme.text));
880 }
881 }
882
883 BookPage::Image { texture, title, text, .. } => {
884 if let Some(t) = title {
885 col = col.child(widget::label(t.as_str()).color(theme.title));
886 }
887 col = col.child(widget::mc_image(texture, 80.0, 80.0));
888 if let Some(t) = text {
889 col = col.child(widget::label(t.as_str()).color(theme.text));
890 }
891 }
892
893 BookPage::Svg { data, title, text } => {
894 if let Some(t) = title {
895 col = col.child(widget::label(t.as_str()).color(theme.title));
896 }
897 overlays.push(OverlayCmd::Svg { data: data.clone(), x: ox, y: oy, w: 64.0, h: 64.0 });
898 col = col.child(widget::spacer().h(68.0));
899 if let Some(t) = text {
900 col = col.child(widget::label(t.as_str()).color(theme.text));
901 }
902 }
903
904 BookPage::CustomText { text, font, color } => {
905 overlays.push(OverlayCmd::Text {
906 text: text.clone(), font: font.clone(), x: ox, y: oy, color: *color,
907 });
908 col = col.child(widget::spacer().h(font.size_px * 1.5));
909 }
910
911 BookPage::Relations { entries, text } => {
912 if let Some(t) = text {
913 col = col.child(widget::label(t.as_str()).color(theme.text));
914 }
915 col = col.child(widget::label("See also:").color(theme.title));
916 for e in entries {
917 col = col.child(widget::label(format!("• {}", e)).color(theme.nav));
918 }
919 }
920
921 BookPage::Entity { entity_type, name, text } => {
922 let display = name.as_deref().unwrap_or(entity_type.as_str());
923 col = col.child(widget::label(display).color(theme.title));
924 if let Some(t) = text {
925 col = col.child(widget::label(t.as_str()).color(theme.text));
926 }
927 }
928
929 BookPage::Pattern { op_id, input, output, text, .. } => {
930 col = col.child(widget::label(op_id.as_str()).color(theme.title));
931 col = col.child(
932 widget::label(format!("{} → {}", input, output)).color(theme.nav)
933 );
934 if !text.is_empty() {
935 col = col.child(widget::label(text.as_str()).color(theme.text));
936 }
937 }
938
939 BookPage::Empty => {}
940 }
941
942 col
943}