summaryrefslogtreecommitdiffstats
path: root/rsnix/src/wm.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--rsnix/src/wm.rs592
1 files changed, 592 insertions, 0 deletions
diff --git a/rsnix/src/wm.rs b/rsnix/src/wm.rs
new file mode 100644
index 0000000..a39702d
--- /dev/null
+++ b/rsnix/src/wm.rs
@@ -0,0 +1,592 @@
1//! Window manager — a shared facility (no dedicated task).
2//!
3//! Implements ADR 0004: overlapping windows backed by per-window pixel
4//! buffers, composited onto the canvas with window-granularity damage
5//! rectangles. The console (`vt`) is layer 0 and is re-rendered (never
6//! re-allocated) when a region is exposed; GUI windows are composited
7//! above it. Chrome (title bar) is WM-drawn; each window's buffer is its
8//! 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
14extern crate alloc;
15
16use core::sync::atomic::{AtomicUsize, Ordering};
17
18use crate::gop;
19use crate::memory;
20use crate::ps2::{AppMouseEvent, KeyEvent, MouseKind};
21use crate::sched::TaskMsg;
22
23/// Height of a window's title bar in pixels (one 8×16 glyph row).
24pub const TITLE_BAR_H: usize = 16;
25
26/// Sentinel: `FOCUSED == CONSOLE` means the console (vt) has focus.
27pub const CONSOLE: usize = usize::MAX;
28
29const MAX_WINDOWS: usize = 16;
30const MIN_W: usize = 64;
31const MIN_H: usize = 64;
32const CASCADE: usize = 24;
33const TITLE_MAX: usize = 32;
34
35// Palette (RGB; `gop::rgb` packs for the current pixel format).
36const TITLE_BG: (u8, u8, u8) = (0x3a, 0x3c, 0x42);
37const TITLE_BG_FOCUS: (u8, u8, u8) = (0x28, 0x4a, 0x8c);
38const TITLE_FG: (u8, u8, u8) = (0xff, 0xff, 0xff);
39const CLOSE_BG: (u8, u8, u8) = (0xb0, 0x30, 0x30);
40const MIN_BG: (u8, u8, u8) = (0x50, 0x50, 0x50);
41
42#[derive(Clone, Copy, PartialEq, Eq)]
43enum WinState {
44 Visible,
45 Minimized,
46}
47
48#[derive(Clone, Copy)]
49struct Window {
50 used: bool,
51 state: WinState,
52 title: [u8; TITLE_MAX],
53 title_len: usize,
54 #[allow(dead_code)] // read once input routing delivers to the owner's queue
55 owner: usize,
56 x: usize,
57 y: usize,
58 w: usize,
59 h: usize,
60 buf: *mut u32,
61}
62
63const EMPTY: Window = Window {
64 used: false,
65 state: WinState::Visible,
66 title: [0; TITLE_MAX],
67 title_len: 0,
68 owner: usize::MAX,
69 x: 0,
70 y: 0,
71 w: 0,
72 h: 0,
73 buf: core::ptr::null_mut(),
74};
75
76static mut WINDOWS: [Window; MAX_WINDOWS] = [EMPTY; MAX_WINDOWS];
77/// Window ids in bottom→top z-order.
78static mut ZORDER: [usize; MAX_WINDOWS] = [usize::MAX; MAX_WINDOWS];
79static mut ZLEN: usize = 0;
80static FOCUSED: AtomicUsize = AtomicUsize::new(CONSOLE);
81static mut CASCADE_X: usize = 0;
82static mut CASCADE_Y: usize = 0;
83
84/// Initialise window state. The console (vt) remains the visible layer 0;
85/// no GUI windows exist yet.
86pub fn init() {
87 unsafe {
88 ZLEN = 0;
89 CASCADE_X = 0;
90 CASCADE_Y = 0;
91 }
92 FOCUSED.store(CONSOLE, Ordering::Relaxed);
93}
94
95/// The focused window, or `None` if the console has focus.
96pub fn focused() -> Option<usize> {
97 let v = FOCUSED.load(Ordering::Relaxed);
98 if v == CONSOLE { None } else { Some(v) }
99}
100
101fn get(id: usize) -> Option<Window> {
102 if id >= MAX_WINDOWS {
103 return None;
104 }
105 let w = unsafe { WINDOWS[id] };
106 if w.used { Some(w) } else { None }
107}
108
109/// Create a window owned by the current task. `w`×`h` is the *content*
110/// size (title bar excluded). Returns the window id, or `None` on a full
111/// table or an allocation failure.
112pub fn create_window(title: &str, w: usize, h: usize) -> Option<usize> {
113 let (cw, ch) = gop::pixel_dims();
114 let w = w.max(MIN_W).min(cw.saturating_sub(8));
115 let h = h.max(MIN_H).min(ch.saturating_sub(TITLE_BAR_H + 8));
116 if w < MIN_W || h < MIN_H || cw == 0 || ch == 0 {
117 return None;
118 }
119
120 let pages = (w * h * 4).div_ceil(4096);
121 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 {
124 for i in 0..(w * h) {
125 *buf.add(i) = 0;
126 }
127 }
128
129 let id = unsafe {
130 let mut slot = None;
131 for i in 0..MAX_WINDOWS {
132 if !WINDOWS[i].used {
133 slot = Some(i);
134 break;
135 }
136 }
137 match slot {
138 Some(i) => i,
139 None => {
140 memory::free_pages(buf as usize, pages);
141 return None;
142 }
143 }
144 };
145
146 let (x, y) = unsafe {
147 let mut px = CASCADE_X;
148 let mut py = CASCADE_Y;
149 if px + w > cw {
150 px = 0;
151 }
152 if py + TITLE_BAR_H + h > ch {
153 py = 0;
154 }
155 CASCADE_X = px + CASCADE;
156 CASCADE_Y = py + CASCADE;
157 (px, py)
158 };
159
160 let mut tl = [0u8; TITLE_MAX];
161 let n = title.len().min(TITLE_MAX);
162 tl[..n].copy_from_slice(&title.as_bytes()[..n]);
163
164 unsafe {
165 WINDOWS[id] = Window {
166 used: true,
167 state: WinState::Visible,
168 title: tl,
169 title_len: n,
170 owner: crate::sched::current(),
171 x,
172 y,
173 w,
174 h,
175 buf,
176 };
177 ZORDER[ZLEN] = id;
178 ZLEN += 1;
179 }
180 focus(id);
181 Some(id)
182}
183
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.
186pub fn flush(id: usize) {
187 let Some(win) = get(id) else {
188 return;
189 };
190 composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H);
191}
192
193/// Destroy a window: free its buffer, remove it, restore the exposed region.
194pub fn destroy(id: usize) {
195 let Some(win) = get(id) else {
196 return;
197 };
198 let (x, y, w, h) = (win.x, win.y, win.w, win.h + TITLE_BAR_H);
199 if !win.buf.is_null() {
200 let pages = (win.w * win.h * 4).div_ceil(4096);
201 memory::free_pages(win.buf as usize, pages);
202 }
203 remove_from_zorder(id);
204 unsafe {
205 WINDOWS[id] = EMPTY;
206 }
207 if focused() == Some(id) {
208 if unsafe { ZLEN } > 0 {
209 focus(unsafe { ZORDER[ZLEN - 1] });
210 } else {
211 focus_console();
212 }
213 }
214 composite_rect(x, y, w, h);
215}
216
217/// Minimize a window: it is no longer rendered (the app keeps running).
218pub fn minimize(id: usize) {
219 let Some(win) = get(id) else {
220 return;
221 };
222 if win.state == WinState::Minimized {
223 return;
224 }
225 let (x, y, w, h) = (win.x, win.y, win.w, win.h + TITLE_BAR_H);
226 unsafe {
227 WINDOWS[id].state = WinState::Minimized;
228 }
229 remove_from_zorder(id);
230 if focused() == Some(id) {
231 focus_console();
232 }
233 composite_rect(x, y, w, h);
234}
235
236/// Restore a minimized window (back to the top, focused).
237pub fn restore(id: usize) {
238 let Some(win) = get(id) else {
239 return;
240 };
241 if win.state == WinState::Visible {
242 return;
243 }
244 unsafe {
245 WINDOWS[id].state = WinState::Visible;
246 ZORDER[ZLEN] = id;
247 ZLEN += 1;
248 }
249 focus(id);
250 composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H);
251}
252
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.
255pub fn focus(id: usize) {
256 let Some(win) = get(id) else {
257 return;
258 };
259 if win.state != WinState::Visible {
260 return;
261 }
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();
269 FOCUSED.store(id, Ordering::Relaxed);
270 if let Some(p) = prev
271 && p != id
272 && let Some(pw) = get(p)
273 {
274 let _ = crate::sched::send(pw.owner, TaskMsg::Focus(false));
275 composite_rect(pw.x, pw.y, pw.w, TITLE_BAR_H);
276 }
277 let _ = crate::sched::send(win.owner, TaskMsg::Focus(true));
278 composite_rect(win.x, win.y, win.w, win.h + TITLE_BAR_H);
279}
280
281/// Move focus to the console.
282pub fn focus_console() {
283 let prev = focused();
284 FOCUSED.store(CONSOLE, Ordering::Relaxed);
285 if let Some(p) = prev
286 && let Some(pw) = get(p)
287 {
288 let _ = crate::sched::send(pw.owner, TaskMsg::Focus(false));
289 composite_rect(pw.x, pw.y, pw.w, TITLE_BAR_H);
290 }
291}
292
293/// Cycle focus: console → top window → … → bottom window → console.
294pub fn focus_next() {
295 unsafe {
296 if ZLEN == 0 {
297 focus_console();
298 return;
299 }
300 match focused() {
301 None => focus(ZORDER[ZLEN - 1]),
302 Some(cur) => {
303 let mut idx = usize::MAX;
304 for i in 0..ZLEN {
305 if ZORDER[i] == cur {
306 idx = i;
307 break;
308 }
309 }
310 if idx == usize::MAX || idx == 0 {
311 focus_console();
312 } else {
313 focus(ZORDER[idx - 1]);
314 }
315 }
316 }
317 }
318}
319
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.
322pub fn handle_key(ev: KeyEvent) -> bool {
323 match ev {
324 KeyEvent::F(1) => {
325 spawn_terminal();
326 true
327 }
328 KeyEvent::F(2) => {
329 focus_next();
330 true
331 }
332 KeyEvent::F(3) => {
333 if let Some(id) = focused() {
334 minimize(id);
335 }
336 true
337 }
338 KeyEvent::F(4) => {
339 if let Some(id) = focused() {
340 request_close(id);
341 }
342 true
343 }
344 _ => false,
345 }
346}
347
348fn spawn_terminal() {
349 // Terminal app deferred (needs per-instance vt + per-instance shell).
350 crate::serial_println!("wm: F1 (spawn terminal) not implemented yet");
351}
352
353fn remove_from_zorder(id: usize) {
354 unsafe {
355 let mut j = 0;
356 for i in 0..ZLEN {
357 if ZORDER[i] != id {
358 ZORDER[j] = ZORDER[i];
359 j += 1;
360 }
361 }
362 ZLEN = j;
363 }
364}
365
366/// Repaint a canvas region bottom→top: restore the console (layer 0), then
367/// blit every visible window that intersects, then push once.
368fn composite_rect(x: usize, y: usize, w: usize, h: usize) {
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
378/// Blit the portion of a window that intersects `(rx, ry, rw, rh)`.
379fn draw_window_intersect(id: usize, rx: usize, ry: usize, rw: usize, rh: usize) {
380 let win = unsafe { WINDOWS[id] };
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).
417fn 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 };
424 let bgc = gop::rgb(bg.0, bg.1, bg.2);
425 gop::shadow_fill_rect(win.x, win.y, win.w, TITLE_BAR_H, bgc);
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 }
433
434 // Close button (right edge, red) and minimize button (grey).
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).
454pub 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.
459pub 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.
464pub 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 }
475 out
476}
477
478/// Request a cooperative close: deliver `Close` to the owning app (which is
479/// expected to call `destroy` and exit).
480pub fn request_close(id: usize) {
481 if let Some(w) = get(id) {
482 let _ = crate::sched::send(w.owner, TaskMsg::Close);
483 }
484}
485
486#[derive(Clone, Copy, PartialEq, Eq)]
487enum Hit {
488 None,
489 Close,
490 Minimize,
491 Title,
492 Content,
493}
494
495/// Topmost visible window whose *outer* bounds contain the point.
496pub fn window_at(x: usize, y: usize) -> Option<usize> {
497 unsafe {
498 for i in (0..ZLEN).rev() {
499 let id = ZORDER[i];
500 let w = WINDOWS[id];
501 if !w.used || w.state != WinState::Visible {
502 continue;
503 }
504 if x >= w.x && x < w.x + w.w && y >= w.y && y < w.y + w.h + TITLE_BAR_H {
505 return Some(id);
506 }
507 }
508 }
509 None
510}
511
512fn hit_test(id: usize, x: usize, y: usize) -> Hit {
513 let Some(w) = get(id) else {
514 return Hit::None;
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 }
526 Hit::Title
527}
528
529/// Route one mouse event (absolute canvas coords, button bitmask, kind).
530pub fn route_mouse(x: i32, y: i32, buttons: u8, kind: MouseKind) {
531 if kind == MouseKind::Press {
532 if x < 0 || y < 0 {
533 return;
534 }
535 let (cx, cy) = (x as usize, y as usize);
536 if let Some(id) = window_at(cx, cy) {
537 match hit_test(id, cx, cy) {
538 Hit::Close => request_close(id),
539 Hit::Minimize => minimize(id),
540 Hit::Title => focus(id),
541 Hit::Content => {
542 focus(id);
543 deliver_mouse(id, cx, cy, buttons, kind);
544 }
545 Hit::None => {}
546 }
547 } else {
548 focus_console();
549 }
550 } else if let Some(id) = focused() {
551 deliver_mouse(id, x as usize, y as usize, buttons, kind);
552 }
553}
554
555fn deliver_mouse(id: usize, cx: usize, cy: usize, buttons: u8, kind: MouseKind) {
556 let Some(w) = get(id) else {
557 return;
558 };
559 let rx = cx as i32 - w.x as i32;
560 let ry = cy as i32 - (w.y + TITLE_BAR_H) as i32;
561 if rx < 0 || ry < 0 || rx >= w.w as i32 || ry >= w.h as i32 {
562 return; // out of bounds — suppressed
563 }
564 let ev = AppMouseEvent {
565 x: rx,
566 y: ry,
567 buttons,
568 kind,
569 };
570 let _ = crate::sched::send(w.owner, TaskMsg::AppMouse(ev));
571}
572
573/// Spawn the keyboard pump task (routes keys to the focused GUI window).
574pub fn spawn_input_pump() -> Option<usize> {
575 crate::sched::spawn(pump_main, "wm-input")
576}
577
578fn pump_main() {
579 loop {
580 if focused().is_some() {
581 while let Some(ev) = crate::ps2::try_poll_key().or_else(crate::uhci::try_poll_key) {
582 if !handle_key(ev)
583 && let Some(id) = focused()
584 && let Some(o) = owner(id)
585 {
586 let _ = crate::sched::send(o, TaskMsg::Key(ev));
587 }
588 }
589 }
590 crate::sched::sleep_ms(5);
591 }
592}