summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-17 18:56:04 +0200
committersalmonik <salmonik@tuta.io>2026-08-17 18:56:04 +0200
commit5558e3509d020a0e021a77f0617044497b2f2aee (patch)
treecd7b6cee9f7c327f9fae7ad9ac27602d1a97b222
parentUSB2/1.1 (EHCI/UHCI) drivers + real-hardware bring-up hardening (diff)
downloadrs-dos-5558e3509d020a0e021a77f0617044497b2f2aee.tar.gz
rs-dos-5558e3509d020a0e021a77f0617044497b2f2aee.zip
Windowed GUI: wm module, input routing, and the paint app
- Add the wm window-manager facility per ADR 0004: overlapping pixel-surface windows, damage-rectangle compositing, WM-drawn title bar, focus/minimize/close, and F1-F4 keybindings - Add gop shadow compositing primitives and vt::repaint_region so the console stays layer 0 beneath windows - Route mouse (absolute coords, press/release edges) and keyboard (focus-aware input pump) to the focused window - Add the rspaint drawing-program crate (pen, 8 colors, clear) and shell draw/windows/restore commands - Extend TaskMsg with AppMouse/Focus/Close and document the design in CONTEXT.md + ADR 0004
-rw-r--r--CONTEXT.md52
-rw-r--r--Cargo.lock8
-rw-r--r--Cargo.toml2
-rw-r--r--docs/adr/0004-windowed-gui-and-compositor.md78
-rw-r--r--rsdos/src/main.rs5
-rw-r--r--rsnix/src/cursor.rs19
-rw-r--r--rsnix/src/gop.rs103
-rw-r--r--rsnix/src/lib.rs9
-rw-r--r--rsnix/src/ps2.rs20
-rw-r--r--rsnix/src/sched.rs59
-rw-r--r--rsnix/src/tty.rs25
-rw-r--r--rsnix/src/vt.rs25
-rw-r--r--rsnix/src/wm.rs592
-rw-r--r--rspaint/Cargo.toml10
-rw-r--r--rspaint/src/lib.rs122
-rw-r--r--shell/Cargo.toml1
-rw-r--r--shell/src/lib.rs67
17 files changed, 1152 insertions, 45 deletions
diff --git a/CONTEXT.md b/CONTEXT.md
index 48cb3a4..9fccbe4 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -77,6 +77,58 @@ Two-phase model matching output:
77The framebuffer address stays valid after `ExitBootServices` — the GPU's 77The framebuffer address stays valid after `ExitBootServices` — the GPU's
78linear frame buffer is system memory that we own once boot services are gone. 78linear frame buffer is system memory that we own once boot services are gone.
79 79
80## Window
81
82A rectangular display surface owned by exactly one task and composited
83onto the 800×600 canvas by the window manager. The global console (vt)
84becomes the first window, owned by the shell; further windows are spawned
85per task (a terminal, a drawing canvas, …). Windows may overlap, so they
86carry a z-order. Every window is a pixel surface: a terminal window is a
87text grid rendered into its own pixels, a drawing canvas draws pixels
88directly. The ruling constraint is "stupid simple".
89
90## Chrome
91
92Window-manager-owned UI drawn on the canvas but not a window: the
93background/desktop fill, per-window title bars, and (later) a
94launcher/menu. Chrome is not focusable and does not take input as a
95window.
96
97## Title bar
98
99Per-window chrome strip at the top of a window showing the window's title
100and its minimize and close buttons. Drawn by the window manager, not the
101app.
102
103## Minimized
104
105A window state in which the window is not rendered (hidden); the app keeps
106running. The inverse of the normal visible state.
107
108## Close
109
110The action that destroys a window and ends its app.
111
112## App
113
114A task that owns exactly one window and receives input events through its
115message queue. A GUI program is an app; there is no separate "process"
116concept — the app is its task plus its window. The console is app 0
117(window 0).
118
119## Focus
120
121The single window (if any) that receives keyboard and mouse input. Focus
122is set by clicking a window, which also raises it; input is delivered to
123the focused app's message queue. With no focused window, a click only
124sets focus.
125
126## Keybinding
127
128A global keyboard shortcut handled by the window manager before input is
129routed to the focused window. It triggers a WM action (spawn a terminal,
130focus/restore the next window, close, …) and is never delivered to an app.
131
80## Keyboard layout 132## Keyboard layout
81 133
82Scancode set 1, US QWERTY layout. Hardcoded scancode→ASCII table. 134Scancode set 1, US QWERTY layout. Hardcoded scancode→ASCII table.
diff --git a/Cargo.lock b/Cargo.lock
index 515158d..76b5a2a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -22,9 +22,17 @@ name = "rsnix"
22version = "0.5.10" 22version = "0.5.10"
23 23
24[[package]] 24[[package]]
25name = "rspaint"
26version = "0.1.0"
27dependencies = [
28 "rsnix",
29]
30
31[[package]]
25name = "shell" 32name = "shell"
26version = "0.5.10" 33version = "0.5.10"
27dependencies = [ 34dependencies = [
28 "rsdosedit", 35 "rsdosedit",
29 "rsnix", 36 "rsnix",
37 "rspaint",
30] 38]
diff --git a/Cargo.toml b/Cargo.toml
index 704d86c..90d28c7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[workspace] 1[workspace]
2resolver = "2" 2resolver = "2"
3members = ["rsnix", "shell", "rsdos", "rsdosedit"] 3members = ["rsnix", "shell", "rsdos", "rsdosedit", "rspaint"]
4 4
5[workspace.package] 5[workspace.package]
6version = "0.5.10" 6version = "0.5.10"
diff --git a/docs/adr/0004-windowed-gui-and-compositor.md b/docs/adr/0004-windowed-gui-and-compositor.md
new file mode 100644
index 0000000..86e8b30
--- /dev/null
+++ b/docs/adr/0004-windowed-gui-and-compositor.md
@@ -0,0 +1,78 @@
1# ADR 0004 — Overlapping-window GUI with damage-rectangle compositing and a shared-facility window manager
2
3## Status
4
5Accepted.
6
7## Context
8
9The kernel's only display surface was a single global text terminal (`vt`),
10shared by the shell, klog, and fault dumps. We want a "proper GUI" with
11programs (a terminal, a drawing program, …). The kernel is
12TempleOS-inspired: one identity-mapped address space, no userspace
13separation, preemptive round-robin tasks. The display must stay viable on
14real hardware (uncached GOP framebuffers), not just fast on QEMU. The
15existing cursor task already routed mouse events to a single "forward
16target" in anticipation of a window manager.
17
18## Decision
19
20Introduce an overlapping-window GUI:
21
22- **Window** is the fundamental unit: a rectangular, task-owned **pixel
23 surface** composited onto the fixed 800×600 canvas, with z-order.
24- **Every window is a raster surface.** A terminal window renders its text
25 grid into its own pixels; a drawing program draws pixels directly. One
26 content model.
27- **Damage-rectangle compositing** (not full-frame redraw) to stay
28 real-hardware-viable. Damage granularity is **window-level first**
29 (repaint a dirty window's whole bounds) — revisit sub-window rectangles
30 if the console proves slow.
31- **Repaint** = blit the dirty window, then re-overlay any occluders above
32 it (rectangle intersection, not subtraction).
33- **The window manager is a shared facility, not a task** — in a single
34 address space there is no isolation to enforce, so a dedicated task only
35 adds round-trips.
36- **App = a task owning one window** (no separate process concept). The
37 console is app 0.
38- **Focus = click-to-focus + raise**; keyboard and mouse go to the focused
39 window.
40- **The console is the launcher**; no taskbar/menu in the first pass.
41- **Chrome** (background, per-window title bars, later a menu) is
42 window-manager-drawn, not windows. A title bar is a strip above a
43 **content-only** app buffer, with title, minimize, and close.
44- **Restore** of minimized windows is via a console command
45 (`windows`/`restore`) plus global **keybindings** F1–F4 (spawn terminal,
46 focus next, minimize, close); F1–F4 are reserved and invisible to apps.
47- **Window buffers** are dedicated `w×h×4` buffers allocated from the page
48 allocator (the same mechanism as the shadow backbuffer); full-screen
49 (800×600) content is allowed.
50- **Drawing is explicit** — the app writes into its buffer and calls
51 `flush()` to request a composite; there is no automatic per-tick
52 compositing.
53- **App event contract** — an app's queue delivers `Key`, `Mouse { x, y,
54 buttons, kind }` (absolute window-relative; `kind` = Move/Press/Release),
55 `Focus(bool)`, `Close`, `Timer`, and `User(u64)`. Mouse events outside
56 the focused window's content bounds are suppressed.
57- **Minimum window size** is 64×64 content.
58- **Window lifecycle** — a window is created by and bound to its task: it
59 is freed (buffer returned) when the task exits, via a reap hook. Close
60 is cooperative: `Close` is delivered and the app exits; three F4 presses
61 in quick succession force-teardown (free the window, kill the task). The
62 console (app 0) never closes.
63- **Placement** — new windows cascade (+24px each, wrapping) and are
64 focused and raised on creation; the console is full-canvas at (0,0).
65 Windows are fixed-position and fixed-size in v1: move is the first
66 post-v1 feature and resize is deferred.
67
68## Consequences
69
70- Positive: one content model, one compositing path, minimal concepts.
71- Negative: window-level damage means a full-screen console repaints its
72 whole rectangle per keystroke — accepted for now, revisitable.
73- Negative: windows are fixed-size initially (no resize).
74- The existing `vt` dirty-cell path is demoted to a per-terminal-window
75 trigger; the window manager becomes the only writer of the framebuffer
76 (plus the cursor overlay).
77- Open (not yet decided): the terminal-app design (per-instance vt + shell),
78 and the save/image-format + image-viewer design (both deferred).
diff --git a/rsdos/src/main.rs b/rsdos/src/main.rs
index 0e159e6..89df5ea 100644
--- a/rsdos/src/main.rs
+++ b/rsdos/src/main.rs
@@ -418,6 +418,11 @@ pub unsafe extern "efiapi" fn efi_main(image_handle: Handle, system_table: *mut
418 // later, and prints the exact field values when it is. 418 // later, and prints the exact field values when it is.
419 rsnix::vt::debug_verify(); 419 rsnix::vt::debug_verify();
420 420
421 // Windowed GUI: initialise the window manager and its input pump.
422 // The console (vt) stays layer 0; GUI apps are windows composited on top.
423 rsnix::wm::init();
424 rsnix::wm::spawn_input_pump();
425
421 shell::run(); 426 shell::run();
422} 427}
423 428
diff --git a/rsnix/src/cursor.rs b/rsnix/src/cursor.rs
index 31b919e..b986b23 100644
--- a/rsnix/src/cursor.rs
+++ b/rsnix/src/cursor.rs
@@ -49,14 +49,29 @@ fn forward_target() -> Option<usize> {
49} 49}
50 50
51fn task_main() { 51fn task_main() {
52 let mut prev_buttons: u8 = 0;
52 loop { 53 loop {
53 while let Some(ev) = crate::usb::poll_mouse() 54 while let Some(ev) = crate::usb::poll_mouse()
54 .or_else(crate::ehci::poll_mouse) 55 .or_else(crate::ehci::poll_mouse)
55 .or_else(crate::uhci::poll_mouse) 56 .or_else(crate::uhci::poll_mouse)
56 .or_else(crate::ps2::try_poll_mouse) 57 .or_else(crate::ps2::try_poll_mouse)
57 { 58 {
58 crate::gop::cursor_move(ev.dx, ev.dy); 59 let (cx, cy) = crate::gop::cursor_move(ev.dx, ev.dy);
59 crate::gop::cursor_set_buttons(btn_bits(&ev)); 60 let buttons = btn_bits(&ev);
61 crate::gop::cursor_set_buttons(buttons);
62 // Left-button edge detection → press/release.
63 let kind = if buttons & 1 != 0 && prev_buttons & 1 == 0 {
64 crate::ps2::MouseKind::Press
65 } else if buttons & 1 == 0 && prev_buttons & 1 != 0 {
66 crate::ps2::MouseKind::Release
67 } else {
68 crate::ps2::MouseKind::Move
69 };
70 prev_buttons = buttons;
71 // Route to the window manager (focus + deliver to the focused
72 // window with window-relative coordinates).
73 crate::wm::route_mouse(cx as i32, cy as i32, buttons, kind);
74 // Legacy single-target debug routing (`mouse <id>`).
60 if let Some(id) = forward_target() 75 if let Some(id) = forward_target()
61 && crate::sched::send(id, TaskMsg::Mouse(ev)).is_err() 76 && crate::sched::send(id, TaskMsg::Mouse(ev)).is_err()
62 { 77 {
diff --git a/rsnix/src/gop.rs b/rsnix/src/gop.rs
index 676814e..deff226 100644
--- a/rsnix/src/gop.rs
+++ b/rsnix/src/gop.rs
@@ -4,10 +4,10 @@
4/// module is the low-level rasteriser: glyph blits and pixel shifts on the 4/// module is the low-level rasteriser: glyph blits and pixel shifts on the
5/// linear framebuffer. 5/// linear framebuffer.
6use crate::font; 6use crate::font;
7use crate::uefi::{GopModeInfo, GraphicsOutputProtocol, EFI_SUCCESS, PIXEL_BGR, PIXEL_RGB}; 7use crate::uefi::{EFI_SUCCESS, GopModeInfo, GraphicsOutputProtocol, PIXEL_BGR, PIXEL_RGB};
8use core::fmt; 8use core::fmt;
9use core::ptr; 9use core::ptr;
10use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering}; 10use core::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
11 11
12/// Framebuffer state captured before ExitBootServices. 12/// Framebuffer state captured before ExitBootServices.
13pub struct Framebuffer { 13pub struct Framebuffer {
@@ -512,6 +512,105 @@ pub fn fill_rect(x: usize, y: usize, w: usize, h: usize, color: u32) {
512 push_rect(x, y, w, h); 512 push_rect(x, y, w, h);
513} 513}
514 514
515/// Fill a shadow rectangle WITHOUT pushing to the display. The window
516/// manager composites several surfaces into the shadow and pushes once at
517/// the end.
518pub(crate) fn shadow_fill_rect(x: usize, y: usize, w: usize, h: usize, color: u32) {
519 let Some(fb) = fb().as_ref() else {
520 return;
521 };
522 if unsafe { SHADOW.is_null() } || x >= fb.width || y >= fb.height {
523 return;
524 }
525 let w = w.min(fb.width - x);
526 let h = h.min(fb.height - y);
527 let row_words = fb.pitch / 4;
528 unsafe {
529 for dy in 0..h {
530 let off = (y + dy) * row_words + x;
531 for dx in 0..w {
532 *SHADOW.add(off + dx) = color;
533 }
534 }
535 }
536}
537
538/// Copy a row-major `u32` source buffer (stride in pixels) into the shadow
539/// WITHOUT pushing. Clipped to the framebuffer.
540pub(crate) fn shadow_blit(
541 x: usize,
542 y: usize,
543 w: usize,
544 h: usize,
545 src: *const u32,
546 src_stride: usize,
547) {
548 let Some(fb) = fb().as_ref() else {
549 return;
550 };
551 if unsafe { SHADOW.is_null() } || src.is_null() || x >= fb.width || y >= fb.height {
552 return;
553 }
554 let w = w.min(fb.width - x);
555 let h = h.min(fb.height - y);
556 let row_words = fb.pitch / 4;
557 unsafe {
558 for dy in 0..h {
559 let doff = (y + dy) * row_words + x;
560 let soff = dy * src_stride;
561 for dx in 0..w {
562 *SHADOW.add(doff + dx) = *src.add(soff + dx);
563 }
564 }
565 }
566}
567
568/// Render one 8×16 glyph into the shadow at a pixel origin WITHOUT pushing
569/// (for window chrome and console repaint). Same glyph lookup as
570/// `blit_glyph`, but caller-controlled push.
571pub(crate) fn shadow_glyph_px(x: usize, y: usize, ch: u16, fg: (u8, u8, u8), bg: (u8, u8, u8)) {
572 let Some(fb) = fb().as_ref() else {
573 return;
574 };
575 if unsafe { SHADOW.is_null() } {
576 return;
577 }
578 let glyph = font::GLYPH_8X16;
579 let data = if ch < 256 {
580 match glyph.get(ch as usize) {
581 Some(d) => *d,
582 None => return,
583 }
584 } else if let Some(bits) = unicode_glyph_bits(ch) {
585 bits
586 } else {
587 match glyph.get(32) {
588 Some(d) => *d,
589 None => return,
590 }
591 };
592 let fgc = rgb(fg.0, fg.1, fg.2);
593 let bgc = rgb(bg.0, bg.1, bg.2);
594 let row_words = fb.pitch / 4;
595 unsafe {
596 for cy in 0..16 {
597 let py = y + cy;
598 if py >= fb.height {
599 break;
600 }
601 let row_bits = data[cy];
602 for cx in 0..8 {
603 let px = x + cx;
604 if px >= fb.width {
605 break;
606 }
607 let on = row_bits & (0x80 >> cx) != 0;
608 *SHADOW.add(py * row_words + px) = if on { fgc } else { bgc };
609 }
610 }
611 }
612}
613
515/// Print crash/fault lines directly to the framebuffer, bypassing the vt 614/// Print crash/fault lines directly to the framebuffer, bypassing the vt
516/// entirely (the vt state may be corrupt when an exception fires). 615/// entirely (the vt state may be corrupt when an exception fires).
517/// 616///
diff --git a/rsnix/src/lib.rs b/rsnix/src/lib.rs
index f5c3419..a422057 100644
--- a/rsnix/src/lib.rs
+++ b/rsnix/src/lib.rs
@@ -4,6 +4,7 @@ extern crate alloc;
4 4
5pub mod ahci; 5pub mod ahci;
6pub mod cursor; 6pub mod cursor;
7pub mod ehci;
7pub mod fat; 8pub mod fat;
8pub mod font; 9pub mod font;
9pub mod fs; 10pub mod fs;
@@ -14,16 +15,16 @@ pub mod klog;
14pub mod memory; 15pub mod memory;
15pub mod paging; 16pub mod paging;
16pub mod pci; 17pub mod pci;
17pub mod qr;
18pub mod power; 18pub mod power;
19pub mod ps2; 19pub mod ps2;
20pub mod uhci; 20pub mod qr;
21pub mod ehci;
22pub mod usb;
23pub mod rtc; 21pub mod rtc;
24pub mod sched; 22pub mod sched;
25pub mod serial; 23pub mod serial;
26pub mod strbuf; 24pub mod strbuf;
27pub mod tty; 25pub mod tty;
28pub mod uefi; 26pub mod uefi;
27pub mod uhci;
28pub mod usb;
29pub mod vt; 29pub mod vt;
30pub mod wm;
diff --git a/rsnix/src/ps2.rs b/rsnix/src/ps2.rs
index 965e4a0..cac476f 100644
--- a/rsnix/src/ps2.rs
+++ b/rsnix/src/ps2.rs
@@ -463,6 +463,26 @@ pub struct MouseEvent {
463 pub middle: bool, 463 pub middle: bool,
464} 464}
465 465
466/// What happened in a window-relative (GUI) mouse event.
467#[derive(Clone, Copy, Debug, PartialEq, Eq)]
468pub enum MouseKind {
469 Move,
470 Press,
471 Release,
472}
473
474/// A mouse event delivered to a focused GUI window: absolute coordinates
475/// relative to the window's content top-left, the current button bitmask,
476/// and the action (move / press / release). Unlike `MouseEvent` (relative
477/// deltas), this is what an app's drawing loop consumes.
478#[derive(Clone, Copy, Debug)]
479pub struct AppMouseEvent {
480 pub x: i32,
481 pub y: i32,
482 pub buttons: u8,
483 pub kind: MouseKind,
484}
485
466/// Packet assembly state — lives in main context (consumer side), fed by 486/// Packet assembly state — lives in main context (consumer side), fed by
467/// the raw byte ring that the IRQ12 handler fills. 487/// the raw byte ring that the IRQ12 handler fills.
468struct MouseState { 488struct MouseState {
diff --git a/rsnix/src/sched.rs b/rsnix/src/sched.rs
index 259ddf6..39ecb65 100644
--- a/rsnix/src/sched.rs
+++ b/rsnix/src/sched.rs
@@ -59,6 +59,9 @@ const MSG_CAP: usize = 16;
59pub enum TaskMsg { 59pub enum TaskMsg {
60 Key(crate::ps2::KeyEvent), 60 Key(crate::ps2::KeyEvent),
61 Mouse(crate::ps2::MouseEvent), 61 Mouse(crate::ps2::MouseEvent),
62 AppMouse(crate::ps2::AppMouseEvent),
63 Focus(bool),
64 Close,
62 Timer, 65 Timer,
63 User(u64), 66 User(u64),
64} 67}
@@ -94,6 +97,11 @@ pub fn msg_describe(msg: &TaskMsg) -> alloc::string::String {
94 if m.left { "L" } else { "-" }, 97 if m.left { "L" } else { "-" },
95 if m.right { "R" } else { "-" } 98 if m.right { "R" } else { "-" }
96 ), 99 ),
100 TaskMsg::AppMouse(m) => {
101 alloc::format!("AppMouse({},{} {:?} b={:#x})", m.x, m.y, m.kind, m.buttons)
102 }
103 TaskMsg::Focus(f) => alloc::format!("Focus({})", f),
104 TaskMsg::Close => "Close".into(),
97 TaskMsg::Timer => "Timer".into(), 105 TaskMsg::Timer => "Timer".into(),
98 TaskMsg::User(n) => alloc::format!("User({})", n), 106 TaskMsg::User(n) => alloc::format!("User({})", n),
99 } 107 }
@@ -179,7 +187,10 @@ struct SpinWatch {
179 last_rsp: usize, 187 last_rsp: usize,
180 same: u32, 188 same: u32,
181} 189}
182const SPIN_WATCH_INIT: SpinWatch = SpinWatch { last_rsp: 0, same: 0 }; 190const SPIN_WATCH_INIT: SpinWatch = SpinWatch {
191 last_rsp: 0,
192 same: 0,
193};
183static mut SPIN_WATCH: [SpinWatch; MAX_TASKS] = [SPIN_WATCH_INIT; MAX_TASKS]; 194static mut SPIN_WATCH: [SpinWatch; MAX_TASKS] = [SPIN_WATCH_INIT; MAX_TASKS];
184/// A spinning background task is interrupted at roughly half the tick rate 195/// A spinning background task is interrupted at roughly half the tick rate
185/// (it alternates with idle), so 1500 hits is ~30 s of wall time. 196/// (it alternates with idle), so 1500 hits is ~30 s of wall time.
@@ -381,11 +392,7 @@ pub fn spawn(entry: fn(), name: &str) -> Option<usize> {
381 }; 392 };
382 let ok = spawn_at(id, entry, name); 393 let ok = spawn_at(id, entry, name);
383 unlock(); 394 unlock();
384 if ok { 395 if ok { Some(id) } else { None }
385 Some(id)
386 } else {
387 None
388 }
389} 396}
390 397
391unsafe fn reap(id: usize) { 398unsafe fn reap(id: usize) {
@@ -698,10 +705,12 @@ pub(crate) fn fault_dump(kind: &str, cr2: u64, frame: &crate::interrupts::IntFra
698 } 705 }
699 let ra = *(fp as *const u64).add(1); // [rbp+8] = return address 706 let ra = *(fp as *const u64).add(1); // [rbp+8] = return address
700 if let Some((start, end)) = filter 707 if let Some((start, end)) = filter
701 && ra >= start as u64 && ra < end as u64 { 708 && ra >= start as u64
702 bt[bn] = ra; 709 && ra < end as u64
703 bn += 1; 710 {
704 } 711 bt[bn] = ra;
712 bn += 1;
713 }
705 let nfp = *(fp as *const u64); 714 let nfp = *(fp as *const u64);
706 if nfp <= fp { 715 if nfp <= fp {
707 break; // chain must grow upward 716 break; // chain must grow upward
@@ -715,19 +724,21 @@ pub(crate) fn fault_dump(kind: &str, cr2: u64, frame: &crate::interrupts::IntFra
715 while p + 8 <= limit && bn < bt.len() { 724 while p + 8 <= limit && bn < bt.len() {
716 let v = *(p as *const u64); 725 let v = *(p as *const u64);
717 if let Some((start, end)) = filter 726 if let Some((start, end)) = filter
718 && v >= start as u64 && v < end as u64 { 727 && v >= start as u64
719 let mut dup = false; 728 && v < end as u64
720 for i in 0..bn { 729 {
721 if bt[i] == v { 730 let mut dup = false;
722 dup = true; 731 for i in 0..bn {
723 break; 732 if bt[i] == v {
724 } 733 dup = true;
725 } 734 break;
726 if !dup {
727 bt[bn] = v;
728 bn += 1;
729 } 735 }
730 } 736 }
737 if !dup {
738 bt[bn] = v;
739 bn += 1;
740 }
741 }
731 p += 8; 742 p += 8;
732 } 743 }
733 } 744 }
@@ -842,7 +853,11 @@ pub(crate) fn fault_dump(kind: &str, cr2: u64, frame: &crate::interrupts::IntFra
842 &mut w, 853 &mut w,
843 core::format_args!( 854 core::format_args!(
844 "rax={:#x} rcx={:#x} rdx={:#x} rsi={:#x} rdi={:#x}", 855 "rax={:#x} rcx={:#x} rdx={:#x} rsi={:#x} rdi={:#x}",
845 frame.rax, frame.rcx, frame.rdx, frame.rsi, frame.rdi 856 frame.rax,
857 frame.rcx,
858 frame.rdx,
859 frame.rsi,
860 frame.rdi
846 ), 861 ),
847 ); 862 );
848 w.len() 863 w.len()
diff --git a/rsnix/src/tty.rs b/rsnix/src/tty.rs
index 0783521..6102fb5 100644
--- a/rsnix/src/tty.rs
+++ b/rsnix/src/tty.rs
@@ -41,14 +41,31 @@ static mut RAW_LEN: usize = 0;
41/// Combined blocking key poll: USB HID keyboard first (polled UHCI), then 41/// Combined blocking key poll: USB HID keyboard first (polled UHCI), then
42/// the PS/2 path (which parks via `hlt` until an IRQ-fed scancode lands). 42/// the PS/2 path (which parks via `hlt` until an IRQ-fed scancode lands).
43fn poll_key() -> KeyEvent { 43fn poll_key() -> KeyEvent {
44 if let Some(ev) = crate::uhci::try_poll_key() { 44 loop {
45 return ev; 45 // A GUI window has focus: the keyboard pump owns the keys. Park so
46 // the shell/editor don't steal input from the focused app.
47 if crate::wm::focused().is_some() {
48 crate::sched::sleep_ms(5);
49 continue;
50 }
51 if let Some(ev) = crate::uhci::try_poll_key() {
52 return ev;
53 }
54 if let Some(ev) = ps2::try_poll_key() {
55 return ev;
56 }
57 // No key ready: park briefly, then re-check focus (so a focus change
58 // to a GUI window is noticed instead of blocking on a stolen key).
59 crate::sched::sleep_ms(5);
46 } 60 }
47 ps2::poll_key()
48} 61}
49 62
50/// Combined non-blocking key poll (USB first, then PS/2). 63/// Combined non-blocking key poll (USB first, then PS/2). Returns `None`
64/// while a GUI window is focused (the keyboard pump owns the keys).
51fn try_poll_key() -> Option<KeyEvent> { 65fn try_poll_key() -> Option<KeyEvent> {
66 if crate::wm::focused().is_some() {
67 return None;
68 }
52 crate::uhci::try_poll_key().or_else(ps2::try_poll_key) 69 crate::uhci::try_poll_key().or_else(ps2::try_poll_key)
53} 70}
54 71
diff --git a/rsnix/src/vt.rs b/rsnix/src/vt.rs
index 9444063..c5fc5fd 100644
--- a/rsnix/src/vt.rs
+++ b/rsnix/src/vt.rs
@@ -147,6 +147,31 @@ pub fn size() -> (usize, usize) {
147 unsafe { (ROWS, COLS) } 147 unsafe { (ROWS, COLS) }
148} 148}
149 149
150/// Re-render the console's cells into the shadow for a pixel region, so the
151/// window manager can restore the console (layer 0) beneath a window that
152/// moved, closed, or was minimized. Does not push — the caller pushes once.
153pub fn repaint_region(x: usize, y: usize, w: usize, h: usize) {
154 unsafe {
155 if CELLS.is_null() {
156 return;
157 }
158 let (cols, rows) = (COLS, ROWS);
159 let c0 = x / 8;
160 let c1 = (x + w).div_ceil(8).min(cols);
161 let r0 = y / 16;
162 let r1 = (y + h).div_ceil(16).min(rows);
163 let cells = &*CELLS;
164 for r in r0..r1 {
165 for c in c0..c1 {
166 let cell = cells[r * cols + c];
167 let fg = PALETTE[cell.fg as usize];
168 let bg = PALETTE[cell.bg as usize];
169 crate::gop::shadow_glyph_px(c * 8, r * 16, cell.ch, fg, bg);
170 }
171 }
172 }
173}
174
150/// DEBUG (crash-family): read one cell's raw word (ch | fg<<16 | bg<<24) 175/// DEBUG (crash-family): read one cell's raw word (ch | fg<<16 | bg<<24)
151/// from the live cells grid and the rendered grid, for the given text 176/// from the live cells grid and the rendered grid, for the given text
152/// row/col. Returns (raw_cell, raw_rendered). 177/// row/col. Returns (raw_cell, raw_rendered).
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}
diff --git a/rspaint/Cargo.toml b/rspaint/Cargo.toml
new file mode 100644
index 0000000..66364a2
--- /dev/null
+++ b/rspaint/Cargo.toml
@@ -0,0 +1,10 @@
1[package]
2name = "rspaint"
3version = "0.1.0"
4edition = "2024"
5
6[lints]
7workspace = true
8
9[dependencies]
10rsnix = { path = "../rsnix" }
diff --git a/rspaint/src/lib.rs b/rspaint/src/lib.rs
new file mode 100644
index 0000000..7c2e2d2
--- /dev/null
+++ b/rspaint/src/lib.rs
@@ -0,0 +1,122 @@
1#![no_std]
2
3//! Paint — the first GUI app: a freehand pen on a raster canvas.
4
5use rsnix::ps2::{KeyEvent, MouseKind};
6use rsnix::{sched, wm};
7
8const W: usize = 640;
9const H: usize = 480;
10
11/// 8-colour palette selected by keys 1–8.
12const PALETTE: [(u8, u8, u8); 8] = [
13 (255, 255, 255), // 1 white
14 (0, 0, 0), // 2 black
15 (170, 0, 0), // 3 red
16 (0, 170, 0), // 4 green
17 (0, 0, 170), // 5 blue
18 (170, 170, 0), // 6 yellow
19 (0, 170, 170), // 7 cyan
20 (170, 0, 170), // 8 magenta
21];
22
23/// Run the paint program as a spawned task: create a window, then loop on
24/// events (pen strokes from the mouse, colour/clear from keys) until `Close`.
25pub fn run() {
26 let Some(id) = wm::create_window("paint", W, H) else {
27 return;
28 };
29 let Some((buf, _, _)) = wm::buffer(id) else {
30 return;
31 };
32
33 let mut color: usize = 0; // white
34 let mut last: Option<(i32, i32)> = None;
35
36 loop {
37 match sched::recv_blocking() {
38 sched::TaskMsg::Close => break,
39 sched::TaskMsg::AppMouse(m) => match m.kind {
40 MouseKind::Press => {
41 plot(buf, m.x, m.y, color);
42 last = Some((m.x, m.y));
43 wm::flush(id);
44 }
45 MouseKind::Move => {
46 if m.buttons & 1 != 0 {
47 if let Some((lx, ly)) = last {
48 line(buf, lx, ly, m.x, m.y, color);
49 wm::flush(id);
50 }
51 }
52 last = Some((m.x, m.y));
53 }
54 MouseKind::Release => {
55 last = None;
56 }
57 },
58 sched::TaskMsg::Key(k) => {
59 if let KeyEvent::Char(c) = k {
60 match c {
61 '1'..='8' => color = (c as u8 - b'1') as usize,
62 'c' => {
63 fill(buf, 1); // clear to black
64 wm::flush(id);
65 }
66 _ => {}
67 }
68 }
69 }
70 _ => {}
71 }
72 }
73 wm::destroy(id);
74}
75
76fn pack(idx: usize) -> u32 {
77 let (r, g, b) = PALETTE[idx.min(PALETTE.len() - 1)];
78 rsnix::gop::rgb(r, g, b)
79}
80
81fn plot(buf: *mut u32, x: i32, y: i32, color: usize) {
82 if x < 0 || y < 0 || x >= W as i32 || y >= H as i32 {
83 return;
84 }
85 unsafe {
86 *buf.add(y as usize * W + x as usize) = pack(color);
87 }
88}
89
90/// Bresenham line between two points.
91fn line(buf: *mut u32, x0: i32, y0: i32, x1: i32, y1: i32, color: usize) {
92 let mut x = x0;
93 let mut y = y0;
94 let dx = (x1 - x0).abs();
95 let sx = if x0 < x1 { 1 } else { -1 };
96 let dy = -(y1 - y0).abs();
97 let sy = if y0 < y1 { 1 } else { -1 };
98 let mut err = dx + dy;
99 loop {
100 plot(buf, x, y, color);
101 if x == x1 && y == y1 {
102 break;
103 }
104 let e2 = 2 * err;
105 if e2 >= dy {
106 err += dy;
107 x += sx;
108 }
109 if e2 <= dx {
110 err += dx;
111 y += sy;
112 }
113 }
114}
115
116fn fill(buf: *mut u32, color: usize) {
117 unsafe {
118 for i in 0..(W * H) {
119 *buf.add(i) = pack(color);
120 }
121 }
122}
diff --git a/shell/Cargo.toml b/shell/Cargo.toml
index bc35524..f95b574 100644
--- a/shell/Cargo.toml
+++ b/shell/Cargo.toml
@@ -9,3 +9,4 @@ workspace = true
9[dependencies] 9[dependencies]
10rsnix = { path = "../rsnix" } 10rsnix = { path = "../rsnix" }
11rsdosedit = { path = "../rsdosedit" } 11rsdosedit = { path = "../rsdosedit" }
12rspaint = { path = "../rspaint" }
diff --git a/shell/src/lib.rs b/shell/src/lib.rs
index bb048d8..f1addd9 100644
--- a/shell/src/lib.rs
+++ b/shell/src/lib.rs
@@ -53,14 +53,15 @@ pub fn run() -> ! {
53 term_println("!!! PREVIOUS CRASH REPORT:"); 53 term_println("!!! PREVIOUS CRASH REPORT:");
54 let mut buf = [0u8; 256]; 54 let mut buf = [0u8; 256];
55 if let Ok(n) = rsnix::fs::read(h, &mut buf, 0) 55 if let Ok(n) = rsnix::fs::read(h, &mut buf, 0)
56 && n > 0 { 56 && n > 0
57 let s = core::str::from_utf8(&buf[..n]).unwrap_or("?"); 57 {
58 for line in s.split('\n') { 58 let s = core::str::from_utf8(&buf[..n]).unwrap_or("?");
59 if !line.is_empty() { 59 for line in s.split('\n') {
60 term_println(line); 60 if !line.is_empty() {
61 } 61 term_println(line);
62 } 62 }
63 } 63 }
64 }
64 term_println("!!! end of crash report"); 65 term_println("!!! end of crash report");
65 } 66 }
66 67
@@ -739,6 +740,9 @@ write `{} <path> TIL <marker>`",
739 "touch" => cmd_touch(&parsed), 740 "touch" => cmd_touch(&parsed),
740 "rm" => cmd_rm(&parsed), 741 "rm" => cmd_rm(&parsed),
741 "edit" => cmd_edit(&parsed), 742 "edit" => cmd_edit(&parsed),
743 "draw" => cmd_draw(&parsed),
744 "windows" => cmd_windows(&parsed),
745 "restore" => cmd_restore(&parsed),
742 "bg" => cmd_bg(&parsed), 746 "bg" => cmd_bg(&parsed),
743 "jobs" => cmd_jobs(&parsed), 747 "jobs" => cmd_jobs(&parsed),
744 "msgrecv" => cmd_msgrecv(&parsed), 748 "msgrecv" => cmd_msgrecv(&parsed),
@@ -1387,6 +1391,40 @@ fn cmd_edit(p: &Parsed) {
1387 rsnix::gop::clear_screen(); 1391 rsnix::gop::clear_screen();
1388} 1392}
1389 1393
1394fn cmd_draw(_p: &Parsed) {
1395 match rsnix::sched::spawn(rspaint::run, "paint") {
1396 Some(id) => term_println(&alloc::format!(
1397 "draw: paint task {} — F4 or the close button to close",
1398 id
1399 )),
1400 None => term_err("draw: task table full"),
1401 }
1402}
1403
1404fn cmd_windows(_p: &Parsed) {
1405 let list = rsnix::wm::list();
1406 if list.is_empty() {
1407 term_println("windows: none (console only)");
1408 return;
1409 }
1410 for (id, title, minimized) in list {
1411 term_println(&alloc::format!(
1412 " {} {}{}",
1413 id,
1414 title,
1415 if minimized { " (minimized)" } else { "" }
1416 ));
1417 }
1418}
1419
1420fn cmd_restore(p: &Parsed) {
1421 let Some(id) = p.args.first().and_then(|s| s.parse::<usize>().ok()) else {
1422 term_err("usage: restore <id>");
1423 return;
1424 };
1425 rsnix::wm::restore(id);
1426}
1427
1390fn cmd_mem(p: &Parsed) { 1428fn cmd_mem(p: &Parsed) {
1391 let (map, key) = rsnix::memory::get_map(); 1429 let (map, key) = rsnix::memory::get_map();
1392 1430
@@ -1602,14 +1640,19 @@ fn cmd_heapdbg(_p: &Parsed) {
1602 let ov = h.heap_start < fb.saturating_add(fsz) && fb < h.heap_end; 1640 let ov = h.heap_start < fb.saturating_add(fsz) && fb < h.heap_end;
1603 term_println(&alloc::format!( 1641 term_println(&alloc::format!(
1604 "fb {:#x} size={:#x} overlap-heap={}", 1642 "fb {:#x} size={:#x} overlap-heap={}",
1605 fb, fsz, ov 1643 fb,
1644 fsz,
1645 ov
1606 )); 1646 ));
1607 } 1647 }
1608 let probe = h.heap_start.wrapping_add(0x443e0); // historical crash offset 1648 let probe = h.heap_start.wrapping_add(0x443e0); // historical crash offset
1609 match rsnix::memory::freelist_contains(probe) { 1649 match rsnix::memory::freelist_contains(probe) {
1610 Some((b, s, off)) => term_println(&alloc::format!( 1650 Some((b, s, off)) => term_println(&alloc::format!(
1611 "probe {:#x} (+0x443e0) IN free blk {:#x} size={:#x} off={:#x}", 1651 "probe {:#x} (+0x443e0) IN free blk {:#x} size={:#x} off={:#x}",
1612 probe, b, s, off 1652 probe,
1653 b,
1654 s,
1655 off
1613 )), 1656 )),
1614 None => term_println(&alloc::format!("probe {:#x} (+0x443e0) NOT free", probe)), 1657 None => term_println(&alloc::format!("probe {:#x} (+0x443e0) NOT free", probe)),
1615 } 1658 }
@@ -1682,7 +1725,9 @@ fn cmd_heapdbg(_p: &Parsed) {
1682 let (rc, rr) = rsnix::vt::cell_raw(tr, tc); 1725 let (rc, rr) = rsnix::vt::cell_raw(tr, tc);
1683 term_println(&alloc::format!( 1726 term_println(&alloc::format!(
1684 "shadow@{:x} cell={:08x} rendered={:08x}", 1727 "shadow@{:x} cell={:08x} rendered={:08x}",
1685 f, rc, rr 1728 f,
1729 rc,
1730 rr
1686 )); 1731 ));
1687 } 1732 }
1688 } 1733 }
@@ -1697,7 +1742,9 @@ fn cmd_heapdbg(_p: &Parsed) {
1697 match rsnix::memory::freelist_contains(b) { 1742 match rsnix::memory::freelist_contains(b) {
1698 Some((fb, fs, fo)) => term_println(&alloc::format!( 1743 Some((fb, fs, fo)) => term_println(&alloc::format!(
1699 "shadow base IN free list: blk {:#x} size={:#x} off={:#x}", 1744 "shadow base IN free list: blk {:#x} size={:#x} off={:#x}",
1700 fb, fs, fo 1745 fb,
1746 fs,
1747 fo
1701 )), 1748 )),
1702 None => term_println("shadow base NOT in free list"), 1749 None => term_println("shadow base NOT in free list"),
1703 } 1750 }