summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-08-21 20:13:59 +0200
committersalmonik <salmonik@tuta.io>2026-08-21 20:13:59 +0200
commit2f4cf666c0c7bd224932fa48973e7334360c1de3 (patch)
tree7526314b18728b05c4e9d285b9a41cf6fcd6d6d6
parentimage: single dd-able system.img replaces vvfat+data-disk pair (diff)
downloadrs-dos-2f4cf666c0c7bd224932fa48973e7334360c1de3.tar.gz
rs-dos-2f4cf666c0c7bd224932fa48973e7334360c1de3.zip
input: total keyboard routing + per-terminal discipline (ADR 0006 ph1)
wm: FOCUSED is now a plain window id — window 0 (home terminal) is an ordinary focusable window in the z-order. The CONSOLE sentinel (usize::MAX) and the focused()==0 special cases are gone; focus_console() is an alias for focus(0). Desktop clicks focus window 0. The WM input pump routes every key unconditionally: IRQ ring -> pump -> focused window's owner; nothing polls the scancode rings directly any more. tty: read_line() reads from the calling task's message queue (the old global-ring read_line and the duplicate read_line_from_queue are merged); raw mode (read_byte/try_read_byte) consumes the same queue; interrupt_pressed drains the queue instead of the ring. shell: task 0 owns home terminal window 0 and blocks on its queue like a windowed shell; cat/hexdump stdin paths pass the home terminal handle. sched: watchdog comments updated (shell parks on recv_blocking). Verified in QEMU: F1 spawns terminals, F2 cycles focus home->term1-> term2, keystrokes land only in the focused window, ^C cancels a line, edit runs raw over the queue and exits to a working prompt; test-disk regression PASS.
-rw-r--r--rsnix/src/sched.rs13
-rw-r--r--rsnix/src/tty.rs149
-rw-r--r--rsnix/src/wm.rs144
-rwxr-xr-xscripts/test-focus.py56
-rw-r--r--shell/src/lib.rs58
5 files changed, 208 insertions, 212 deletions
diff --git a/rsnix/src/sched.rs b/rsnix/src/sched.rs
index 39ecb65..0c4938e 100644
--- a/rsnix/src/sched.rs
+++ b/rsnix/src/sched.rs
@@ -201,7 +201,7 @@ const WATCHDOG_SPIN_TICKS: u32 = 1500;
201const WATCHDOG_BOOT_SPIN_TICKS: u32 = 750; 201const WATCHDOG_BOOT_SPIN_TICKS: u32 = 750;
202 202
203/// True once the shell REPL is live (set by main.rs before shell::run()). 203/// True once the shell REPL is live (set by main.rs before shell::run()).
204/// The shell legitimately parks in poll_key with a fixed frame when it is; 204/// The shell legitimately blocks on its message queue once live;
205/// before that, task 0 running boot code must not sit in a fixed frame. 205/// before that, task 0 running boot code must not sit in a fixed frame.
206static SHELL_RUNNING: AtomicBool = AtomicBool::new(false); 206static SHELL_RUNNING: AtomicBool = AtomicBool::new(false);
207 207
@@ -264,16 +264,17 @@ pub fn on_tick(cur: usize) -> usize {
264 TASKS[CURRENT].ticks += 1; 264 TASKS[CURRENT].ticks += 1;
265 // Spin detection: a task whose interrupted frame never moves 265 // Spin detection: a task whose interrupted frame never moves
266 // (identical RSP on every tick) is stuck in a hot loop — no yield, 266 // (identical RSP on every tick) is stuck in a hot loop — no yield,
267 // no sleep, no block. The shell (id 0) legitimately parks in 267 // no sleep, no block. The shell (id 0) legitimately blocks on
268 // poll_key with a fixed frame, and idle (id 1) hlt's; both exempt. 268 // recv_blocking with a fixed frame, and idle (id 1) hlt's; both
269 // exempt.
269 let id = CURRENT; 270 let id = CURRENT;
270 let w = &mut SPIN_WATCH[id]; 271 let w = &mut SPIN_WATCH[id];
271 if w.last_rsp == cur { 272 if w.last_rsp == cur {
272 w.same += 1; 273 w.same += 1;
273 // Background tasks (id >= 2): spinning is never legitimate. 274 // Background tasks (id >= 2): spinning is never legitimate.
274 // Task 0 (shell): exempt once the REPL is live (poll_key parks 275 // Task 0 (shell): exempt once the REPL is live (recv_blocking
275 // with a fixed frame); during the boot phase a fixed frame 276 // parks with a fixed frame); during the boot phase a fixed
276 // means usb/ehci init is wedged on real hardware. 277 // frame means usb/ehci init is wedged on real hardware.
277 let threshold = if id == SHELL_TID && !SHELL_RUNNING.load(Ordering::Relaxed) { 278 let threshold = if id == SHELL_TID && !SHELL_RUNNING.load(Ordering::Relaxed) {
278 WATCHDOG_BOOT_SPIN_TICKS 279 WATCHDOG_BOOT_SPIN_TICKS
279 } else if id >= 2 { 280 } else if id >= 2 {
diff --git a/rsnix/src/tty.rs b/rsnix/src/tty.rs
index c44dca8..1358fa9 100644
--- a/rsnix/src/tty.rs
+++ b/rsnix/src/tty.rs
@@ -1,8 +1,9 @@
1//! Line discipline over the keyboard — the kernel's tty layer. 1//! Line discipline over the keyboard — the kernel's tty layer.
2//! 2//!
3//! Keys come from either the PS/2 controller (IRQ-fed scancode ring) or the 3//! Every key is routed by the WM input pump to the focused window's
4//! USB HID boot keyboard (polled UHCI driver). Both are translated to the 4//! owning task as `TaskMsg::Key`; the tty layer consumes only that task's
5//! same `KeyEvent` space, so the shell is input-source agnostic. 5//! message queue (ADR 0006 Phase 1: total routing, per-terminal
6//! discipline). No code path reads the PS/2/USB scancode rings directly.
6//! 7//!
7//! Two modes, like a real UNIX terminal: 8//! Two modes, like a real UNIX terminal:
8//! - **canonical** (`read_line`): input is echoed, buffered until Enter, 9//! - **canonical** (`read_line`): input is echoed, buffered until Enter,
@@ -14,10 +15,10 @@
14//! in order. 15//! in order.
15//! 16//!
16//! `interrupt_pressed` is the cooperative-interrupt check used by streaming 17//! `interrupt_pressed` is the cooperative-interrupt check used by streaming
17//! commands (cat/hexdump): it drains the keyboard non-blockingly and reports 18//! commands (cat/hexdump): it drains the queue non-blockingly and reports
18//! whether ^C was seen. 19//! whether ^C was seen.
19 20
20use crate::ps2::{self, KeyEvent}; 21use crate::ps2::KeyEvent;
21use crate::strbuf; 22use crate::strbuf;
22use crate::{gop, serial}; 23use crate::{gop, serial};
23 24
@@ -38,115 +39,14 @@ pub enum LineInput {
38static mut RAW_QUEUE: [u8; 8] = [0; 8]; 39static mut RAW_QUEUE: [u8; 8] = [0; 8];
39static mut RAW_LEN: usize = 0; 40static mut RAW_LEN: usize = 0;
40 41
41/// Combined blocking key poll: USB HID keyboard first (polled UHCI), then 42/// Read one line in canonical mode from the task's message queue. Keys
42/// the PS/2 path (which parks via `hlt` until an IRQ-fed scancode lands). 43/// arrive as `TaskMsg::Key`, pumped by the WM to the focused window's
43fn poll_key() -> KeyEvent { 44/// owner (this task, if its window is focused). Returns `None` on
44 loop { 45/// `TaskMsg::Close` (window was closed).
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);
60 }
61}
62
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).
65fn try_poll_key() -> Option<KeyEvent> {
66 if crate::wm::focused().is_some() {
67 return None;
68 }
69 crate::uhci::try_poll_key().or_else(ps2::try_poll_key)
70}
71
72// ---------------------------------------------------------------------------
73// canonical mode
74// ---------------------------------------------------------------------------
75
76/// Read one line in canonical mode: echo every key, ^C cancels the line,
77/// ^D on an empty line is EOF. Echo goes to the terminal (GOP) and serial.
78pub fn read_line() -> LineInput {
79 // Interactive input: make sure deferred scrolls are pushed first so
80 // the screen shows the current state (no-op when nothing is pending).
81 crate::vt::sync_scroll();
82 let mut buf = strbuf::String::<LINE_BUF_SIZE>::new();
83 loop {
84 match poll_key() {
85 KeyEvent::Enter => {
86 gop::put_char('\n');
87 serial::write_str("\r\n");
88 return LineInput::Line(buf);
89 }
90 KeyEvent::Ctrl('c') => {
91 // Cancel the current line — like a real terminal, echo ^C.
92 buf.clear();
93 gop::put_char('^');
94 gop::put_char('C');
95 gop::put_char('\n');
96 serial::write_str("^C\r\n");
97 return LineInput::Interrupted;
98 }
99 KeyEvent::Ctrl('d') => {
100 if buf.is_empty() {
101 // EOF on an empty line — the caller decides what to do.
102 gop::put_char('^');
103 gop::put_char('D');
104 gop::put_char('\n');
105 serial::write_str("^D\r\n");
106 return LineInput::Eof;
107 }
108 // ^D with text on the line: ignored (bash semantics).
109 }
110 KeyEvent::Char(c) if c.is_ascii_graphic() || c == ' ' => {
111 if buf.push(c).is_ok() {
112 gop::put_char(c);
113 let mut s = [0u8; 4];
114 let s = c.encode_utf8(&mut s);
115 serial::write_str(s);
116 }
117 }
118 KeyEvent::Backspace => {
119 if buf.pop().is_some() {
120 gop::put_char('\x08');
121 // Serial backspace: BS SP BS (standard terminal protocol)
122 serial::write_str("\x08 \x08");
123 }
124 }
125 KeyEvent::Tab => {
126 for _ in 0..4 {
127 if buf.push(' ').is_ok() {
128 gop::put_char(' ');
129 serial::write_str(" ");
130 }
131 }
132 }
133 _ => {}
134 }
135 }
136}
137
138/// Read one line in canonical mode from the task's message queue (for
139/// windowed shells). Same semantics as `read_line` but keys arrive as
140/// `TaskMsg::Key` instead of from the global PS/2 queue. Returns `None`
141/// on `TaskMsg::Close` (window was closed).
142/// Read one line in canonical mode from the task's message queue (for
143/// windowed shells). Same semantics as `read_line` but keys arrive as
144/// `TaskMsg::Key` instead of from the global PS/2 queue. Returns `None`
145/// on `TaskMsg::Close` (window was closed).
146/// 46///
147/// Handles `TaskMsg::Focus` to toggle the global `ACTIVE` redirect so 47/// Handles `TaskMsg::Focus` to toggle the global `ACTIVE` redirect so
148/// gop_print! goes to the right terminal buffer. 48/// gop_print! goes to the right terminal buffer.
149pub fn read_line_from_queue(term: &mut crate::vt::Terminal) -> Option<LineInput> { 49pub fn read_line(term: &mut crate::vt::Terminal) -> Option<LineInput> {
150 crate::vt::sync_scroll(); 50 crate::vt::sync_scroll();
151 let mut buf = strbuf::String::<LINE_BUF_SIZE>::new(); 51 let mut buf = strbuf::String::<LINE_BUF_SIZE>::new();
152 loop { 52 loop {
@@ -211,12 +111,14 @@ pub fn read_line_from_queue(term: &mut crate::vt::Terminal) -> Option<LineInput>
211 } 111 }
212} 112}
213 113
214/// Non-blocking drain of the keyboard: true if a ^C is pending. Used by 114/// Non-blocking drain of this task's queue: true if a ^C is pending.
215/// streaming commands (cat/hexdump) so a long dump can be interrupted. 115/// Used by streaming commands (cat/hexdump) so a long dump can be
116/// interrupted. Everything else in the queue is dropped — during a
117/// streaming command the terminal owns the input stream.
216pub fn interrupt_pressed() -> bool { 118pub fn interrupt_pressed() -> bool {
217 let mut pressed = false; 119 let mut pressed = false;
218 while let Some(ev) = try_poll_key() { 120 while let Some(m) = crate::sched::recv() {
219 if matches!(ev, KeyEvent::Ctrl('c')) { 121 if matches!(m, crate::sched::TaskMsg::Key(KeyEvent::Ctrl('c'))) {
220 pressed = true; 122 pressed = true;
221 } 123 }
222 } 124 }
@@ -227,8 +129,9 @@ pub fn interrupt_pressed() -> bool {
227// raw mode 129// raw mode
228// --------------------------------------------------------------------------- 130// ---------------------------------------------------------------------------
229 131
230/// Blocking read of the next raw-mode byte. Keys arrive translated to the 132/// Blocking read of the next raw-mode byte. Keys arrive from the task's
231/// terminal byte protocol; multi-byte sequences are delivered in order. 133/// message queue (WM-pumped), translated to the terminal byte protocol;
134/// multi-byte sequences are delivered in order.
232pub fn read_byte() -> u8 { 135pub fn read_byte() -> u8 {
233 // Interactive (raw) input: push deferred scrolls so the full-screen 136 // Interactive (raw) input: push deferred scrolls so the full-screen
234 // app draws over a current framebuffer. 137 // app draws over a current framebuffer.
@@ -237,8 +140,10 @@ pub fn read_byte() -> u8 {
237 if let Some(b) = pop_raw() { 140 if let Some(b) = pop_raw() {
238 return b; 141 return b;
239 } 142 }
240 let ev = poll_key(); 143 match crate::sched::recv_blocking() {
241 enqueue_key(ev); 144 crate::sched::TaskMsg::Key(ev) => enqueue_key(ev),
145 _ => {}
146 }
242 } 147 }
243} 148}
244 149
@@ -247,12 +152,12 @@ pub fn try_read_byte() -> Option<u8> {
247 if let Some(b) = pop_raw() { 152 if let Some(b) = pop_raw() {
248 return Some(b); 153 return Some(b);
249 } 154 }
250 match try_poll_key() { 155 match crate::sched::recv() {
251 Some(ev) => { 156 Some(crate::sched::TaskMsg::Key(ev)) => {
252 enqueue_key(ev); 157 enqueue_key(ev);
253 pop_raw() 158 pop_raw()
254 } 159 }
255 None => None, 160 _ => None,
256 } 161 }
257} 162}
258 163
diff --git a/rsnix/src/wm.rs b/rsnix/src/wm.rs
index 39ad1be..9f3806f 100644
--- a/rsnix/src/wm.rs
+++ b/rsnix/src/wm.rs
@@ -16,8 +16,9 @@ use crate::sched::TaskMsg;
16/// Height of a window's title bar in pixels (one 8×16 glyph row). 16/// Height of a window's title bar in pixels (one 8×16 glyph row).
17pub const TITLE_BAR_H: usize = 16; 17pub const TITLE_BAR_H: usize = 16;
18 18
19/// Sentinel: `FOCUSED == CONSOLE` means the console (vt) has focus. 19/// Window 0 is the home terminal — an ordinary focusable window like any
20pub const CONSOLE: usize = usize::MAX; 20/// other (ADR 0006 Phase 1: no CONSOLE sentinel, keyboard routing is
21/// total: every key goes to `focused()`'s owner).
21 22
22const MAX_WINDOWS: usize = 16; 23const MAX_WINDOWS: usize = 16;
23const MIN_W: usize = 64; 24const MIN_W: usize = 64;
@@ -87,7 +88,7 @@ const EMPTY: Window = Window {
87static mut WINDOWS: [Window; MAX_WINDOWS] = [EMPTY; MAX_WINDOWS]; 88static mut WINDOWS: [Window; MAX_WINDOWS] = [EMPTY; MAX_WINDOWS];
88static mut ZORDER: [usize; MAX_WINDOWS] = [usize::MAX; MAX_WINDOWS]; 89static mut ZORDER: [usize; MAX_WINDOWS] = [usize::MAX; MAX_WINDOWS];
89static mut ZLEN: usize = 0; 90static mut ZLEN: usize = 0;
90static FOCUSED: AtomicUsize = AtomicUsize::new(CONSOLE); 91static FOCUSED: AtomicUsize = AtomicUsize::new(0);
91static mut CASCADE_X: usize = 0; 92static mut CASCADE_X: usize = 0;
92static mut CASCADE_Y: usize = 0; 93static mut CASCADE_Y: usize = 0;
93 94
@@ -111,7 +112,6 @@ pub fn init() {
111 CASCADE_X = 0; 112 CASCADE_X = 0;
112 CASCADE_Y = 0; 113 CASCADE_Y = 0;
113 } 114 }
114 FOCUSED.store(CONSOLE, Ordering::Relaxed);
115} 115}
116 116
117/// Create window 0 (the home terminal): full-screen, chrome-less, 117/// Create window 0 (the home terminal): full-screen, chrome-less,
@@ -124,7 +124,7 @@ pub fn create_home_window(buf: *mut u32, w: usize, h: usize) -> usize {
124 state: WinState::Visible, 124 state: WinState::Visible,
125 title: [0; TITLE_MAX], 125 title: [0; TITLE_MAX],
126 title_len: 0, 126 title_len: 0,
127 owner: usize::MAX, 127 owner: crate::sched::SHELL_TID,
128 x: 0, 128 x: 0,
129 y: 0, 129 y: 0,
130 w, 130 w,
@@ -135,12 +135,17 @@ pub fn create_home_window(buf: *mut u32, w: usize, h: usize) -> usize {
135 ZORDER[0] = id; 135 ZORDER[0] = id;
136 ZLEN = 1; 136 ZLEN = 1;
137 } 137 }
138 FOCUSED.store(0, Ordering::Relaxed);
138 id 139 id
139} 140}
140 141
141pub fn focused() -> Option<usize> { 142pub fn focused() -> Option<usize> {
142 let v = FOCUSED.load(Ordering::Relaxed); 143 let v = FOCUSED.load(Ordering::Relaxed);
143 if v == CONSOLE || v == 0 { None } else { Some(v) } 144 if v < MAX_WINDOWS && get(v).is_some() {
145 Some(v)
146 } else {
147 None
148 }
144} 149}
145 150
146fn get(id: usize) -> Option<Window> { 151fn get(id: usize) -> Option<Window> {
@@ -231,8 +236,10 @@ pub fn flush(id: usize) {
231 let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; 236 let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 };
232 let pad = if id != 0 { 1 } else { 0 }; 237 let pad = if id != 0 { 1 } else { 0 };
233 composite_rect( 238 composite_rect(
234 win.x.saturating_sub(pad), win.y.saturating_sub(pad), 239 win.x.saturating_sub(pad),
235 win.w + 2 * pad, outer_h + 2 * pad, 240 win.y.saturating_sub(pad),
241 win.w + 2 * pad,
242 outer_h + 2 * pad,
236 ); 243 );
237} 244}
238 245
@@ -256,8 +263,8 @@ pub fn destroy(id: usize) {
256 if focused() == Some(id) { 263 if focused() == Some(id) {
257 if unsafe { ZLEN } > 0 { 264 if unsafe { ZLEN } > 0 {
258 focus(unsafe { ZORDER[ZLEN - 1] }); 265 focus(unsafe { ZORDER[ZLEN - 1] });
259 } else { 266 } else if id != 0 {
260 focus_console(); 267 focus(0);
261 } 268 }
262 } 269 }
263 composite_rect(x, y, w, h); 270 composite_rect(x, y, w, h);
@@ -277,7 +284,7 @@ pub fn minimize(id: usize) {
277 } 284 }
278 remove_from_zorder(id); 285 remove_from_zorder(id);
279 if focused() == Some(id) { 286 if focused() == Some(id) {
280 focus_console(); 287 focus(0);
281 } 288 }
282 composite_rect(x, y, w, h); 289 composite_rect(x, y, w, h);
283} 290}
@@ -307,20 +314,12 @@ pub fn focus(id: usize) {
307 return; 314 return;
308 } 315 }
309 let prev = focused(); 316 let prev = focused();
310 // Window 0 (home terminal) is the console — store the CONSOLE 317 FOCUSED.store(id, Ordering::Relaxed);
311 // sentinel so `focused()` returns None and the tty layer reads 318 // Every window lives in the z-order now, window 0 included.
312 // keyboard input directly (same as focus_console). 319 remove_from_zorder(id);
313 if id == 0 { 320 unsafe {
314 FOCUSED.store(CONSOLE, Ordering::Relaxed); 321 ZORDER[ZLEN] = id;
315 } else { 322 ZLEN += 1;
316 FOCUSED.store(id, Ordering::Relaxed);
317 }
318 if id != 0 {
319 remove_from_zorder(id);
320 unsafe {
321 ZORDER[ZLEN] = id;
322 ZLEN += 1;
323 }
324 } 323 }
325 if let Some(p) = prev 324 if let Some(p) = prev
326 && p != id 325 && p != id
@@ -331,8 +330,10 @@ pub fn focus(id: usize) {
331 let oh = pw.h + if pw.chrome { TITLE_BAR_H } else { 0 }; 330 let oh = pw.h + if pw.chrome { TITLE_BAR_H } else { 0 };
332 let pad = if p != 0 { 1 } else { 0 }; 331 let pad = if p != 0 { 1 } else { 0 };
333 composite_rect( 332 composite_rect(
334 pw.x.saturating_sub(pad), pw.y.saturating_sub(pad), 333 pw.x.saturating_sub(pad),
335 pw.w + 2 * pad, oh + 2 * pad, 334 pw.y.saturating_sub(pad),
335 pw.w + 2 * pad,
336 oh + 2 * pad,
336 ); 337 );
337 } 338 }
338 let _ = crate::sched::send(win.owner, TaskMsg::Focus(true)); 339 let _ = crate::sched::send(win.owner, TaskMsg::Focus(true));
@@ -340,35 +341,26 @@ pub fn focus(id: usize) {
340 let oh = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; 341 let oh = win.h + if win.chrome { TITLE_BAR_H } else { 0 };
341 let pad = if id != 0 { 1 } else { 0 }; 342 let pad = if id != 0 { 1 } else { 0 };
342 composite_rect( 343 composite_rect(
343 win.x.saturating_sub(pad), win.y.saturating_sub(pad), 344 win.x.saturating_sub(pad),
344 win.w + 2 * pad, oh + 2 * pad, 345 win.y.saturating_sub(pad),
346 win.w + 2 * pad,
347 oh + 2 * pad,
345 ); 348 );
346} 349}
347 350
351/// Legacy name — the home terminal is now just window 0.
348pub fn focus_console() { 352pub fn focus_console() {
349 let prev = focused(); 353 focus(0);
350 FOCUSED.store(CONSOLE, Ordering::Relaxed);
351 if let Some(p) = prev
352 && let Some(pw) = get(p)
353 {
354 let _ = crate::sched::send(pw.owner, TaskMsg::Focus(false));
355 let oh = pw.h + if pw.chrome { TITLE_BAR_H } else { 0 };
356 let pad = if p != 0 { 1 } else { 0 };
357 composite_rect(
358 pw.x.saturating_sub(pad), pw.y.saturating_sub(pad),
359 pw.w + 2 * pad, oh + 2 * pad,
360 );
361 }
362} 354}
363 355
364pub fn focus_next() { 356pub fn focus_next() {
365 unsafe { 357 unsafe {
366 if ZLEN == 0 {
367 focus_console();
368 return;
369 }
370 match focused() { 358 match focused() {
371 None => focus(ZORDER[ZLEN - 1]), 359 None => {
360 if ZLEN > 0 {
361 focus(ZORDER[ZLEN - 1]);
362 }
363 }
372 Some(cur) => { 364 Some(cur) => {
373 let mut idx = usize::MAX; 365 let mut idx = usize::MAX;
374 for i in 0..ZLEN { 366 for i in 0..ZLEN {
@@ -378,7 +370,9 @@ pub fn focus_next() {
378 } 370 }
379 } 371 }
380 if idx == usize::MAX || idx == 0 { 372 if idx == usize::MAX || idx == 0 {
381 focus_console(); 373 if ZLEN > 0 {
374 focus(ZORDER[ZLEN - 1]);
375 }
382 } else { 376 } else {
383 focus(ZORDER[idx - 1]); 377 focus(ZORDER[idx - 1]);
384 } 378 }
@@ -530,6 +524,7 @@ fn mouse_press(x: i32, y: i32, buttons: u8) {
530 Hit::None => {} 524 Hit::None => {}
531 } 525 }
532 } else { 526 } else {
527 // Click on the bare desktop: focus the home terminal (window 0).
533 focus_console(); 528 focus_console();
534 } 529 }
535} 530}
@@ -606,8 +601,18 @@ fn move_window(id: usize, nx: usize, ny: usize) {
606 } 601 }
607 let pad: usize = 1; 602 let pad: usize = 1;
608 let oh = w.h + TITLE_BAR_H; 603 let oh = w.h + TITLE_BAR_H;
609 composite_rect(ox.saturating_sub(pad), oy.saturating_sub(pad), w.w + 2 * pad, oh + 2 * pad); 604 composite_rect(
610 composite_rect(nx.saturating_sub(pad), ny.saturating_sub(pad), w.w + 2 * pad, oh + 2 * pad); 605 ox.saturating_sub(pad),
606 oy.saturating_sub(pad),
607 w.w + 2 * pad,
608 oh + 2 * pad,
609 );
610 composite_rect(
611 nx.saturating_sub(pad),
612 ny.saturating_sub(pad),
613 w.w + 2 * pad,
614 oh + 2 * pad,
615 );
611} 616}
612 617
613pub fn owner(id: usize) -> Option<usize> { 618pub fn owner(id: usize) -> Option<usize> {
@@ -637,13 +642,14 @@ pub fn spawn_input_pump() -> Option<usize> {
637} 642}
638 643
639fn pump_main() { 644fn pump_main() {
645 // Total routing (ADR 0006 Phase 1): every key from every source is
646 // pumped to the focused window's owner. No task ever polls the
647 // global rings directly; when nothing is focused the keys drop.
640 loop { 648 loop {
641 if let Some(id) = focused() { 649 let o = focused().and_then(owner).unwrap_or(crate::sched::SHELL_TID);
642 let o = owner(id).unwrap_or(0); 650 while let Some(ev) = crate::ps2::try_poll_key().or_else(crate::uhci::try_poll_key) {
643 while let Some(ev) = crate::ps2::try_poll_key().or_else(crate::uhci::try_poll_key) { 651 if !handle_key(ev) {
644 if !handle_key(ev) { 652 let _ = crate::sched::send(o, TaskMsg::Key(ev));
645 let _ = crate::sched::send(o, TaskMsg::Key(ev));
646 }
647 } 653 }
648 } 654 }
649 crate::sched::sleep_ms(5); 655 crate::sched::sleep_ms(5);
@@ -669,11 +675,19 @@ fn composite_rect(x: usize, y: usize, w: usize, h: usize) {
669/// 1px border drawn around every window except the root (id 0). 675/// 1px border drawn around every window except the root (id 0).
670/// Blue when focused, grey when not. 676/// Blue when focused, grey when not.
671fn draw_window_border(id: usize, rx: usize, ry: usize, rw: usize, rh: usize) { 677fn draw_window_border(id: usize, rx: usize, ry: usize, rw: usize, rh: usize) {
672 if id == 0 { return; } 678 if id == 0 {
679 return;
680 }
673 let win = unsafe { WINDOWS[id] }; 681 let win = unsafe { WINDOWS[id] };
674 if !win.used || win.state != WinState::Visible { return; } 682 if !win.used || win.state != WinState::Visible {
683 return;
684 }
675 let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 }; 685 let outer_h = win.h + if win.chrome { TITLE_BAR_H } else { 0 };
676 let bg = if focused() == Some(id) { TITLE_BG_FOCUS } else { TITLE_BG }; 686 let bg = if focused() == Some(id) {
687 TITLE_BG_FOCUS
688 } else {
689 TITLE_BG
690 };
677 let bc = gop::rgb(bg.0, bg.1, bg.2); 691 let bc = gop::rgb(bg.0, bg.1, bg.2);
678 // Border sits 1px outside the window's outer rect. 692 // Border sits 1px outside the window's outer rect.
679 let bx = win.x.saturating_sub(1); 693 let bx = win.x.saturating_sub(1);
@@ -684,12 +698,14 @@ fn draw_window_border(id: usize, rx: usize, ry: usize, rw: usize, rh: usize) {
684 let iy = by.max(ry); 698 let iy = by.max(ry);
685 let ix2 = (bx + bw).min(rx + rw); 699 let ix2 = (bx + bw).min(rx + rw);
686 let iy2 = (by + bh).min(ry + rh); 700 let iy2 = (by + bh).min(ry + rh);
687 if ix >= ix2 || iy >= iy2 { return; } 701 if ix >= ix2 || iy >= iy2 {
702 return;
703 }
688 // Four edges. 704 // Four edges.
689 gop::shadow_fill_rect(ix, iy, ix2 - ix, 1, bc); // top 705 gop::shadow_fill_rect(ix, iy, ix2 - ix, 1, bc); // top
690 gop::shadow_fill_rect(ix, iy2 - 1, ix2 - ix, 1, bc); // bottom 706 gop::shadow_fill_rect(ix, iy2 - 1, ix2 - ix, 1, bc); // bottom
691 gop::shadow_fill_rect(ix, iy + 1, 1, iy2 - iy - 2, bc); // left 707 gop::shadow_fill_rect(ix, iy + 1, 1, iy2 - iy - 2, bc); // left
692 gop::shadow_fill_rect(ix2 - 1, iy + 1, 1, iy2 - iy - 2, bc); // right 708 gop::shadow_fill_rect(ix2 - 1, iy + 1, 1, iy2 - iy - 2, bc); // right
693} 709}
694 710
695/// Blit the portion of a window that intersects `(rx, ry, rw, rh)`. 711/// Blit the portion of a window that intersects `(rx, ry, rw, rh)`.
diff --git a/scripts/test-focus.py b/scripts/test-focus.py
new file mode 100755
index 0000000..e1bbe2d
--- /dev/null
+++ b/scripts/test-focus.py
@@ -0,0 +1,56 @@
1#!/usr/bin/env python3
2"""Drive the focus-routing gate: 3 windows, type markers, verify routing.
3
4Connects to the QEMU monitor once and keeps the socket open (the per-call
5scripts/sendkeys.py pattern loses the socket when the monitor resets).
6
7Usage: python3 scripts/test-focus.py /tmp/qmon.sock
8"""
9import socket
10import sys
11import time
12
13sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
14sock.connect(sys.argv[1] if len(sys.argv) > 1 else "/tmp/sysboot-mon.sock")
15
16
17def sk(k, d=0.35):
18 sock.sendall(f"sendkey {k}\n".encode())
19 time.sleep(d)
20
21
22def line(txt, d=0.8):
23 for ch in txt:
24 if ch == " ":
25 sk("spc", 0.1)
26 elif ch.isupper():
27 sk("shift-" + ch.lower(), 0.12)
28 else:
29 sk(ch, 0.12)
30 sk("ret", d)
31
32
33time.sleep(0.5)
34
35# Home is focused at boot.
36line("windows") # expect: home + nothing (or existing windows)
37time.sleep(1.5)
38
39# F1 -> term1 (focused on creation)
40sk("f1", 1.5)
41line("echo TERM1MARK")
42time.sleep(1.0)
43
44# F1 -> term2 (focused on creation)
45sk("f1", 1.5)
46line("echo TERM2MARK")
47time.sleep(1.0)
48
49# F2 cycles z-order: term2 -> term1
50sk("f2", 0.8)
51line("echo CYCLED")
52time.sleep(1.0)
53
54# F2 again: term1 -> home (window 0, oldest)
55sk("f2", 0.8)
56line("windows")
diff --git a/shell/src/lib.rs b/shell/src/lib.rs
index f2a0e6e..d1710cc 100644
--- a/shell/src/lib.rs
+++ b/shell/src/lib.rs
@@ -30,7 +30,9 @@ use alloc::vec::Vec;
30use rsnix::tty::{self, LineInput}; 30use rsnix::tty::{self, LineInput};
31use rsnix::{gop_print, gop_println}; 31use rsnix::{gop_print, gop_println};
32 32
33/// Run the shell forever. 33/// Run the home-terminal shell (task 0). The home terminal is an
34/// ordinary WM window (id 0) owned by this task, so input flows through
35/// the same pumped queue as any windowed shell.
34pub fn run() -> ! { 36pub fn run() -> ! {
35 // Row 0 is the boot-marker status line (white-on-black, the last boot 37 // Row 0 is the boot-marker status line (white-on-black, the last boot
36 // stage drawn during init). Real hardware has no serial console, so 38 // stage drawn during init). Real hardware has no serial console, so
@@ -64,6 +66,7 @@ pub fn run() -> ! {
64 } 66 }
65 term_println("!!! end of crash report"); 67 term_println("!!! end of crash report");
66 } 68 }
69 let term = rsnix::vt::home_terminal();
67 70
68 loop { 71 loop {
69 // Push any deferred scrolls so the prompt renders on a current 72 // Push any deferred scrolls so the prompt renders on a current
@@ -77,12 +80,18 @@ pub fn run() -> ! {
77 } 80 }
78 // Print prompt 81 // Print prompt
79 term_print("> "); 82 term_print("> ");
80 83 // Read one line (tty canonical mode over the pumped queue).
81 // Read one line from keyboard (tty canonical mode) 84 match tty::read_line(term) {
82 match tty::read_line() { 85 None => {
83 LineInput::Interrupted => continue, // ^C - fresh prompt 86 // Window 0 is never closed; a Close message here means
84 LineInput::Eof => continue, // ^D on an empty line - fresh prompt (no poweroff) 87 // shutdown. Park forever (the idle task takes over).
85 LineInput::Line(line) => { 88 loop {
89 rsnix::sched::sleep_ms(1000);
90 }
91 }
92 Some(LineInput::Interrupted) => continue, // ^C - fresh prompt
93 Some(LineInput::Eof) => continue, // ^D - fresh prompt (no poweroff)
94 Some(LineInput::Line(line)) => {
86 let trimmed = line.trim(); 95 let trimmed = line.trim();
87 if trimmed.is_empty() { 96 if trimmed.is_empty() {
88 continue; 97 continue;
@@ -94,15 +103,14 @@ pub fn run() -> ! {
94} 103}
95 104
96/// Run a shell instance inside a windowed terminal. Same as `run()` but 105/// Run a shell instance inside a windowed terminal. Same as `run()` but
97/// reads from the task's message queue (Fed by the WM input pump) and 106/// for a spawned F1 terminal (own task + window).
98/// writes to the active terminal (set by the caller via `vt::set_active`).
99/// Returns when the window is closed (Close message or ^D on empty line). 107/// Returns when the window is closed (Close message or ^D on empty line).
100pub fn run_windowed(term: &mut rsnix::vt::Terminal) { 108pub fn run_windowed(term: &mut rsnix::vt::Terminal) {
101 term_println(&alloc::format!("rs-dos v{}", env!("CARGO_PKG_VERSION"))); 109 term_println(&alloc::format!("rs-dos v{}", env!("CARGO_PKG_VERSION")));
102 loop { 110 loop {
103 rsnix::vt::sync_scroll(); 111 rsnix::vt::sync_scroll();
104 term_print("> "); 112 term_print("> ");
105 match tty::read_line_from_queue(term) { 113 match tty::read_line(term) {
106 None => return, // window closed 114 None => return, // window closed
107 Some(LineInput::Interrupted) => continue, 115 Some(LineInput::Interrupted) => continue,
108 Some(LineInput::Eof) => return, // ^D in windowed mode = close 116 Some(LineInput::Eof) => return, // ^D in windowed mode = close
@@ -1033,9 +1041,11 @@ fn cmd_cat(p: &Parsed) {
1033/// Read lines from the tty until ^C/^D (or an exact marker line, when 1041/// Read lines from the tty until ^C/^D (or an exact marker line, when
1034/// `marker` is Some - the marker itself is not part of the input). 1042/// `marker` is Some - the marker itself is not part of the input).
1035fn cat_stdin(pipe: &mut Pipeline, marker: Option<&str>) -> bool { 1043fn cat_stdin(pipe: &mut Pipeline, marker: Option<&str>) -> bool {
1044 let term = rsnix::vt::home_terminal();
1036 loop { 1045 loop {
1037 match tty::read_line() { 1046 match tty::read_line(term) {
1038 LineInput::Line(l) => { 1047 None => return true, // window closed = EOF
1048 Some(LineInput::Line(l)) => {
1039 if marker.is_some() && l.as_str() == marker.unwrap() { 1049 if marker.is_some() && l.as_str() == marker.unwrap() {
1040 return true; 1050 return true;
1041 } 1051 }
@@ -1043,8 +1053,8 @@ fn cat_stdin(pipe: &mut Pipeline, marker: Option<&str>) -> bool {
1043 return false; 1053 return false;
1044 } 1054 }
1045 } 1055 }
1046 LineInput::Interrupted => return false, // ^C aborts the stream 1056 Some(LineInput::Interrupted) => return false, // ^C aborts the stream
1047 LineInput::Eof => return true, // ^D = EOF 1057 Some(LineInput::Eof) => return true, // ^D = EOF
1048 } 1058 }
1049 } 1059 }
1050} 1060}
@@ -1280,9 +1290,11 @@ fn hexdump_stdin(pipe: &mut Pipeline, marker: Option<&str>) {
1280 row_len: 0, 1290 row_len: 0,
1281 alive: true, 1291 alive: true,
1282 }; 1292 };
1293 let term = rsnix::vt::home_terminal();
1283 loop { 1294 loop {
1284 match tty::read_line() { 1295 match tty::read_line(term) {
1285 LineInput::Line(l) => { 1296 None => break,
1297 Some(LineInput::Line(l)) => {
1286 if marker.is_some() && l.as_str() == marker.unwrap() { 1298 if marker.is_some() && l.as_str() == marker.unwrap() {
1287 break; 1299 break;
1288 } 1300 }
@@ -1295,8 +1307,8 @@ fn hexdump_stdin(pipe: &mut Pipeline, marker: Option<&str>) {
1295 break; 1307 break;
1296 } 1308 }
1297 } 1309 }
1298 LineInput::Interrupted => break, 1310 Some(LineInput::Interrupted) => break,
1299 LineInput::Eof => break, 1311 Some(LineInput::Eof) => break,
1300 } 1312 }
1301 } 1313 }
1302 ctx.flush(); 1314 ctx.flush();
@@ -1429,7 +1441,11 @@ fn cmd_draw(_p: &Parsed) {
1429 1441
1430fn cmd_term(_p: &Parsed) { 1442fn cmd_term(_p: &Parsed) {
1431 // Debug: check if the callback is registered. 1443 // Debug: check if the callback is registered.
1432 let cb_set = unsafe { core::ptr::addr_of!(rsnix::wm::SPAWN_TERMINAL_CB).read().is_some() }; 1444 let cb_set = unsafe {
1445 core::ptr::addr_of!(rsnix::wm::SPAWN_TERMINAL_CB)
1446 .read()
1447 .is_some()
1448 };
1433 term_println(&alloc::format!("term: SPAWN_TERMINAL_CB set = {}", cb_set)); 1449 term_println(&alloc::format!("term: SPAWN_TERMINAL_CB set = {}", cb_set));
1434 if !cb_set { 1450 if !cb_set {
1435 term_err("term: callback not registered — cannot spawn"); 1451 term_err("term: callback not registered — cannot spawn");
@@ -1441,7 +1457,9 @@ fn cmd_term(_p: &Parsed) {
1441 let h = 400usize.min(ch); 1457 let h = 400usize.min(ch);
1442 term_println(&alloc::format!("term: requesting window {}x{}", w, h)); 1458 term_println(&alloc::format!("term: requesting window {}x{}", w, h));
1443 unsafe { 1459 unsafe {
1444 let cb = core::ptr::addr_of!(rsnix::wm::SPAWN_TERMINAL_CB).read().unwrap(); 1460 let cb = core::ptr::addr_of!(rsnix::wm::SPAWN_TERMINAL_CB)
1461 .read()
1462 .unwrap();
1445 cb("term dbg", w, h); 1463 cb("term dbg", w, h);
1446 } 1464 }
1447 term_println("term: callback returned (task should be spawning)"); 1465 term_println("term: callback returned (task should be spawning)");