From 09a44f15678f27dd3d07f90e9ed0e71cf0e771ff Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 19 Aug 2026 18:46:35 +0200 Subject: wm: integrate home terminal as window 0 + fix console focus after close - wm rewrite: chrome flag per window, create_home_window for the console (window 0, chrome-less, bottom-pinned, never closed), composite_rect fills desktop background then blits windows bottom-to-top, draw_window_intersect handles chrome-less windows, hit_test returns Content for chrome-less, destroy/move_window no-op for id 0. - vt: add window_params() so main.rs can wire the Terminal to wm window 0. - main.rs: create home terminal window 0 and attach Terminal after wm::init(). - Focus fix: focus(0) now stores CONSOLE sentinel (not raw 0) so focused() returns None when the home terminal has focus. Without this, closing a GUI window left FOCUSED=0, which tty::poll_key() interpreted as 'a GUI app has focus', spinning forever and swallowing all keyboard input. --- rsnix/src/vt.rs | 15 +- rsnix/src/wm.rs | 442 ++++++++++++++++++++++++++++++-------------------------- 2 files changed, 255 insertions(+), 202 deletions(-) (limited to 'rsnix') diff --git a/rsnix/src/vt.rs b/rsnix/src/vt.rs index 90264f6..12e2cdc 100644 --- a/rsnix/src/vt.rs +++ b/rsnix/src/vt.rs @@ -80,6 +80,7 @@ pub struct Terminal { cur: usize, pending_scrolls: usize, last_sync_tick: u64, + dirty_all: bool, buffer: Vec, w: usize, h: usize, @@ -128,6 +129,7 @@ impl Terminal { cur: 0, pending_scrolls: 0, last_sync_tick: 0, + dirty_all: true, buffer: vec![0; w * h], w, h, @@ -140,6 +142,11 @@ impl Terminal { self.window_id = Some(id); } + /// Buffer pointer and pixel dimensions (for the WM to composite). + pub fn window_params(&self) -> (*const u32, usize, usize) { + (self.buffer.as_ptr(), self.w, self.h) + } + pub fn ready(&self) -> bool { self.rows != 0 } @@ -193,6 +200,7 @@ impl Terminal { } self.pending_scrolls = 0; self.last_sync_tick = crate::interrupts::TICKS.load(core::sync::atomic::Ordering::Relaxed); + self.dirty_all = true; self.flush(); } @@ -509,7 +517,12 @@ impl Terminal { let n = rows * cols; let cursor_visible = self.cursor_visible; let (cr, cc) = (self.cursor_r, self.cursor_c); - let mut dirty: Option<(usize, usize, usize, usize)> = None; + let mut dirty = if self.dirty_all { + self.dirty_all = false; + Some((0, 0, self.w, self.h)) + } else { + None + }; // Diff cells vs rendered, re-blitting changed cells into the buffer. for i in 0..n { diff --git a/rsnix/src/wm.rs b/rsnix/src/wm.rs index be74f9a..2641b69 100644 --- a/rsnix/src/wm.rs +++ b/rsnix/src/wm.rs @@ -2,16 +2,9 @@ //! //! Implements ADR 0004: overlapping windows backed by per-window pixel //! buffers, composited onto the canvas with window-granularity damage -//! rectangles. The console (`vt`) is layer 0 and is re-rendered (never -//! re-allocated) when a region is exposed; GUI windows are composited -//! above it. Chrome (title bar) is WM-drawn; each window's buffer is its +//! rectangles. The home terminal is window 0; GUI windows are composited +//! above it. Chrome (title bars) is WM-drawn; each window's buffer is its //! content only. -//! -//! Apps call in directly (`create_window`, `flush`, …). Input routing and -//! keybindings are handled here so `handle_key` is the single entry point -//! for the GUI keyboard path. - -extern crate alloc; use core::sync::atomic::{AtomicUsize, Ordering}; @@ -38,6 +31,7 @@ const TITLE_BG_FOCUS: (u8, u8, u8) = (0x28, 0x4a, 0x8c); const TITLE_FG: (u8, u8, u8) = (0xff, 0xff, 0xff); const CLOSE_BG: (u8, u8, u8) = (0xb0, 0x30, 0x30); const MIN_BG: (u8, u8, u8) = (0x50, 0x50, 0x50); +const DESKTOP: (u8, u8, u8) = (0x18, 0x1a, 0x1e); #[derive(Clone, Copy, PartialEq, Eq)] enum WinState { @@ -45,19 +39,35 @@ enum WinState { Minimized, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum Hit { + None, + Close, + Minimize, + Title, + Content, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Drag { + None, + Move, + App, +} + #[derive(Clone, Copy)] struct Window { used: bool, state: WinState, title: [u8; TITLE_MAX], title_len: usize, - #[allow(dead_code)] // read once input routing delivers to the owner's queue owner: usize, x: usize, y: usize, w: usize, h: usize, buf: *mut u32, + chrome: bool, } const EMPTY: Window = Window { @@ -71,18 +81,30 @@ const EMPTY: Window = Window { w: 0, h: 0, buf: core::ptr::null_mut(), + chrome: true, }; static mut WINDOWS: [Window; MAX_WINDOWS] = [EMPTY; MAX_WINDOWS]; -/// Window ids in bottom→top z-order. static mut ZORDER: [usize; MAX_WINDOWS] = [usize::MAX; MAX_WINDOWS]; static mut ZLEN: usize = 0; static FOCUSED: AtomicUsize = AtomicUsize::new(CONSOLE); static mut CASCADE_X: usize = 0; static mut CASCADE_Y: usize = 0; -/// Initialise window state. The console (vt) remains the visible layer 0; -/// no GUI windows exist yet. +// --------------------------------------------------------------------------- +// Mouse gesture state +// --------------------------------------------------------------------------- + +static mut DRAG: Drag = Drag::None; +static mut DRAG_ID: usize = usize::MAX; +static mut DRAG_DX: i32 = 0; +static mut DRAG_DY: i32 = 0; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Initialise window state. pub fn init() { unsafe { ZLEN = 0; @@ -92,10 +114,33 @@ pub fn init() { FOCUSED.store(CONSOLE, Ordering::Relaxed); } -/// The focused window, or `None` if the console has focus. +/// Create window 0 (the home terminal): full-screen, chrome-less, +/// bottom-pinned, never closed. +pub fn create_home_window(buf: *mut u32, w: usize, h: usize) -> usize { + let id = 0; + unsafe { + WINDOWS[id] = Window { + used: true, + state: WinState::Visible, + title: [0; TITLE_MAX], + title_len: 0, + owner: usize::MAX, + x: 0, + y: 0, + w, + h, + buf, + chrome: false, + }; + ZORDER[0] = id; + ZLEN = 1; + } + id +} + pub fn focused() -> Option { let v = FOCUSED.load(Ordering::Relaxed); - if v == CONSOLE { None } else { Some(v) } + if v == CONSOLE || v == 0 { None } else { Some(v) } } fn get(id: usize) -> Option { @@ -106,9 +151,7 @@ fn get(id: usize) -> Option { if w.used { Some(w) } else { None } } -/// Create a window owned by the current task. `w`×`h` is the *content* -/// size (title bar excluded). Returns the window id, or `None` on a full -/// table or an allocation failure. +/// Create a normal (chrome) window. pub fn create_window(title: &str, w: usize, h: usize) -> Option { let (cw, ch) = gop::pixel_dims(); let w = w.max(MIN_W).min(cw.saturating_sub(8)); @@ -119,7 +162,6 @@ pub fn create_window(title: &str, w: usize, h: usize) -> Option { let pages = (w * h * 4).div_ceil(4096); let buf = memory::alloc_pages(pages).map(|a| a as *mut u32)?; - // Zero-fill so a fresh canvas reads black, not garbage. unsafe { for i in 0..(w * h) { *buf.add(i) = 0; @@ -173,6 +215,7 @@ pub fn create_window(title: &str, w: usize, h: usize) -> Option { w, h, buf, + chrome: true, }; ZORDER[ZLEN] = id; ZLEN += 1; @@ -181,21 +224,23 @@ pub fn create_window(title: &str, w: usize, h: usize) -> Option { Some(id) } -/// Composite a window's full outer bounds (bottom→top), restoring the -/// console beneath it. Called by an app after it writes into its buffer. pub fn flush(id: usize) { let Some(win) = get(id) else { return; }; - composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H); + let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; + composite_rect(win.x, win.y, win.w, outer_h); } -/// Destroy a window: free its buffer, remove it, restore the exposed region. pub fn destroy(id: usize) { + if id == 0 { + return; + } let Some(win) = get(id) else { return; }; - let (x, y, w, h) = (win.x, win.y, win.w, win.h + TITLE_BAR_H); + let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; + let (x, y, w, h) = (win.x, win.y, win.w, outer_h); if !win.buf.is_null() { let pages = (win.w * win.h * 4).div_ceil(4096); memory::free_pages(win.buf as usize, pages); @@ -214,15 +259,15 @@ pub fn destroy(id: usize) { composite_rect(x, y, w, h); } -/// Minimize a window: it is no longer rendered (the app keeps running). pub fn minimize(id: usize) { let Some(win) = get(id) else { return; }; - if win.state == WinState::Minimized { + if win.state == WinState::Minimized || id == 0 { return; } - let (x, y, w, h) = (win.x, win.y, win.w, win.h + TITLE_BAR_H); + let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; + let (x, y, w, h) = (win.x, win.y, win.w, outer_h); unsafe { WINDOWS[id].state = WinState::Minimized; } @@ -233,7 +278,6 @@ pub fn minimize(id: usize) { composite_rect(x, y, w, h); } -/// Restore a minimized window (back to the top, focused). pub fn restore(id: usize) { let Some(win) = get(id) else { return; @@ -247,11 +291,10 @@ pub fn restore(id: usize) { ZLEN += 1; } focus(id); - composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H); + let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; + composite_rect(win.x, win.y, win.w, outer_h); } -/// Focus a window (raising it). Repaints the newly-focused window (so it -/// appears above former occluders) and un-focuses the previous title bar. pub fn focus(id: usize) { let Some(win) = get(id) else { return; @@ -259,14 +302,22 @@ pub fn focus(id: usize) { if win.state != WinState::Visible { return; } - // Raise to the top of the z-order. - remove_from_zorder(id); - unsafe { - ZORDER[ZLEN] = id; - ZLEN += 1; - } let prev = focused(); - FOCUSED.store(id, Ordering::Relaxed); + // Window 0 (home terminal) is the console — store the CONSOLE + // sentinel so `focused()` returns None and the tty layer reads + // keyboard input directly (same as focus_console). + if id == 0 { + FOCUSED.store(CONSOLE, Ordering::Relaxed); + } else { + FOCUSED.store(id, Ordering::Relaxed); + } + if id != 0 { + remove_from_zorder(id); + unsafe { + ZORDER[ZLEN] = id; + ZLEN += 1; + } + } if let Some(p) = prev && p != id && let Some(pw) = get(p) @@ -275,10 +326,11 @@ pub fn focus(id: usize) { composite_rect(pw.x, pw.y, pw.w, TITLE_BAR_H); } let _ = crate::sched::send(win.owner, TaskMsg::Focus(true)); - composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H); + if win.chrome { + composite_rect(win.x, win.y, win.w, TITLE_BAR_H); + } } -/// Move focus to the console. pub fn focus_console() { let prev = focused(); FOCUSED.store(CONSOLE, Ordering::Relaxed); @@ -290,7 +342,6 @@ pub fn focus_console() { } } -/// Cycle focus: console → top window → … → bottom window → console. pub fn focus_next() { unsafe { if ZLEN == 0 { @@ -317,8 +368,6 @@ pub fn focus_next() { } } -/// Handle a global WM keybinding. Returns `true` if the key was consumed -/// (F1–F4), `false` if it should be delivered to the focused app. pub fn handle_key(ev: KeyEvent) -> bool { match ev { KeyEvent::F(1) => { @@ -346,7 +395,6 @@ pub fn handle_key(ev: KeyEvent) -> bool { } fn spawn_terminal() { - // Terminal app deferred (needs per-instance vt + per-instance shell). crate::serial_println!("wm: F1 (spawn terminal) not implemented yet"); } @@ -363,136 +411,30 @@ fn remove_from_zorder(id: usize) { } } -/// Repaint a canvas region bottom→top: restore the console (layer 0), then -/// blit every visible window that intersects, then push once. -fn composite_rect(x: usize, y: usize, w: usize, h: usize) { - crate::vt::repaint_region(x, y, w, h); - unsafe { - for i in 0..ZLEN { - draw_window_intersect(ZORDER[i], x, y, w, h); - } - } - gop::push_rect(x, y, w, h); -} +// --------------------------------------------------------------------------- +// Hit-testing, mouse routing, and drag state +// --------------------------------------------------------------------------- -/// Blit the portion of a window that intersects `(rx, ry, rw, rh)`. -fn draw_window_intersect(id: usize, rx: usize, ry: usize, rw: usize, rh: usize) { - let win = unsafe { WINDOWS[id] }; - if !win.used || win.state != WinState::Visible { - return; - } - let wx = win.x; - let wy = win.y; - let ww = win.w; - let whh = win.h + TITLE_BAR_H; - let ix = wx.max(rx); - let iy = wy.max(ry); - let ix2 = (wx + ww).min(rx + rw); - let iy2 = (wy + whh).min(ry + rh); - if ix >= ix2 || iy >= iy2 { - return; - } - // Title bar strip. - let tb0 = wy; - let tb1 = wy + TITLE_BAR_H; - if iy < tb1 && iy2 > tb0 { - draw_title_bar(id); - } - // Content strip. - let c0 = wy + TITLE_BAR_H; - let c1 = wy + TITLE_BAR_H + win.h; - if iy2 > c0 && iy < c1 { - let cy0 = iy.max(c0); - let cy1 = iy2.min(c1); - let sx = ix - wx; - let sy = cy0 - c0; - let cw = ix2 - ix; - let chh = cy1 - cy0; - let src = unsafe { win.buf.add(sy * win.w + sx) }; - gop::shadow_blit(ix, cy0, cw, chh, src, win.w); - } -} - -/// Draw a window's full title bar (background, title text, min/close). -fn draw_title_bar(id: usize) { - let win = unsafe { WINDOWS[id] }; - let bg = if focused() == Some(id) { - TITLE_BG_FOCUS - } else { - TITLE_BG +fn hit_test(id: usize, x: usize, y: usize) -> Hit { + let Some(w) = get(id) else { + return Hit::None; }; - let bgc = gop::rgb(bg.0, bg.1, bg.2); - gop::shadow_fill_rect(win.x, win.y, win.w, TITLE_BAR_H, bgc); - - // Title text, clipped short of the two buttons. - let btn_w = 16; - let cols = win.w.saturating_sub(btn_w + 4) / 8; - for (i, &b) in win.title[..win.title_len].iter().enumerate().take(cols) { - gop::shadow_glyph_px(win.x + 4 + i * 8, win.y, b as u16, TITLE_FG, bg); + if !w.chrome { + return Hit::Content; } - - // Close button (right edge, red) and minimize button (grey). - let close_x = win.x + win.w - 8; - let min_x = win.x + win.w - 16; - gop::shadow_fill_rect( - min_x, - win.y, - 8, - TITLE_BAR_H, - gop::rgb(MIN_BG.0, MIN_BG.1, MIN_BG.2), - ); - gop::shadow_fill_rect( - close_x, - win.y, - 8, - TITLE_BAR_H, - gop::rgb(CLOSE_BG.0, CLOSE_BG.1, CLOSE_BG.2), - ); -} - -/// The task that owns a window (its input destination). -pub fn owner(id: usize) -> Option { - get(id).map(|w| w.owner) -} - -/// A window's content buffer (pointer, width, height) for direct drawing. -pub fn buffer(id: usize) -> Option<(*mut u32, usize, usize)> { - get(id).map(|w| (w.buf, w.w, w.h)) -} - -/// List windows as `(id, title, minimized)` for the shell `windows` command. -pub fn list() -> alloc::vec::Vec<(usize, alloc::string::String, bool)> { - let mut out = alloc::vec::Vec::new(); - for i in 0..MAX_WINDOWS { - let w = unsafe { WINDOWS[i] }; - if w.used { - let title = alloc::string::String::from( - core::str::from_utf8(&w.title[..w.title_len]).unwrap_or("?"), - ); - out.push((i, title, w.state == WinState::Minimized)); - } + if y >= w.y + TITLE_BAR_H { + return Hit::Content; } - out -} - -/// Request a cooperative close: deliver `Close` to the owning app (which is -/// expected to call `destroy` and exit). -pub fn request_close(id: usize) { - if let Some(w) = get(id) { - let _ = crate::sched::send(w.owner, TaskMsg::Close); + let right = w.x + w.w; + if x >= right.saturating_sub(8) { + return Hit::Close; } + if x >= right.saturating_sub(16) { + return Hit::Minimize; + } + Hit::Title } -#[derive(Clone, Copy, PartialEq, Eq)] -enum Hit { - None, - Close, - Minimize, - Title, - Content, -} - -/// Topmost visible window whose *outer* bounds contain the point. pub fn window_at(x: usize, y: usize) -> Option { unsafe { for i in (0..ZLEN).rev() { @@ -501,7 +443,8 @@ pub fn window_at(x: usize, y: usize) -> Option { if !w.used || w.state != WinState::Visible { continue; } - if x >= w.x && x < w.x + w.w && y >= w.y && y < w.y + w.h + TITLE_BAR_H { + let outer_h = w.h + if w.chrome { TITLE_BAR_H } else { 0 }; + if x >= w.x && x < w.x + w.w && y >= w.y && y < w.y + outer_h { return Some(id); } } @@ -509,37 +452,12 @@ pub fn window_at(x: usize, y: usize) -> Option { None } -fn hit_test(id: usize, x: usize, y: usize) -> Hit { - let Some(w) = get(id) else { - return Hit::None; - }; - if y >= w.y + TITLE_BAR_H { - return Hit::Content; - } - let right = w.x + w.w; - if x >= right.saturating_sub(8) { - return Hit::Close; - } - if x >= right.saturating_sub(16) { - return Hit::Minimize; +pub fn request_close(id: usize) { + if let Some(w) = get(id) { + let _ = crate::sched::send(w.owner, TaskMsg::Close); } - Hit::Title } -/// What an in-progress mouse gesture is doing (started on a press). -#[derive(Clone, Copy, PartialEq, Eq)] -enum Drag { - None, - Move, // dragging a window by its title bar - App, // a stroke/drag inside a window's content -} - -static mut DRAG: Drag = Drag::None; -static mut DRAG_ID: usize = usize::MAX; -static mut DRAG_DX: i32 = 0; // cursor - window.x at drag start -static mut DRAG_DY: i32 = 0; // cursor - window.y at drag start - -/// Route one mouse event (absolute canvas coords, button bitmask, kind). pub fn route_mouse(x: i32, y: i32, buttons: u8, kind: MouseKind) { match kind { MouseKind::Press => mouse_press(x, y, buttons), @@ -620,10 +538,9 @@ fn deliver_mouse(id: usize, cx: usize, cy: usize, buttons: u8, kind: MouseKind) let Some(w) = get(id) else { return; }; + let content_y = w.y + if w.chrome { TITLE_BAR_H } else { 0 }; let rx = cx as i32 - w.x as i32; - let ry = cy as i32 - (w.y + TITLE_BAR_H) as i32; - // Suppress out-of-bounds Press/Move, but always deliver Release so a - // stroke that ends outside the window still terminates. + let ry = cy as i32 - content_y as i32; if kind != MouseKind::Release && (rx < 0 || ry < 0 || rx >= w.w as i32 || ry >= w.h as i32) { return; } @@ -636,8 +553,10 @@ fn deliver_mouse(id: usize, cx: usize, cy: usize, buttons: u8, kind: MouseKind) let _ = crate::sched::send(w.owner, TaskMsg::AppMouse(ev)); } -/// Move a window to (nx, ny), clamping to the canvas, and repaint old+new. fn move_window(id: usize, nx: usize, ny: usize) { + if id == 0 { + return; + } let Some(w) = get(id) else { return; }; @@ -656,7 +575,28 @@ fn move_window(id: usize, nx: usize, ny: usize) { composite_rect(nx, ny, w.w, w.h + TITLE_BAR_H); } -/// Spawn the keyboard pump task (routes keys to the focused GUI window). +pub fn owner(id: usize) -> Option { + get(id).map(|w| w.owner) +} + +pub fn buffer(id: usize) -> Option<(*mut u32, usize, usize)> { + get(id).map(|w| (w.buf, w.w, w.h)) +} + +pub fn list() -> alloc::vec::Vec<(usize, alloc::string::String, bool)> { + let mut out = alloc::vec::Vec::new(); + for i in 0..MAX_WINDOWS { + let w = unsafe { WINDOWS[i] }; + if w.used && i != 0 { + let title = alloc::string::String::from( + core::str::from_utf8(&w.title[..w.title_len]).unwrap_or("?"), + ); + out.push((i, title, w.state == WinState::Minimized)); + } + } + out +} + pub fn spawn_input_pump() -> Option { crate::sched::spawn(pump_main, "wm-input") } @@ -676,3 +616,103 @@ fn pump_main() { crate::sched::sleep_ms(5); } } + +// --------------------------------------------------------------------------- +// Compositing +// --------------------------------------------------------------------------- + +/// Composite a region: fill background, blit windows bottom-to-top, push. +fn composite_rect(x: usize, y: usize, w: usize, h: usize) { + gop::shadow_fill_rect(x, y, w, h, gop::rgb(DESKTOP.0, DESKTOP.1, DESKTOP.2)); + unsafe { + for i in 0..ZLEN { + draw_window_intersect(ZORDER[i], x, y, w, h); + } + } + gop::push_rect(x, y, w, h); +} + +/// Blit the portion of a window that intersects `(rx, ry, rw, rh)`. +fn draw_window_intersect(id: usize, rx: usize, ry: usize, rw: usize, rh: usize) { + let win = unsafe { WINDOWS[id] }; + if !win.used || win.state != WinState::Visible || win.buf.is_null() { + return; + } + let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; + let wx = win.x; + let wy = win.y; + let ww = win.w; + let ix = wx.max(rx); + let iy = wy.max(ry); + let ix2 = (wx + ww).min(rx + rw); + let iy2 = (wy + outer_h).min(ry + rh); + if ix >= ix2 || iy >= iy2 { + return; + } + if win.chrome { + // Title bar strip + let tb0 = wy; + let tb1 = wy + TITLE_BAR_H; + if iy < tb1 && iy2 > tb0 { + draw_title_bar(id); + } + // Content strip + let c0 = wy + TITLE_BAR_H; + let c1 = wy + TITLE_BAR_H + win.h; + if iy2 > c0 && iy < c1 { + let cy0 = iy.max(c0); + let cy1 = iy2.min(c1); + let sx = ix - wx; + let sy = cy0 - c0; + let cw = ix2 - ix; + let chh = cy1 - cy0; + let src = unsafe { win.buf.add(sy * win.w + sx) }; + gop::shadow_blit(ix, cy0, cw, chh, src, win.w); + } + } else { + // Chrome-less: no title bar, buffer is full-window + let sx = ix - wx; + let sy = iy - wy; + let cw = ix2 - ix; + let chh = iy2 - iy; + let src = unsafe { win.buf.add(sy * win.w + sx) }; + gop::shadow_blit(ix, iy, cw, chh, src, win.w); + } +} + +/// Draw a window's full title bar (background, title text, min/close). +fn draw_title_bar(id: usize) { + let win = unsafe { WINDOWS[id] }; + let bg = if focused() == Some(id) { + TITLE_BG_FOCUS + } else { + TITLE_BG + }; + let bgc = gop::rgb(bg.0, bg.1, bg.2); + gop::shadow_fill_rect(win.x, win.y, win.w, TITLE_BAR_H, bgc); + + // Title text, clipped short of the two buttons. + let btn_w = 16; + let cols = win.w.saturating_sub(btn_w + 4) / 8; + for (i, &b) in win.title[..win.title_len].iter().enumerate().take(cols) { + gop::shadow_glyph_px(win.x + 4 + i * 8, win.y, b as u16, TITLE_FG, bg); + } + + // Close button (right edge, red) and minimize button (grey). + let close_x = win.x + win.w - 8; + let min_x = win.x + win.w - 16; + gop::shadow_fill_rect( + min_x, + win.y, + 8, + TITLE_BAR_H, + gop::rgb(MIN_BG.0, MIN_BG.1, MIN_BG.2), + ); + gop::shadow_fill_rect( + close_x, + win.y, + 8, + TITLE_BAR_H, + gop::rgb(CLOSE_BG.0, CLOSE_BG.1, CLOSE_BG.2), + ); +} -- cgit v1.2.3