diff options
| author | salmonik <salmonik@tuta.io> | 2026-08-19 18:46:35 +0200 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-08-19 18:46:35 +0200 |
| commit | 09a44f15678f27dd3d07f90e9ed0e71cf0e771ff (patch) | |
| tree | e9942dd03df3eb16b5b261cb6b0e129390593ef6 | |
| parent | vt → per-instance Terminal rendering into a pixel buffer (diff) | |
| download | rs-dos-09a44f15678f27dd3d07f90e9ed0e71cf0e771ff.tar.gz rs-dos-09a44f15678f27dd3d07f90e9ed0e71cf0e771ff.zip | |
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.
| -rw-r--r-- | rsdos/src/main.rs | 6 | ||||
| -rw-r--r-- | rsnix/src/vt.rs | 15 | ||||
| -rw-r--r-- | rsnix/src/wm.rs | 442 |
3 files changed, 261 insertions, 202 deletions
diff --git a/rsdos/src/main.rs b/rsdos/src/main.rs index b147e63..6d2fa4a 100644 --- a/rsdos/src/main.rs +++ b/rsdos/src/main.rs | |||
| @@ -413,6 +413,12 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut | |||
| 413 | // Windowed GUI: initialise the window manager and its input pump. | 413 | // Windowed GUI: initialise the window manager and its input pump. |
| 414 | // The console (vt) stays layer 0; GUI apps are windows composited on top. | 414 | // The console (vt) stays layer 0; GUI apps are windows composited on top. |
| 415 | rsnix::wm::init(); | 415 | rsnix::wm::init(); |
| 416 | { | ||
| 417 | let t = rsnix::vt::home_terminal(); | ||
| 418 | let (buf, w, h) = t.window_params(); | ||
| 419 | let id = rsnix::wm::create_home_window(buf as *mut u32, w, h); | ||
| 420 | t.set_window(id); | ||
| 421 | } | ||
| 416 | rsnix::wm::spawn_input_pump(); | 422 | rsnix::wm::spawn_input_pump(); |
| 417 | 423 | ||
| 418 | shell::run(); | 424 | shell::run(); |
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 { | |||
| 80 | cur: usize, | 80 | cur: usize, |
| 81 | pending_scrolls: usize, | 81 | pending_scrolls: usize, |
| 82 | last_sync_tick: u64, | 82 | last_sync_tick: u64, |
| 83 | dirty_all: bool, | ||
| 83 | buffer: Vec<u32>, | 84 | buffer: Vec<u32>, |
| 84 | w: usize, | 85 | w: usize, |
| 85 | h: usize, | 86 | h: usize, |
| @@ -128,6 +129,7 @@ impl Terminal { | |||
| 128 | cur: 0, | 129 | cur: 0, |
| 129 | pending_scrolls: 0, | 130 | pending_scrolls: 0, |
| 130 | last_sync_tick: 0, | 131 | last_sync_tick: 0, |
| 132 | dirty_all: true, | ||
| 131 | buffer: vec![0; w * h], | 133 | buffer: vec![0; w * h], |
| 132 | w, | 134 | w, |
| 133 | h, | 135 | h, |
| @@ -140,6 +142,11 @@ impl Terminal { | |||
| 140 | self.window_id = Some(id); | 142 | self.window_id = Some(id); |
| 141 | } | 143 | } |
| 142 | 144 | ||
| 145 | /// Buffer pointer and pixel dimensions (for the WM to composite). | ||
| 146 | pub fn window_params(&self) -> (*const u32, usize, usize) { | ||
| 147 | (self.buffer.as_ptr(), self.w, self.h) | ||
| 148 | } | ||
| 149 | |||
| 143 | pub fn ready(&self) -> bool { | 150 | pub fn ready(&self) -> bool { |
| 144 | self.rows != 0 | 151 | self.rows != 0 |
| 145 | } | 152 | } |
| @@ -193,6 +200,7 @@ impl Terminal { | |||
| 193 | } | 200 | } |
| 194 | self.pending_scrolls = 0; | 201 | self.pending_scrolls = 0; |
| 195 | self.last_sync_tick = crate::interrupts::TICKS.load(core::sync::atomic::Ordering::Relaxed); | 202 | self.last_sync_tick = crate::interrupts::TICKS.load(core::sync::atomic::Ordering::Relaxed); |
| 203 | self.dirty_all = true; | ||
| 196 | self.flush(); | 204 | self.flush(); |
| 197 | } | 205 | } |
| 198 | 206 | ||
| @@ -509,7 +517,12 @@ impl Terminal { | |||
| 509 | let n = rows * cols; | 517 | let n = rows * cols; |
| 510 | let cursor_visible = self.cursor_visible; | 518 | let cursor_visible = self.cursor_visible; |
| 511 | let (cr, cc) = (self.cursor_r, self.cursor_c); | 519 | let (cr, cc) = (self.cursor_r, self.cursor_c); |
| 512 | let mut dirty: Option<(usize, usize, usize, usize)> = None; | 520 | let mut dirty = if self.dirty_all { |
| 521 | self.dirty_all = false; | ||
| 522 | Some((0, 0, self.w, self.h)) | ||
| 523 | } else { | ||
| 524 | None | ||
| 525 | }; | ||
| 513 | 526 | ||
| 514 | // Diff cells vs rendered, re-blitting changed cells into the buffer. | 527 | // Diff cells vs rendered, re-blitting changed cells into the buffer. |
| 515 | for i in 0..n { | 528 | 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 @@ | |||
| 2 | //! | 2 | //! |
| 3 | //! Implements ADR 0004: overlapping windows backed by per-window pixel | 3 | //! Implements ADR 0004: overlapping windows backed by per-window pixel |
| 4 | //! buffers, composited onto the canvas with window-granularity damage | 4 | //! buffers, composited onto the canvas with window-granularity damage |
| 5 | //! rectangles. The console (`vt`) is layer 0 and is re-rendered (never | 5 | //! rectangles. The home terminal is window 0; GUI windows are composited |
| 6 | //! re-allocated) when a region is exposed; GUI windows are composited | 6 | //! above it. Chrome (title bars) is WM-drawn; each window's buffer is its |
| 7 | //! above it. Chrome (title bar) is WM-drawn; each window's buffer is its | ||
| 8 | //! content only. | 7 | //! content only. |
| 9 | //! | ||
| 10 | //! Apps call in directly (`create_window`, `flush`, …). Input routing and | ||
| 11 | //! keybindings are handled here so `handle_key` is the single entry point | ||
| 12 | //! for the GUI keyboard path. | ||
| 13 | |||
| 14 | extern crate alloc; | ||
| 15 | 8 | ||
| 16 | use core::sync::atomic::{AtomicUsize, Ordering}; | 9 | use core::sync::atomic::{AtomicUsize, Ordering}; |
| 17 | 10 | ||
| @@ -38,6 +31,7 @@ const TITLE_BG_FOCUS: (u8, u8, u8) = (0x28, 0x4a, 0x8c); | |||
| 38 | const TITLE_FG: (u8, u8, u8) = (0xff, 0xff, 0xff); | 31 | const TITLE_FG: (u8, u8, u8) = (0xff, 0xff, 0xff); |
| 39 | const CLOSE_BG: (u8, u8, u8) = (0xb0, 0x30, 0x30); | 32 | const CLOSE_BG: (u8, u8, u8) = (0xb0, 0x30, 0x30); |
| 40 | const MIN_BG: (u8, u8, u8) = (0x50, 0x50, 0x50); | 33 | const MIN_BG: (u8, u8, u8) = (0x50, 0x50, 0x50); |
| 34 | const DESKTOP: (u8, u8, u8) = (0x18, 0x1a, 0x1e); | ||
| 41 | 35 | ||
| 42 | #[derive(Clone, Copy, PartialEq, Eq)] | 36 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 43 | enum WinState { | 37 | enum WinState { |
| @@ -45,19 +39,35 @@ enum WinState { | |||
| 45 | Minimized, | 39 | Minimized, |
| 46 | } | 40 | } |
| 47 | 41 | ||
| 42 | #[derive(Clone, Copy, PartialEq, Eq)] | ||
| 43 | enum Hit { | ||
| 44 | None, | ||
| 45 | Close, | ||
| 46 | Minimize, | ||
| 47 | Title, | ||
| 48 | Content, | ||
| 49 | } | ||
| 50 | |||
| 51 | #[derive(Clone, Copy, PartialEq, Eq)] | ||
| 52 | enum Drag { | ||
| 53 | None, | ||
| 54 | Move, | ||
| 55 | App, | ||
| 56 | } | ||
| 57 | |||
| 48 | #[derive(Clone, Copy)] | 58 | #[derive(Clone, Copy)] |
| 49 | struct Window { | 59 | struct Window { |
| 50 | used: bool, | 60 | used: bool, |
| 51 | state: WinState, | 61 | state: WinState, |
| 52 | title: [u8; TITLE_MAX], | 62 | title: [u8; TITLE_MAX], |
| 53 | title_len: usize, | 63 | title_len: usize, |
| 54 | #[allow(dead_code)] // read once input routing delivers to the owner's queue | ||
| 55 | owner: usize, | 64 | owner: usize, |
| 56 | x: usize, | 65 | x: usize, |
| 57 | y: usize, | 66 | y: usize, |
| 58 | w: usize, | 67 | w: usize, |
| 59 | h: usize, | 68 | h: usize, |
| 60 | buf: *mut u32, | 69 | buf: *mut u32, |
| 70 | chrome: bool, | ||
| 61 | } | 71 | } |
| 62 | 72 | ||
| 63 | const EMPTY: Window = Window { | 73 | const EMPTY: Window = Window { |
| @@ -71,18 +81,30 @@ const EMPTY: Window = Window { | |||
| 71 | w: 0, | 81 | w: 0, |
| 72 | h: 0, | 82 | h: 0, |
| 73 | buf: core::ptr::null_mut(), | 83 | buf: core::ptr::null_mut(), |
| 84 | chrome: true, | ||
| 74 | }; | 85 | }; |
| 75 | 86 | ||
| 76 | static mut WINDOWS: [Window; MAX_WINDOWS] = [EMPTY; MAX_WINDOWS]; | 87 | static mut WINDOWS: [Window; MAX_WINDOWS] = [EMPTY; MAX_WINDOWS]; |
| 77 | /// Window ids in bottom→top z-order. | ||
| 78 | static mut ZORDER: [usize; MAX_WINDOWS] = [usize::MAX; MAX_WINDOWS]; | 88 | static mut ZORDER: [usize; MAX_WINDOWS] = [usize::MAX; MAX_WINDOWS]; |
| 79 | static mut ZLEN: usize = 0; | 89 | static mut ZLEN: usize = 0; |
| 80 | static FOCUSED: AtomicUsize = AtomicUsize::new(CONSOLE); | 90 | static FOCUSED: AtomicUsize = AtomicUsize::new(CONSOLE); |
| 81 | static mut CASCADE_X: usize = 0; | 91 | static mut CASCADE_X: usize = 0; |
| 82 | static mut CASCADE_Y: usize = 0; | 92 | static mut CASCADE_Y: usize = 0; |
| 83 | 93 | ||
| 84 | /// Initialise window state. The console (vt) remains the visible layer 0; | 94 | // --------------------------------------------------------------------------- |
| 85 | /// no GUI windows exist yet. | 95 | // Mouse gesture state |
| 96 | // --------------------------------------------------------------------------- | ||
| 97 | |||
| 98 | static mut DRAG: Drag = Drag::None; | ||
| 99 | static mut DRAG_ID: usize = usize::MAX; | ||
| 100 | static mut DRAG_DX: i32 = 0; | ||
| 101 | static mut DRAG_DY: i32 = 0; | ||
| 102 | |||
| 103 | // --------------------------------------------------------------------------- | ||
| 104 | // Public API | ||
| 105 | // --------------------------------------------------------------------------- | ||
| 106 | |||
| 107 | /// Initialise window state. | ||
| 86 | pub fn init() { | 108 | pub fn init() { |
| 87 | unsafe { | 109 | unsafe { |
| 88 | ZLEN = 0; | 110 | ZLEN = 0; |
| @@ -92,10 +114,33 @@ pub fn init() { | |||
| 92 | FOCUSED.store(CONSOLE, Ordering::Relaxed); | 114 | FOCUSED.store(CONSOLE, Ordering::Relaxed); |
| 93 | } | 115 | } |
| 94 | 116 | ||
| 95 | /// The focused window, or `None` if the console has focus. | 117 | /// Create window 0 (the home terminal): full-screen, chrome-less, |
| 118 | /// bottom-pinned, never closed. | ||
| 119 | pub fn create_home_window(buf: *mut u32, w: usize, h: usize) -> usize { | ||
| 120 | let id = 0; | ||
| 121 | unsafe { | ||
| 122 | WINDOWS[id] = Window { | ||
| 123 | used: true, | ||
| 124 | state: WinState::Visible, | ||
| 125 | title: [0; TITLE_MAX], | ||
| 126 | title_len: 0, | ||
| 127 | owner: usize::MAX, | ||
| 128 | x: 0, | ||
| 129 | y: 0, | ||
| 130 | w, | ||
| 131 | h, | ||
| 132 | buf, | ||
| 133 | chrome: false, | ||
| 134 | }; | ||
| 135 | ZORDER[0] = id; | ||
| 136 | ZLEN = 1; | ||
| 137 | } | ||
| 138 | id | ||
| 139 | } | ||
| 140 | |||
| 96 | pub fn focused() -> Option<usize> { | 141 | pub fn focused() -> Option<usize> { |
| 97 | let v = FOCUSED.load(Ordering::Relaxed); | 142 | let v = FOCUSED.load(Ordering::Relaxed); |
| 98 | if v == CONSOLE { None } else { Some(v) } | 143 | if v == CONSOLE || v == 0 { None } else { Some(v) } |
| 99 | } | 144 | } |
| 100 | 145 | ||
| 101 | fn get(id: usize) -> Option<Window> { | 146 | fn get(id: usize) -> Option<Window> { |
| @@ -106,9 +151,7 @@ fn get(id: usize) -> Option<Window> { | |||
| 106 | if w.used { Some(w) } else { None } | 151 | if w.used { Some(w) } else { None } |
| 107 | } | 152 | } |
| 108 | 153 | ||
| 109 | /// Create a window owned by the current task. `w`×`h` is the *content* | 154 | /// Create a normal (chrome) window. |
| 110 | /// size (title bar excluded). Returns the window id, or `None` on a full | ||
| 111 | /// table or an allocation failure. | ||
| 112 | pub fn create_window(title: &str, w: usize, h: usize) -> Option<usize> { | 155 | pub fn create_window(title: &str, w: usize, h: usize) -> Option<usize> { |
| 113 | let (cw, ch) = gop::pixel_dims(); | 156 | let (cw, ch) = gop::pixel_dims(); |
| 114 | let w = w.max(MIN_W).min(cw.saturating_sub(8)); | 157 | 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<usize> { | |||
| 119 | 162 | ||
| 120 | let pages = (w * h * 4).div_ceil(4096); | 163 | let pages = (w * h * 4).div_ceil(4096); |
| 121 | let buf = memory::alloc_pages(pages).map(|a| a as *mut u32)?; | 164 | let buf = memory::alloc_pages(pages).map(|a| a as *mut u32)?; |
| 122 | // Zero-fill so a fresh canvas reads black, not garbage. | ||
| 123 | unsafe { | 165 | unsafe { |
| 124 | for i in 0..(w * h) { | 166 | for i in 0..(w * h) { |
| 125 | *buf.add(i) = 0; | 167 | *buf.add(i) = 0; |
| @@ -173,6 +215,7 @@ pub fn create_window(title: &str, w: usize, h: usize) -> Option<usize> { | |||
| 173 | w, | 215 | w, |
| 174 | h, | 216 | h, |
| 175 | buf, | 217 | buf, |
| 218 | chrome: true, | ||
| 176 | }; | 219 | }; |
| 177 | ZORDER[ZLEN] = id; | 220 | ZORDER[ZLEN] = id; |
| 178 | ZLEN += 1; | 221 | ZLEN += 1; |
| @@ -181,21 +224,23 @@ pub fn create_window(title: &str, w: usize, h: usize) -> Option<usize> { | |||
| 181 | Some(id) | 224 | Some(id) |
| 182 | } | 225 | } |
| 183 | 226 | ||
| 184 | /// Composite a window's full outer bounds (bottom→top), restoring the | ||
| 185 | /// console beneath it. Called by an app after it writes into its buffer. | ||
| 186 | pub fn flush(id: usize) { | 227 | pub fn flush(id: usize) { |
| 187 | let Some(win) = get(id) else { | 228 | let Some(win) = get(id) else { |
| 188 | return; | 229 | return; |
| 189 | }; | 230 | }; |
| 190 | composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H); | 231 | let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; |
| 232 | composite_rect(win.x, win.y, win.w, outer_h); | ||
| 191 | } | 233 | } |
| 192 | 234 | ||
| 193 | /// Destroy a window: free its buffer, remove it, restore the exposed region. | ||
| 194 | pub fn destroy(id: usize) { | 235 | pub fn destroy(id: usize) { |
| 236 | if id == 0 { | ||
| 237 | return; | ||
| 238 | } | ||
| 195 | let Some(win) = get(id) else { | 239 | let Some(win) = get(id) else { |
| 196 | return; | 240 | return; |
| 197 | }; | 241 | }; |
| 198 | let (x, y, w, h) = (win.x, win.y, win.w, win.h + TITLE_BAR_H); | 242 | let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; |
| 243 | let (x, y, w, h) = (win.x, win.y, win.w, outer_h); | ||
| 199 | if !win.buf.is_null() { | 244 | if !win.buf.is_null() { |
| 200 | let pages = (win.w * win.h * 4).div_ceil(4096); | 245 | let pages = (win.w * win.h * 4).div_ceil(4096); |
| 201 | memory::free_pages(win.buf as usize, pages); | 246 | memory::free_pages(win.buf as usize, pages); |
| @@ -214,15 +259,15 @@ pub fn destroy(id: usize) { | |||
| 214 | composite_rect(x, y, w, h); | 259 | composite_rect(x, y, w, h); |
| 215 | } | 260 | } |
| 216 | 261 | ||
| 217 | /// Minimize a window: it is no longer rendered (the app keeps running). | ||
| 218 | pub fn minimize(id: usize) { | 262 | pub fn minimize(id: usize) { |
| 219 | let Some(win) = get(id) else { | 263 | let Some(win) = get(id) else { |
| 220 | return; | 264 | return; |
| 221 | }; | 265 | }; |
| 222 | if win.state == WinState::Minimized { | 266 | if win.state == WinState::Minimized || id == 0 { |
| 223 | return; | 267 | return; |
| 224 | } | 268 | } |
| 225 | let (x, y, w, h) = (win.x, win.y, win.w, win.h + TITLE_BAR_H); | 269 | let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; |
| 270 | let (x, y, w, h) = (win.x, win.y, win.w, outer_h); | ||
| 226 | unsafe { | 271 | unsafe { |
| 227 | WINDOWS[id].state = WinState::Minimized; | 272 | WINDOWS[id].state = WinState::Minimized; |
| 228 | } | 273 | } |
| @@ -233,7 +278,6 @@ pub fn minimize(id: usize) { | |||
| 233 | composite_rect(x, y, w, h); | 278 | composite_rect(x, y, w, h); |
| 234 | } | 279 | } |
| 235 | 280 | ||
| 236 | /// Restore a minimized window (back to the top, focused). | ||
| 237 | pub fn restore(id: usize) { | 281 | pub fn restore(id: usize) { |
| 238 | let Some(win) = get(id) else { | 282 | let Some(win) = get(id) else { |
| 239 | return; | 283 | return; |
| @@ -247,11 +291,10 @@ pub fn restore(id: usize) { | |||
| 247 | ZLEN += 1; | 291 | ZLEN += 1; |
| 248 | } | 292 | } |
| 249 | focus(id); | 293 | focus(id); |
| 250 | composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H); | 294 | let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; |
| 295 | composite_rect(win.x, win.y, win.w, outer_h); | ||
| 251 | } | 296 | } |
| 252 | 297 | ||
| 253 | /// Focus a window (raising it). Repaints the newly-focused window (so it | ||
| 254 | /// appears above former occluders) and un-focuses the previous title bar. | ||
| 255 | pub fn focus(id: usize) { | 298 | pub fn focus(id: usize) { |
| 256 | let Some(win) = get(id) else { | 299 | let Some(win) = get(id) else { |
| 257 | return; | 300 | return; |
| @@ -259,14 +302,22 @@ pub fn focus(id: usize) { | |||
| 259 | if win.state != WinState::Visible { | 302 | if win.state != WinState::Visible { |
| 260 | return; | 303 | return; |
| 261 | } | 304 | } |
| 262 | // Raise to the top of the z-order. | ||
| 263 | remove_from_zorder(id); | ||
| 264 | unsafe { | ||
| 265 | ZORDER[ZLEN] = id; | ||
| 266 | ZLEN += 1; | ||
| 267 | } | ||
| 268 | let prev = focused(); | 305 | let prev = focused(); |
| 269 | FOCUSED.store(id, Ordering::Relaxed); | 306 | // Window 0 (home terminal) is the console — store the CONSOLE |
| 307 | // sentinel so `focused()` returns None and the tty layer reads | ||
| 308 | // keyboard input directly (same as focus_console). | ||
| 309 | if id == 0 { | ||
| 310 | FOCUSED.store(CONSOLE, Ordering::Relaxed); | ||
| 311 | } else { | ||
| 312 | FOCUSED.store(id, Ordering::Relaxed); | ||
| 313 | } | ||
| 314 | if id != 0 { | ||
| 315 | remove_from_zorder(id); | ||
| 316 | unsafe { | ||
| 317 | ZORDER[ZLEN] = id; | ||
| 318 | ZLEN += 1; | ||
| 319 | } | ||
| 320 | } | ||
| 270 | if let Some(p) = prev | 321 | if let Some(p) = prev |
| 271 | && p != id | 322 | && p != id |
| 272 | && let Some(pw) = get(p) | 323 | && let Some(pw) = get(p) |
| @@ -275,10 +326,11 @@ pub fn focus(id: usize) { | |||
| 275 | composite_rect(pw.x, pw.y, pw.w, TITLE_BAR_H); | 326 | composite_rect(pw.x, pw.y, pw.w, TITLE_BAR_H); |
| 276 | } | 327 | } |
| 277 | let _ = crate::sched::send(win.owner, TaskMsg::Focus(true)); | 328 | let _ = crate::sched::send(win.owner, TaskMsg::Focus(true)); |
| 278 | composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H); | 329 | if win.chrome { |
| 330 | composite_rect(win.x, win.y, win.w, TITLE_BAR_H); | ||
| 331 | } | ||
| 279 | } | 332 | } |
| 280 | 333 | ||
| 281 | /// Move focus to the console. | ||
| 282 | pub fn focus_console() { | 334 | pub fn focus_console() { |
| 283 | let prev = focused(); | 335 | let prev = focused(); |
| 284 | FOCUSED.store(CONSOLE, Ordering::Relaxed); | 336 | FOCUSED.store(CONSOLE, Ordering::Relaxed); |
| @@ -290,7 +342,6 @@ pub fn focus_console() { | |||
| 290 | } | 342 | } |
| 291 | } | 343 | } |
| 292 | 344 | ||
| 293 | /// Cycle focus: console → top window → … → bottom window → console. | ||
| 294 | pub fn focus_next() { | 345 | pub fn focus_next() { |
| 295 | unsafe { | 346 | unsafe { |
| 296 | if ZLEN == 0 { | 347 | if ZLEN == 0 { |
| @@ -317,8 +368,6 @@ pub fn focus_next() { | |||
| 317 | } | 368 | } |
| 318 | } | 369 | } |
| 319 | 370 | ||
| 320 | /// Handle a global WM keybinding. Returns `true` if the key was consumed | ||
| 321 | /// (F1–F4), `false` if it should be delivered to the focused app. | ||
| 322 | pub fn handle_key(ev: KeyEvent) -> bool { | 371 | pub fn handle_key(ev: KeyEvent) -> bool { |
| 323 | match ev { | 372 | match ev { |
| 324 | KeyEvent::F(1) => { | 373 | KeyEvent::F(1) => { |
| @@ -346,7 +395,6 @@ pub fn handle_key(ev: KeyEvent) -> bool { | |||
| 346 | } | 395 | } |
| 347 | 396 | ||
| 348 | fn spawn_terminal() { | 397 | fn spawn_terminal() { |
| 349 | // Terminal app deferred (needs per-instance vt + per-instance shell). | ||
| 350 | crate::serial_println!("wm: F1 (spawn terminal) not implemented yet"); | 398 | crate::serial_println!("wm: F1 (spawn terminal) not implemented yet"); |
| 351 | } | 399 | } |
| 352 | 400 | ||
| @@ -363,136 +411,30 @@ fn remove_from_zorder(id: usize) { | |||
| 363 | } | 411 | } |
| 364 | } | 412 | } |
| 365 | 413 | ||
| 366 | /// Repaint a canvas region bottom→top: restore the console (layer 0), then | 414 | // --------------------------------------------------------------------------- |
| 367 | /// blit every visible window that intersects, then push once. | 415 | // Hit-testing, mouse routing, and drag state |
| 368 | fn composite_rect(x: usize, y: usize, w: usize, h: usize) { | 416 | // --------------------------------------------------------------------------- |
| 369 | crate::vt::repaint_region(x, y, w, h); | ||
| 370 | unsafe { | ||
| 371 | for i in 0..ZLEN { | ||
| 372 | draw_window_intersect(ZORDER[i], x, y, w, h); | ||
| 373 | } | ||
| 374 | } | ||
| 375 | gop::push_rect(x, y, w, h); | ||
| 376 | } | ||
| 377 | 417 | ||
| 378 | /// Blit the portion of a window that intersects `(rx, ry, rw, rh)`. | 418 | fn hit_test(id: usize, x: usize, y: usize) -> Hit { |
| 379 | fn draw_window_intersect(id: usize, rx: usize, ry: usize, rw: usize, rh: usize) { | 419 | let Some(w) = get(id) else { |
| 380 | let win = unsafe { WINDOWS[id] }; | 420 | return Hit::None; |
| 381 | if !win.used || win.state != WinState::Visible { | ||
| 382 | return; | ||
| 383 | } | ||
| 384 | let wx = win.x; | ||
| 385 | let wy = win.y; | ||
| 386 | let ww = win.w; | ||
| 387 | let whh = win.h + TITLE_BAR_H; | ||
| 388 | let ix = wx.max(rx); | ||
| 389 | let iy = wy.max(ry); | ||
| 390 | let ix2 = (wx + ww).min(rx + rw); | ||
| 391 | let iy2 = (wy + whh).min(ry + rh); | ||
| 392 | if ix >= ix2 || iy >= iy2 { | ||
| 393 | return; | ||
| 394 | } | ||
| 395 | // Title bar strip. | ||
| 396 | let tb0 = wy; | ||
| 397 | let tb1 = wy + TITLE_BAR_H; | ||
| 398 | if iy < tb1 && iy2 > tb0 { | ||
| 399 | draw_title_bar(id); | ||
| 400 | } | ||
| 401 | // Content strip. | ||
| 402 | let c0 = wy + TITLE_BAR_H; | ||
| 403 | let c1 = wy + TITLE_BAR_H + win.h; | ||
| 404 | if iy2 > c0 && iy < c1 { | ||
| 405 | let cy0 = iy.max(c0); | ||
| 406 | let cy1 = iy2.min(c1); | ||
| 407 | let sx = ix - wx; | ||
| 408 | let sy = cy0 - c0; | ||
| 409 | let cw = ix2 - ix; | ||
| 410 | let chh = cy1 - cy0; | ||
| 411 | let src = unsafe { win.buf.add(sy * win.w + sx) }; | ||
| 412 | gop::shadow_blit(ix, cy0, cw, chh, src, win.w); | ||
| 413 | } | ||
| 414 | } | ||
| 415 | |||
| 416 | /// Draw a window's full title bar (background, title text, min/close). | ||
| 417 | fn draw_title_bar(id: usize) { | ||
| 418 | let win = unsafe { WINDOWS[id] }; | ||
| 419 | let bg = if focused() == Some(id) { | ||
| 420 | TITLE_BG_FOCUS | ||
| 421 | } else { | ||
| 422 | TITLE_BG | ||
| 423 | }; | 421 | }; |
| 424 | let bgc = gop::rgb(bg.0, bg.1, bg.2); | 422 | if !w.chrome { |
| 425 | gop::shadow_fill_rect(win.x, win.y, win.w, TITLE_BAR_H, bgc); | 423 | return Hit::Content; |
| 426 | |||
| 427 | // Title text, clipped short of the two buttons. | ||
| 428 | let btn_w = 16; | ||
| 429 | let cols = win.w.saturating_sub(btn_w + 4) / 8; | ||
| 430 | for (i, &b) in win.title[..win.title_len].iter().enumerate().take(cols) { | ||
| 431 | gop::shadow_glyph_px(win.x + 4 + i * 8, win.y, b as u16, TITLE_FG, bg); | ||
| 432 | } | 424 | } |
| 433 | 425 | if y >= w.y + TITLE_BAR_H { | |
| 434 | // Close button (right edge, red) and minimize button (grey). | 426 | return Hit::Content; |
| 435 | let close_x = win.x + win.w - 8; | ||
| 436 | let min_x = win.x + win.w - 16; | ||
| 437 | gop::shadow_fill_rect( | ||
| 438 | min_x, | ||
| 439 | win.y, | ||
| 440 | 8, | ||
| 441 | TITLE_BAR_H, | ||
| 442 | gop::rgb(MIN_BG.0, MIN_BG.1, MIN_BG.2), | ||
| 443 | ); | ||
| 444 | gop::shadow_fill_rect( | ||
| 445 | close_x, | ||
| 446 | win.y, | ||
| 447 | 8, | ||
| 448 | TITLE_BAR_H, | ||
| 449 | gop::rgb(CLOSE_BG.0, CLOSE_BG.1, CLOSE_BG.2), | ||
| 450 | ); | ||
| 451 | } | ||
| 452 | |||
| 453 | /// The task that owns a window (its input destination). | ||
| 454 | pub fn owner(id: usize) -> Option<usize> { | ||
| 455 | get(id).map(|w| w.owner) | ||
| 456 | } | ||
| 457 | |||
| 458 | /// A window's content buffer (pointer, width, height) for direct drawing. | ||
| 459 | pub fn buffer(id: usize) -> Option<(*mut u32, usize, usize)> { | ||
| 460 | get(id).map(|w| (w.buf, w.w, w.h)) | ||
| 461 | } | ||
| 462 | |||
| 463 | /// List windows as `(id, title, minimized)` for the shell `windows` command. | ||
| 464 | pub fn list() -> alloc::vec::Vec<(usize, alloc::string::String, bool)> { | ||
| 465 | let mut out = alloc::vec::Vec::new(); | ||
| 466 | for i in 0..MAX_WINDOWS { | ||
| 467 | let w = unsafe { WINDOWS[i] }; | ||
| 468 | if w.used { | ||
| 469 | let title = alloc::string::String::from( | ||
| 470 | core::str::from_utf8(&w.title[..w.title_len]).unwrap_or("?"), | ||
| 471 | ); | ||
| 472 | out.push((i, title, w.state == WinState::Minimized)); | ||
| 473 | } | ||
| 474 | } | 427 | } |
| 475 | out | 428 | let right = w.x + w.w; |
| 476 | } | 429 | if x >= right.saturating_sub(8) { |
| 477 | 430 | return Hit::Close; | |
| 478 | /// Request a cooperative close: deliver `Close` to the owning app (which is | ||
| 479 | /// expected to call `destroy` and exit). | ||
| 480 | pub fn request_close(id: usize) { | ||
| 481 | if let Some(w) = get(id) { | ||
| 482 | let _ = crate::sched::send(w.owner, TaskMsg::Close); | ||
| 483 | } | 431 | } |
| 432 | if x >= right.saturating_sub(16) { | ||
| 433 | return Hit::Minimize; | ||
| 434 | } | ||
| 435 | Hit::Title | ||
| 484 | } | 436 | } |
| 485 | 437 | ||
| 486 | #[derive(Clone, Copy, PartialEq, Eq)] | ||
| 487 | enum Hit { | ||
| 488 | None, | ||
| 489 | Close, | ||
| 490 | Minimize, | ||
| 491 | Title, | ||
| 492 | Content, | ||
| 493 | } | ||
| 494 | |||
| 495 | /// Topmost visible window whose *outer* bounds contain the point. | ||
| 496 | pub fn window_at(x: usize, y: usize) -> Option<usize> { | 438 | pub fn window_at(x: usize, y: usize) -> Option<usize> { |
| 497 | unsafe { | 439 | unsafe { |
| 498 | for i in (0..ZLEN).rev() { | 440 | for i in (0..ZLEN).rev() { |
| @@ -501,7 +443,8 @@ pub fn window_at(x: usize, y: usize) -> Option<usize> { | |||
| 501 | if !w.used || w.state != WinState::Visible { | 443 | if !w.used || w.state != WinState::Visible { |
| 502 | continue; | 444 | continue; |
| 503 | } | 445 | } |
| 504 | if x >= w.x && x < w.x + w.w && y >= w.y && y < w.y + w.h + TITLE_BAR_H { | 446 | let outer_h = w.h + if w.chrome { TITLE_BAR_H } else { 0 }; |
| 447 | if x >= w.x && x < w.x + w.w && y >= w.y && y < w.y + outer_h { | ||
| 505 | return Some(id); | 448 | return Some(id); |
| 506 | } | 449 | } |
| 507 | } | 450 | } |
| @@ -509,37 +452,12 @@ pub fn window_at(x: usize, y: usize) -> Option<usize> { | |||
| 509 | None | 452 | None |
| 510 | } | 453 | } |
| 511 | 454 | ||
| 512 | fn hit_test(id: usize, x: usize, y: usize) -> Hit { | 455 | pub fn request_close(id: usize) { |
| 513 | let Some(w) = get(id) else { | 456 | if let Some(w) = get(id) { |
| 514 | return Hit::None; | 457 | let _ = crate::sched::send(w.owner, TaskMsg::Close); |
| 515 | }; | ||
| 516 | if y >= w.y + TITLE_BAR_H { | ||
| 517 | return Hit::Content; | ||
| 518 | } | ||
| 519 | let right = w.x + w.w; | ||
| 520 | if x >= right.saturating_sub(8) { | ||
| 521 | return Hit::Close; | ||
| 522 | } | ||
| 523 | if x >= right.saturating_sub(16) { | ||
| 524 | return Hit::Minimize; | ||
| 525 | } | 458 | } |
| 526 | Hit::Title | ||
| 527 | } | 459 | } |
| 528 | 460 | ||
| 529 | /// What an in-progress mouse gesture is doing (started on a press). | ||
| 530 | #[derive(Clone, Copy, PartialEq, Eq)] | ||
| 531 | enum Drag { | ||
| 532 | None, | ||
| 533 | Move, // dragging a window by its title bar | ||
| 534 | App, // a stroke/drag inside a window's content | ||
| 535 | } | ||
| 536 | |||
| 537 | static mut DRAG: Drag = Drag::None; | ||
| 538 | static mut DRAG_ID: usize = usize::MAX; | ||
| 539 | static mut DRAG_DX: i32 = 0; // cursor - window.x at drag start | ||
| 540 | static mut DRAG_DY: i32 = 0; // cursor - window.y at drag start | ||
| 541 | |||
| 542 | /// Route one mouse event (absolute canvas coords, button bitmask, kind). | ||
| 543 | pub fn route_mouse(x: i32, y: i32, buttons: u8, kind: MouseKind) { | 461 | pub fn route_mouse(x: i32, y: i32, buttons: u8, kind: MouseKind) { |
| 544 | match kind { | 462 | match kind { |
| 545 | MouseKind::Press => mouse_press(x, y, buttons), | 463 | MouseKind::Press => mouse_press(x, y, buttons), |
| @@ -620,10 +538,9 @@ fn deliver_mouse(id: usize, cx: usize, cy: usize, buttons: u8, kind: MouseKind) | |||
| 620 | let Some(w) = get(id) else { | 538 | let Some(w) = get(id) else { |
| 621 | return; | 539 | return; |
| 622 | }; | 540 | }; |
| 541 | let content_y = w.y + if w.chrome { TITLE_BAR_H } else { 0 }; | ||
| 623 | let rx = cx as i32 - w.x as i32; | 542 | let rx = cx as i32 - w.x as i32; |
| 624 | let ry = cy as i32 - (w.y + TITLE_BAR_H) as i32; | 543 | let ry = cy as i32 - content_y as i32; |
| 625 | // Suppress out-of-bounds Press/Move, but always deliver Release so a | ||
| 626 | // stroke that ends outside the window still terminates. | ||
| 627 | if kind != MouseKind::Release && (rx < 0 || ry < 0 || rx >= w.w as i32 || ry >= w.h as i32) { | 544 | if kind != MouseKind::Release && (rx < 0 || ry < 0 || rx >= w.w as i32 || ry >= w.h as i32) { |
| 628 | return; | 545 | return; |
| 629 | } | 546 | } |
| @@ -636,8 +553,10 @@ fn deliver_mouse(id: usize, cx: usize, cy: usize, buttons: u8, kind: MouseKind) | |||
| 636 | let _ = crate::sched::send(w.owner, TaskMsg::AppMouse(ev)); | 553 | let _ = crate::sched::send(w.owner, TaskMsg::AppMouse(ev)); |
| 637 | } | 554 | } |
| 638 | 555 | ||
| 639 | /// Move a window to (nx, ny), clamping to the canvas, and repaint old+new. | ||
| 640 | fn move_window(id: usize, nx: usize, ny: usize) { | 556 | fn move_window(id: usize, nx: usize, ny: usize) { |
| 557 | if id == 0 { | ||
| 558 | return; | ||
| 559 | } | ||
| 641 | let Some(w) = get(id) else { | 560 | let Some(w) = get(id) else { |
| 642 | return; | 561 | return; |
| 643 | }; | 562 | }; |
| @@ -656,7 +575,28 @@ fn move_window(id: usize, nx: usize, ny: usize) { | |||
| 656 | composite_rect(nx, ny, w.w, w.h + TITLE_BAR_H); | 575 | composite_rect(nx, ny, w.w, w.h + TITLE_BAR_H); |
| 657 | } | 576 | } |
| 658 | 577 | ||
| 659 | /// Spawn the keyboard pump task (routes keys to the focused GUI window). | 578 | pub fn owner(id: usize) -> Option<usize> { |
| 579 | get(id).map(|w| w.owner) | ||
| 580 | } | ||
| 581 | |||
| 582 | pub fn buffer(id: usize) -> Option<(*mut u32, usize, usize)> { | ||
| 583 | get(id).map(|w| (w.buf, w.w, w.h)) | ||
| 584 | } | ||
| 585 | |||
| 586 | pub fn list() -> alloc::vec::Vec<(usize, alloc::string::String, bool)> { | ||
| 587 | let mut out = alloc::vec::Vec::new(); | ||
| 588 | for i in 0..MAX_WINDOWS { | ||
| 589 | let w = unsafe { WINDOWS[i] }; | ||
| 590 | if w.used && i != 0 { | ||
| 591 | let title = alloc::string::String::from( | ||
| 592 | core::str::from_utf8(&w.title[..w.title_len]).unwrap_or("?"), | ||
| 593 | ); | ||
| 594 | out.push((i, title, w.state == WinState::Minimized)); | ||
| 595 | } | ||
| 596 | } | ||
| 597 | out | ||
| 598 | } | ||
| 599 | |||
| 660 | pub fn spawn_input_pump() -> Option<usize> { | 600 | pub fn spawn_input_pump() -> Option<usize> { |
| 661 | crate::sched::spawn(pump_main, "wm-input") | 601 | crate::sched::spawn(pump_main, "wm-input") |
| 662 | } | 602 | } |
| @@ -676,3 +616,103 @@ fn pump_main() { | |||
| 676 | crate::sched::sleep_ms(5); | 616 | crate::sched::sleep_ms(5); |
| 677 | } | 617 | } |
| 678 | } | 618 | } |
| 619 | |||
| 620 | // --------------------------------------------------------------------------- | ||
| 621 | // Compositing | ||
| 622 | // --------------------------------------------------------------------------- | ||
| 623 | |||
| 624 | /// Composite a region: fill background, blit windows bottom-to-top, push. | ||
| 625 | fn composite_rect(x: usize, y: usize, w: usize, h: usize) { | ||
| 626 | gop::shadow_fill_rect(x, y, w, h, gop::rgb(DESKTOP.0, DESKTOP.1, DESKTOP.2)); | ||
| 627 | unsafe { | ||
| 628 | for i in 0..ZLEN { | ||
| 629 | draw_window_intersect(ZORDER[i], x, y, w, h); | ||
| 630 | } | ||
| 631 | } | ||
| 632 | gop::push_rect(x, y, w, h); | ||
| 633 | } | ||
| 634 | |||
| 635 | /// Blit the portion of a window that intersects `(rx, ry, rw, rh)`. | ||
| 636 | fn draw_window_intersect(id: usize, rx: usize, ry: usize, rw: usize, rh: usize) { | ||
| 637 | let win = unsafe { WINDOWS[id] }; | ||
| 638 | if !win.used || win.state != WinState::Visible || win.buf.is_null() { | ||
| 639 | return; | ||
| 640 | } | ||
| 641 | let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; | ||
| 642 | let wx = win.x; | ||
| 643 | let wy = win.y; | ||
| 644 | let ww = win.w; | ||
| 645 | let ix = wx.max(rx); | ||
| 646 | let iy = wy.max(ry); | ||
| 647 | let ix2 = (wx + ww).min(rx + rw); | ||
| 648 | let iy2 = (wy + outer_h).min(ry + rh); | ||
| 649 | if ix >= ix2 || iy >= iy2 { | ||
| 650 | return; | ||
| 651 | } | ||
| 652 | if win.chrome { | ||
| 653 | // Title bar strip | ||
| 654 | let tb0 = wy; | ||
| 655 | let tb1 = wy + TITLE_BAR_H; | ||
| 656 | if iy < tb1 && iy2 > tb0 { | ||
| 657 | draw_title_bar(id); | ||
| 658 | } | ||
| 659 | // Content strip | ||
| 660 | let c0 = wy + TITLE_BAR_H; | ||
| 661 | let c1 = wy + TITLE_BAR_H + win.h; | ||
| 662 | if iy2 > c0 && iy < c1 { | ||
| 663 | let cy0 = iy.max(c0); | ||
| 664 | let cy1 = iy2.min(c1); | ||
| 665 | let sx = ix - wx; | ||
| 666 | let sy = cy0 - c0; | ||
| 667 | let cw = ix2 - ix; | ||
| 668 | let chh = cy1 - cy0; | ||
| 669 | let src = unsafe { win.buf.add(sy * win.w + sx) }; | ||
| 670 | gop::shadow_blit(ix, cy0, cw, chh, src, win.w); | ||
| 671 | } | ||
| 672 | } else { | ||
| 673 | // Chrome-less: no title bar, buffer is full-window | ||
| 674 | let sx = ix - wx; | ||
| 675 | let sy = iy - wy; | ||
| 676 | let cw = ix2 - ix; | ||
| 677 | let chh = iy2 - iy; | ||
| 678 | let src = unsafe { win.buf.add(sy * win.w + sx) }; | ||
| 679 | gop::shadow_blit(ix, iy, cw, chh, src, win.w); | ||
| 680 | } | ||
| 681 | } | ||
| 682 | |||
| 683 | /// Draw a window's full title bar (background, title text, min/close). | ||
| 684 | fn draw_title_bar(id: usize) { | ||
| 685 | let win = unsafe { WINDOWS[id] }; | ||
| 686 | let bg = if focused() == Some(id) { | ||
| 687 | TITLE_BG_FOCUS | ||
| 688 | } else { | ||
| 689 | TITLE_BG | ||
| 690 | }; | ||
| 691 | let bgc = gop::rgb(bg.0, bg.1, bg.2); | ||
| 692 | gop::shadow_fill_rect(win.x, win.y, win.w, TITLE_BAR_H, bgc); | ||
| 693 | |||
| 694 | // Title text, clipped short of the two buttons. | ||
| 695 | let btn_w = 16; | ||
| 696 | let cols = win.w.saturating_sub(btn_w + 4) / 8; | ||
| 697 | for (i, &b) in win.title[..win.title_len].iter().enumerate().take(cols) { | ||
| 698 | gop::shadow_glyph_px(win.x + 4 + i * 8, win.y, b as u16, TITLE_FG, bg); | ||
| 699 | } | ||
| 700 | |||
| 701 | // Close button (right edge, red) and minimize button (grey). | ||
| 702 | let close_x = win.x + win.w - 8; | ||
| 703 | let min_x = win.x + win.w - 16; | ||
| 704 | gop::shadow_fill_rect( | ||
| 705 | min_x, | ||
| 706 | win.y, | ||
| 707 | 8, | ||
| 708 | TITLE_BAR_H, | ||
| 709 | gop::rgb(MIN_BG.0, MIN_BG.1, MIN_BG.2), | ||
| 710 | ); | ||
| 711 | gop::shadow_fill_rect( | ||
| 712 | close_x, | ||
| 713 | win.y, | ||
| 714 | 8, | ||
| 715 | TITLE_BAR_H, | ||
| 716 | gop::rgb(CLOSE_BG.0, CLOSE_BG.1, CLOSE_BG.2), | ||
| 717 | ); | ||
| 718 | } | ||
