Skip to main content

pixel8_console/
ui.rs

1//! Editor chrome shared by all modes: the red tab bar, the status bar,
2//! mouse state and the cursor. Everything is drawn with the runtime's
3//! fantasy-console primitives — no native widgets anywhere.
4
5use crate::shell::Mode;
6use pixel8_runtime::{
7    assets::{Note, SpriteSheet, SPRITES_PER_ROW, SPRITE_COUNT},
8    fb::{Framebuffer, HEIGHT, WIDTH},
9    font,
10    palette::col,
11    ui as rui,
12};
13
14/// An 8×8 one-bit icon: each byte is a row, MSB is the left pixel.
15pub type Icon8 = [u8; 8];
16
17/// Blit an [`Icon8`] at (x, y) in the given colour; unset bits are left untouched.
18pub fn draw_icon8(fb: &mut Framebuffer, icon: &Icon8, x: i32, y: i32, color: u8) {
19    for (ry, row) in icon.iter().enumerate() {
20        for rx in 0..8 {
21            if row & (0x80 >> rx) != 0 {
22                fb.pset(x + rx, y + ry as i32, color);
23            }
24        }
25    }
26}
27
28/// The pencil glyph, sampled pixel-for-pixel from PICO-8 (shared by the sprite
29/// and map editors).
30pub const ICON_PENCIL: Icon8 = [0x08, 0x1C, 0x3E, 0x7C, 0xB8, 0x90, 0xE0, 0x00];
31
32/// Mouse state in virtual-screen coordinates.
33#[derive(Debug, Clone, Copy)]
34pub struct Mouse {
35    pub x: i32,
36    pub y: i32,
37    pub left: bool,
38    pub right: bool,
39    /// Edge-triggered: pressed since the last frame.
40    pub left_pressed: bool,
41    pub right_pressed: bool,
42}
43
44impl Default for Mouse {
45    fn default() -> Self {
46        // Off-screen until the first real cursor event arrives.
47        Self {
48            x: -16,
49            y: -16,
50            left: false,
51            right: false,
52            left_pressed: false,
53            right_pressed: false,
54        }
55    }
56}
57
58impl Mouse {
59    /// Clear edge flags; called by the shell at the end of each tick.
60    pub fn end_frame(&mut self) {
61        self.left_pressed = false;
62        self.right_pressed = false;
63    }
64
65    pub fn over(&self, x0: i32, y0: i32, x1: i32, y1: i32) -> bool {
66        self.x >= x0 && self.x <= x1 && self.y >= y0 && self.y <= y1
67    }
68}
69
70const TABS: [(&rui::Icon, Mode); 5] = [
71    (&rui::ICON_CODE, Mode::Code),
72    (&rui::ICON_SPRITE, Mode::Sprite),
73    (&rui::ICON_MAP, Mode::Map),
74    (&rui::ICON_SFX, Mode::Sfx),
75    (&rui::ICON_MUSIC, Mode::Music),
76];
77
78/// Leftmost and rightmost lit columns of an icon (its ink bounds, 0..8).
79fn ink_bounds(icon: &rui::Icon) -> (i32, i32) {
80    let mut lo = 8;
81    let mut hi = -1;
82    for &row in icon.iter() {
83        for c in 0..8 {
84            if row & (0x80 >> c) != 0 {
85                lo = lo.min(c);
86                hi = hi.max(c);
87            }
88        }
89    }
90    (lo, hi)
91}
92
93/// X offset of each tab's 8×8 cell, packed right-aligned so adjacent icons'
94/// ink is always one blank pixel apart — each icon's own edge margin is
95/// absorbed rather than added, keeping every icon equidistant.
96fn tab_positions() -> [i32; TABS.len()] {
97    /// Blank pixels between one icon's last ink column and the next's first.
98    const GAP: i32 = 1;
99    /// Rightmost ink column of the last icon (1px in from the screen edge).
100    const RIGHT: i32 = WIDTH - 2;
101    let n = TABS.len();
102    let mut xs = [0i32; TABS.len()];
103    xs[n - 1] = RIGHT - ink_bounds(TABS[n - 1].0).1;
104    for i in (0..n - 1).rev() {
105        let hi = ink_bounds(TABS[i].0).1;
106        let lo_next = ink_bounds(TABS[i + 1].0).0;
107        xs[i] = xs[i + 1] + lo_next - hi - (GAP + 1);
108    }
109    xs
110}
111
112fn tab_x(i: usize) -> i32 {
113    tab_positions()[i]
114}
115
116/// Top bar: the red strip and the five editor tab icons, right-aligned.
117/// Per-editor top-left content (the code filename, the SFX mode buttons) is
118/// drawn by the shell on top of this.
119pub fn draw_tab_bar(fb: &mut Framebuffer, active: Mode) {
120    fb.rectfill(0, 0, WIDTH - 1, 7, col::RED);
121    // The active tab is distinguished by icon colour only (peach), never a
122    // background box — a box in the inactive-icon colour just melds with them.
123    for (i, (icon, mode)) in TABS.iter().enumerate() {
124        let x = tab_x(i);
125        let color = if *mode == active {
126            col::PEACH
127        } else {
128            col::DARK_PURPLE
129        };
130        rui::icon(fb, icon, x, 0, color);
131    }
132}
133
134/// Left edge of the filename label in the tab bar.
135const FILENAME_X: i32 = 2;
136
137/// The filename label's right-edge limit: it stays one blank column clear of
138/// the leftmost tab icon, so a long name never overdraws the tabs or shares a
139/// click with them.
140fn filename_limit() -> i32 {
141    tab_x(0) - 2
142}
143
144/// Draw the code editor's current filename in the top-left of the tab bar, in
145/// peach to match the highlighted code tab. Truncated to stay clear of the tab
146/// icons; empty when no project file is open (a loaded cart), so nothing shows.
147pub fn code_filename(fb: &mut Framebuffer, name: &str) {
148    let max = ((filename_limit() - FILENAME_X) / font::GLYPH_W).max(0) as usize;
149    let shown: String = name.chars().take(max).collect();
150    fb.print(&shown, FILENAME_X, 1, col::PEACH);
151}
152
153/// Whether a left-press landed on the top-left filename (the click target that
154/// opens the file picker). False for an empty name; the region is clamped clear
155/// of the tab icons so one click never routes to both.
156pub fn filename_clicked(mouse: &Mouse, name: &str) -> bool {
157    !name.is_empty()
158        && mouse.left_pressed
159        && mouse.y < 8
160        && mouse.x >= 1
161        && mouse.x < filename_limit()
162        && mouse.x <= FILENAME_X + font::text_width(name)
163}
164
165/// The tab index whose 8×8 cell contains screen-x `x`, if any.
166fn tab_at(x: i32) -> Option<usize> {
167    (0..TABS.len()).find(|&i| x >= tab_x(i) - 1 && x <= tab_x(i) + 7)
168}
169
170/// Which tab (index into `EDITOR_MODES`) was clicked this frame, if any.
171pub fn tab_bar_click(mouse: &Mouse) -> Option<usize> {
172    if !mouse.left_pressed || mouse.y >= 8 {
173        return None;
174    }
175    tab_at(mouse.x)
176}
177
178/// The tab the cursor hovers in the top bar, if any (for the bottom-bar hint).
179pub fn tab_bar_hover(mouse: &Mouse) -> Option<usize> {
180    if mouse.y >= 8 {
181        return None;
182    }
183    tab_at(mouse.x)
184}
185
186/// Display name for tab `i`, in `TABS` order.
187pub fn tab_name(i: usize) -> &'static str {
188    const NAMES: [&str; TABS.len()] = ["Code", "Sprite", "Map", "SFX", "Music"];
189    NAMES[i]
190}
191
192/// Bottom status bar with a single line of text.
193pub fn status_bar(fb: &mut Framebuffer, text: &str) {
194    fb.rectfill(0, HEIGHT - 8, WIDTH - 1, HEIGHT - 1, col::RED);
195    fb.print(text, 2, HEIGHT - 7, col::DARK_PURPLE);
196}
197
198/// Frames a transient status message stays up (~2.5s at 60fps).
199pub const STATUS_TTL: u16 = 150;
200
201/// A transient bottom-bar message with a frame countdown; falls back to a
202/// static hint once it expires. Callers pass text already sized for the bar.
203#[derive(Default)]
204pub struct StatusMsg {
205    text: Option<String>,
206    ttl: u16,
207}
208
209impl StatusMsg {
210    /// Show `text` for [`STATUS_TTL`] frames.
211    pub fn set(&mut self, text: String) {
212        self.text = Some(text);
213        self.ttl = STATUS_TTL;
214    }
215
216    /// Count down one frame, clearing the message at zero. Call once per tick.
217    pub fn tick(&mut self) {
218        if self.ttl > 0 {
219            self.ttl -= 1;
220            if self.ttl == 0 {
221                self.text = None;
222            }
223        }
224    }
225
226    /// Draw the message if set, else `fallback`.
227    pub fn show(&self, fb: &mut Framebuffer, fallback: &str) {
228        status_bar(fb, self.text.as_deref().unwrap_or(fallback));
229    }
230
231    /// The current message text, if any (used by tests to assert paste feedback).
232    #[cfg(test)]
233    pub fn current(&self) -> Option<&str> {
234        self.text.as_deref()
235    }
236}
237
238// ---------------------------------------------------------------------------
239// The sprite strip along the bottom of the sprite and map editors.
240// ---------------------------------------------------------------------------
241
242/// Height of the strip band: four rows of sprites at full size.
243pub const STRIP_H: i32 = 32;
244
245/// Pixel `(px, py)` of the sprite at sheet pixel `(ox, oy)` drawn `n` pixels a
246/// side, `n` being 8 or a half of it. Each sample comes from the middle of the
247/// block it stands for rather than its corner — at one pixel a sprite that is
248/// the difference between showing the art and showing its margin.
249pub fn sprite_sample(sheet: &SpriteSheet, ox: i32, oy: i32, n: i32, px: i32, py: i32) -> u8 {
250    let step = 8 / n;
251    sheet.get(ox + px * step + step / 2, oy + py * step + step / 2)
252}
253
254const SHEET_COLS: i32 = SPRITES_PER_ROW as i32;
255const SHEET_ROWS: i32 = (SPRITE_COUNT / SPRITES_PER_ROW) as i32;
256
257/// A window onto the sprite sheet, filling the band: `rows` sheet rows from
258/// `row0`, drawn at `cell` pixels a side.
259///
260/// Blocks up to four cells tall are shown at full size, four rows to a page.
261/// A taller one would run off the bottom of the band, so the strip halves its
262/// cells to fit eight rows instead and follows the block rather than the page —
263/// the point of the strip being to show what is selected.
264#[derive(Debug, Clone, Copy)]
265pub struct Strip {
266    /// Screen side of one cell.
267    pub cell: i32,
268    /// Sheet rows on show.
269    pub rows: i32,
270    /// Left edge: half-size cells are half as wide, so the strip sits centred.
271    pub x0: i32,
272    /// First sheet row on show.
273    pub row0: i32,
274}
275
276impl Strip {
277    /// The strip for a `size`-cell block whose top-left is at sheet row `row`,
278    /// with `page` choosing the rows whenever the block leaves a choice.
279    pub fn new(size: i32, row: i32, page: u32) -> Self {
280        let follows = Self::follows_block(size);
281        let (cell, rows) = if follows { (4, 8) } else { (8, 4) };
282        let row0 = if follows { row } else { page as i32 * rows };
283        Self {
284            cell,
285            rows,
286            x0: (WIDTH - SHEET_COLS * cell) / 2,
287            row0: row0.clamp(0, SHEET_ROWS - rows),
288        }
289    }
290
291    /// Whether the strip follows the block instead of the page, which it does
292    /// once the block no longer fits the band at full size.
293    pub fn follows_block(size: i32) -> bool {
294        size * 8 > STRIP_H
295    }
296
297    /// The sheet cell `(column, row)` at screen point `(x, y)`, for a strip with
298    /// its top edge at `top`.
299    pub fn cell_at(&self, top: i32, x: i32, y: i32) -> Option<(i32, i32)> {
300        let (cx, cy) = ((x - self.x0) / self.cell, (y - top) / self.cell);
301        let inside = x >= self.x0 && cx < SHEET_COLS && y >= top && cy < self.rows;
302        inside.then_some((cx, self.row0 + cy))
303    }
304
305    /// Paint the band and the rows on show, sampling the sheet down when the
306    /// cells are at half size.
307    pub fn draw(&self, fb: &mut Framebuffer, sprites: &SpriteSheet, top: i32) {
308        fb.rectfill(0, top, WIDTH - 1, top + STRIP_H - 1, col::DARK_GREY);
309        for cy in 0..self.rows {
310            for cx in 0..SHEET_COLS {
311                let (sx, sy) = (cx * 8, (self.row0 + cy) * 8);
312                for py in 0..self.cell {
313                    for px in 0..self.cell {
314                        fb.pset(
315                            self.x0 + cx * self.cell + px,
316                            top + cy * self.cell + py,
317                            sprite_sample(sprites, sx, sy, self.cell, px, py),
318                        );
319                    }
320                }
321            }
322        }
323    }
324
325    /// Box the `size`-cell block at sheet cell `(col, row)`, clipped to the rows
326    /// on show — a block the strip does not follow can start on another page.
327    pub fn draw_block(&self, fb: &mut Framebuffer, top: i32, col: i32, row: i32, size: i32) {
328        let y0 = (row - self.row0).max(0);
329        let y1 = (row + size - self.row0).min(self.rows);
330        if y1 <= y0 {
331            return;
332        }
333        let x = self.x0 + col * self.cell;
334        let y = top + y0 * self.cell;
335        fb.rect(
336            x,
337            y,
338            x + size * self.cell - 1,
339            y + (y1 - y0) * self.cell - 1,
340            col::WHITE,
341        );
342    }
343}
344
345// ---------------------------------------------------------------------------
346// Shared SFX/music editor chrome, matching PICO-8's. Pixel-exact icons (flow
347// buttons, waveform palette, the wave/circle toggles) are blitted from grids
348// lifted verbatim from PICO-8's framebuffer.
349// ---------------------------------------------------------------------------
350
351/// Paint a pixel grid (one hex palette index per cell; `.` = black/0; `5` = the
352/// dark-grey editor background, treated as transparent so it shows through).
353pub fn blit(fb: &mut Framebuffer, x0: i32, y0: i32, rows: &[&str]) {
354    for (dy, row) in rows.iter().enumerate() {
355        for (dx, ch) in row.chars().enumerate() {
356            if ch == '5' {
357                continue;
358            }
359            let c = ch.to_digit(16).unwrap_or(0) as u8;
360            fb.pset(x0 + dx as i32, y0 + dy as i32, c);
361        }
362    }
363}
364
365/// Left-pointing arrow (apex at left).
366pub fn arrow_l(fb: &mut Framebuffer, x: i32, y: i32, c: u8) {
367    fb.pset(x, y + 2, c);
368    fb.line(x + 1, y + 1, x + 1, y + 3, c);
369    fb.line(x + 2, y, x + 2, y + 4, c);
370}
371
372/// Right-pointing arrow (apex at right).
373pub fn arrow_r(fb: &mut Framebuffer, x: i32, y: i32, c: u8) {
374    fb.line(x, y, x, y + 4, c);
375    fb.line(x + 1, y + 1, x + 1, y + 3, c);
376    fb.pset(x + 2, y + 2, c);
377}
378
379/// The two top-left view-mode buttons: a bar-chart (pitch) and a 3x3 dot grid
380/// (tracker). The active one is peach, the other dark-purple.
381pub fn mode_buttons(fb: &mut Framebuffer, pitch_active: bool) {
382    let (bars, grid) = if pitch_active {
383        (col::PEACH, col::DARK_PURPLE)
384    } else {
385        (col::DARK_PURPLE, col::PEACH)
386    };
387    for i in 0..4 {
388        fb.line(5 + i * 2, 1, 5 + i * 2, 6, bars);
389    }
390    for r in 0..3 {
391        for c in 0..4 {
392            fb.pset(15 + c * 2, 2 + r * 2, grid);
393        }
394    }
395}
396
397/// The two top-left view buttons for the sprite/map editors: a panelled layout
398/// glyph (normal, a box with a divider strip) and a hollow box (fullscreen). The
399/// active one is peach, the other dark-purple. Hit regions match `mode_buttons`:
400/// `over(4, 0, 12, 7)` for normal, `over(13, 0, 22, 7)` for fullscreen.
401pub fn view_buttons(fb: &mut Framebuffer, fullscreen: bool) {
402    let (normal, full) = if fullscreen {
403        (col::DARK_PURPLE, col::PEACH)
404    } else {
405        (col::PEACH, col::DARK_PURPLE)
406    };
407    // Normal: a small panel with a divider near the bottom (canvas + sheet).
408    fb.rect(5, 1, 11, 6, normal);
409    fb.line(5, 4, 11, 4, normal);
410    // Fullscreen: a single hollow box (the whole screen, no panels).
411    fb.rect(15, 1, 21, 6, full);
412}
413
414/// The music grid's Pat/Sfx toggle, drawn in the top bar: two labels flanking a
415/// slide switch whose raised knob sits on the active side (the active label is
416/// white). The whole switch is one click target (handled by the music editor).
417pub fn pat_sfx_toggle(fb: &mut Framebuffer, sfx_active: bool) {
418    let pat_c = if sfx_active {
419        col::DARK_PURPLE
420    } else {
421        col::WHITE
422    };
423    let sfx_c = if sfx_active {
424        col::WHITE
425    } else {
426        col::DARK_PURPLE
427    };
428    fb.print("Pat", 28, 1, pat_c);
429    // A thin recessed groove with a knob standing proud of it on the active
430    // side — so it reads as a slide switch, not a filled bar.
431    fb.rectfill(43, 3, 56, 4, col::DARK_PURPLE);
432    let kx = if sfx_active { 51 } else { 43 };
433    fb.rectfill(kx, 1, kx + 5, 6, col::WHITE);
434    fb.print("Sfx", 59, 1, sfx_c);
435}
436
437/// A channel enable toggle: a 5x5 light-grey box with a white centre when on.
438pub fn radio(fb: &mut Framebuffer, x: i32, y: i32, on: bool) {
439    fb.rect(x, y, x + 4, y + 4, col::LIGHT_GREY);
440    if on {
441        fb.pset(x + 2, y + 2, col::WHITE);
442    }
443}
444
445/// The "edit this SFX" pencil glyph (lavender).
446pub fn pencil(fb: &mut Framebuffer, x: i32, y: i32) {
447    fb.line(x + 3, y, x, y + 3, col::LAVENDER);
448    fb.line(x + 4, y + 1, x + 1, y + 4, col::LAVENDER);
449}
450
451const LETTERS: [&str; 12] = ["c", "c", "d", "d", "e", "f", "f", "g", "g", "a", "a", "b"];
452const SHARP: [bool; 12] = [
453    false, true, false, true, false, false, true, false, true, false, true, false,
454];
455
456/// One tracker note cell at (x, y), in PICO-8's field layout/colours: letter
457/// (white) · `#` accidental · octave (light grey) · instrument (pink, or green
458/// when a custom instrument) · volume (blue) · effect (grey `.` / orange digit).
459/// A silent step (volume 0) renders as a faint dotted line.
460pub fn note_cell(fb: &mut Framebuffer, x: i32, y: i32, note: Note) {
461    if note.volume == 0 {
462        for gx in (x + 2..x + 27).step_by(3) {
463            fb.pset(gx, y + 4, col::DARK_BLUE);
464        }
465        return;
466    }
467    let k = (note.pitch % 12) as usize;
468    fb.print(LETTERS[k], x + 2, y, col::WHITE);
469    if SHARP[k] {
470        fb.print("#", x + 6, y, col::WHITE);
471    }
472    fb.print(&format!("{}", note.pitch / 12), x + 10, y, col::LIGHT_GREY);
473    let inst_col = if note.instrument().is_some() {
474        col::GREEN
475    } else {
476        col::PINK
477    };
478    fb.print(&format!("{}", note.wave_index()), x + 15, y, inst_col);
479    fb.print(&format!("{}", note.volume), x + 20, y, col::BLUE);
480    if note.effect == 0 {
481        fb.print(".", x + 24, y, col::DARK_GREY);
482    } else {
483        fb.print(&format!("{}", note.effect), x + 24, y, col::ORANGE);
484    }
485}
486
487/// Flow-control flags (loop-start, loop-back, stop), lifted from PICO-8.
488pub const FLOW: [&str; 8] = [
489    "555555555555555555555555555",
490    "555555c55555555555555555555",
491    "555555cc5555515515551111555",
492    "555cccccc555115515551111555",
493    "555c..cc.551111115551111555",
494    "555c55c.555.11...5551111555",
495    "555.55.55555.1555555....555",
496    "5555555555555.5555555555555",
497];
498
499/// The 8 SFX waveform-graph palette boxes (box 0 red/selected here), lifted
500/// from PICO-8. `8` = red box, `6` = grey box, `7` = white graph line.
501pub const PALETTE: [&str; 6] = [
502    "888888885666666665666666665666666665666666665666666665666666665",
503    "888778885666667665666666775667777765666677765667666665667666765",
504    "887887885666776765666677675667666765666676765676766665767676765",
505    "878888785677666765667766675667666765666676765766676765777777775",
506    "788888875766666675776666675777666775777776775766677675676767675",
507    "888888885666666665666666665666666665666666665666666665676666675",
508];
509
510/// The palette's display-mode circle toggle (lavender), lifted from PICO-8.
511pub const CIRCLE: [&str; 6] = [
512    "555555555",
513    "55555dd55",
514    "5555d55d5",
515    "5555d55d5",
516    "55555dd55",
517    "555555555",
518];
519
520/// The header default-waveform wave icon (lavender), lifted from PICO-8.
521pub const WAVEI: [&str; 5] = [
522    "555555555",
523    "555d55555",
524    "55d5d5555",
525    "5d555d5d5",
526    "555555d55",
527];
528
529/// Draw the mouse cursor on top of everything.
530pub fn draw_cursor(fb: &mut Framebuffer, mouse: &Mouse) {
531    rui::cursor(fb, mouse.x, mouse.y);
532}
533
534#[cfg(test)]
535mod paste_status_tests {
536    use super::*;
537
538    #[test]
539    fn status_msg_falls_back_then_expires() {
540        let mut m = StatusMsg::default();
541        assert_eq!(m.current(), None);
542        m.set("done".into());
543        assert_eq!(m.current(), Some("done"));
544        for _ in 0..STATUS_TTL {
545            m.tick();
546        }
547        assert_eq!(m.current(), None);
548    }
549}
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn pencil_icon_draws_its_lit_pixels() {
557        let mut fb = Framebuffer::new();
558        draw_icon8(&mut fb, &ICON_PENCIL, 10, 20, col::WHITE);
559        // Row 0 of ICON_PENCIL (0x08) lights exactly column 4.
560        assert_eq!(fb.pget(10 + 4, 20), col::WHITE, "row 0 bit 4 lit");
561        assert_eq!(fb.pget(10, 20), col::BLACK, "row 0 bit 0 unlit");
562        // Row 6 (0xE0) lights columns 0..=2.
563        assert_eq!(fb.pget(10, 26), col::WHITE, "row 6 bit 0 lit");
564    }
565
566    #[test]
567    fn active_tab_has_no_background_box() {
568        let mut fb = Framebuffer::new();
569        draw_tab_bar(&mut fb, Mode::Music);
570        // The active tab is shown by icon colour only: the cell behind it must
571        // stay red, not a dark-purple box that melds with the other icons.
572        let x = tab_x(4);
573        assert_eq!(
574            fb.pget(x - 1, 3),
575            col::RED,
576            "no background box behind the active tab"
577        );
578    }
579
580    #[test]
581    fn tab_bar_has_no_title_text() {
582        // The old "Code" label sat at (2, 1); that area must now be plain red.
583        let mut fb = Framebuffer::new();
584        draw_tab_bar(&mut fb, Mode::Code);
585        for x in 2..18 {
586            assert_eq!(fb.pget(x, 1), col::RED, "title row must be blank at x={x}");
587        }
588    }
589
590    #[test]
591    fn hover_maps_x_to_the_tab_and_its_name() {
592        // Inside the first tab's cell -> index 0 ("Code"); off the tabs -> None.
593        let over_first = Mouse {
594            x: tab_x(0) + 3,
595            y: 3,
596            ..Default::default()
597        };
598        assert_eq!(tab_bar_hover(&over_first), Some(0));
599        assert_eq!(tab_name(tab_bar_hover(&over_first).unwrap()), "Code");
600        let last = TABS.len() - 1;
601        let over_last = Mouse {
602            x: tab_x(last) + 3,
603            y: 3,
604            ..Default::default()
605        };
606        assert_eq!(tab_name(tab_bar_hover(&over_last).unwrap()), "Music");
607        // Far left (over the filename area) and below the bar are not tabs.
608        let off = Mouse {
609            x: 1,
610            y: 3,
611            ..Default::default()
612        };
613        assert_eq!(tab_bar_hover(&off), None);
614        let below = Mouse {
615            x: tab_x(0) + 3,
616            y: 8,
617            ..Default::default()
618        };
619        assert_eq!(tab_bar_hover(&below), None);
620    }
621
622    #[test]
623    fn code_filename_draws_and_is_clickable() {
624        let mut fb = Framebuffer::new();
625        code_filename(&mut fb, "lib.rs");
626        // Some pixel in the label band is lit peach.
627        let lit = (2..2 + font::text_width("lib.rs"))
628            .any(|x| (1..7).any(|y| fb.pget(x, y) == col::PEACH));
629        assert!(lit, "filename should render in peach");
630
631        let press = Mouse {
632            x: 3,
633            y: 2,
634            left_pressed: true,
635            ..Default::default()
636        };
637        assert!(filename_clicked(&press, "lib.rs"));
638        // Empty name: nothing to click.
639        assert!(!filename_clicked(&press, ""));
640        // Past the text, below the bar, and a non-press are all rejected.
641        let far = Mouse { x: 120, ..press };
642        assert!(!filename_clicked(&far, "lib.rs"));
643        let below = Mouse { y: 9, ..press };
644        assert!(!filename_clicked(&below, "lib.rs"));
645        let hover = Mouse {
646            left_pressed: false,
647            ..press
648        };
649        assert!(!filename_clicked(&hover, "lib.rs"));
650    }
651
652    #[test]
653    fn view_buttons_light_the_active_view() {
654        // Normal active: the layout glyph is peach, the box glyph dark-purple.
655        let mut fb = Framebuffer::new();
656        view_buttons(&mut fb, false);
657        assert_eq!(fb.pget(5, 1), col::PEACH);
658        assert_eq!(fb.pget(15, 1), col::DARK_PURPLE);
659        // Fullscreen active: the colours swap.
660        let mut fb2 = Framebuffer::new();
661        view_buttons(&mut fb2, true);
662        assert_eq!(fb2.pget(5, 1), col::DARK_PURPLE);
663        assert_eq!(fb2.pget(15, 1), col::PEACH);
664    }
665
666    #[test]
667    fn tabs_are_equidistant_by_ink() {
668        // Every adjacent pair of icons is separated by exactly one blank column
669        // between their ink, regardless of each bitmap's own edge margins.
670        let xs = tab_positions();
671        let gaps: Vec<i32> = (0..TABS.len() - 1)
672            .map(|i| {
673                let this_right = xs[i] + ink_bounds(TABS[i].0).1;
674                let next_left = xs[i + 1] + ink_bounds(TABS[i + 1].0).0;
675                next_left - this_right - 1
676            })
677            .collect();
678        assert!(gaps.iter().all(|&g| g == 1), "uneven tab gaps: {gaps:?}");
679    }
680}