Skip to main content

dotzuki_engine/render/
painter.rs

1use crate::render::{BracketSides, Rgba, TilePos, TileRect};
2
3pub trait Painter {
4    fn clear(&mut self, color: Rgba);
5    fn draw_text_box(&mut self, rect: TileRect, color: Rgba);
6    fn draw_text(&mut self, pos: TilePos, text: &str, color: Rgba);
7    fn draw_glyph(&mut self, pos: TilePos, glyph: char, color: Rgba);
8    fn draw_pixel_rect(&mut self, px: u32, py: u32, pw: u32, ph: u32, color: Rgba);
9    /// Pixel backend draws `tile_id` via the fallback text glyph. Recording backends log only `tile_id`.
10    fn draw_gb_tile(&mut self, pos: TilePos, tile_id: u8, fallback: &str, color: Rgba);
11
12    // ── Proportional (pixel-precise) text — opt-in high-resolution path ──────
13    //
14    // The legacy methods above place every glyph on the 8×8 tile grid, which is
15    // correct for Game Boy half-width fonts but clips/overlaps proportional CJK
16    // glyphs. These methods let the layout engine render at true pixel precision
17    // with per-glyph advance. They have default impls that fall back to the tile
18    // path, so existing `Painter` implementations (and all recording/mock
19    // painters) keep compiling unchanged; only pixel backends override them.
20
21    /// Draw `text` starting at pixel `(px, py)` with proportional per-glyph
22    /// advance. Default: round to the nearest tile and use [`Painter::draw_text`].
23    fn draw_text_px(&mut self, px: u32, py: u32, text: &str, color: Rgba) {
24        self.draw_text(TilePos::new(px / 8, py / 8), text, color);
25    }
26
27    /// The pixel width `text` would occupy via [`Painter::draw_text_px`].
28    /// Default: 8px per char (the tile cell width).
29    fn measure_text_px(&self, text: &str) -> u32 {
30        text.chars().count() as u32 * 8
31    }
32
33    /// Draw `text` at pixel `(px, py)` scaled by an integer factor — every glyph
34    /// pixel becomes a `scale × scale` block. Powers big title/heading text. The
35    /// default ignores `scale` and falls back to [`Painter::draw_text_px`], so
36    /// recording/mock backends stay unchanged; pixel backends override it.
37    fn draw_text_px_scaled(&mut self, px: u32, py: u32, text: &str, scale: u32, color: Rgba) {
38        let _ = scale;
39        self.draw_text_px(px, py, text, color);
40    }
41
42    /// The pixel width `text` occupies via [`Painter::draw_text_px_scaled`].
43    /// Default: [`Painter::measure_text_px`] × `scale`.
44    fn measure_text_px_scaled(&self, text: &str, scale: u32) -> u32 {
45        self.measure_text_px(text) * scale.max(1)
46    }
47
48    /// Whether this backend renders true proportional pixel text (overrides the
49    /// two methods above). The layout engine only takes its proportional path
50    /// when this is `true`, so recording/mock backends stay on the tile path.
51    fn supports_proportional(&self) -> bool {
52        false
53    }
54
55    /// Blit a full-colour `src_w × src_h` RGBA image (row-major) into the pixel
56    /// box `(dst_px, dst_py, dst_w, dst_h)`, nearest-neighbour scaled to fill the
57    /// box. Fully-transparent (`a == 0`) source pixels are skipped; `flip_x`/
58    /// `flip_y` mirror the source. Used by the layout engine's `image` element.
59    ///
60    /// Default: per-destination-pixel via [`Painter::draw_pixel_rect`], so every
61    /// existing backend (incl. recording/mock painters) works unchanged; pixel
62    /// backends may override for speed.
63    fn draw_rgba(
64        &mut self,
65        dst_px: u32,
66        dst_py: u32,
67        dst_w: u32,
68        dst_h: u32,
69        pixels: &[Rgba],
70        src_w: u32,
71        src_h: u32,
72        flip_x: bool,
73        flip_y: bool,
74    ) {
75        if src_w == 0 || src_h == 0 || dst_w == 0 || dst_h == 0 {
76            return;
77        }
78        for dy in 0..dst_h {
79            let mut sy = dy * src_h / dst_h;
80            if flip_y {
81                sy = src_h - 1 - sy;
82            }
83            for dx in 0..dst_w {
84                let mut sx = dx * src_w / dst_w;
85                if flip_x {
86                    sx = src_w - 1 - sx;
87                }
88                let idx = (sy * src_w + sx) as usize;
89                let Some(&c) = pixels.get(idx) else { continue };
90                if c.a == 0 {
91                    continue;
92                }
93                self.draw_pixel_rect(dst_px + dx, dst_py + dy, 1, 1, c);
94            }
95        }
96    }
97}
98
99pub struct Ui<'p, P: Painter> {
100    painter: &'p mut P,
101    origin_tx: u32,
102    origin_ty: u32,
103}
104
105impl<'p, P: Painter> Ui<'p, P> {
106    pub fn new(painter: &'p mut P) -> Self {
107        Self {
108            painter,
109            origin_tx: 0,
110            origin_ty: 0,
111        }
112    }
113
114    /// Returns a mutable reference to the underlying [`Painter`].
115    pub fn painter(&mut self) -> &mut P {
116        self.painter
117    }
118
119    pub fn clear(&mut self, color: impl Into<Rgba>) {
120        self.painter.clear(color.into());
121    }
122
123    pub fn text_box<F>(&mut self, rect: TileRect, color: impl Into<Rgba>, border: bool, body: F)
124    where
125        F: FnOnce(&mut Frame<'_, P>),
126    {
127        let absolute = rect.translated(self.origin_tx, self.origin_ty);
128        if border {
129            self.painter.draw_text_box(absolute, color.into());
130        }
131        let inset: u32 = if border { 1 } else { 0 };
132        let mut frame = Frame {
133            painter: self.painter,
134            origin_tx: absolute.tx + inset,
135            origin_ty: absolute.ty + inset,
136        };
137        body(&mut frame);
138    }
139}
140
141pub struct Frame<'p, P: Painter> {
142    painter: &'p mut P,
143    origin_tx: u32,
144    origin_ty: u32,
145}
146
147impl<'p, P: Painter> Frame<'p, P> {
148    pub fn label(&mut self, tx: u32, ty: u32, text: &str, color: impl Into<Rgba>) {
149        self.painter.draw_text(
150            TilePos::new(self.origin_tx + tx, self.origin_ty + ty),
151            text,
152            color.into(),
153        );
154    }
155
156    pub fn cursor_at(&mut self, tx: u32, ty: u32, color: impl Into<Rgba>) {
157        self.cursor_glyph_at(tx, ty, '\u{25B6}', color);
158    }
159
160    pub fn cursor_glyph_at(&mut self, tx: u32, ty: u32, glyph: char, color: impl Into<Rgba>) {
161        self.painter.draw_glyph(
162            TilePos::new(self.origin_tx + tx, self.origin_ty + ty),
163            glyph,
164            color.into(),
165        );
166    }
167
168    /// Draw a glyph at a screen-absolute tile position. Bypasses the frame
169    /// origin so the glyph appears at the requested column/row regardless of
170    /// where the enclosing text_box is placed. Useful for cursors that sit at
171    /// the border edge of a box (where frame-relative coordinates would be
172    /// negative and thus inexpressible as u32).
173    pub fn abs_glyph(&mut self, tx: u32, ty: u32, glyph: char, color: impl Into<Rgba>) {
174        self.painter
175            .draw_glyph(TilePos::new(tx, ty), glyph, color.into());
176    }
177
178    pub fn menu_list(
179        &mut self,
180        tx: u32,
181        ty: u32,
182        items: &[&str],
183        cursor: usize,
184        row_step: u32,
185        color: impl Into<Rgba>,
186    ) {
187        let color = color.into();
188        for (i, item) in items.iter().enumerate() {
189            let row = ty + (i as u32) * row_step;
190            self.label(tx + 1, row, item, color);
191            if i == cursor {
192                self.cursor_at(tx, row, color);
193            }
194        }
195    }
196
197    pub fn pixel_rect(
198        &mut self,
199        dx_px: u32,
200        dy_px: u32,
201        w_px: u32,
202        h_px: u32,
203        color: impl Into<Rgba>,
204    ) {
205        let (ox_px, oy_px) = TilePos::new(self.origin_tx, self.origin_ty).to_pixels();
206        self.painter
207            .draw_pixel_rect(ox_px + dx_px, oy_px + dy_px, w_px, h_px, color.into());
208    }
209
210    pub fn sub_text_box<F>(&mut self, rect: TileRect, color: impl Into<Rgba>, body: F)
211    where
212        F: FnOnce(&mut Frame<'_, P>),
213    {
214        let absolute = rect.translated(self.origin_tx, self.origin_ty);
215        self.painter.draw_text_box(absolute, color.into());
216        let mut child = Frame {
217            painter: self.painter,
218            origin_tx: absolute.tx + 1,
219            origin_ty: absolute.ty + 1,
220        };
221        body(&mut child);
222    }
223
224    pub fn gb_tile(
225        &mut self,
226        tx: u32,
227        ty: u32,
228        tile_id: u8,
229        fallback: &str,
230        color: impl Into<Rgba>,
231    ) {
232        self.painter.draw_gb_tile(
233            TilePos::new(self.origin_tx + tx, self.origin_ty + ty),
234            tile_id,
235            fallback,
236            color.into(),
237        );
238    }
239
240    /// Draws a partial border (one or more sides) inside the tile rect
241    /// `rect`. Pixel offsets match the interior corner of the GB box-drawing
242    /// glyphs (`+6` on the right of a tile column, `+6` on the bottom of a
243    /// tile row), so the bracket aligns exactly with `text_box` borders.
244    ///
245    /// If `with_arrow` is true, draws a 4px halfarrow (`<`) at the
246    /// far-left of the bottom edge — matching original `DrawLineBox`
247    /// terminator. Requires `sides.bottom = true`.
248    pub fn bracket_box(
249        &mut self,
250        rect: TileRect,
251        sides: BracketSides,
252        with_arrow: bool,
253        color: impl Into<Rgba>,
254    ) {
255        let color = color.into();
256        // Interior-corner offsets: right edge x = (last_col)*8 + 6,
257        // bottom edge y = (last_row)*8 + 6 — matches box_tiles glyph
258        // pixels in pokered-renderer::embedded_font.
259        let left_px = rect.tx * 8;
260        let right_px = (rect.tx + rect.tw - 1) * 8 + 6;
261        let top_px = rect.ty * 8;
262        let bot_px = (rect.ty + rect.th - 1) * 8 + 6;
263
264        if sides.right {
265            self.pixel_rect(right_px, top_px, 1, bot_px - top_px + 1, color);
266        }
267        if sides.left {
268            self.pixel_rect(left_px, top_px, 1, bot_px - top_px + 1, color);
269        }
270        if sides.top {
271            self.pixel_rect(left_px, top_px, right_px - left_px + 1, 1, color);
272        }
273        if sides.bottom {
274            self.pixel_rect(left_px, bot_px, right_px - left_px + 1, 1, color);
275            if with_arrow && left_px >= 3 {
276                let arrow_left = left_px - 3;
277                self.pixel_rect(arrow_left, bot_px, 4, 1, color);
278                self.pixel_rect(arrow_left, bot_px - 1, 1, 1, color);
279                self.pixel_rect(arrow_left, bot_px + 1, 1, 1, color);
280            }
281        }
282    }
283
284    /// 1-pixel-wide vertical line at the right interior edge of tile column
285    /// `tx`, spanning `length_tiles` tile rows starting at `ty`.
286    pub fn vline(&mut self, tx: u32, ty: u32, length_tiles: u32, color: impl Into<Rgba>) {
287        let px = tx * 8 + 6;
288        let py = ty * 8;
289        self.pixel_rect(px, py, 1, length_tiles * 8, color);
290    }
291
292    /// 1-pixel-tall horizontal line at the bottom interior edge of tile row
293    /// `ty`, spanning `length_tiles` tile columns starting at `tx`.
294    pub fn hline(&mut self, tx: u32, ty: u32, length_tiles: u32, color: impl Into<Rgba>) {
295        let px = tx * 8;
296        let py = ty * 8 + 6;
297        self.pixel_rect(px, py, length_tiles * 8, 1, color);
298    }
299
300    /// Draws a vertical sequence of label-value pairs starting at tile
301    /// `(tx, ty)`. Each pair places `label` at `(tx, row)` and `value` at
302    /// `(tx + value_indent_x, row + 1)`, advancing by `row_step` tile rows.
303    pub fn label_value_grid(
304        &mut self,
305        tx: u32,
306        ty: u32,
307        rows: &[LabelValue<'_>],
308        value_indent_x: u32,
309        row_step: u32,
310        label_color: impl Into<Rgba>,
311        value_color: impl Into<Rgba>,
312    ) {
313        let label_color = label_color.into();
314        let value_color = value_color.into();
315        for (i, lv) in rows.iter().enumerate() {
316            let row = ty + (i as u32) * row_step;
317            self.label(tx, row, lv.label, label_color);
318            self.label(tx + value_indent_x, row + 1, &lv.value, value_color);
319        }
320    }
321}
322
323/// Label and formatted value, for [`Frame::label_value_grid`].
324#[derive(Debug, Clone)]
325pub struct LabelValue<'a> {
326    pub label: &'a str,
327    pub value: String,
328}
329
330impl<'a> LabelValue<'a> {
331    pub fn new(label: &'a str, value: impl Into<String>) -> Self {
332        Self {
333            label,
334            value: value.into(),
335        }
336    }
337}