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,
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// Shared SFX/music editor chrome, matching PICO-8's. Pixel-exact icons (flow
240// buttons, waveform palette, the wave/circle toggles) are blitted from grids
241// lifted verbatim from PICO-8's framebuffer.
242// ---------------------------------------------------------------------------
243
244/// Paint a pixel grid (one hex palette index per cell; `.` = black/0; `5` = the
245/// dark-grey editor background, treated as transparent so it shows through).
246pub fn blit(fb: &mut Framebuffer, x0: i32, y0: i32, rows: &[&str]) {
247    for (dy, row) in rows.iter().enumerate() {
248        for (dx, ch) in row.chars().enumerate() {
249            if ch == '5' {
250                continue;
251            }
252            let c = ch.to_digit(16).unwrap_or(0) as u8;
253            fb.pset(x0 + dx as i32, y0 + dy as i32, c);
254        }
255    }
256}
257
258/// Left-pointing arrow (apex at left).
259pub fn arrow_l(fb: &mut Framebuffer, x: i32, y: i32, c: u8) {
260    fb.pset(x, y + 2, c);
261    fb.line(x + 1, y + 1, x + 1, y + 3, c);
262    fb.line(x + 2, y, x + 2, y + 4, c);
263}
264
265/// Right-pointing arrow (apex at right).
266pub fn arrow_r(fb: &mut Framebuffer, x: i32, y: i32, c: u8) {
267    fb.line(x, y, x, y + 4, c);
268    fb.line(x + 1, y + 1, x + 1, y + 3, c);
269    fb.pset(x + 2, y + 2, c);
270}
271
272/// The two top-left view-mode buttons: a bar-chart (pitch) and a 3x3 dot grid
273/// (tracker). The active one is peach, the other dark-purple.
274pub fn mode_buttons(fb: &mut Framebuffer, pitch_active: bool) {
275    let (bars, grid) = if pitch_active {
276        (col::PEACH, col::DARK_PURPLE)
277    } else {
278        (col::DARK_PURPLE, col::PEACH)
279    };
280    for i in 0..4 {
281        fb.line(5 + i * 2, 1, 5 + i * 2, 6, bars);
282    }
283    for r in 0..3 {
284        for c in 0..4 {
285            fb.pset(15 + c * 2, 2 + r * 2, grid);
286        }
287    }
288}
289
290/// The two top-left view buttons for the sprite/map editors: a panelled layout
291/// glyph (normal, a box with a divider strip) and a hollow box (fullscreen). The
292/// active one is peach, the other dark-purple. Hit regions match `mode_buttons`:
293/// `over(4, 0, 12, 7)` for normal, `over(13, 0, 22, 7)` for fullscreen.
294pub fn view_buttons(fb: &mut Framebuffer, fullscreen: bool) {
295    let (normal, full) = if fullscreen {
296        (col::DARK_PURPLE, col::PEACH)
297    } else {
298        (col::PEACH, col::DARK_PURPLE)
299    };
300    // Normal: a small panel with a divider near the bottom (canvas + sheet).
301    fb.rect(5, 1, 11, 6, normal);
302    fb.line(5, 4, 11, 4, normal);
303    // Fullscreen: a single hollow box (the whole screen, no panels).
304    fb.rect(15, 1, 21, 6, full);
305}
306
307/// The music grid's Pat/Sfx toggle, drawn in the top bar: two labels flanking a
308/// slide switch whose raised knob sits on the active side (the active label is
309/// white). The whole switch is one click target (handled by the music editor).
310pub fn pat_sfx_toggle(fb: &mut Framebuffer, sfx_active: bool) {
311    let pat_c = if sfx_active {
312        col::DARK_PURPLE
313    } else {
314        col::WHITE
315    };
316    let sfx_c = if sfx_active {
317        col::WHITE
318    } else {
319        col::DARK_PURPLE
320    };
321    fb.print("Pat", 28, 1, pat_c);
322    // A thin recessed groove with a knob standing proud of it on the active
323    // side — so it reads as a slide switch, not a filled bar.
324    fb.rectfill(43, 3, 56, 4, col::DARK_PURPLE);
325    let kx = if sfx_active { 51 } else { 43 };
326    fb.rectfill(kx, 1, kx + 5, 6, col::WHITE);
327    fb.print("Sfx", 59, 1, sfx_c);
328}
329
330/// A channel enable toggle: a 5x5 light-grey box with a white centre when on.
331pub fn radio(fb: &mut Framebuffer, x: i32, y: i32, on: bool) {
332    fb.rect(x, y, x + 4, y + 4, col::LIGHT_GREY);
333    if on {
334        fb.pset(x + 2, y + 2, col::WHITE);
335    }
336}
337
338/// The "edit this SFX" pencil glyph (lavender).
339pub fn pencil(fb: &mut Framebuffer, x: i32, y: i32) {
340    fb.line(x + 3, y, x, y + 3, col::LAVENDER);
341    fb.line(x + 4, y + 1, x + 1, y + 4, col::LAVENDER);
342}
343
344const LETTERS: [&str; 12] = ["c", "c", "d", "d", "e", "f", "f", "g", "g", "a", "a", "b"];
345const SHARP: [bool; 12] = [
346    false, true, false, true, false, false, true, false, true, false, true, false,
347];
348
349/// One tracker note cell at (x, y), in PICO-8's field layout/colours: letter
350/// (white) · `#` accidental · octave (light grey) · instrument (pink, or green
351/// when a custom instrument) · volume (blue) · effect (grey `.` / orange digit).
352/// A silent step (volume 0) renders as a faint dotted line.
353pub fn note_cell(fb: &mut Framebuffer, x: i32, y: i32, note: Note) {
354    if note.volume == 0 {
355        for gx in (x + 2..x + 27).step_by(3) {
356            fb.pset(gx, y + 4, col::DARK_BLUE);
357        }
358        return;
359    }
360    let k = (note.pitch % 12) as usize;
361    fb.print(LETTERS[k], x + 2, y, col::WHITE);
362    if SHARP[k] {
363        fb.print("#", x + 6, y, col::WHITE);
364    }
365    fb.print(&format!("{}", note.pitch / 12), x + 10, y, col::LIGHT_GREY);
366    let inst_col = if note.instrument().is_some() {
367        col::GREEN
368    } else {
369        col::PINK
370    };
371    fb.print(&format!("{}", note.wave_index()), x + 15, y, inst_col);
372    fb.print(&format!("{}", note.volume), x + 20, y, col::BLUE);
373    if note.effect == 0 {
374        fb.print(".", x + 24, y, col::DARK_GREY);
375    } else {
376        fb.print(&format!("{}", note.effect), x + 24, y, col::ORANGE);
377    }
378}
379
380/// Flow-control flags (loop-start, loop-back, stop), lifted from PICO-8.
381pub const FLOW: [&str; 8] = [
382    "555555555555555555555555555",
383    "555555c55555555555555555555",
384    "555555cc5555515515551111555",
385    "555cccccc555115515551111555",
386    "555c..cc.551111115551111555",
387    "555c55c.555.11...5551111555",
388    "555.55.55555.1555555....555",
389    "5555555555555.5555555555555",
390];
391
392/// The 8 SFX waveform-graph palette boxes (box 0 red/selected here), lifted
393/// from PICO-8. `8` = red box, `6` = grey box, `7` = white graph line.
394pub const PALETTE: [&str; 6] = [
395    "888888885666666665666666665666666665666666665666666665666666665",
396    "888778885666667665666666775667777765666677765667666665667666765",
397    "887887885666776765666677675667666765666676765676766665767676765",
398    "878888785677666765667766675667666765666676765766676765777777775",
399    "788888875766666675776666675777666775777776775766677675676767675",
400    "888888885666666665666666665666666665666666665666666665676666675",
401];
402
403/// The palette's display-mode circle toggle (lavender), lifted from PICO-8.
404pub const CIRCLE: [&str; 6] = [
405    "555555555",
406    "55555dd55",
407    "5555d55d5",
408    "5555d55d5",
409    "55555dd55",
410    "555555555",
411];
412
413/// The header default-waveform wave icon (lavender), lifted from PICO-8.
414pub const WAVEI: [&str; 5] = [
415    "555555555",
416    "555d55555",
417    "55d5d5555",
418    "5d555d5d5",
419    "555555d55",
420];
421
422/// Draw the mouse cursor on top of everything.
423pub fn draw_cursor(fb: &mut Framebuffer, mouse: &Mouse) {
424    rui::cursor(fb, mouse.x, mouse.y);
425}
426
427#[cfg(test)]
428mod paste_status_tests {
429    use super::*;
430
431    #[test]
432    fn status_msg_falls_back_then_expires() {
433        let mut m = StatusMsg::default();
434        assert_eq!(m.current(), None);
435        m.set("done".into());
436        assert_eq!(m.current(), Some("done"));
437        for _ in 0..STATUS_TTL {
438            m.tick();
439        }
440        assert_eq!(m.current(), None);
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn pencil_icon_draws_its_lit_pixels() {
450        let mut fb = Framebuffer::new();
451        draw_icon8(&mut fb, &ICON_PENCIL, 10, 20, col::WHITE);
452        // Row 0 of ICON_PENCIL (0x08) lights exactly column 4.
453        assert_eq!(fb.pget(10 + 4, 20), col::WHITE, "row 0 bit 4 lit");
454        assert_eq!(fb.pget(10, 20), col::BLACK, "row 0 bit 0 unlit");
455        // Row 6 (0xE0) lights columns 0..=2.
456        assert_eq!(fb.pget(10, 26), col::WHITE, "row 6 bit 0 lit");
457    }
458
459    #[test]
460    fn active_tab_has_no_background_box() {
461        let mut fb = Framebuffer::new();
462        draw_tab_bar(&mut fb, Mode::Music);
463        // The active tab is shown by icon colour only: the cell behind it must
464        // stay red, not a dark-purple box that melds with the other icons.
465        let x = tab_x(4);
466        assert_eq!(
467            fb.pget(x - 1, 3),
468            col::RED,
469            "no background box behind the active tab"
470        );
471    }
472
473    #[test]
474    fn tab_bar_has_no_title_text() {
475        // The old "Code" label sat at (2, 1); that area must now be plain red.
476        let mut fb = Framebuffer::new();
477        draw_tab_bar(&mut fb, Mode::Code);
478        for x in 2..18 {
479            assert_eq!(fb.pget(x, 1), col::RED, "title row must be blank at x={x}");
480        }
481    }
482
483    #[test]
484    fn hover_maps_x_to_the_tab_and_its_name() {
485        // Inside the first tab's cell -> index 0 ("Code"); off the tabs -> None.
486        let over_first = Mouse {
487            x: tab_x(0) + 3,
488            y: 3,
489            ..Default::default()
490        };
491        assert_eq!(tab_bar_hover(&over_first), Some(0));
492        assert_eq!(tab_name(tab_bar_hover(&over_first).unwrap()), "Code");
493        let last = TABS.len() - 1;
494        let over_last = Mouse {
495            x: tab_x(last) + 3,
496            y: 3,
497            ..Default::default()
498        };
499        assert_eq!(tab_name(tab_bar_hover(&over_last).unwrap()), "Music");
500        // Far left (over the filename area) and below the bar are not tabs.
501        let off = Mouse {
502            x: 1,
503            y: 3,
504            ..Default::default()
505        };
506        assert_eq!(tab_bar_hover(&off), None);
507        let below = Mouse {
508            x: tab_x(0) + 3,
509            y: 8,
510            ..Default::default()
511        };
512        assert_eq!(tab_bar_hover(&below), None);
513    }
514
515    #[test]
516    fn code_filename_draws_and_is_clickable() {
517        let mut fb = Framebuffer::new();
518        code_filename(&mut fb, "lib.rs");
519        // Some pixel in the label band is lit peach.
520        let lit = (2..2 + font::text_width("lib.rs"))
521            .any(|x| (1..7).any(|y| fb.pget(x, y) == col::PEACH));
522        assert!(lit, "filename should render in peach");
523
524        let press = Mouse {
525            x: 3,
526            y: 2,
527            left_pressed: true,
528            ..Default::default()
529        };
530        assert!(filename_clicked(&press, "lib.rs"));
531        // Empty name: nothing to click.
532        assert!(!filename_clicked(&press, ""));
533        // Past the text, below the bar, and a non-press are all rejected.
534        let far = Mouse { x: 120, ..press };
535        assert!(!filename_clicked(&far, "lib.rs"));
536        let below = Mouse { y: 9, ..press };
537        assert!(!filename_clicked(&below, "lib.rs"));
538        let hover = Mouse {
539            left_pressed: false,
540            ..press
541        };
542        assert!(!filename_clicked(&hover, "lib.rs"));
543    }
544
545    #[test]
546    fn view_buttons_light_the_active_view() {
547        // Normal active: the layout glyph is peach, the box glyph dark-purple.
548        let mut fb = Framebuffer::new();
549        view_buttons(&mut fb, false);
550        assert_eq!(fb.pget(5, 1), col::PEACH);
551        assert_eq!(fb.pget(15, 1), col::DARK_PURPLE);
552        // Fullscreen active: the colours swap.
553        let mut fb2 = Framebuffer::new();
554        view_buttons(&mut fb2, true);
555        assert_eq!(fb2.pget(5, 1), col::DARK_PURPLE);
556        assert_eq!(fb2.pget(15, 1), col::PEACH);
557    }
558
559    #[test]
560    fn tabs_are_equidistant_by_ink() {
561        // Every adjacent pair of icons is separated by exactly one blank column
562        // between their ink, regardless of each bitmap's own edge margins.
563        let xs = tab_positions();
564        let gaps: Vec<i32> = (0..TABS.len() - 1)
565            .map(|i| {
566                let this_right = xs[i] + ink_bounds(TABS[i].0).1;
567                let next_left = xs[i + 1] + ink_bounds(TABS[i + 1].0).0;
568                next_left - this_right - 1
569            })
570            .collect();
571        assert!(gaps.iter().all(|&g| g == 1), "uneven tab gaps: {gaps:?}");
572    }
573}