dotzuki_renderer/layout_engine/elements/border.rs
1//! Game Boy-style tile border rendering.
2//!
3//! A [`Border`] draws a bordered rectangle using configurable tile indices.
4//! Each of the 4 corners, 4 edge segments, and the fill area can use
5//! independent tile IDs. When no tile style is set (`tiles` is `None`),
6//! the entire rectangle is filled with a solid background color.
7
8use dotzuki_engine::render::{Rgba, Painter, TilePos, TileRect};
9
10// ── BorderTiles ──────────────────────────────────────────────────────────
11
12/// Tile indices for a Game Boy-style box border.
13///
14/// Each field corresponds to a specific position in the border grid:
15///
16/// ```text
17/// ┌──────┬──────┬──────┬──────┬──────┐
18/// │ tl │ top │ top │ top │ tr │
19/// ├──────┼──────┼──────┼──────┼──────┤
20/// │ left │ fill │ fill │ fill │ right│
21/// ├──────┼──────┼──────┼──────┼──────┤
22/// │ left │ fill │ fill │ fill │ right│
23/// ├──────┼──────┼──────┼──────┼──────┤
24/// │ bl │ bot │ bot │ bot │ br │
25/// └──────┴──────┴──────┴──────┴──────┘
26/// ```
27///
28/// Defaults match the box-drawing tile IDs from the embedded font
29/// (0x79–0x7F).
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct BorderTiles {
32 /// Top-left corner tile.
33 pub top_left: u8,
34 /// Top edge tile (repeated across the top).
35 pub top: u8,
36 /// Top-right corner tile.
37 pub top_right: u8,
38 /// Left edge tile (repeated down the left side).
39 pub left: u8,
40 /// Right edge tile (repeated down the right side).
41 pub right: u8,
42 /// Bottom-left corner tile.
43 pub bottom_left: u8,
44 /// Bottom edge tile (repeated across the bottom).
45 pub bottom: u8,
46 /// Bottom-right corner tile.
47 pub bottom_right: u8,
48 /// Fill tile for the interior.
49 pub fill: u8,
50}
51
52impl Default for BorderTiles {
53 fn default() -> Self {
54 Self {
55 top_left: 0x79, // ┌
56 top: 0x7A, // ─
57 top_right: 0x7B, // ┐
58 left: 0x7C, // │
59 right: 0x7C, // │
60 bottom_left: 0x7D, // └
61 bottom: 0x7A, // ─
62 bottom_right: 0x7E, // ┘
63 fill: 0x7F, // space
64 }
65 }
66}
67
68// ── Border ───────────────────────────────────────────────────────────────
69
70/// A Game Boy-style bordered rectangle.
71///
72/// # Examples
73///
74/// ```
75/// use dotzuki_renderer::layout_engine::elements::border::{Border, BorderTiles};
76/// use dotzuki_engine::render::{TileRect, Rgba};
77///
78/// let rect = TileRect::new(1, 1, 10, 5);
79/// let border = Border::new(rect, Rgba::INK_BLACK);
80///
81/// // Use custom tiles if needed
82/// let custom_tiles = BorderTiles {
83/// top_left: 0x01,
84/// top_right: 0x02,
85/// bottom_left: 0x03,
86/// bottom_right: 0x04,
87/// ..BorderTiles::default()
88/// };
89/// let styled = Border::with_tiles(rect, custom_tiles, Rgba::INK_DARK_GRAY);
90/// ```
91#[derive(Debug, Clone)]
92pub struct Border {
93 /// The area this border occupies (in tiles).
94 pub rect: TileRect,
95 /// Optional tile indices for the border and fill.
96 /// When `None`, the border renders as a solid color fill.
97 pub tiles: Option<BorderTiles>,
98 /// The ink color used for drawing tiles or the solid fill.
99 pub color: Rgba,
100}
101
102impl Border {
103 /// Create a border with default tile indices.
104 #[inline]
105 pub fn new(rect: TileRect, color: Rgba) -> Self {
106 Self {
107 rect,
108 tiles: Some(BorderTiles::default()),
109 color,
110 }
111 }
112
113 /// Create a border with custom tile indices.
114 #[inline]
115 pub fn with_tiles(rect: TileRect, tiles: BorderTiles, color: Rgba) -> Self {
116 Self {
117 rect,
118 tiles: Some(tiles),
119 color,
120 }
121 }
122
123 /// Create a borderless fill (no tiles, just a solid color rectangle).
124 ///
125 /// Useful as a background fill behind other elements.
126 #[inline]
127 pub fn fill(rect: TileRect, color: Rgba) -> Self {
128 Self {
129 rect,
130 tiles: None,
131 color,
132 }
133 }
134
135 /// Whether this border has a tile style set.
136 #[inline]
137 pub fn has_style(&self) -> bool {
138 self.tiles.is_some()
139 }
140
141 // ── Rendering ──────────────────────────────────────────────────────
142
143 /// Render this border into the given [`Painter`].
144 ///
145 /// When `tiles` is `Some`, draws the border using tile IDs:
146 /// corners, edges, and fill. When `tiles` is `None`, fills the
147 /// entire rectangle with `color` using a single pixel-rect call.
148 pub fn render(&self, painter: &mut dyn Painter) {
149 let rect = self.rect;
150
151 match &self.tiles {
152 Some(tiles) => {
153 // Corners (drawn even for 1×1 rects)
154 painter.draw_gb_tile(
155 TilePos::new(rect.tx, rect.ty),
156 tiles.top_left,
157 " ",
158 self.color,
159 );
160 if rect.tw > 1 {
161 painter.draw_gb_tile(
162 TilePos::new(rect.tx + rect.tw - 1, rect.ty),
163 tiles.top_right,
164 " ",
165 self.color,
166 );
167 }
168 if rect.th > 1 {
169 painter.draw_gb_tile(
170 TilePos::new(rect.tx, rect.ty + rect.th - 1),
171 tiles.bottom_left,
172 " ",
173 self.color,
174 );
175 }
176 if rect.tw > 1 && rect.th > 1 {
177 painter.draw_gb_tile(
178 TilePos::new(rect.tx + rect.tw - 1, rect.ty + rect.th - 1),
179 tiles.bottom_right,
180 " ",
181 self.color,
182 );
183 }
184
185 // Top edge
186 for x in 1..rect.tw.saturating_sub(1) {
187 painter.draw_gb_tile(
188 TilePos::new(rect.tx + x, rect.ty),
189 tiles.top,
190 " ",
191 self.color,
192 );
193 }
194
195 // Bottom edge
196 if rect.th > 1 {
197 for x in 1..rect.tw.saturating_sub(1) {
198 painter.draw_gb_tile(
199 TilePos::new(rect.tx + x, rect.ty + rect.th - 1),
200 tiles.bottom,
201 " ",
202 self.color,
203 );
204 }
205 }
206
207 // Left edge
208 for y in 1..rect.th.saturating_sub(1) {
209 painter.draw_gb_tile(
210 TilePos::new(rect.tx, rect.ty + y),
211 tiles.left,
212 " ",
213 self.color,
214 );
215 }
216
217 // Right edge
218 if rect.tw > 1 {
219 for y in 1..rect.th.saturating_sub(1) {
220 painter.draw_gb_tile(
221 TilePos::new(rect.tx + rect.tw - 1, rect.ty + y),
222 tiles.right,
223 " ",
224 self.color,
225 );
226 }
227 }
228
229 // Fill interior
230 for y in 1..rect.th.saturating_sub(1) {
231 for x in 1..rect.tw.saturating_sub(1) {
232 painter.draw_gb_tile(
233 TilePos::new(rect.tx + x, rect.ty + y),
234 tiles.fill,
235 " ",
236 self.color,
237 );
238 }
239 }
240 }
241 None => {
242 // Solid color fill — no border tiles
243 painter.draw_pixel_rect(
244 rect.tx * 8,
245 rect.ty * 8,
246 rect.tw * 8,
247 rect.th * 8,
248 self.color,
249 );
250 }
251 }
252 }
253}
254
255// ── Tests ────────────────────────────────────────────────────────────────
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use dotzuki_engine::render::TileRect;
261
262 // ── BorderTiles tests ──────────────────────────────────────────────
263
264 #[test]
265 fn default_tiles_match_textbox_constants() {
266 let t = BorderTiles::default();
267 assert_eq!(t.top_left, 0x79);
268 assert_eq!(t.top, 0x7A);
269 assert_eq!(t.top_right, 0x7B);
270 assert_eq!(t.left, 0x7C);
271 assert_eq!(t.right, 0x7C);
272 assert_eq!(t.bottom_left, 0x7D);
273 assert_eq!(t.bottom, 0x7A);
274 assert_eq!(t.bottom_right, 0x7E);
275 assert_eq!(t.fill, 0x7F);
276 }
277
278 #[test]
279 fn custom_tiles_support() {
280 let t = BorderTiles {
281 top_left: 0x01,
282 top: 0x02,
283 top_right: 0x03,
284 left: 0x04,
285 right: 0x05,
286 bottom_left: 0x06,
287 bottom: 0x07,
288 bottom_right: 0x08,
289 fill: 0x09,
290 };
291 assert_eq!(t.top_left, 0x01);
292 assert_eq!(t.right, 0x05);
293 assert_eq!(t.fill, 0x09);
294 }
295
296 #[test]
297 fn border_tiles_copy_and_eq() {
298 let a = BorderTiles::default();
299 let b = a;
300 assert_eq!(a, b);
301
302 let mut c = a;
303 c.top_left = 0x00;
304 assert_ne!(a, c);
305 }
306
307 // ── Border construction tests ─────────────────────────────────────
308
309 #[test]
310 fn new_border_has_default_tiles() {
311 let rect = TileRect::new(0, 0, 10, 5);
312 let b = Border::new(rect, Rgba::INK_BLACK);
313 assert!(b.has_style());
314 assert_eq!(b.tiles, Some(BorderTiles::default()));
315 assert_eq!(b.rect, rect);
316 }
317
318 #[test]
319 fn with_tiles_sets_custom_tiles() {
320 let rect = TileRect::new(2, 3, 8, 4);
321 let tiles = BorderTiles {
322 top_left: 0x10,
323 ..BorderTiles::default()
324 };
325 let b = Border::with_tiles(rect, tiles, Rgba::INK_DARK_GRAY);
326 assert!(b.has_style());
327 assert_eq!(b.tiles.unwrap().top_left, 0x10);
328 }
329
330 #[test]
331 fn fill_border_has_no_style() {
332 let rect = TileRect::new(0, 0, 20, 18);
333 let b = Border::fill(rect, Rgba::INK_WHITE);
334 assert!(!b.has_style());
335 assert_eq!(b.tiles, None);
336 assert_eq!(b.color, Rgba::INK_WHITE);
337 }
338
339 #[test]
340 fn has_style_reflects_tiles_presence() {
341 let rect = TileRect::new(0, 0, 4, 4);
342
343 let styled = Border::new(rect, Rgba::INK_BLACK);
344 assert!(styled.has_style());
345
346 let fill_only = Border::fill(rect, Rgba::INK_WHITE);
347 assert!(!fill_only.has_style());
348 }
349
350 #[test]
351 fn minimal_1x1_border() {
352 // A 1×1 border should just draw the top-left corner
353 let rect = TileRect::new(5, 5, 1, 1);
354 let b = Border::new(rect, Rgba::INK_BLACK);
355 assert!(b.has_style());
356 assert_eq!(b.rect.tw, 1);
357 assert_eq!(b.rect.th, 1);
358 }
359
360 #[test]
361 fn thin_horizontal_border() {
362 // 10-wide × 1-high: just top edge between two corners
363 let rect = TileRect::new(0, 0, 10, 1);
364 let b = Border::new(rect, Rgba::INK_BLACK);
365 assert_eq!(b.rect.tw, 10);
366 assert_eq!(b.rect.th, 1);
367 }
368
369 #[test]
370 fn thin_vertical_border() {
371 // 1-wide × 5-high: just left edge between two corners
372 let rect = TileRect::new(0, 0, 1, 5);
373 let b = Border::new(rect, Rgba::INK_BLACK);
374 assert_eq!(b.rect.tw, 1);
375 assert_eq!(b.rect.th, 5);
376 }
377}