From bf9d9dbf352e6486f721dc8f0458526ecd7dbd6e Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Mon, 28 Sep 2026 09:20:34 +0000 Subject: [PATCH 01/29] mk: build the Linux-guest test image without first-boot setup With NONOS_LINUX_GUESTS=1 the kernel was still built with the setup profile. Under it the apps, the Linux personality among them, spawn only once first-boot setup exits, and setup waits for keys. An unattended guest boot therefore never started its guest: in 180 s after `[INIT] Capsules spawned` the log had `[SETUP] keyboard held` and no `[APP-LINUX]` or `[LINUX]` line. That image now builds `microkernel-desktop-gui` with `nonos-stark-attest`, the same capsule set without setup, so the apps spawn at boot. Every other build of nonos-mk-desktop-gui-prod is unchanged. --- mk/20-build.mk | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/mk/20-build.mk b/mk/20-build.mk index 893340e79..ae5e484be 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -1187,10 +1187,18 @@ DESKTOP_GUI_CAPSULE_ARTIFACTS := $(DESKTOP_BASE_CAPSULE_ARTIFACTS) \ $(DESKTOP_STD_TOOL_ARTIFACTS) \ $(ZK_POLICY_ROOT) +# A Linux-guest test image boots unattended, and first-boot setup waits for +# keys nobody presses. Under the setup profile the apps, the Linux personality +# among them, spawn only once setup exits, so that image would never start its +# guest. It builds the desktop profile without first-boot setup instead. nonos-mk-desktop-gui-prod: $(DESKTOP_GUI_CAPSULE_ARTIFACTS) \ nonos-mk-verify-desktop-gui-capsules \ nonos-mk-check-deps nonos-mk-ensure-signing-key +ifeq ($(NONOS_LINUX_GUESTS),1) + $(call nonos_kernel_build,microkernel-desktop-gui + nonos-stark-attest (unattended guest test),microkernel-desktop-gui$(_boot_comma)nonos-stark-attest) +else $(call nonos_kernel_build,microkernel-setup-wizard + nonos-stark-attest,microkernel-setup-wizard$(_boot_comma)nonos-stark-attest) +endif # nonos-mk-install-prod: the desktop profile with the NVMe driver capsule in # it. The desktop cut leaves NVMe out because a driver whose hardware is absent From 5ab0a0912032e470b2448bdaa9a8fd04ba7a2a59 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Mon, 28 Sep 2026 09:20:34 +0000 Subject: [PATCH 02/29] exit: keep a thread group's page tables until its last member is gone Threads run on their leader's tables without owning an address space entry, so release freed the tables when the leader was finalized, whatever its threads were doing. A thread still in the process table can be on the CPU under those tables, taking its own kill. When a Go guest exited, the personality killed its threads and then its leader, and the timer drain finalized the leader while a Go thread was running. The kernel then took a page fault fetching the double fault gate, and the machine reset: CR3 was the guest's, FS held the Go thread's TLS, and CR2 was the IDT's entry for vector 8. Release now hands the address space to another process in the table that runs on the same tables, and rebinds that process's capability token to the ASID it now owns. The last holder's release frees the tables. --- .../paging/manager/api/address_space.rs | 12 +++++++ src/memory/paging/manager/api/mod.rs | 4 +-- .../address_space/lifecycle/release.rs | 33 +++++++++++++++++-- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/memory/paging/manager/api/address_space.rs b/src/memory/paging/manager/api/address_space.rs index 7d0950a48..99be15ac6 100644 --- a/src/memory/paging/manager/api/address_space.rs +++ b/src/memory/paging/manager/api/address_space.rs @@ -38,6 +38,18 @@ pub fn lookup_asid_for_process(process_id: u32) -> Option { lock_responsive(&PAGING_MANAGER).lookup_asid_for_process(process_id) } +/// Make `process_id` the owner of the tables `asid` names, so its release is +/// the one that frees them. False when there is no such address space. +pub fn hand_over_address_space(asid: u32, process_id: u32) -> bool { + match lock_responsive(&PAGING_MANAGER).address_spaces.get_mut(&asid) { + Some(space) => { + space.process_id = process_id; + true + } + None => false, + } +} + pub fn switch_to_process_address_space(process_id: u32) -> PagingResult<()> { let asid = lookup_asid_for_process(process_id) .ok_or(crate::memory::paging::error::PagingError::AddressSpaceNotFound)?; diff --git a/src/memory/paging/manager/api/mod.rs b/src/memory/paging/manager/api/mod.rs index 61b032185..7eea35570 100644 --- a/src/memory/paging/manager/api/mod.rs +++ b/src/memory/paging/manager/api/mod.rs @@ -27,8 +27,8 @@ mod stats; mod tlb_ops; pub use address_space::{ - cleanup_address_space, create_address_space, get_process_cr3, lookup_asid_for_process, - switch_address_space, switch_to_process_address_space, + cleanup_address_space, create_address_space, get_process_cr3, hand_over_address_space, + lookup_asid_for_process, switch_address_space, switch_to_process_address_space, }; pub use faults::handle_page_fault; pub use init::{init, is_initialized}; diff --git a/src/process/address_space/lifecycle/release.rs b/src/process/address_space/lifecycle/release.rs index 2ae992d8e..20ad4e12b 100644 --- a/src/process/address_space/lifecycle/release.rs +++ b/src/process/address_space/lifecycle/release.rs @@ -29,9 +29,36 @@ pub fn release(pcb: &Arc) { // table, so it freed whatever address space happened to be current; the // ASID-scoped teardown frees the leaf frames as well, so it is the only // path that touches the right tables. - if let Some(asid) = crate::memory::paging::manager::lookup_asid_for_process(pcb.pid) { - if crate::memory::paging::manager::cleanup_address_space(asid).is_err() { - crate::sys::serial::println(b"[EXIT] address_space_cleanup_failed"); + let Some(asid) = crate::memory::paging::manager::lookup_asid_for_process(pcb.pid) else { + return; + }; + /* + * A thread runs on its group's tables without owning them, and until it + * leaves the process table it can still be on a CPU under them, taking + * its own kill or parked on its kernel stack. Freed when the owner went + * first, they were reused under a running thread, and a threaded guest's + * exit triple faulted. They pass to a thread still in the table instead, + * and the last holder's release frees them. + */ + if let Some(heir) = holder_after(pcb) { + if crate::memory::paging::manager::hand_over_address_space(asid, heir.pid) { + // Its token names the ASID it runs in, which it now owns. + let _ = crate::process::caps::rebind_address_space(&heir); + return; } } + if crate::memory::paging::manager::cleanup_address_space(asid).is_err() { + crate::sys::serial::println(b"[EXIT] address_space_cleanup_failed"); + } +} + +fn holder_after(pcb: &ProcessControlBlock) -> Option> { + let tables = pcb.cr3.load(Ordering::Acquire); + if tables == 0 { + return None; + } + crate::process::core::PROCESS_TABLE + .get_all_processes() + .into_iter() + .find(|p| p.pid != pcb.pid && p.cr3.load(Ordering::Acquire) == tables) } From 3fc1ac19d32d1a84e482287f2adefce1915920be Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Mon, 28 Sep 2026 09:20:35 +0000 Subject: [PATCH 03/29] foreign: start a guest's clone child on its parent's registers A Linux clone child resumes after its parent's `syscall` holding the parent's registers, with rax zero and rsp the new stack. MkForeignThread started the child on fresh registers instead, and both runtimes that start threads here call through a register in the child: Go's `runtime.clone` calls r12 and reads m and g from r13 and r9, and musl 1.2.4's `__clone` calls r9. The first gohello boot started two threads, and they faulted at `rip=0` and at a heap address on an NX page. MkForeignThread takes a fifth argument, the guest thread parked in the call that asked. Given, the new thread starts on a copy of that thread's parked registers, with rax zero, the entry and stack given, and the parent's FS base unless a TLS is named. The source thread must be supervised by the caller and in the same thread group as the target, so no guest receives another's register contents. Zero keeps the fresh start. The personality passes the calling thread, and passes a TLS only when CLONE_SETTLS is set; before, a clone without it installed the fifth argument, whatever it held, as the child's FS base. --- abi/syscalls.toml | 2 +- src/process/foreign/thread.rs | 60 ++++++++++++++++++- src/syscall/microkernel/dispatch/process.rs | 2 +- .../src/linux/call/spawn/clone.rs | 15 +++-- userland/libc/src/foreign.rs | 9 ++- 5 files changed, 76 insertions(+), 12 deletions(-) diff --git a/abi/syscalls.toml b/abi/syscalls.toml index 210a70b84..798d02d5f 100644 --- a/abi/syscalls.toml +++ b/abi/syscalls.toml @@ -662,7 +662,7 @@ ret = {type="i64"} [desc.MFTH] nr = 0x4854464D caps = ["ForeignExec"] -args = [{name="pid",type="u32",dir="in"},{name="entry",type="u64",dir="in"},{name="rsp",type="u64",dir="in"},{name="tls",type="u64",dir="in"}] +args = [{name="pid",type="u32",dir="in"},{name="entry",type="u64",dir="in"},{name="rsp",type="u64",dir="in"},{name="tls",type="u64",dir="in"},{name="from",type="u32",dir="in"}] ret = {type="i64"} [desc.MPTL] diff --git a/src/process/foreign/thread.rs b/src/process/foreign/thread.rs index fd3d230a7..f46ea39a5 100644 --- a/src/process/foreign/thread.rs +++ b/src/process/foreign/thread.rs @@ -17,12 +17,20 @@ //! A second thread inside a guest. use super::peer_guard::{in_user_half, pid_arg}; +use crate::arch::context::SavedUser; use crate::process::core::{admit_thread, spawn_thread_parked}; -use crate::syscall::microkernel::errnos::{ERRNO_INVAL, ERRNO_NOMEM, ERRNO_PERM}; +use crate::syscall::microkernel::errnos::{ERRNO_INVAL, ERRNO_NOENT, ERRNO_NOMEM, ERRNO_PERM}; /// `MkForeignThread`: a thread in `pid`, sharing its address space and /// supervised by the same caller. -pub fn sys_foreign_thread(pid: u64, entry: u64, rsp: u64, tls: u64) -> i64 { +/// +/// `from` is zero, or the thread of that guest parked in the call that asked +/// for this one. Given, the new thread starts on a copy of its registers, as a +/// Linux clone child does: zero in the return register, the stack and entry +/// given here, and the parent's thread pointer unless `tls` names another. Go +/// hands the child its function and its thread state in registers and calls +/// through them, so a child started on fresh registers called address zero. +pub fn sys_foreign_thread(pid: u64, entry: u64, rsp: u64, tls: u64, from: u64) -> i64 { let Some(caller) = crate::process::current_pid() else { return ERRNO_INVAL; }; @@ -44,12 +52,37 @@ pub fn sys_foreign_thread(pid: u64, entry: u64, rsp: u64, tls: u64) -> i64 { if tls != 0 && !in_user_half(tls, 1) { return ERRNO_INVAL; } + let parent = match from { + 0 => None, + raw => match parked_parent(caller, pid, raw) { + Ok(p) => Some(p), + Err(e) => return e, + }, + }; let Ok(tid) = spawn_thread_parked(pid, entry, rsp) else { return ERRNO_NOMEM; }; + let tls = match (tls, &parent) { + (0, Some((_, parent_tls))) => *parent_tls, + _ => tls, + }; if tls != 0 { crate::process::with_process(tid, |pcb| pcb.set_tls_base(tls)); } + if let Some((mut regs, _)) = parent { + regs.rax = 0; + regs.rip = entry; + regs.rsp = rsp; + /* + * A saved context is what the switch resumes when no first entry is + * pending, which is how a forked child starts; the fresh entry the + * spawn prepared would otherwise win and drop every register. + */ + crate::process::with_process(tid, |pcb| { + pcb.pending_user_entry.lock().take(); + *pcb.saved_user_context.lock() = Some(regs); + }); + } if !super::registry::insert(tid, caller) { crate::process::exit::teardown(tid, ERRNO_NOMEM as i32, false); return ERRNO_NOMEM; @@ -57,3 +90,26 @@ pub fn sys_foreign_thread(pid: u64, entry: u64, rsp: u64, tls: u64) -> i64 { admit_thread(tid); tid as i64 } + +/* + * The registers and thread pointer of the thread that asked. It must be one + * this caller supervises, in the same thread group as `pid`: a copy across + * groups would hand one guest another's register contents. It must also be + * parked in a call, since only then are its registers held aside. + */ +fn parked_parent(caller: u32, pid: u32, raw: u64) -> Result<(SavedUser, u64), i64> { + let from = pid_arg(raw)?; + if super::registry::supervisor_of(from) != Some(caller) { + return Err(ERRNO_PERM); + } + let group = |p: u32| crate::process::with_process(p, |pcb| pcb.thread_group_id()); + let (Some(want), Some(have)) = (group(pid), group(from)) else { + return Err(ERRNO_INVAL); + }; + if want != have { + return Err(ERRNO_PERM); + } + let regs = super::trap_frame::parked_frame(from).ok_or(ERRNO_NOENT)?; + let parent_tls = crate::process::with_process(from, |pcb| pcb.get_tls_base()).unwrap_or(0); + Ok((regs, parent_tls)) +} diff --git a/src/syscall/microkernel/dispatch/process.rs b/src/syscall/microkernel/dispatch/process.rs index 18e11d28c..dddf6a4a9 100644 --- a/src/syscall/microkernel/dispatch/process.rs +++ b/src/syscall/microkernel/dispatch/process.rs @@ -95,7 +95,7 @@ pub(super) fn handle(nr: u64, a: Args) -> Option { SYS_PEER_MAP => sys_peer_map(a.a0, a.a1, a.a2, a.a3), SYS_PEER_COPY => sys_peer_copy(a.a0, a.a1, a.a2, a.a3, a.a4), SYS_PEER_PROTECT => sys_peer_protect(a.a0, a.a1, a.a2, a.a3), - SYS_FOREIGN_THREAD => sys_foreign_thread(a.a0, a.a1, a.a2, a.a3), + SYS_FOREIGN_THREAD => sys_foreign_thread(a.a0, a.a1, a.a2, a.a3, a.a4), SYS_PEER_TLS => sys_peer_tls(a.a0, a.a1), SYS_FOREIGN_FORK => sys_foreign_fork(a.a0), SYS_PEER_UNMAP => sys_peer_unmap(a.a0, a.a1, a.a2), diff --git a/userland/capsule_linux/src/linux/call/spawn/clone.rs b/userland/capsule_linux/src/linux/call/spawn/clone.rs index 3ecdb0cae..e9c8ddfa8 100644 --- a/userland/capsule_linux/src/linux/call/spawn/clone.rs +++ b/userland/capsule_linux/src/linux/call/spawn/clone.rs @@ -24,13 +24,18 @@ use crate::linux::serve::Answer; const CLONE_VM: u64 = 0x100; const CLONE_THREAD: u64 = 0x10000; +const CLONE_SETTLS: u64 = 0x80000; -/// musl's `__clone` resumes the child at the instruction after its own -/// `syscall`, with rax zero and rsp pointing at the function and argument it -/// pushed. +/// A Linux clone child resumes at the instruction after its parent's +/// `syscall`, on its parent's registers with rax zero and rsp the new stack. +/// Both runtimes that start threads here call through a register in the +/// child: musl's `__clone` pops the argument and calls r9, Go's calls r12. pub fn clone(guest: &mut Guest, frame: &ForeignFrame) -> Answer { let a = frame.args(); - let (flags, stack, tls) = (a[0], a[1], a[4]); + let (flags, stack) = (a[0], a[1]); + // The fifth argument is a thread pointer only when the flag says so; + // without it the child keeps its parent's. + let tls = if flags & CLONE_SETTLS != 0 { a[4] } else { 0 }; if flags & (CLONE_VM | CLONE_THREAD) != CLONE_VM | CLONE_THREAD { /* * A new process, not a thread. That is fork, and fork needs an @@ -48,7 +53,7 @@ pub fn clone(guest: &mut Guest, frame: &ForeignFrame) -> Answer { if stack == 0 { return Answer::value(errno::fail(errno::EINVAL)); } - let tid = mk_foreign_thread(guest.pid, frame.rip, stack, tls); + let tid = mk_foreign_thread(guest.pid, frame.rip, stack, tls, frame.pid); if tid < 0 { return Answer::value(errno::fail(errno::ENOMEM)); } diff --git a/userland/libc/src/foreign.rs b/userland/libc/src/foreign.rs index f3217b39e..a50956d75 100644 --- a/userland/libc/src/foreign.rs +++ b/userland/libc/src/foreign.rs @@ -54,9 +54,12 @@ pub fn mk_foreign_exec(pid: u32, entry: u64, rsp: u64) -> i64 { } /// A thread in a guest, sharing its address space. `tls` is the FS base -/// it wakes with, which a C runtime reads before anything else. -pub fn mk_foreign_thread(pid: u32, entry: u64, rsp: u64, tls: u64) -> i64 { - call_raw(N_MK_FOREIGN_THREAD, [pid as u64, entry, rsp, tls, 0, 0]) +/// it wakes with, which a C runtime reads before anything else. `from` is +/// the guest thread parked in the call that asked for it, whose registers the +/// new thread starts on, as a Linux clone child does; zero starts it on fresh +/// ones. +pub fn mk_foreign_thread(pid: u32, entry: u64, rsp: u64, tls: u64, from: u32) -> i64 { + call_raw(N_MK_FOREIGN_THREAD, [pid as u64, entry, rsp, tls, from as u64, 0]) } /// Block until a guest of this process makes a call the kernel refuses, From 874f3ecfc204a8cc6775775ae47005b2032cdc98 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Mon, 28 Sep 2026 09:42:16 +0000 Subject: [PATCH 04/29] kill: let a foreign supervisor end the guests it hosts MkKill admitted a parent or a holder of ProcessControl. A guest thread's parent is its group leader, not the supervisor, so when a Linux guest exited, the personality's kill of each thread was refused with EPERM, and the refusal was dropped. The threads outlived their process. Once the personality had exited, their parked calls were abandoned and they ran Go code with no one to answer them: a second goconc boot showed a guest thread faulting at 0x1006 and then at address zero after `[LINUX] guest exited`. MkKill now also admits the caller the foreign registry names as the target's supervisor. The personality prints a refused kill as `[LINUX] kill refused: pid outlives its process, errno `, so a thread that survives its process is named in the log. --- src/syscall/microkernel/kill.rs | 9 ++++++++- userland/capsule_linux/src/linux/serve/family_reap.rs | 10 +++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/syscall/microkernel/kill.rs b/src/syscall/microkernel/kill.rs index 5274cf1f2..8ab3ac8bc 100644 --- a/src/syscall/microkernel/kill.rs +++ b/src/syscall/microkernel/kill.rs @@ -32,6 +32,13 @@ pub fn sys_kill(pid: u64, sig: u64) -> i64 { // unrelated pid needs the ProcessControl capability, held only by the // process manager, so a compromised app cannot terminate other capsules. let is_parent = caller != 0 && get_parent_pid(target) == Some(caller); + /* + * A foreign supervisor ends the guests it hosts. A guest thread's parent + * is its group leader, not the supervisor, so without this a guest's + * exit left its threads running, and once the supervisor was gone they + * ran on with no one to answer their calls. + */ + let supervises = caller != 0 && crate::process::foreign::supervisor_of(target) == Some(caller); let controls = caller != 0 && with_process(caller, |pcb| { pcb.caps_bits.load(core::sync::atomic::Ordering::Relaxed) @@ -39,7 +46,7 @@ pub fn sys_kill(pid: u64, sig: u64) -> i64 { != 0 }) .unwrap_or(false); - if !is_parent && !controls { + if !is_parent && !supervises && !controls { return ERRNO_PERM; } if !pid_alive(target) { diff --git a/userland/capsule_linux/src/linux/serve/family_reap.rs b/userland/capsule_linux/src/linux/serve/family_reap.rs index 16b102a88..831a95f57 100644 --- a/userland/capsule_linux/src/linux/serve/family_reap.rs +++ b/userland/capsule_linux/src/linux/serve/family_reap.rs @@ -31,7 +31,15 @@ impl Family { let gone = self.guests.remove(i); let code = gone.exited.unwrap_or(0); for tid in gone.threads.iter().chain([gone.pid].iter()) { - let _ = mk_kill(*tid as u64, SIGKILL); + let rc = mk_kill(*tid as u64, SIGKILL); + if rc < 0 { + // Refused, it runs on after its process ended. + let line = alloc::format!( + "[LINUX] kill refused: pid {tid} outlives its process, errno {}\n", + -rc + ); + let _ = nonos_libc::mk_debug(line.as_ptr(), line.len()); + } } if gone.pid == self.root { self.root_code = code; From a06600d4cc17f8dcfbbad6500db2f42b7af69fcc Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Mon, 28 Sep 2026 12:56:50 +0000 Subject: [PATCH 05/29] app_skeleton: let an app keep its window open when close is pressed Pressing the close button now asks the app first, through close_requested. An app holding unsaved work returns false and says so in its own window; the next press closes. The default closes as before, so no other app changes. --- userland/app_skeleton/src/app/behavior.rs | 8 ++++++++ userland/app_skeleton/src/runner/drain_ipc.rs | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/userland/app_skeleton/src/app/behavior.rs b/userland/app_skeleton/src/app/behavior.rs index 32d84781c..f67e25b26 100644 --- a/userland/app_skeleton/src/app/behavior.rs +++ b/userland/app_skeleton/src/app/behavior.rs @@ -58,4 +58,12 @@ pub trait App { fn on_accessory_event(&mut self, _event: InputEvent) -> EventOutcome { EventOutcome::Idle } + + /// Asked when the window's close button is pressed. An app holding work + /// the user would lose returns false, says so in its own window, and the + /// window stays open; pressing close again is the user's answer. Defaults + /// to closing. + fn close_requested(&mut self) -> bool { + true + } } diff --git a/userland/app_skeleton/src/runner/drain_ipc.rs b/userland/app_skeleton/src/runner/drain_ipc.rs index 9d13fbfee..234269ea6 100644 --- a/userland/app_skeleton/src/runner/drain_ipc.rs +++ b/userland/app_skeleton/src/runner/drain_ipc.rs @@ -77,6 +77,14 @@ pub(super) fn drain( let event = decorations::normalize(event); click_focus::handle(event, wm_port, window_id, request_id); match decorations::handle(width, height, maximized, event) { + /* + * An app with work the user would lose keeps its window and says + * why; the next press on close is the answer. + */ + Some(EventOutcome::Close) if !app.close_requested() => { + repaint = true; + continue; + } Some(EventOutcome::Close) => { return DrainResult { repaint, From adb4a7968852b26c70328571bd9be862a199269d Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Mon, 28 Sep 2026 12:56:51 +0000 Subject: [PATCH 06/29] text_editor: never overwrite a file with text that was not read from it A failed open no longer renames the tab, so the next save cannot write the old text over the file that failed, and a failed open from the tree opens no tab. Opening puts the file in its own tab and never replaces text; a load resets undo, redo and selection. A new document is untitled instead of carrying /notes.txt unread, and saving it asks for a path. Save As asks before replacing another file, and a document takes a new name only once the write lands. Undo and redo track a save point, so undoing back to the saved text is clean and undoing past it is not, even after old steps are dropped. Every close control and the window close button ask once before discarding unsaved text, and Esc no longer closes the window. editor_proofs compiles again, with save-point proofs: 34 pass. --- .../capsule_text_editor/src/editor/app.rs | 12 +++- .../src/editor/ctrl_open.rs | 67 +++++++++++------- .../src/editor/ctrl_save.rs | 48 +++++++++---- .../capsule_text_editor/src/editor/dismiss.rs | 30 ++++++++ .../capsule_text_editor/src/editor/edit.rs | 36 ++++------ .../capsule_text_editor/src/editor/event.rs | 3 +- .../src/editor/event_editor.rs | 29 ++++---- .../src/editor/goto_prompt.rs | 34 ++++++++++ .../capsule_text_editor/src/editor/mod.rs | 10 ++- .../src/editor/open_prompt.rs | 42 ++++++++++++ .../src/editor/path_prompt.rs | 67 ++++++++++-------- .../src/editor/quick_act.rs | 10 +-- .../capsule_text_editor/src/editor/reflow.rs | 7 ++ .../src/editor/save_point.rs | 65 ++++++++++++++++++ .../capsule_text_editor/src/editor/state.rs | 5 +- .../src/editor/state_new.rs | 16 +++-- .../src/editor/undo_push.rs | 61 +++++++++++++++++ .../src/editor/ws_close.rs | 68 +++++++++++++++++++ .../src/editor/ws_menubar.rs | 2 +- .../capsule_text_editor/src/editor/ws_open.rs | 52 +++++++------- .../src/editor/ws_pointer.rs | 2 +- userland/editor_proofs/Cargo.lock | 2 +- userland/editor_proofs/src/lib.rs | 15 ++++ .../editor_proofs/src/save_history_tests.rs | 56 +++++++++++++++ userland/editor_proofs/src/save_tests.rs | 62 +++++++++++++++++ 25 files changed, 650 insertions(+), 151 deletions(-) create mode 100644 userland/capsule_text_editor/src/editor/dismiss.rs create mode 100644 userland/capsule_text_editor/src/editor/goto_prompt.rs create mode 100644 userland/capsule_text_editor/src/editor/open_prompt.rs create mode 100644 userland/capsule_text_editor/src/editor/save_point.rs create mode 100644 userland/capsule_text_editor/src/editor/undo_push.rs create mode 100644 userland/capsule_text_editor/src/editor/ws_close.rs create mode 100644 userland/editor_proofs/src/save_history_tests.rs create mode 100644 userland/editor_proofs/src/save_tests.rs diff --git a/userland/capsule_text_editor/src/editor/app.rs b/userland/capsule_text_editor/src/editor/app.rs index f4373c393..509a49812 100644 --- a/userland/capsule_text_editor/src/editor/app.rs +++ b/userland/capsule_text_editor/src/editor/app.rs @@ -49,7 +49,10 @@ pub struct Editor { /// A close was asked for on a document with unsaved edits and refused /// once. Cleared by anything else, so the confirmation cannot be /// satisfied by a keystroke from a minute ago. - pub(super) close_armed: bool, + /// The tab whose unsaved text the next close request discards. + pub(super) close_armed: Option, + /// The window close button was pressed once with unsaved tabs open. + pub(super) quit_armed: bool, pub(super) owner_pid: u32, // Tab pixel spans from the last paint, used to hit-test tab-strip clicks. pub(super) tab_layout: Vec, @@ -82,7 +85,8 @@ impl Editor { mru: Vec::new(), sidebar_open: true, chord_ctrl_k: false, - close_armed: false, + close_armed: None, + quit_armed: false, owner_pid: 0, tab_layout: Vec::new(), last_w: 0, @@ -124,4 +128,8 @@ impl App for Editor { fn paint(&mut self, fb: &mut PaintBuffer) { self.paint_shell(fb); } + + fn close_requested(&mut self) -> bool { + self.confirm_quit() + } } diff --git a/userland/capsule_text_editor/src/editor/ctrl_open.rs b/userland/capsule_text_editor/src/editor/ctrl_open.rs index c10de3f48..9905b4163 100644 --- a/userland/capsule_text_editor/src/editor/ctrl_open.rs +++ b/userland/capsule_text_editor/src/editor/ctrl_open.rs @@ -14,39 +14,60 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_app_skeleton::{clients::vfs, EventOutcome}; +//! Loading a file into a document. +//! +//! The path becomes the document's only once its bytes are in the buffer. A +//! failed read leaves the document exactly as it was, name included, so the +//! next save cannot write the old text over the file that failed to open. + +use nonos_app_skeleton::clients::vfs; use super::mode::mode_for_path; use super::resolve_owner_pid::resolve_owner_pid; use super::state::{State, CAPACITY}; -pub(super) fn ctrl_open(state: &mut State) -> EventOutcome { +/// Read `path` into `state`. On failure `status` says why and nothing else in +/// the document changes. +pub(super) fn load(state: &mut State, path: &[u8]) -> bool { + if path.is_empty() || path.len() > state.path.len() { + state.status = b"no path given"; + return false; + } if !resolve_owner_pid(state) { - state.status = b"open failed"; - return EventOutcome::Repaint; + state.status = b"open failed: file service not reachable"; + return false; } - let path = state.path[..state.path_len].to_vec(); - if let Ok((size, _)) = vfs::stat(state.owner_pid, &path) { + if let Ok((size, _)) = vfs::stat(state.owner_pid, path) { if size > CAPACITY as u64 { - state.status = b"file too large"; - return EventOutcome::Repaint; + state.status = b"open refused: file is larger than 256 KiB"; + return false; } } - match vfs::read_file(state.owner_pid, &path, CAPACITY as u32) { - Ok(bytes) if core::str::from_utf8(&bytes).is_ok() && bytes.len() <= CAPACITY => { - state.buf[..bytes.len()].copy_from_slice(&bytes); - state.len = bytes.len(); - state.dirty = false; - state.status = b"opened"; - // Open at the top of the file with the caret ready to edit. - state.caret = 0; - state.scroll_line = 0; - let p = core::str::from_utf8(&state.path[..state.path_len]).unwrap_or(""); - state.mode = mode_for_path(p); - state.reflow(); + let bytes = match vfs::read_file(state.owner_pid, path, CAPACITY as u32) { + Ok(b) => b, + Err(_) => { + state.status = b"open failed: file could not be read"; + return false; } - Ok(_) => state.status = b"file is not valid utf-8", - Err(_) => state.status = b"open failed", + }; + if bytes.len() > CAPACITY { + state.status = b"open refused: file is larger than 256 KiB"; + return false; + } + if core::str::from_utf8(&bytes).is_err() { + state.status = b"open refused: file is not valid UTF-8"; + return false; } - EventOutcome::Repaint + state.buf[..bytes.len()].copy_from_slice(&bytes); + state.len = bytes.len(); + state.path[..path.len()].copy_from_slice(path); + state.path_len = path.len(); + state.reset_history(); + state.status = b"opened"; + state.caret = 0; + state.scroll_line = 0; + let p = core::str::from_utf8(path).unwrap_or(""); + state.mode = mode_for_path(p); + state.reflow(); + true } diff --git a/userland/capsule_text_editor/src/editor/ctrl_save.rs b/userland/capsule_text_editor/src/editor/ctrl_save.rs index d2418b185..6b6177368 100644 --- a/userland/capsule_text_editor/src/editor/ctrl_save.rs +++ b/userland/capsule_text_editor/src/editor/ctrl_save.rs @@ -14,25 +14,49 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +//! Writing a document to a file. +//! +//! The document takes the new name only when the write lands, so a Save As +//! that fails leaves the document named after the file that holds its text. + use nonos_app_skeleton::{clients::vfs, EventOutcome}; +use super::path_prompt; use super::resolve_owner_pid::resolve_owner_pid; -use super::state::State; +use super::state::{PromptOp, State}; pub(super) fn ctrl_save(state: &mut State) -> EventOutcome { - if !resolve_owner_pid(state) { - state.status = b"save failed"; - return EventOutcome::Repaint; + // An untitled document has nowhere to go yet, so Save means Save As. + if state.path_len == 0 { + return path_prompt::start(state, PromptOp::Save); } let path = state.path[..state.path_len].to_vec(); - let ok = vfs::write_file(state.owner_pid, &path, &state.buf[..state.len]).is_ok(); - if ok { - super::notify::notify_saved(state); + write_to(state, &path); + EventOutcome::Repaint +} + +/// Whether a file is already at `path`, for asking before replacing it. +pub(super) fn exists(state: &mut State, path: &[u8]) -> bool { + resolve_owner_pid(state) && vfs::stat(state.owner_pid, path).is_ok() +} + +pub(super) fn write_to(state: &mut State, path: &[u8]) -> bool { + if path.is_empty() || path.len() > state.path.len() { + state.status = b"save failed: no path given"; + return false; } - // Only a write that landed makes the document clean again. - if ok { - state.dirty = false; + if !resolve_owner_pid(state) { + state.status = b"save failed: file service not reachable"; + return false; } - state.status = if ok { b"saved" } else { b"save failed" }; - EventOutcome::Repaint + if vfs::write_file(state.owner_pid, path, &state.buf[..state.len]).is_err() { + state.status = b"save failed: file could not be written"; + return false; + } + state.path[..path.len()].copy_from_slice(path); + state.path_len = path.len(); + state.mark_saved(); + super::notify::notify_saved(state); + state.status = b"saved"; + true } diff --git a/userland/capsule_text_editor/src/editor/dismiss.rs b/userland/capsule_text_editor/src/editor/dismiss.rs new file mode 100644 index 000000000..359ab8332 --- /dev/null +++ b/userland/capsule_text_editor/src/editor/dismiss.rs @@ -0,0 +1,30 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Esc in the document: it dismisses a selection and nothing more. It used +//! to close the window, one stray key away from losing a document. + +use nonos_app_skeleton::EventOutcome; + +use super::state::State; + +pub(super) fn dismiss(state: &mut State) -> EventOutcome { + if state.sel_anchor.take().is_some() { + EventOutcome::Repaint + } else { + EventOutcome::Idle + } +} diff --git a/userland/capsule_text_editor/src/editor/edit.rs b/userland/capsule_text_editor/src/editor/edit.rs index 5b59d75cb..2c87d6793 100644 --- a/userland/capsule_text_editor/src/editor/edit.rs +++ b/userland/capsule_text_editor/src/editor/edit.rs @@ -24,13 +24,15 @@ use alloc::vec::Vec; use super::state::{State, CAPACITY}; /// A reversible edit: at `at`, `inserted_len` bytes replaced `deleted`. +/// `id` names the document state the edit leads to, so the save point can be +/// compared against the top of the undo stack after any replay. pub struct EditOp { pub at: usize, pub deleted: Vec, pub inserted_len: usize, + pub id: u64, } -const MAX_UNDO: usize = 400; impl State { /// Replace `buf[at..at+del]` with `ins`, recording an undo step and clearing @@ -41,6 +43,7 @@ impl State { }; self.push_undo(at, removed, ins.len()); self.redo.clear(); + self.refresh_dirty(); true } @@ -51,6 +54,7 @@ impl State { match self.apply_op(&op) { Some(reverse) => { self.redo.push(reverse); + self.refresh_dirty(); true } None => { @@ -67,6 +71,7 @@ impl State { match self.apply_op(&op) { Some(reverse) => { self.undo.push(reverse); + self.refresh_dirty(); true } None => { @@ -92,30 +97,15 @@ impl State { Some(removed) } - // Apply one op and return its inverse, for undo/redo replay. + /* + * Apply one op and return its inverse, for undo/redo replay. Undoing + * lands on the state the op below it names, and redoing lands on the + * state this op names, so the inverse carries the id of where it leads. + */ fn apply_op(&mut self, op: &EditOp) -> Option { let removed = self.splice(op.at, op.inserted_len, &op.deleted)?; self.caret = op.at + op.deleted.len(); - Some(EditOp { at: op.at, deleted: removed, inserted_len: op.deleted.len() }) - } - - // Push an undo step, coalescing a run of single-character typing so one - // Ctrl-Z removes a word rather than a letter. - fn push_undo(&mut self, at: usize, removed: Vec, inserted_len: usize) { - // The one place every mutation passes through, so the one place that - // needs to know the document has moved away from what is on disk. - self.dirty = true; - if removed.is_empty() && inserted_len == 1 { - if let Some(last) = self.undo.last_mut() { - if last.deleted.is_empty() && last.at + last.inserted_len == at { - last.inserted_len += 1; - return; - } - } - } - self.undo.push(EditOp { at, deleted: removed, inserted_len }); - if self.undo.len() > MAX_UNDO { - self.undo.remove(0); - } + self.sel_anchor = None; + Some(EditOp { at: op.at, deleted: removed, inserted_len: op.deleted.len(), id: op.id }) } } diff --git a/userland/capsule_text_editor/src/editor/event.rs b/userland/capsule_text_editor/src/editor/event.rs index 18c331a7d..cab8ba47c 100644 --- a/userland/capsule_text_editor/src/editor/event.rs +++ b/userland/capsule_text_editor/src/editor/event.rs @@ -97,7 +97,8 @@ pub fn on_event(state: &mut State, event: InputEvent) -> EventOutcome { } } let changed = match event.code { - KEY_ESC => return EventOutcome::Close, + // Esc dismisses; it never closes the window (see `dismiss`). + KEY_ESC => return super::dismiss::dismiss(state), // Backspace and Delete remove the selection as a unit when there is one. KEY_BACKSPACE => state.delete_sel() || state.backspace(), KEY_DELETE => state.delete_sel() || state.delete_forward(), diff --git a/userland/capsule_text_editor/src/editor/event_editor.rs b/userland/capsule_text_editor/src/editor/event_editor.rs index 5c2ac0b8e..66174d3ff 100644 --- a/userland/capsule_text_editor/src/editor/event_editor.rs +++ b/userland/capsule_text_editor/src/editor/event_editor.rs @@ -40,10 +40,11 @@ impl Editor { // Any keystroke that is not a second Ctrl+W disarms a pending close, // so a confirmation cannot be answered by an unrelated key later. - if event.is_key_down() - && !(event.flags & MOD_CTRL != 0 && matches!(event.code, 0x57 | 0x77)) - { - self.close_armed = false; + if event.is_key_down() { + self.quit_armed = false; + if !(event.flags & MOD_CTRL != 0 && matches!(event.code, 0x57 | 0x77)) { + self.close_armed = None; + } } // The name entry owns the keyboard while it is open. @@ -59,6 +60,12 @@ impl Editor { { return self.finish_quick_open(); } + if event.is_key_down() + && event.code == KEY_ENTER + && self.doc_ref().prompt == Some(PromptOp::Open) + { + return self.finish_open_prompt(); + } // Shell-level shortcuts, taken before the document sees them because // what they move belongs to the shell rather than to the text. @@ -105,20 +112,10 @@ impl Editor { } if event.flags & MOD_SHIFT == 0 && matches!(event.code, 0x57 | 0x77) { let at = self.active; - // A document with unsaved edits costs two presses. `close_tab` - // removes it outright and there is no undo across a close, so - // one slip next to Ctrl+S would be unrecoverable work. The - // arming is cleared by any other key below. - if self.docs[at].dirty && !self.close_armed { - self.close_armed = true; - self.docs[at].status = b"unsaved: Ctrl+W again to close, Ctrl+S to write"; - return EventOutcome::Repaint; - } - self.close_armed = false; - self.close_tab(at); + self.request_close(at); return EventOutcome::Repaint; } - self.close_armed = false; + self.close_armed = None; } if event.kind == InputKind::Wheel { diff --git a/userland/capsule_text_editor/src/editor/goto_prompt.rs b/userland/capsule_text_editor/src/editor/goto_prompt.rs new file mode 100644 index 000000000..084bfcc33 --- /dev/null +++ b/userland/capsule_text_editor/src/editor/goto_prompt.rs @@ -0,0 +1,34 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Go to a line: move the caret to the line the prompt names. + +use nonos_app_skeleton::EventOutcome; + +use super::state::State; + +/// Move the caret to the line the prompt names, and scroll so it is visible. +pub(super) fn goto(state: &mut State, len: usize) -> EventOutcome { + let Some(n) = super::goto_line::parse_line_number(&state.prompt_path[..len]) else { + state.status = b"not a line number"; + return EventOutcome::Repaint; + }; + state.caret = super::goto_line::offset_of_line(&state.buf[..state.len], n); + let rows = state.visible_rows; + super::follow_caret::follow_caret(state, rows); + state.status = b"jumped"; + EventOutcome::Repaint +} diff --git a/userland/capsule_text_editor/src/editor/mod.rs b/userland/capsule_text_editor/src/editor/mod.rs index 1cc7afe45..b8b9ea7d5 100644 --- a/userland/capsule_text_editor/src/editor/mod.rs +++ b/userland/capsule_text_editor/src/editor/mod.rs @@ -31,6 +31,7 @@ mod ctrl_open; mod ctrl_paste; mod ctrl_save; mod delete; +mod dismiss; mod doc_pos; mod edit; mod event; @@ -39,8 +40,7 @@ mod find; mod find_key; mod follow_caret; mod goto_line; -mod quick_act; -mod quick_open; +mod goto_prompt; mod highlight; mod home; mod indent; @@ -61,6 +61,7 @@ mod notify; mod on_ctrl; mod on_ctrl_nav; mod on_nav; +mod open_prompt; mod pagebreak; mod pagebreak_mark; mod paint; @@ -72,10 +73,13 @@ mod panel_press; mod panel_rows; mod path_prompt; mod position_at; +mod quick_act; +mod quick_open; mod reflow; mod replace; mod resolve_owner_pid; mod ribbon; +mod save_point; mod sb_entry; mod sb_menu; mod screen; @@ -97,11 +101,13 @@ mod table_rules; mod theme; mod toggle_comment; mod tree; +mod undo_push; mod unsupported; mod visual_lines; mod widget; mod word_nav; mod wordcount; +mod ws_close; mod ws_event; mod ws_info; mod ws_menu; diff --git a/userland/capsule_text_editor/src/editor/open_prompt.rs b/userland/capsule_text_editor/src/editor/open_prompt.rs new file mode 100644 index 000000000..8f12819a3 --- /dev/null +++ b/userland/capsule_text_editor/src/editor/open_prompt.rs @@ -0,0 +1,42 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Finishing the Open prompt: the typed path opens in its own tab. + +use super::app::Editor; +use nonos_app_skeleton::EventOutcome; + +impl Editor { + /// Finish the Open prompt: the typed path opens in its own tab, so the + /// document the prompt was started from keeps its text and its name. + pub(super) fn finish_open_prompt(&mut self) -> EventOutcome { + let d = self.doc(); + let typed = d.prompt_path[..d.prompt_len].to_vec(); + d.prompt = None; + d.prompt_len = 0; + if typed.is_empty() { + d.status = b"no path given"; + return EventOutcome::Repaint; + } + match core::str::from_utf8(&typed) { + Ok(path) => { + self.open_path(path); + } + Err(_) => self.doc().status = b"open refused: path is not valid UTF-8", + } + EventOutcome::Repaint + } +} diff --git a/userland/capsule_text_editor/src/editor/path_prompt.rs b/userland/capsule_text_editor/src/editor/path_prompt.rs index 2e32c40d8..31b50efaa 100644 --- a/userland/capsule_text_editor/src/editor/path_prompt.rs +++ b/userland/capsule_text_editor/src/editor/path_prompt.rs @@ -17,12 +17,12 @@ use nonos_app_skeleton::{EventOutcome, InputEvent, KEY_BACKSPACE, KEY_ENTER, KEY_ESC, MOD_CTRL}; use super::ctrl_export::ctrl_export; -use super::ctrl_open::ctrl_open; -use super::ctrl_save::ctrl_save; +use super::ctrl_save::{exists, write_to}; use super::state::{PromptOp, State}; pub(super) fn start(state: &mut State, op: PromptOp) -> EventOutcome { state.prompt = Some(op); + state.overwrite_armed = false; // Save As starts from the current path so a small edit is quick. Open // starts empty, because opening a different file meant backspacing the // whole of the old one first. @@ -43,19 +43,6 @@ pub(super) fn start(state: &mut State, op: PromptOp) -> EventOutcome { EventOutcome::Repaint } -/// Move the caret to the line the prompt names, and scroll so it is visible. -fn goto(state: &mut State, len: usize) -> EventOutcome { - let Some(n) = super::goto_line::parse_line_number(&state.prompt_path[..len]) else { - state.status = b"not a line number"; - return EventOutcome::Repaint; - }; - state.caret = super::goto_line::offset_of_line(&state.buf[..state.len], n); - let rows = state.visible_rows; - super::follow_caret::follow_caret(state, rows); - state.status = b"jumped"; - EventOutcome::Repaint -} - pub(super) fn on_key(state: &mut State, event: InputEvent) -> EventOutcome { let Some(op) = state.prompt else { return EventOutcome::Idle }; match event.code { @@ -69,6 +56,7 @@ pub(super) fn on_key(state: &mut State, event: InputEvent) -> EventOutcome { if state.prompt_len > 0 { state.prompt_len -= 1; } + state.overwrite_armed = false; } KEY_ENTER => { state.prompt = None; @@ -81,25 +69,43 @@ pub(super) fn on_key(state: &mut State, event: InputEvent) -> EventOutcome { state.prompt_len = 0; return ctrl_export(state, n); } - // Goto moves the caret and touches nothing else. It must return - // before the commit below, which would otherwise overwrite the - // document's path with a line number. + /* + * Goto moves the caret and touches nothing else, so a line number + * can never be taken for a path. + */ if op == PromptOp::Goto { let n = state.prompt_len; state.prompt_len = 0; - return goto(state, n); + return super::goto_prompt::goto(state, n); } - // Commit point: the typed path becomes the document's only here. - state.path[..state.prompt_len].copy_from_slice(&state.prompt_path[..state.prompt_len]); - state.path_len = state.prompt_len; - state.prompt_len = 0; - return match op { - PromptOp::Open => ctrl_open(state), - PromptOp::Save => ctrl_save(state), - // Quick never reaches here: the shell takes its Enter, because - // resolving the name needs the tree. - PromptOp::Export | PromptOp::Goto | PromptOp::Quick => EventOutcome::Repaint, - }; + let n = state.prompt_len; + let target = state.prompt_path[..n].to_vec(); + match op { + PromptOp::Save => { + let same = target[..] == state.path[..state.path_len]; + /* + * Replacing another file is asked once: the prompt stays + * open with the name, and Enter again is the answer. + */ + if !same && !state.overwrite_armed && exists(state, &target) { + state.prompt = Some(PromptOp::Save); + state.overwrite_armed = true; + state.status = b"a file with that name exists: Enter again replaces it"; + return EventOutcome::Repaint; + } + state.overwrite_armed = false; + state.prompt_len = 0; + write_to(state, &target); + } + /* + * Open and Quick never reach here: the shell finishes them, + * because only it can choose which tab the file goes into. + */ + PromptOp::Open | PromptOp::Quick | PromptOp::Export | PromptOp::Goto => { + state.prompt_len = 0; + } + } + return EventOutcome::Repaint; } code => { if event.flags & MOD_CTRL == 0 { @@ -107,6 +113,7 @@ pub(super) fn on_key(state: &mut State, event: InputEvent) -> EventOutcome { if ch.is_ascii_graphic() && state.prompt_len < 255 { state.prompt_path[state.prompt_len] = ch as u8; state.prompt_len += 1; + state.overwrite_armed = false; } } } diff --git a/userland/capsule_text_editor/src/editor/quick_act.rs b/userland/capsule_text_editor/src/editor/quick_act.rs index f9e5dc702..0465eec86 100644 --- a/userland/capsule_text_editor/src/editor/quick_act.rs +++ b/userland/capsule_text_editor/src/editor/quick_act.rs @@ -55,16 +55,10 @@ impl Editor { }; let path = files[hit].clone(); - let bytes = path.as_bytes(); let d = self.doc(); d.prompt = None; d.prompt_len = 0; - // The document's path is the commit point, exactly as the open prompt - // does it, so the file loads through the one path that already handles - // a read failing. - let n = bytes.len().min(d.path.len()); - d.path[..n].copy_from_slice(&bytes[..n]); - d.path_len = n; - super::ctrl_open::ctrl_open(d) + self.open_path(&path); + EventOutcome::Repaint } } diff --git a/userland/capsule_text_editor/src/editor/reflow.rs b/userland/capsule_text_editor/src/editor/reflow.rs index 0d5599589..f3d4627a7 100644 --- a/userland/capsule_text_editor/src/editor/reflow.rs +++ b/userland/capsule_text_editor/src/editor/reflow.rs @@ -25,6 +25,13 @@ use super::state::State; use crate::doc::align::Align; use crate::doc::paginate::paginate; use crate::doc::text_bridge::doc_from_text; +/* + * The machine measures with the real font; host proofs, which have no font + * service, measure with fixed advances so the same reflow runs on both. + */ +#[cfg(not(target_os = "none"))] +use crate::doc::measure::FixedMeasurer as TtfMeasurer; +#[cfg(target_os = "none")] use crate::doc::ttf_measure::TtfMeasurer; impl State { diff --git a/userland/capsule_text_editor/src/editor/save_point.rs b/userland/capsule_text_editor/src/editor/save_point.rs new file mode 100644 index 000000000..54997fa8c --- /dev/null +++ b/userland/capsule_text_editor/src/editor/save_point.rs @@ -0,0 +1,65 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Whether the document matches what was last read or written. +//! +//! Each edit names the state it leads to, and the save point records the name +//! of the state on disk. The document is clean exactly when the top of the undo +//! stack names that state, so undoing back to the saved text is clean again and +//! undoing past it is not. + +use super::state::State; + +/// Ids of document states: the next edit's, the one on disk, and the one +/// history bottoms out at once old steps are dropped. +#[derive(Default)] +pub struct SavePoint { + pub next_op: u64, + pub on_disk: u64, + pub floor: u64, +} + +impl State { + /// The state the buffer is in now: the top of the undo stack, or the floor + /// history bottoms out at. + pub(super) fn top_id(&self) -> u64 { + self.undo.last().map_or(self.saved.floor, |op| op.id) + } + + pub(super) fn refresh_dirty(&mut self) { + self.dirty = self.top_id() != self.saved.on_disk; + } + + /// The buffer now matches the file on disk. + pub(super) fn mark_saved(&mut self) { + self.saved.on_disk = self.top_id(); + self.dirty = false; + } + + /// A fresh load: the old history describes a different text, so replaying + /// it here would corrupt this one. Selection and search go with it. + pub(super) fn reset_history(&mut self) { + self.undo.clear(); + self.redo.clear(); + self.saved.floor = self.saved.next_op; + self.saved.on_disk = self.saved.next_op; + self.dirty = false; + self.sel_anchor = None; + self.selecting = false; + self.find_active = false; + self.replace_active = false; + } +} diff --git a/userland/capsule_text_editor/src/editor/state.rs b/userland/capsule_text_editor/src/editor/state.rs index 07f36b76c..ff42ab87f 100644 --- a/userland/capsule_text_editor/src/editor/state.rs +++ b/userland/capsule_text_editor/src/editor/state.rs @@ -17,7 +17,6 @@ // 256 KiB per document, heap-backed, enough for any real source file while // keeping a dozen open tabs cheap. pub const CAPACITY: usize = 256 * 1024; -pub const PATH: &[u8] = b"/notes.txt"; #[derive(Clone, Copy, PartialEq)] pub enum PromptOp { @@ -66,6 +65,8 @@ pub struct State { // whatever had been half typed, and the next save wrote that instead. pub prompt_path: [u8; 256], pub prompt_len: usize, + /// Save As named an existing file and the next Enter replaces it. + pub overwrite_armed: bool, pub shell_port: u32, // Undo and redo stacks of reversible edits. Every mutation goes through // `apply_edit`, so both stay in sync with the buffer. @@ -73,6 +74,8 @@ pub struct State { /// written. Tracked here rather than inferred from the undo depth, because /// undoing back to a coincidentally equal depth is not the same document. pub dirty: bool, + /// Which document state is on disk. See `save_point`. + pub saved: super::save_point::SavePoint, pub undo: alloc::vec::Vec, pub redo: alloc::vec::Vec, // Incremental find: the query being typed and whether the find bar is open. diff --git a/userland/capsule_text_editor/src/editor/state_new.rs b/userland/capsule_text_editor/src/editor/state_new.rs index b5b705bfe..75a5fdf2d 100644 --- a/userland/capsule_text_editor/src/editor/state_new.rs +++ b/userland/capsule_text_editor/src/editor/state_new.rs @@ -14,12 +14,16 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use super::state::{State, PATH}; +use super::state::State; impl State { pub fn new() -> Self { - let mut path = [0u8; 256]; - path[..PATH.len()].copy_from_slice(PATH); + /* + * A new document is untitled. It used to carry a default file name + * without reading that file, so the first save replaced whatever the + * file held with the new text. + */ + let path = [0u8; 256]; let mut s = State { owner_pid: 0, buf: alloc::vec![0; super::state::CAPACITY], @@ -37,12 +41,14 @@ impl State { glyph_advance: super::layout::GLYPH_ADVANCE, status: b"Ctrl-O open Ctrl-S save Ctrl-E export Ctrl-C copy Ctrl-V paste", path, - path_len: PATH.len(), + path_len: 0, prompt: None, prompt_path: [0u8; 256], prompt_len: 0, + overwrite_armed: false, shell_port: 0, dirty: false, + saved: Default::default(), undo: alloc::vec::Vec::new(), redo: alloc::vec::Vec::new(), find_active: false, @@ -60,7 +66,7 @@ impl State { height: 980.0, margin: 56.0, }, - mode: super::mode::mode_for_path(core::str::from_utf8(PATH).unwrap_or("")), + mode: super::mode::mode_for_path(""), }; s.reflow(); s diff --git a/userland/capsule_text_editor/src/editor/undo_push.rs b/userland/capsule_text_editor/src/editor/undo_push.rs new file mode 100644 index 000000000..7196b558d --- /dev/null +++ b/userland/capsule_text_editor/src/editor/undo_push.rs @@ -0,0 +1,61 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Recording an edit on the undo stack, merging a typing run into one +//! step and dropping the oldest steps in batches. + +use super::edit::EditOp; +use super::state::State; +use alloc::vec::Vec; + +const MAX_UNDO: usize = 400; +/// Oldest steps are dropped in batches, so a long session does not shift +/// the whole stack on every keystroke once it is full. +const UNDO_SLACK: usize = 64; + +impl State { + /* + * Push an undo step, coalescing a run of single-character typing so one + * Ctrl-Z removes a word rather than a letter. + */ + pub(super) fn push_undo(&mut self, at: usize, removed: Vec, inserted_len: usize) { + self.saved.next_op = self.saved.next_op.wrapping_add(1).max(1); + let id = self.saved.next_op; + if removed.is_empty() && inserted_len == 1 { + if let Some(last) = self.undo.last_mut() { + /* + * A merged step is a new document state, so it takes the new + * id: a save made mid-word must not match the finished word. + */ + if last.deleted.is_empty() && last.at + last.inserted_len == at { + last.inserted_len += 1; + last.id = id; + return; + } + } + } + self.undo.push(EditOp { at, deleted: removed, inserted_len, id }); + if self.undo.len() > MAX_UNDO + UNDO_SLACK { + let cut = self.undo.len() - MAX_UNDO; + /* + * Undo can no longer reach below the dropped steps, so the state + * they led to becomes the bottom of history. + */ + self.saved.floor = self.undo[cut - 1].id; + self.undo.drain(..cut); + } + } +} diff --git a/userland/capsule_text_editor/src/editor/ws_close.rs b/userland/capsule_text_editor/src/editor/ws_close.rs new file mode 100644 index 000000000..1af35f552 --- /dev/null +++ b/userland/capsule_text_editor/src/editor/ws_close.rs @@ -0,0 +1,68 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Closing tabs, and the window, without losing unsaved text. + +use super::app::Editor; +use super::state::State; + +impl Editor { + /// Close a tab the user asked to close. Unsaved text costs a second + /// request on the same tab, from any close control, because closing + /// cannot be undone. Returns whether the tab went. + pub(super) fn request_close(&mut self, idx: usize) -> bool { + let Some(d) = self.docs.get_mut(idx) else { return false }; + if d.dirty && self.close_armed != Some(idx) { + self.close_armed = Some(idx); + d.status = b"unsaved changes: close again to discard them, Ctrl+S to save"; + self.active = idx; + return false; + } + self.close_armed = None; + self.close_tab(idx); + true + } + + fn close_tab(&mut self, idx: usize) { + if idx >= self.docs.len() { + return; + } + self.docs.remove(idx); + if self.docs.is_empty() { + self.docs.push(State::new()); + self.active = 0; + return; + } + if self.active > idx || self.active >= self.docs.len() { + self.active = self.active.saturating_sub(1).min(self.docs.len() - 1); + } + } + + /// The window close button: unsaved text anywhere costs a second press, + /// with the first unsaved tab brought to the front to say so. + pub(super) fn confirm_quit(&mut self) -> bool { + let unsaved = self.docs.iter().position(|d| d.dirty); + match unsaved { + Some(i) if !self.quit_armed => { + self.quit_armed = true; + self.active = i; + self.docs[i].status = b"unsaved changes: close the window again to discard them"; + false + } + _ => true, + } + } +} diff --git a/userland/capsule_text_editor/src/editor/ws_menubar.rs b/userland/capsule_text_editor/src/editor/ws_menubar.rs index b17883bce..28ccfb25f 100644 --- a/userland/capsule_text_editor/src/editor/ws_menubar.rs +++ b/userland/capsule_text_editor/src/editor/ws_menubar.rs @@ -53,7 +53,7 @@ impl Editor { MenuCmd::NewTab => self.new_tab(), MenuCmd::CloseTab => { let idx = self.active; - self.close_tab(idx); + self.request_close(idx); } MenuCmd::ToggleSidebar => { self.sidebar_open = !self.sidebar_open; diff --git a/userland/capsule_text_editor/src/editor/ws_open.rs b/userland/capsule_text_editor/src/editor/ws_open.rs index 52a7272a2..f6a0f5fd3 100644 --- a/userland/capsule_text_editor/src/editor/ws_open.rs +++ b/userland/capsule_text_editor/src/editor/ws_open.rs @@ -18,7 +18,7 @@ //! file) and close tabs, always leaving at least one document open. use super::app::Editor; -use super::ctrl_open::ctrl_open; +use super::ctrl_open::load; use super::state::State; fn doc_path(d: &State) -> &str { @@ -26,36 +26,38 @@ fn doc_path(d: &State) -> &str { } impl Editor { - pub(super) fn open_path(&mut self, path: &str) { - if path.len() > 255 { - return; - } - self.mru_note(path); + /// Open `path` in its own tab: the tab that already holds it, else the + /// front tab when it is an untouched blank, else a new tab. Nothing that + /// holds text is ever replaced, and a file that fails to load opens no tab. + pub(super) fn open_path(&mut self, path: &str) -> bool { if let Some(i) = self.docs.iter().position(|d| doc_path(d) == path) { self.active = i; - return; + return true; + } + let front = self.active.min(self.docs.len().saturating_sub(1)); + let blank = self + .docs + .get(front) + .map(|d| d.len == 0 && !d.dirty && d.path_len == 0) + .unwrap_or(false); + if blank { + let ok = load(&mut self.docs[front], path.as_bytes()); + if ok { + self.mru_note(path); + } + return ok; } let mut d = State::new(); - d.path[..path.len()].copy_from_slice(path.as_bytes()); - d.path_len = path.len(); d.owner_pid = self.owner_pid; - ctrl_open(&mut d); + if !load(&mut d, path.as_bytes()) { + // The reason belongs where the user is looking. + let why = d.status; + self.doc().status = why; + return false; + } + self.mru_note(path); self.docs.push(d); self.active = self.docs.len() - 1; - } - - pub(super) fn close_tab(&mut self, idx: usize) { - if idx >= self.docs.len() { - return; - } - self.docs.remove(idx); - if self.docs.is_empty() { - self.docs.push(State::new()); - self.active = 0; - return; - } - if self.active > idx || self.active >= self.docs.len() { - self.active = self.active.saturating_sub(1).min(self.docs.len() - 1); - } + true } } diff --git a/userland/capsule_text_editor/src/editor/ws_pointer.rs b/userland/capsule_text_editor/src/editor/ws_pointer.rs index 34a41f9d7..a6e08d4e6 100644 --- a/userland/capsule_text_editor/src/editor/ws_pointer.rs +++ b/userland/capsule_text_editor/src/editor/ws_pointer.rs @@ -97,7 +97,7 @@ impl Editor { self.toolbar_action(btn); } else if let Some((idx, close)) = tab_hit(&self.tab_layout, x) { if close { - self.close_tab(idx); + self.request_close(idx); } else { self.active = idx; } diff --git a/userland/editor_proofs/Cargo.lock b/userland/editor_proofs/Cargo.lock index 89111bba2..b17e92d58 100644 --- a/userland/editor_proofs/Cargo.lock +++ b/userland/editor_proofs/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "editor_proofs" -version = "0.1.0" +version = "0.3.0" diff --git a/userland/editor_proofs/src/lib.rs b/userland/editor_proofs/src/lib.rs index f0f32271a..fdb3627de 100644 --- a/userland/editor_proofs/src/lib.rs +++ b/userland/editor_proofs/src/lib.rs @@ -22,6 +22,15 @@ extern crate alloc; +#[path = "../../capsule_text_editor/src/doc/mod.rs"] +pub mod doc; +#[path = "../../capsule_text_editor/src/editor/mode.rs"] +pub mod mode; +#[path = "../../capsule_text_editor/src/editor/reflow.rs"] +pub mod reflow; +#[path = "../../capsule_text_editor/src/editor/save_point.rs"] +pub mod save_point; + #[path = "../../capsule_text_editor/src/editor/autoclose.rs"] pub mod autoclose; #[path = "../../capsule_text_editor/src/editor/backspace.rs"] @@ -70,6 +79,8 @@ pub mod state; pub mod state_new; #[path = "../../capsule_text_editor/src/editor/theme.rs"] pub mod theme; +#[path = "../../capsule_text_editor/src/editor/undo_push.rs"] +pub mod undo_push; #[path = "../../capsule_text_editor/src/editor/toggle_comment.rs"] pub mod toggle_comment; #[path = "../../capsule_text_editor/src/editor/visual_lines.rs"] @@ -81,3 +92,7 @@ pub mod word_nav; mod edit_tests; #[cfg(test)] mod feature_tests; +#[cfg(test)] +mod save_history_tests; +#[cfg(test)] +mod save_tests; diff --git a/userland/editor_proofs/src/save_history_tests.rs b/userland/editor_proofs/src/save_history_tests.rs new file mode 100644 index 000000000..346dffa6d --- /dev/null +++ b/userland/editor_proofs/src/save_history_tests.rs @@ -0,0 +1,56 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The save point across dropped history and a reload. + +use crate::edit_tests::doc; + +#[test] +fn an_edit_after_undoing_past_the_save_never_matches_it_again() { + let mut s = doc("abc"); + s.caret = 3; + assert!(s.insert(b"X")); + s.mark_saved(); + assert!(s.undo()); + // A different edit replaces the redo branch that held the saved text. + assert!(s.insert(b"Y")); + assert!(s.dirty); + assert!(s.undo()); + assert!(s.dirty, "abc is not the saved abcX"); +} + +#[test] +fn dropped_history_cannot_undo_to_a_false_clean() { + let mut s = doc(""); + // Separate steps: a space after each letter breaks the typing run. + for _ in 0..600 { + assert!(s.apply_edit(s.len, 0, b"ab")); + } + while s.undo() {} + assert!(s.len > 0, "the oldest steps were dropped"); + assert!(s.dirty, "the buffer is not the empty text that was loaded"); +} + +#[test] +fn a_reload_forgets_the_old_history() { + let mut s = doc("abc"); + s.caret = 3; + assert!(s.insert(b"d")); + s.reset_history(); + assert!(!s.dirty); + assert!(!s.undo(), "undo must not replay edits made to another text"); + assert!(s.sel_anchor.is_none()); +} diff --git a/userland/editor_proofs/src/save_tests.rs b/userland/editor_proofs/src/save_tests.rs new file mode 100644 index 000000000..d0348ec7e --- /dev/null +++ b/userland/editor_proofs/src/save_tests.rs @@ -0,0 +1,62 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Proofs for the save point: the document is clean exactly when its text is +//! the text last read or written, whatever undo and redo did in between. + +use crate::edit_tests::{doc, text}; + +#[test] +fn a_loaded_document_is_clean() { + let s = doc("abc"); + assert!(!s.dirty); +} + +#[test] +fn undo_back_to_the_saved_text_is_clean() { + let mut s = doc("abc"); + s.caret = 3; + assert!(s.insert(b"d")); + assert!(s.dirty); + assert!(s.undo()); + assert_eq!(text(&s), "abc"); + assert!(!s.dirty, "the text on disk is back"); +} + +#[test] +fn undo_after_a_save_is_dirty() { + let mut s = doc("abc"); + s.caret = 3; + assert!(s.insert(b"d")); + s.mark_saved(); + assert!(!s.dirty); + assert!(s.undo()); + assert!(s.dirty, "the buffer no longer matches the saved file"); + assert!(s.redo()); + assert!(!s.dirty, "redo returns to the saved text"); +} + +#[test] +fn typing_on_after_a_mid_word_save_is_dirty() { + let mut s = doc(""); + for b in b"ab" { + assert!(s.insert(&[*b])); + } + s.mark_saved(); + // The next letter merges into the same undo step; it is still a change. + assert!(s.insert(b"c")); + assert!(s.dirty); +} From 7e51ab417b0ffb34233c3e3a280898f4af52c5ac Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Mon, 28 Sep 2026 12:56:51 +0000 Subject: [PATCH 07/29] nonos_vt: an xterm-compatible terminal screen, parser and input encoder A no_std crate the terminal will draw from. The parser is the DEC state machine: CSI with private markers, colon sub-parameters and intermediates, OSC, DCS, SOS/PM/APC swallowed, CAN/SUB/ESC aborting any sequence, UTF-8 split across reads. The screen defers the wrap at the last column, so a full-screen program can fill the bottom-right cell, and implements IL, DL, RI, SU, SD, ICH, DCH, ECH, REP, tab stops, origin and insert modes, DECSC/DECRC, the 47/1047/1048/1049 alternate screens, DEC line drawing, wide characters kept whole, and combining marks and ZWJ sequences in one cell. Resize re-wraps the normal screen and its history. It answers DA1, DA2, DSR, DECRQM, XTVERSION, XTGETTCAP, DECRQSS, size reports and OSC colour queries, since editors wait for them. A program may not move or resize its window, and a clipboard read (OSC 52 ?) is refused and counted. Keys, mouse (X10 and SGR), focus and paste encode per mode; paste strips escape bytes so pasted text cannot end the bracket and run as commands. Every buffer output can grow has a ceiling in limits. 69 tests, including a seeded mutation fuzz that interleaves hostile output with resizes, copies and searches and checks the cursor and row widths after every step; it found a cursor left below the screen after a resize, fixed here. --- userland/nonos_vt/Cargo.lock | 7 ++ userland/nonos_vt/Cargo.toml | 17 +++++ userland/nonos_vt/src/cell/attr.rs | 35 +++++++++ userland/nonos_vt/src/cell/data.rs | 69 +++++++++++++++++ userland/nonos_vt/src/cell/mod.rs | 26 +++++++ userland/nonos_vt/src/cell/pen.rs | 28 +++++++ userland/nonos_vt/src/cell/underline.rs | 40 ++++++++++ userland/nonos_vt/src/charset.rs | 71 ++++++++++++++++++ userland/nonos_vt/src/color/mod.rs | 27 +++++++ userland/nonos_vt/src/color/name.rs | 36 +++++++++ userland/nonos_vt/src/color/palette.rs | 63 ++++++++++++++++ userland/nonos_vt/src/color/xterm.rs | 38 ++++++++++ userland/nonos_vt/src/input/keys.rs | 73 ++++++++++++++++++ userland/nonos_vt/src/input/keys_encode.rs | 75 +++++++++++++++++++ userland/nonos_vt/src/input/keys_seq.rs | 44 +++++++++++ userland/nonos_vt/src/input/mod.rs | 31 ++++++++ userland/nonos_vt/src/input/mouse.rs | 60 +++++++++++++++ userland/nonos_vt/src/input/mouse_encode.rs | 73 ++++++++++++++++++ userland/nonos_vt/src/input/paste.rs | 49 ++++++++++++ userland/nonos_vt/src/lib.rs | 47 ++++++++++++ userland/nonos_vt/src/limits.rs | 57 ++++++++++++++ userland/nonos_vt/src/line/data.rs | 69 +++++++++++++++++ userland/nonos_vt/src/line/marks.rs | 70 +++++++++++++++++ userland/nonos_vt/src/line/mod.rs | 30 ++++++++ userland/nonos_vt/src/line/shift.rs | 68 +++++++++++++++++ userland/nonos_vt/src/line/trim.rs | 35 +++++++++ userland/nonos_vt/src/line/write.rs | 70 +++++++++++++++++ userland/nonos_vt/src/out.rs | 30 ++++++++ userland/nonos_vt/src/params/collect.rs | 72 ++++++++++++++++++ userland/nonos_vt/src/params/mod.rs | 25 +++++++ userland/nonos_vt/src/params/read.rs | 68 +++++++++++++++++ userland/nonos_vt/src/parser/advance.rs | 62 +++++++++++++++ userland/nonos_vt/src/parser/csi_states.rs | 57 ++++++++++++++ userland/nonos_vt/src/parser/dcs_states.rs | 50 +++++++++++++ userland/nonos_vt/src/parser/escape.rs | 66 ++++++++++++++++ userland/nonos_vt/src/parser/ground.rs | 53 +++++++++++++ userland/nonos_vt/src/parser/handler.rs | 34 +++++++++ userland/nonos_vt/src/parser/machine.rs | 66 ++++++++++++++++ userland/nonos_vt/src/parser/mod.rs | 32 ++++++++ userland/nonos_vt/src/parser/seq.rs | 61 +++++++++++++++ userland/nonos_vt/src/parser/strings.rs | 63 ++++++++++++++++ userland/nonos_vt/src/term/abs.rs | 57 ++++++++++++++ userland/nonos_vt/src/term/alt.rs | 59 +++++++++++++++ userland/nonos_vt/src/term/answer/dcs.rs | 58 ++++++++++++++ userland/nonos_vt/src/term/answer/mod.rs | 25 +++++++ userland/nonos_vt/src/term/answer/reply.rs | 44 +++++++++++ userland/nonos_vt/src/term/answer/report.rs | 64 ++++++++++++++++ userland/nonos_vt/src/term/answer/tcap.rs | 57 ++++++++++++++ userland/nonos_vt/src/term/answer/winops.rs | 42 +++++++++++ userland/nonos_vt/src/term/control.rs | 54 +++++++++++++ userland/nonos_vt/src/term/csi.rs | 72 ++++++++++++++++++ userland/nonos_vt/src/term/csi_move.rs | 67 +++++++++++++++++ userland/nonos_vt/src/term/csi_place.rs | 67 +++++++++++++++++ userland/nonos_vt/src/term/cursor_style.rs | 40 ++++++++++ userland/nonos_vt/src/term/edit.rs | 66 ++++++++++++++++ userland/nonos_vt/src/term/erase.rs | 73 ++++++++++++++++++ userland/nonos_vt/src/term/esc.rs | 66 ++++++++++++++++ userland/nonos_vt/src/term/feed.rs | 60 +++++++++++++++ userland/nonos_vt/src/term/glyph.rs | 72 ++++++++++++++++++ userland/nonos_vt/src/term/handler.rs | 47 ++++++++++++ userland/nonos_vt/src/term/history.rs | 49 ++++++++++++ userland/nonos_vt/src/term/index.rs | 53 +++++++++++++ userland/nonos_vt/src/term/join.rs | 49 ++++++++++++ userland/nonos_vt/src/term/lines.rs | 63 ++++++++++++++++ userland/nonos_vt/src/term/logical.rs | 44 +++++++++++ userland/nonos_vt/src/term/mod.rs | 73 ++++++++++++++++++ userland/nonos_vt/src/term/mode_state.rs | 55 ++++++++++++++ userland/nonos_vt/src/term/modes.rs | 73 ++++++++++++++++++ userland/nonos_vt/src/term/modes_type.rs | 74 ++++++++++++++++++ userland/nonos_vt/src/term/mouse_mode.rs | 31 ++++++++ userland/nonos_vt/src/term/new.rs | 71 ++++++++++++++++++ userland/nonos_vt/src/term/osc/dispatch.rs | 75 +++++++++++++++++++ userland/nonos_vt/src/term/osc/links.rs | 50 +++++++++++++ userland/nonos_vt/src/term/osc/mod.rs | 25 +++++++ userland/nonos_vt/src/term/osc/osc_color.rs | 53 +++++++++++++ userland/nonos_vt/src/term/osc/osc_get.rs | 52 +++++++++++++ userland/nonos_vt/src/term/osc/osc_misc.rs | 68 +++++++++++++++++ userland/nonos_vt/src/term/osc/osc_palette.rs | 73 ++++++++++++++++++ userland/nonos_vt/src/term/pick.rs | 66 ++++++++++++++++ userland/nonos_vt/src/term/print.rs | 58 ++++++++++++++ userland/nonos_vt/src/term/reflow/gather.rs | 56 ++++++++++++++ userland/nonos_vt/src/term/reflow/mod.rs | 28 +++++++ userland/nonos_vt/src/term/reflow/place.rs | 68 +++++++++++++++++ userland/nonos_vt/src/term/reflow/placed.rs | 25 +++++++ userland/nonos_vt/src/term/reflow/rewrap.rs | 73 ++++++++++++++++++ .../nonos_vt/src/term/reflow/rewrap_all.rs | 61 +++++++++++++++ userland/nonos_vt/src/term/reset.rs | 59 +++++++++++++++ userland/nonos_vt/src/term/resize.rs | 72 ++++++++++++++++++ userland/nonos_vt/src/term/save.rs | 59 +++++++++++++++ userland/nonos_vt/src/term/screen.rs | 73 ++++++++++++++++++ userland/nonos_vt/src/term/screen_modes.rs | 62 +++++++++++++++ userland/nonos_vt/src/term/scroll.rs | 67 +++++++++++++++++ userland/nonos_vt/src/term/search.rs | 71 ++++++++++++++++++ userland/nonos_vt/src/term/search_match.rs | 39 ++++++++++ userland/nonos_vt/src/term/sgr.rs | 61 +++++++++++++++ userland/nonos_vt/src/term/sgr_attr.rs | 58 ++++++++++++++ userland/nonos_vt/src/term/sgr_colour.rs | 53 +++++++++++++ userland/nonos_vt/src/term/soft_reset.rs | 40 ++++++++++ userland/nonos_vt/src/term/state.rs | 71 ++++++++++++++++++ userland/nonos_vt/src/term/tab_stops.rs | 42 +++++++++++ userland/nonos_vt/src/term/tabs.rs | 60 +++++++++++++++ userland/nonos_vt/src/term/text.rs | 74 ++++++++++++++++++ userland/nonos_vt/src/term/types.rs | 56 ++++++++++++++ userland/nonos_vt/src/term/view.rs | 72 ++++++++++++++++++ userland/nonos_vt/src/term/view_draw.rs | 65 ++++++++++++++++ userland/nonos_vt/src/term/view_text.rs | 49 ++++++++++++ userland/nonos_vt/src/url.rs | 68 +++++++++++++++++ userland/nonos_vt/src/utf8/mod.rs | 23 ++++++ userland/nonos_vt/src/utf8/push.rs | 58 ++++++++++++++ userland/nonos_vt/src/utf8/state.rs | 45 +++++++++++ userland/nonos_vt/src/width/check.rs | 43 +++++++++++ userland/nonos_vt/src/width/lookup.rs | 57 ++++++++++++++ userland/nonos_vt/src/width/mod.rs | 30 ++++++++ userland/nonos_vt/src/width/wide.rs | 40 ++++++++++ userland/nonos_vt/src/width/zero_high.rs | 43 +++++++++++ userland/nonos_vt/src/width/zero_low.rs | 45 +++++++++++ userland/nonos_vt/tests/input.rs | 60 +++++++++++++++ userland/nonos_vt/tests/input_pointer.rs | 57 ++++++++++++++ userland/nonos_vt/tests/mutate.rs | 61 +++++++++++++++ userland/nonos_vt/tests/noise.rs | 41 ++++++++++ userland/nonos_vt/tests/parser.rs | 61 +++++++++++++++ userland/nonos_vt/tests/parser_strings.rs | 58 ++++++++++++++ userland/nonos_vt/tests/reflow.rs | 55 ++++++++++++++ userland/nonos_vt/tests/reflow_rows.rs | 45 +++++++++++ userland/nonos_vt/tests/refusals.rs | 55 ++++++++++++++ userland/nonos_vt/tests/report.rs | 67 +++++++++++++++++ userland/nonos_vt/tests/screen.rs | 57 ++++++++++++++ userland/nonos_vt/tests/screen_chars.rs | 57 ++++++++++++++ userland/nonos_vt/tests/screen_colour.rs | 40 ++++++++++ userland/nonos_vt/tests/screen_cursor.rs | 72 ++++++++++++++++++ userland/nonos_vt/tests/screen_region.rs | 69 +++++++++++++++++ userland/nonos_vt/tests/support/check.rs | 58 ++++++++++++++ userland/nonos_vt/tests/support/log.rs | 55 ++++++++++++++ userland/nonos_vt/tests/support/pieces.rs | 67 +++++++++++++++++ userland/nonos_vt/tests/support/rng.rs | 34 +++++++++ userland/nonos_vt/tests/support/term.rs | 25 +++++++ userland/nonos_vt/tests/text.rs | 62 +++++++++++++++ 137 files changed, 7414 insertions(+) create mode 100644 userland/nonos_vt/Cargo.lock create mode 100644 userland/nonos_vt/Cargo.toml create mode 100644 userland/nonos_vt/src/cell/attr.rs create mode 100644 userland/nonos_vt/src/cell/data.rs create mode 100644 userland/nonos_vt/src/cell/mod.rs create mode 100644 userland/nonos_vt/src/cell/pen.rs create mode 100644 userland/nonos_vt/src/cell/underline.rs create mode 100644 userland/nonos_vt/src/charset.rs create mode 100644 userland/nonos_vt/src/color/mod.rs create mode 100644 userland/nonos_vt/src/color/name.rs create mode 100644 userland/nonos_vt/src/color/palette.rs create mode 100644 userland/nonos_vt/src/color/xterm.rs create mode 100644 userland/nonos_vt/src/input/keys.rs create mode 100644 userland/nonos_vt/src/input/keys_encode.rs create mode 100644 userland/nonos_vt/src/input/keys_seq.rs create mode 100644 userland/nonos_vt/src/input/mod.rs create mode 100644 userland/nonos_vt/src/input/mouse.rs create mode 100644 userland/nonos_vt/src/input/mouse_encode.rs create mode 100644 userland/nonos_vt/src/input/paste.rs create mode 100644 userland/nonos_vt/src/lib.rs create mode 100644 userland/nonos_vt/src/limits.rs create mode 100644 userland/nonos_vt/src/line/data.rs create mode 100644 userland/nonos_vt/src/line/marks.rs create mode 100644 userland/nonos_vt/src/line/mod.rs create mode 100644 userland/nonos_vt/src/line/shift.rs create mode 100644 userland/nonos_vt/src/line/trim.rs create mode 100644 userland/nonos_vt/src/line/write.rs create mode 100644 userland/nonos_vt/src/out.rs create mode 100644 userland/nonos_vt/src/params/collect.rs create mode 100644 userland/nonos_vt/src/params/mod.rs create mode 100644 userland/nonos_vt/src/params/read.rs create mode 100644 userland/nonos_vt/src/parser/advance.rs create mode 100644 userland/nonos_vt/src/parser/csi_states.rs create mode 100644 userland/nonos_vt/src/parser/dcs_states.rs create mode 100644 userland/nonos_vt/src/parser/escape.rs create mode 100644 userland/nonos_vt/src/parser/ground.rs create mode 100644 userland/nonos_vt/src/parser/handler.rs create mode 100644 userland/nonos_vt/src/parser/machine.rs create mode 100644 userland/nonos_vt/src/parser/mod.rs create mode 100644 userland/nonos_vt/src/parser/seq.rs create mode 100644 userland/nonos_vt/src/parser/strings.rs create mode 100644 userland/nonos_vt/src/term/abs.rs create mode 100644 userland/nonos_vt/src/term/alt.rs create mode 100644 userland/nonos_vt/src/term/answer/dcs.rs create mode 100644 userland/nonos_vt/src/term/answer/mod.rs create mode 100644 userland/nonos_vt/src/term/answer/reply.rs create mode 100644 userland/nonos_vt/src/term/answer/report.rs create mode 100644 userland/nonos_vt/src/term/answer/tcap.rs create mode 100644 userland/nonos_vt/src/term/answer/winops.rs create mode 100644 userland/nonos_vt/src/term/control.rs create mode 100644 userland/nonos_vt/src/term/csi.rs create mode 100644 userland/nonos_vt/src/term/csi_move.rs create mode 100644 userland/nonos_vt/src/term/csi_place.rs create mode 100644 userland/nonos_vt/src/term/cursor_style.rs create mode 100644 userland/nonos_vt/src/term/edit.rs create mode 100644 userland/nonos_vt/src/term/erase.rs create mode 100644 userland/nonos_vt/src/term/esc.rs create mode 100644 userland/nonos_vt/src/term/feed.rs create mode 100644 userland/nonos_vt/src/term/glyph.rs create mode 100644 userland/nonos_vt/src/term/handler.rs create mode 100644 userland/nonos_vt/src/term/history.rs create mode 100644 userland/nonos_vt/src/term/index.rs create mode 100644 userland/nonos_vt/src/term/join.rs create mode 100644 userland/nonos_vt/src/term/lines.rs create mode 100644 userland/nonos_vt/src/term/logical.rs create mode 100644 userland/nonos_vt/src/term/mod.rs create mode 100644 userland/nonos_vt/src/term/mode_state.rs create mode 100644 userland/nonos_vt/src/term/modes.rs create mode 100644 userland/nonos_vt/src/term/modes_type.rs create mode 100644 userland/nonos_vt/src/term/mouse_mode.rs create mode 100644 userland/nonos_vt/src/term/new.rs create mode 100644 userland/nonos_vt/src/term/osc/dispatch.rs create mode 100644 userland/nonos_vt/src/term/osc/links.rs create mode 100644 userland/nonos_vt/src/term/osc/mod.rs create mode 100644 userland/nonos_vt/src/term/osc/osc_color.rs create mode 100644 userland/nonos_vt/src/term/osc/osc_get.rs create mode 100644 userland/nonos_vt/src/term/osc/osc_misc.rs create mode 100644 userland/nonos_vt/src/term/osc/osc_palette.rs create mode 100644 userland/nonos_vt/src/term/pick.rs create mode 100644 userland/nonos_vt/src/term/print.rs create mode 100644 userland/nonos_vt/src/term/reflow/gather.rs create mode 100644 userland/nonos_vt/src/term/reflow/mod.rs create mode 100644 userland/nonos_vt/src/term/reflow/place.rs create mode 100644 userland/nonos_vt/src/term/reflow/placed.rs create mode 100644 userland/nonos_vt/src/term/reflow/rewrap.rs create mode 100644 userland/nonos_vt/src/term/reflow/rewrap_all.rs create mode 100644 userland/nonos_vt/src/term/reset.rs create mode 100644 userland/nonos_vt/src/term/resize.rs create mode 100644 userland/nonos_vt/src/term/save.rs create mode 100644 userland/nonos_vt/src/term/screen.rs create mode 100644 userland/nonos_vt/src/term/screen_modes.rs create mode 100644 userland/nonos_vt/src/term/scroll.rs create mode 100644 userland/nonos_vt/src/term/search.rs create mode 100644 userland/nonos_vt/src/term/search_match.rs create mode 100644 userland/nonos_vt/src/term/sgr.rs create mode 100644 userland/nonos_vt/src/term/sgr_attr.rs create mode 100644 userland/nonos_vt/src/term/sgr_colour.rs create mode 100644 userland/nonos_vt/src/term/soft_reset.rs create mode 100644 userland/nonos_vt/src/term/state.rs create mode 100644 userland/nonos_vt/src/term/tab_stops.rs create mode 100644 userland/nonos_vt/src/term/tabs.rs create mode 100644 userland/nonos_vt/src/term/text.rs create mode 100644 userland/nonos_vt/src/term/types.rs create mode 100644 userland/nonos_vt/src/term/view.rs create mode 100644 userland/nonos_vt/src/term/view_draw.rs create mode 100644 userland/nonos_vt/src/term/view_text.rs create mode 100644 userland/nonos_vt/src/url.rs create mode 100644 userland/nonos_vt/src/utf8/mod.rs create mode 100644 userland/nonos_vt/src/utf8/push.rs create mode 100644 userland/nonos_vt/src/utf8/state.rs create mode 100644 userland/nonos_vt/src/width/check.rs create mode 100644 userland/nonos_vt/src/width/lookup.rs create mode 100644 userland/nonos_vt/src/width/mod.rs create mode 100644 userland/nonos_vt/src/width/wide.rs create mode 100644 userland/nonos_vt/src/width/zero_high.rs create mode 100644 userland/nonos_vt/src/width/zero_low.rs create mode 100644 userland/nonos_vt/tests/input.rs create mode 100644 userland/nonos_vt/tests/input_pointer.rs create mode 100644 userland/nonos_vt/tests/mutate.rs create mode 100644 userland/nonos_vt/tests/noise.rs create mode 100644 userland/nonos_vt/tests/parser.rs create mode 100644 userland/nonos_vt/tests/parser_strings.rs create mode 100644 userland/nonos_vt/tests/reflow.rs create mode 100644 userland/nonos_vt/tests/reflow_rows.rs create mode 100644 userland/nonos_vt/tests/refusals.rs create mode 100644 userland/nonos_vt/tests/report.rs create mode 100644 userland/nonos_vt/tests/screen.rs create mode 100644 userland/nonos_vt/tests/screen_chars.rs create mode 100644 userland/nonos_vt/tests/screen_colour.rs create mode 100644 userland/nonos_vt/tests/screen_cursor.rs create mode 100644 userland/nonos_vt/tests/screen_region.rs create mode 100644 userland/nonos_vt/tests/support/check.rs create mode 100644 userland/nonos_vt/tests/support/log.rs create mode 100644 userland/nonos_vt/tests/support/pieces.rs create mode 100644 userland/nonos_vt/tests/support/rng.rs create mode 100644 userland/nonos_vt/tests/support/term.rs create mode 100644 userland/nonos_vt/tests/text.rs diff --git a/userland/nonos_vt/Cargo.lock b/userland/nonos_vt/Cargo.lock new file mode 100644 index 000000000..6f3f2f9ff --- /dev/null +++ b/userland/nonos_vt/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "nonos_vt" +version = "0.1.0" diff --git a/userland/nonos_vt/Cargo.toml b/userland/nonos_vt/Cargo.toml new file mode 100644 index 000000000..bc4c1753a --- /dev/null +++ b/userland/nonos_vt/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "nonos_vt" +version = "0.1.0" +edition = "2021" +authors = ["NONOS Contributors"] +license = "AGPL-3.0" +description = "Terminal emulation for NONOS: an xterm-compatible screen, parser and input encoder" + +[lib] +name = "nonos_vt" +path = "src/lib.rs" + +[dependencies] + +[profile.release] +overflow-checks = true +panic = "abort" diff --git a/userland/nonos_vt/src/cell/attr.rs b/userland/nonos_vt/src/cell/attr.rs new file mode 100644 index 000000000..75fdab5aa --- /dev/null +++ b/userland/nonos_vt/src/cell/attr.rs @@ -0,0 +1,35 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Attribute bits on a cell. + +pub const BOLD: u16 = 1 << 0; +pub const DIM: u16 = 1 << 1; +pub const ITALIC: u16 = 1 << 2; +/// Three bits holding an `Underline` style. +pub const UNDERLINE_SHIFT: u16 = 3; +pub const UNDERLINE_MASK: u16 = 0b111 << UNDERLINE_SHIFT; +pub const BLINK: u16 = 1 << 6; +pub const INVERSE: u16 = 1 << 7; +pub const HIDDEN: u16 = 1 << 8; +pub const STRIKE: u16 = 1 << 9; +pub const OVERLINE: u16 = 1 << 10; +/// The left half of a character two columns wide. +pub const WIDE: u16 = 1 << 11; +/// The right half. It draws nothing; the cell before it drew both halves. +pub const WIDE_TAIL: u16 = 1 << 12; +/// The attributes a pen carries. Width belongs to the character written. +pub const PEN: u16 = !(WIDE | WIDE_TAIL); diff --git a/userland/nonos_vt/src/cell/data.rs b/userland/nonos_vt/src/cell/data.rs new file mode 100644 index 000000000..d71fbb27c --- /dev/null +++ b/userland/nonos_vt/src/cell/data.rs @@ -0,0 +1,69 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A cell, and the pen that writes cells. + +use super::attr; +use super::pen::Pen; +use super::Underline; +use crate::color::Color; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Cell { + pub ch: char, + pub fg: Color, + pub bg: Color, + pub attr: u16, + /// OSC 8 target, 1-based into the terminal's link table; 0 is none. + pub link: u16, + /// Combining marks, 1-based into the line's mark table; 0 is none. + pub mark: u16, +} + +impl Cell { + pub const BLANK: Cell = + Cell { ch: ' ', fg: Color::Default, bg: Color::Default, attr: 0, link: 0, mark: 0 }; + + /// An erased cell: blank, but keeping the pen's background, as xterm + /// erases. + pub fn erased(pen: &Pen) -> Cell { + Cell { bg: pen.bg, ..Cell::BLANK } + } + + pub fn underline(&self) -> Underline { + Underline::from_bits((self.attr & attr::UNDERLINE_MASK) >> attr::UNDERLINE_SHIFT) + } + + pub fn is_wide(&self) -> bool { + self.attr & attr::WIDE != 0 + } + + pub fn is_tail(&self) -> bool { + self.attr & attr::WIDE_TAIL != 0 + } + + /// Nothing a reader would miss if the cell were dropped from a line end. + pub fn is_plain_blank(&self) -> bool { + let quiet = self.attr == 0 && self.mark == 0 && self.link == 0; + self.ch == ' ' && self.bg == Color::Default && quiet + } +} + +impl Default for Cell { + fn default() -> Cell { + Cell::BLANK + } +} diff --git a/userland/nonos_vt/src/cell/mod.rs b/userland/nonos_vt/src/cell/mod.rs new file mode 100644 index 000000000..d21a70cbe --- /dev/null +++ b/userland/nonos_vt/src/cell/mod.rs @@ -0,0 +1,26 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One character position on the screen and the attributes it is drawn with. + +pub mod attr; +mod data; +mod pen; +mod underline; + +pub use data::Cell; +pub use pen::Pen; +pub use underline::Underline; diff --git a/userland/nonos_vt/src/cell/pen.rs b/userland/nonos_vt/src/cell/pen.rs new file mode 100644 index 000000000..579522c34 --- /dev/null +++ b/userland/nonos_vt/src/cell/pen.rs @@ -0,0 +1,28 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The pen: what a program last set, which every cell it writes takes. + +use crate::color::Color; + +/// What newly written and erased cells take from the program. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct Pen { + pub fg: Color, + pub bg: Color, + pub attr: u16, + pub link: u16, +} diff --git a/userland/nonos_vt/src/cell/underline.rs b/userland/nonos_vt/src/cell/underline.rs new file mode 100644 index 000000000..830428595 --- /dev/null +++ b/userland/nonos_vt/src/cell/underline.rs @@ -0,0 +1,40 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The underline styles SGR 4 selects with its sub-parameter. + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Underline { + None, + Single, + Double, + Curly, + Dotted, + Dashed, +} + +impl Underline { + pub fn from_bits(v: u16) -> Underline { + match v { + 1 => Underline::Single, + 2 => Underline::Double, + 3 => Underline::Curly, + 4 => Underline::Dotted, + 5 => Underline::Dashed, + _ => Underline::None, + } + } +} diff --git a/userland/nonos_vt/src/charset.rs b/userland/nonos_vt/src/charset.rs new file mode 100644 index 000000000..5897b5516 --- /dev/null +++ b/userland/nonos_vt/src/charset.rs @@ -0,0 +1,71 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The character sets a program can switch in: the DEC line-drawing set +//! that ncurses falls back to, and the UK set's pound sign. + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Charset { + #[default] + Ascii, + DecSpecial, + Uk, +} + +/// G0 to G3 and which of them is in use. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct Charsets { + pub g: [Charset; 4], + /// The set GL maps: 0 after SI, 1 after SO. + pub gl: usize, + /// A single shift, SS2 or SS3, for the next character only. + pub single: Option, +} + +impl Charsets { + /// Map a printable ASCII character through the set in use. + pub fn map(&mut self, c: char) -> char { + let set = match self.single.take() { + Some(i) => self.g[i], + None => self.g[self.gl.min(3)], + }; + match set { + Charset::Ascii => c, + Charset::Uk if c == '#' => '£', + Charset::Uk => c, + Charset::DecSpecial => dec_special(c), + } + } +} + +pub fn designate(final_byte: u8) -> Charset { + match final_byte { + b'0' => Charset::DecSpecial, + b'A' => Charset::Uk, + _ => Charset::Ascii, + } +} + +fn dec_special(c: char) -> char { + const TABLE: [char; 32] = [ + '\u{00A0}', '◆', '▒', '␉', '␌', '␍', '␊', '°', '±', '␤', '␋', '┘', '┐', '┌', '└', '┼', '⎺', + '⎻', '─', '⎼', '⎽', '├', '┤', '┴', '┬', '│', '≤', '≥', 'π', '≠', '£', '·', + ]; + match c as u32 { + 0x5F..=0x7E => TABLE[(c as u32 - 0x5F) as usize], + _ => c, + } +} diff --git a/userland/nonos_vt/src/color/mod.rs b/userland/nonos_vt/src/color/mod.rs new file mode 100644 index 000000000..272131a2a --- /dev/null +++ b/userland/nonos_vt/src/color/mod.rs @@ -0,0 +1,27 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Colours as a program names them, and the palette that turns them into +//! pixels. A cell keeps the name, not the pixel, so a theme change or an +//! OSC 4 recolour reaches text already on screen. + +mod name; +mod palette; +mod xterm; + +pub use name::{dim, rgb, Color}; +pub use palette::Palette; +pub use xterm::xterm_color; diff --git a/userland/nonos_vt/src/color/name.rs b/userland/nonos_vt/src/color/name.rs new file mode 100644 index 000000000..dd2872ba5 --- /dev/null +++ b/userland/nonos_vt/src/color/name.rs @@ -0,0 +1,36 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A colour as a cell records it. + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Color { + /// The theme's foreground or background, whichever slot this is. + #[default] + Default, + /// One of the 256 palette entries. + Indexed(u8), + Rgb(u8, u8, u8), +} + +pub fn rgb(r: u8, g: u8, b: u8) -> u32 { + ((r as u32) << 16) | ((g as u32) << 8) | b as u32 +} + +/// Halve a colour toward black, for dim text. +pub fn dim(c: u32) -> u32 { + (c >> 1) & 0x7F7F7F +} diff --git a/userland/nonos_vt/src/color/palette.rs b/userland/nonos_vt/src/color/palette.rs new file mode 100644 index 000000000..67798cfd3 --- /dev/null +++ b/userland/nonos_vt/src/color/palette.rs @@ -0,0 +1,63 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Colours as 0xRRGGBB for each name a cell can carry. + +use super::name::{rgb, Color}; +use super::xterm::xterm_color; + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Palette { + pub colors: [u32; 256], + pub fg: u32, + pub bg: u32, + pub cursor: u32, +} + +impl Palette { + pub fn xterm() -> Palette { + let mut colors = [0u32; 256]; + for (i, c) in colors.iter_mut().enumerate() { + *c = xterm_color(i as u8); + } + Palette { colors, fg: 0xE5E5E5, bg: 0x000000, cursor: 0xE5E5E5 } + } + + /// The pixel for `c` in a foreground slot. `bright` is bold text asking + /// for the bright variant of the first eight colours, as xterm draws it. + pub fn fg_of(&self, c: Color, bright: bool) -> u32 { + match c { + Color::Default => self.fg, + Color::Indexed(i) if bright && i < 8 => self.colors[i as usize + 8], + Color::Indexed(i) => self.colors[i as usize], + Color::Rgb(r, g, b) => rgb(r, g, b), + } + } + + pub fn bg_of(&self, c: Color) -> u32 { + match c { + Color::Default => self.bg, + Color::Indexed(i) => self.colors[i as usize], + Color::Rgb(r, g, b) => rgb(r, g, b), + } + } +} + +impl Default for Palette { + fn default() -> Palette { + Palette::xterm() + } +} diff --git a/userland/nonos_vt/src/color/xterm.rs b/userland/nonos_vt/src/color/xterm.rs new file mode 100644 index 000000000..c10156f6d --- /dev/null +++ b/userland/nonos_vt/src/color/xterm.rs @@ -0,0 +1,38 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The xterm palette: sixteen named colours, a 6x6x6 cube, then a 24-step +//! grey ramp. + +const BASE16: [u32; 16] = [ + 0x000000, 0xCD0000, 0x00CD00, 0xCDCD00, 0x0000EE, 0xCD00CD, 0x00CDCD, 0xE5E5E5, 0x7F7F7F, + 0xFF0000, 0x00FF00, 0xFFFF00, 0x5C5CFF, 0xFF00FF, 0x00FFFF, 0xFFFFFF, +]; + +pub fn xterm_color(i: u8) -> u32 { + match i { + 0..=15 => BASE16[i as usize], + 16..=231 => { + let n = i - 16; + let level = |v: u8| if v == 0 { 0u32 } else { 55 + 40 * v as u32 }; + (level(n / 36) << 16) | (level(n / 6 % 6) << 8) | level(n % 6) + } + _ => { + let g = 8 + 10 * (i as u32 - 232); + (g << 16) | (g << 8) | g + } + } +} diff --git a/userland/nonos_vt/src/input/keys.rs b/userland/nonos_vt/src/input/keys.rs new file mode 100644 index 000000000..cc54ab492 --- /dev/null +++ b/userland/nonos_vt/src/input/keys.rs @@ -0,0 +1,73 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Keys to bytes. + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Key { + /// A character as typed, Shift already applied. + Char(char), + Enter, + Tab, + Backspace, + Escape, + Up, + Down, + Left, + Right, + Home, + End, + PageUp, + PageDown, + Insert, + Delete, + /// F1 to F12. + F(u8), +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct Mods { + pub shift: bool, + pub alt: bool, + pub ctrl: bool, +} + +impl Mods { + /// xterm's modifier parameter: 1 plus shift 1, alt 2, ctrl 4. + pub(super) fn param(&self) -> u8 { + 1 + self.shift as u8 + 2 * self.alt as u8 + 4 * self.ctrl as u8 + } + + pub(super) fn any(&self) -> bool { + self.shift || self.alt || self.ctrl + } +} + +/// Control-key bytes: letters and the punctuation around them. +pub(super) fn ctrl_byte(c: char) -> Option { + Some(match c { + 'a'..='z' => c as u8 - b'a' + 1, + 'A'..='Z' => c as u8 - b'A' + 1, + '@' | ' ' | '2' => 0, + '[' | '3' => 0x1B, + '\\' | '4' => 0x1C, + ']' | '5' => 0x1D, + '^' | '6' => 0x1E, + '_' | '-' | '7' => 0x1F, + '?' | '8' => 0x7F, + _ => return None, + }) +} diff --git a/userland/nonos_vt/src/input/keys_encode.rs b/userland/nonos_vt/src/input/keys_encode.rs new file mode 100644 index 000000000..3c5983ec1 --- /dev/null +++ b/userland/nonos_vt/src/input/keys_encode.rs @@ -0,0 +1,75 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The bytes each key sends under the modes a program set. + +use alloc::vec::Vec; + +use super::keys::{ctrl_byte, Key, Mods}; +use super::keys_seq::{letter, tilde}; +use crate::term::Modes; + +/// Append the bytes for `key` to `out`. Returns false for a key that sends +/// nothing. +pub fn encode_key(key: Key, m: Mods, modes: &Modes, out: &mut Vec) -> bool { + let app = modes.cursor_keys; + match key { + Key::Char(c) => { + if m.alt { + out.push(0x1B); + } + match m.ctrl.then(|| ctrl_byte(c)).flatten() { + Some(b) => out.push(b), + None => { + let mut buf = [0u8; 4]; + out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + } + } + Key::Enter => { + if m.alt { + out.push(0x1B); + } + out.extend_from_slice(if modes.newline { b"\r\n" } else { b"\r" }); + } + Key::Tab if m.shift => out.extend_from_slice(b"\x1b[Z"), + Key::Tab => out.push(b'\t'), + Key::Backspace => { + if m.alt { + out.push(0x1B); + } + out.push(if m.ctrl { 0x08 } else { 0x7F }); + } + Key::Escape => out.push(0x1B), + Key::Up => letter(out, 'A', m, app), + Key::Down => letter(out, 'B', m, app), + Key::Right => letter(out, 'C', m, app), + Key::Left => letter(out, 'D', m, app), + Key::Home => letter(out, 'H', m, app), + Key::End => letter(out, 'F', m, app), + Key::Insert => tilde(out, 2, m), + Key::Delete => tilde(out, 3, m), + Key::PageUp => tilde(out, 5, m), + Key::PageDown => tilde(out, 6, m), + Key::F(n @ 1..=4) => letter(out, (b'P' + n - 1) as char, m, true), + Key::F(n @ 5..=12) => { + const CODES: [u8; 8] = [15, 17, 18, 19, 20, 21, 23, 24]; + tilde(out, CODES[(n - 5) as usize], m); + } + Key::F(_) => return false, + } + true +} diff --git a/userland/nonos_vt/src/input/keys_seq.rs b/userland/nonos_vt/src/input/keys_seq.rs new file mode 100644 index 000000000..833a1a124 --- /dev/null +++ b/userland/nonos_vt/src/input/keys_seq.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The two shapes a special key's sequence takes. + +use alloc::vec::Vec; +use core::fmt::Write; + +use super::keys::Mods; +use crate::out::Out; + +/// A key with a final letter: `ESC [ A`, `ESC O A` in application mode, +/// `ESC [ 1 ; m A` with modifiers. +pub(super) fn letter(out: &mut Vec, fin: char, m: Mods, ss3: bool) { + if m.any() { + let _ = write!(Out(out), "\x1b[1;{}{fin}", m.param()); + } else if ss3 { + let _ = write!(Out(out), "\x1bO{fin}"); + } else { + let _ = write!(Out(out), "\x1b[{fin}"); + } +} + +/// A key numbered with a tilde: `ESC [ n ~`, `ESC [ n ; m ~`. +pub(super) fn tilde(out: &mut Vec, n: u8, m: Mods) { + if m.any() { + let _ = write!(Out(out), "\x1b[{n};{}~", m.param()); + } else { + let _ = write!(Out(out), "\x1b[{n}~"); + } +} diff --git a/userland/nonos_vt/src/input/mod.rs b/userland/nonos_vt/src/input/mod.rs new file mode 100644 index 000000000..0ba70fbd5 --- /dev/null +++ b/userland/nonos_vt/src/input/mod.rs @@ -0,0 +1,31 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What a program reads when a person types, points or pastes, encoded as +//! xterm encodes it under the modes the program set. + +mod keys; +mod keys_encode; +mod keys_seq; +mod mouse; +mod mouse_encode; +mod paste; + +pub use keys::{Key, Mods}; +pub use keys_encode::encode_key; +pub use mouse::{Button, MouseEvent, MouseKind}; +pub use mouse_encode::{encode_focus, encode_mouse}; +pub use paste::encode_paste; diff --git a/userland/nonos_vt/src/input/mouse.rs b/userland/nonos_vt/src/input/mouse.rs new file mode 100644 index 000000000..5ee5f9213 --- /dev/null +++ b/userland/nonos_vt/src/input/mouse.rs @@ -0,0 +1,60 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Pointer events to bytes, for programs that asked for them. + +use super::keys::Mods; +use crate::term::MouseMode; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Button { + Left, + Middle, + Right, + WheelUp, + WheelDown, + /// Motion with no button held. + None, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum MouseKind { + Press, + Release, + Motion, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct MouseEvent { + pub kind: MouseKind, + pub button: Button, + /// Cell, 0-based. + pub col: usize, + pub row: usize, + pub mods: Mods, +} + +pub(super) fn wanted(ev: &MouseEvent, mode: MouseMode) -> bool { + let wheel = matches!(ev.button, Button::WheelUp | Button::WheelDown); + match (mode, ev.kind) { + (MouseMode::Off, _) => false, + (MouseMode::Press, k) => k == MouseKind::Press, + (_, MouseKind::Press | MouseKind::Release) => !(wheel && ev.kind == MouseKind::Release), + (MouseMode::Drag, MouseKind::Motion) => ev.button != Button::None, + (MouseMode::Motion, MouseKind::Motion) => true, + (MouseMode::Click, MouseKind::Motion) => false, + } +} diff --git a/userland/nonos_vt/src/input/mouse_encode.rs b/userland/nonos_vt/src/input/mouse_encode.rs new file mode 100644 index 000000000..30f95680b --- /dev/null +++ b/userland/nonos_vt/src/input/mouse_encode.rs @@ -0,0 +1,73 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Pointer reports in the SGR form, or the old one-byte form when its +//! coordinates fit. + +use alloc::vec::Vec; +use core::fmt::Write; + +use super::mouse::{wanted, Button, MouseEvent, MouseKind}; +use crate::out::Out; +use crate::term::{Modes, MouseMode}; + +/// Append the report for `ev`, or return false when the program did not ask +/// for it, or when the old encoding cannot carry its position. +pub fn encode_mouse(ev: &MouseEvent, modes: &Modes, out: &mut Vec) -> bool { + if !wanted(ev, modes.mouse) { + return false; + } + let mut cb: u32 = match ev.button { + Button::Left => 0, + Button::Middle => 1, + Button::Right => 2, + Button::None => 3, + Button::WheelUp => 64, + Button::WheelDown => 65, + }; + if ev.kind == MouseKind::Motion { + cb += 32; + } + if modes.mouse != MouseMode::Press { + let m = ev.mods; + cb += 4 * m.shift as u32 + 8 * m.alt as u32 + 16 * m.ctrl as u32; + } + let (x, y) = (ev.col + 1, ev.row + 1); + if modes.mouse_sgr { + let end = if ev.kind == MouseKind::Release { 'm' } else { 'M' }; + let _ = write!(Out(out), "\x1b[<{cb};{x};{y}{end}"); + return true; + } + // The old form has no release per button and one byte per coordinate. + if ev.kind == MouseKind::Release { + cb = (cb & !3) | 3; + } + if x > 223 || y > 223 { + return false; + } + out.extend_from_slice(b"\x1b[M"); + out.extend_from_slice(&[(cb + 32) as u8, (x + 32) as u8, (y + 32) as u8]); + true +} + +/// `CSI I` on focus, `CSI O` on losing it, when the program asked. +pub fn encode_focus(focused: bool, modes: &Modes, out: &mut Vec) -> bool { + if !modes.focus { + return false; + } + out.extend_from_slice(if focused { b"\x1b[I" } else { b"\x1b[O" }); + true +} diff --git a/userland/nonos_vt/src/input/paste.rs b/userland/nonos_vt/src/input/paste.rs new file mode 100644 index 000000000..7b243e558 --- /dev/null +++ b/userland/nonos_vt/src/input/paste.rs @@ -0,0 +1,49 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Pasted text to bytes. +//! +//! Escape bytes and other controls are stripped first. Without that, text +//! copied from a web page could carry `ESC [ 201 ~`, end the bracket early, +//! and have the rest run as typed commands. Line ends become carriage +//! returns, which is what the Enter key sends. + +use alloc::vec::Vec; + +use crate::term::Modes; + +pub fn encode_paste(text: &str, modes: &Modes, out: &mut Vec) { + if modes.bracketed_paste { + out.extend_from_slice(b"\x1b[200~"); + } + let mut prev_cr = false; + for c in text.chars() { + match c { + '\n' if prev_cr => {} + '\r' | '\n' => out.push(b'\r'), + '\t' => out.push(b'\t'), + c if c.is_control() => {} + c => { + let mut buf = [0u8; 4]; + out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + } + prev_cr = c == '\r'; + } + if modes.bracketed_paste { + out.extend_from_slice(b"\x1b[201~"); + } +} diff --git a/userland/nonos_vt/src/lib.rs b/userland/nonos_vt/src/lib.rs new file mode 100644 index 000000000..4355c869d --- /dev/null +++ b/userland/nonos_vt/src/lib.rs @@ -0,0 +1,47 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Terminal emulation: the bytes a program writes become a screen of cells, +//! and the keys a person presses become the bytes that program reads. +//! +//! The parser follows the DEC state machine, so every byte has one meaning in +//! every state and nothing a program writes can leave it stuck. The screen +//! answers what xterm answers for the sequences full-screen tools depend on, +//! resizes by re-wrapping its lines, and keeps a bounded scrollback. Every +//! buffer that hostile output could grow has a fixed ceiling in `limits`. + +#![no_std] + +extern crate alloc; + +pub mod cell; +pub mod charset; +pub mod color; +pub mod input; +pub mod limits; +pub mod line; +mod out; +pub mod params; +pub mod parser; +pub mod term; +pub mod url; +pub mod utf8; +pub mod width; + +pub use cell::{Cell, Pen, Underline}; +pub use color::{Color, Palette}; +pub use line::Line; +pub use term::{ClipboardRequest, CursorShape, CursorView, Modes, MouseMode, Pos, Term}; diff --git a/userland/nonos_vt/src/limits.rs b/userland/nonos_vt/src/limits.rs new file mode 100644 index 000000000..66872f643 --- /dev/null +++ b/userland/nonos_vt/src/limits.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Ceilings on everything a program's output can make this crate hold. + +/// Screen size bounds. A resize outside them is clamped. +pub const MIN_COLS: usize = 2; +pub const MIN_ROWS: usize = 1; +pub const MAX_COLS: usize = 1000; +pub const MAX_ROWS: usize = 500; + +/// Scrollback lines kept when the host does not choose. +pub const DEFAULT_SCROLLBACK: usize = 5000; +pub const MAX_SCROLLBACK: usize = 100_000; + +/// Numeric parameters in one control sequence, and the largest value one +/// may carry. A sequence with more parameters is ignored whole, because +/// acting on a truncated list would apply the wrong settings. +pub const MAX_PARAMS: usize = 32; +pub const MAX_PARAM: u16 = u16::MAX; +pub const MAX_INTERMEDIATES: usize = 2; + +/// String sequences: OSC and DCS payloads past these are dropped whole. +pub const MAX_OSC: usize = 4096; +pub const MAX_DCS: usize = 1024; + +pub const MAX_TITLE: usize = 256; +pub const MAX_TITLE_STACK: usize = 10; + +/// Distinct OSC 8 hyperlink targets held, and the longest one kept. +pub const MAX_LINKS: usize = 1024; +pub const MAX_LINK: usize = 2048; + +/// Reply bytes held for a program that is not reading them. Past this the +/// newest replies are dropped: a program that never reads cannot make the +/// terminal allocate without bound by asking questions. +pub const MAX_REPLY: usize = 4096; + +/// Combining marks one line may carry. +pub const MAX_MARKS_PER_LINE: usize = 512; + +/// Repeat count accepted by REP, and prompt starts remembered for jumping. +pub const MAX_REPEAT: usize = MAX_COLS * MAX_ROWS; +pub const MAX_PROMPT_MARKS: usize = 1024; diff --git a/userland/nonos_vt/src/line/data.rs b/userland/nonos_vt/src/line/data.rs new file mode 100644 index 000000000..6b6798bb1 --- /dev/null +++ b/userland/nonos_vt/src/line/data.rs @@ -0,0 +1,69 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The line itself. + +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; + +use crate::cell::Cell; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Line { + pub cells: Vec, + /// The text ran on to the next line rather than ending here, so a copy or + /// a re-wrap joins the two. + pub wrapped: bool, + pub(super) marks: Vec, +} + +impl Line { + pub fn new(cols: usize, fill: Cell) -> Line { + Line { cells: vec![fill; cols], wrapped: false, marks: Vec::new() } + } + + pub fn len(&self) -> usize { + self.cells.len() + } + + pub fn is_empty(&self) -> bool { + self.cells.is_empty() + } + + pub fn cell(&self, x: usize) -> Cell { + self.cells.get(x).copied().unwrap_or(Cell::BLANK) + } + + pub fn clear(&mut self, fill: Cell) { + for c in self.cells.iter_mut() { + *c = fill; + } + self.marks.clear(); + self.wrapped = false; + } + + /// Append `cell`, carrying its marks over from `src`. + pub fn push_from(&mut self, src: &Line, cell: Cell) { + self.cells.push(Cell { mark: 0, ..cell }); + if let Some(s) = src.marks_of(&cell) { + let x = self.cells.len() - 1; + for ch in s.chars() { + self.add_mark(x, ch); + } + } + } +} diff --git a/userland/nonos_vt/src/line/marks.rs b/userland/nonos_vt/src/line/marks.rs new file mode 100644 index 000000000..873be7087 --- /dev/null +++ b/userland/nonos_vt/src/line/marks.rs @@ -0,0 +1,70 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Combining marks, held beside the cells they are drawn over. + +use alloc::string::String; + +use super::Line; +use crate::cell::Cell; +use crate::limits::MAX_MARKS_PER_LINE; + +impl Line { + /// The combining marks drawn over `cell`, if it has any. + pub fn marks_of(&self, cell: &Cell) -> Option<&str> { + let i = (cell.mark as usize).checked_sub(1)?; + self.marks.get(i).map(|s| s.as_str()) + } + + /// Put a combining mark over the cell at `x`. Refused, and the mark + /// dropped, once the line holds as many as it may. + pub fn add_mark(&mut self, x: usize, c: char) -> bool { + let Some(cell) = self.cells.get(x).copied() else { return false }; + if let Some(i) = (cell.mark as usize).checked_sub(1) { + if let Some(s) = self.marks.get_mut(i) { + let room = s.len() < 32; + if room { + s.push(c); + } + return room; + } + } + if self.marks.len() >= MAX_MARKS_PER_LINE { + self.compact_marks(); + if self.marks.len() >= MAX_MARKS_PER_LINE { + return false; + } + } + self.marks.push(String::from(c)); + self.cells[x].mark = self.marks.len() as u16; + true + } + + /// Drop marks no cell points at any more, renumbering the rest. + fn compact_marks(&mut self) { + let old = core::mem::take(&mut self.marks); + for cell in self.cells.iter_mut() { + let Some(i) = (cell.mark as usize).checked_sub(1) else { continue }; + cell.mark = match old.get(i) { + Some(s) => { + self.marks.push(s.clone()); + self.marks.len() as u16 + } + None => 0, + }; + } + } +} diff --git a/userland/nonos_vt/src/line/mod.rs b/userland/nonos_vt/src/line/mod.rs new file mode 100644 index 000000000..b24b64c82 --- /dev/null +++ b/userland/nonos_vt/src/line/mod.rs @@ -0,0 +1,30 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One row of cells, on screen or in scrollback. +//! +//! A screen line is always as wide as the screen. A scrollback line may be +//! shorter: trailing blanks are dropped when it leaves the screen, and a +//! reader asking past its end gets a blank. Every write keeps two-column +//! characters whole: overwriting either half blanks the other. + +mod data; +mod marks; +mod shift; +mod trim; +mod write; + +pub use data::Line; diff --git a/userland/nonos_vt/src/line/shift.rs b/userland/nonos_vt/src/line/shift.rs new file mode 100644 index 000000000..a7d13afd6 --- /dev/null +++ b/userland/nonos_vt/src/line/shift.rs @@ -0,0 +1,68 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Moving a line's cells: inserting, deleting, resizing and trimming. + +use super::Line; +use crate::cell::Cell; + +impl Line { + /// Open `n` blank cells at `x`, pushing the rest right and off the end. + pub fn insert_blanks(&mut self, x: usize, n: usize, fill: Cell) { + let len = self.cells.len(); + if x >= len || n == 0 { + return; + } + let n = n.min(len - x); + self.split_wide_at(x); + self.cells[x..].rotate_right(n); + for c in &mut self.cells[x..x + n] { + *c = fill; + } + self.drop_cut_head(); + } + + /// Remove `n` cells at `x`, pulling the rest left and blanking the end. + pub fn delete_cells(&mut self, x: usize, n: usize, fill: Cell) { + let len = self.cells.len(); + if x >= len || n == 0 { + return; + } + let n = n.min(len - x); + self.split_wide_at(x); + self.split_wide_at(x + n - 1); + self.cells[x..].rotate_left(n); + for c in &mut self.cells[len - n..] { + *c = fill; + } + } + + /// A wide character pushed half off the right edge loses its head too. + fn drop_cut_head(&mut self) { + if let Some(last) = self.cells.last_mut() { + if last.is_wide() { + let bg = last.bg; + *last = Cell { bg, ..Cell::BLANK }; + } + } + } + + /// Change the width to `cols`, padding with `fill`. + pub fn resize(&mut self, cols: usize, fill: Cell) { + self.cells.resize(cols, fill); + self.drop_cut_head(); + } +} diff --git a/userland/nonos_vt/src/line/trim.rs b/userland/nonos_vt/src/line/trim.rs new file mode 100644 index 000000000..d104cf88b --- /dev/null +++ b/userland/nonos_vt/src/line/trim.rs @@ -0,0 +1,35 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What of a line is text, and dropping the rest when it leaves the screen. + +use super::Line; + +impl Line { + /// Cells up to the last one a reader would miss. + pub fn content_len(&self) -> usize { + self.cells.iter().rposition(|c| !c.is_plain_blank()).map_or(0, |i| i + 1) + } + + /// Drop trailing blanks, for a line leaving the screen. A wrapped line + /// keeps them: its spaces run on into the next line and are text. + pub fn trim(&mut self) { + if !self.wrapped { + let n = self.content_len(); + self.cells.truncate(n); + } + } +} diff --git a/userland/nonos_vt/src/line/write.rs b/userland/nonos_vt/src/line/write.rs new file mode 100644 index 000000000..80435e7a7 --- /dev/null +++ b/userland/nonos_vt/src/line/write.rs @@ -0,0 +1,70 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Writes into a line that keep two-column characters whole. + +use super::Line; +use crate::cell::Cell; + +impl Line { + /// Blank the other half of any wide character that has a cell at `x` + /// about to be overwritten. + pub(super) fn split_wide_at(&mut self, x: usize) { + let Some(c) = self.cells.get(x).copied() else { return }; + if c.is_tail() && x > 0 { + let bg = self.cells[x - 1].bg; + self.cells[x - 1] = Cell { bg, ..Cell::BLANK }; + } + if c.is_wide() { + if let Some(t) = self.cells.get_mut(x + 1) { + let bg = t.bg; + *t = Cell { bg, ..Cell::BLANK }; + } + } + } + + pub fn put(&mut self, x: usize, cell: Cell) { + if x >= self.cells.len() { + return; + } + self.split_wide_at(x); + self.cells[x] = cell; + } + + /// Write a two-column character: its head at `x`, its tail after. + pub fn put_pair(&mut self, x: usize, head: Cell, tail: Cell) { + if x + 1 >= self.cells.len() { + return; + } + self.split_wide_at(x); + self.split_wide_at(x + 1); + self.cells[x] = head; + self.cells[x + 1] = tail; + } + + /// Blank `[from, to)`. + pub fn erase(&mut self, from: usize, to: usize, fill: Cell) { + let to = to.min(self.cells.len()); + if from >= to { + return; + } + self.split_wide_at(from); + self.split_wide_at(to - 1); + for c in &mut self.cells[from..to] { + *c = fill; + } + } +} diff --git a/userland/nonos_vt/src/out.rs b/userland/nonos_vt/src/out.rs new file mode 100644 index 000000000..81011fadf --- /dev/null +++ b/userland/nonos_vt/src/out.rs @@ -0,0 +1,30 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Formatting straight into a byte buffer, for the sequences this crate +//! writes. + +use alloc::vec::Vec; +use core::fmt::Write; + +pub(crate) struct Out<'a>(pub(crate) &'a mut Vec); + +impl Write for Out<'_> { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + self.0.extend_from_slice(s.as_bytes()); + Ok(()) + } +} diff --git a/userland/nonos_vt/src/params/collect.rs b/userland/nonos_vt/src/params/collect.rs new file mode 100644 index 000000000..f3e1cbe72 --- /dev/null +++ b/userland/nonos_vt/src/params/collect.rs @@ -0,0 +1,72 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Collecting parameters as their bytes arrive. + +use crate::limits::{MAX_PARAM, MAX_PARAMS}; + +#[derive(Clone, Default, Debug)] +pub struct Params { + pub(super) vals: [u16; MAX_PARAMS], + /// Bit `i` set: value `i` followed a colon, a sub-parameter of the one + /// before it. + pub(super) sub: u32, + pub(super) len: usize, + /// More parameters than fit; the sequence is ignored. + pub overflow: bool, +} + +impl Params { + pub fn clear(&mut self) { + self.len = 0; + self.sub = 0; + self.overflow = false; + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + pub fn digit(&mut self, d: u8) { + if self.len == 0 { + self.len = 1; + self.vals[0] = 0; + } + let v = &mut self.vals[self.len - 1]; + *v = (*v as u32 * 10 + d as u32).min(MAX_PARAM as u32) as u16; + } + + /// A separator: `colon` makes the next value a sub-parameter. + pub fn separator(&mut self, colon: bool) { + if self.len == 0 { + self.len = 1; + self.vals[0] = 0; + } + if self.len == MAX_PARAMS { + self.overflow = true; + return; + } + self.vals[self.len] = 0; + if colon { + self.sub |= 1 << self.len; + } + self.len += 1; + } +} diff --git a/userland/nonos_vt/src/params/mod.rs b/userland/nonos_vt/src/params/mod.rs new file mode 100644 index 000000000..e73ac6c74 --- /dev/null +++ b/userland/nonos_vt/src/params/mod.rs @@ -0,0 +1,25 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The numbers in a control sequence. `;` separates parameters and `:` +//! separates the sub-parameters of one, as in `38:2::255:0:0`, so a colour +//! written either way reads the same. + +mod collect; +mod read; + +pub use collect::Params; +pub use read::Groups; diff --git a/userland/nonos_vt/src/params/read.rs b/userland/nonos_vt/src/params/read.rs new file mode 100644 index 000000000..7d9033e9a --- /dev/null +++ b/userland/nonos_vt/src/params/read.rs @@ -0,0 +1,68 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Reading parameters back, by position or grouped with their +//! sub-parameters. + +use super::Params; + +impl Params { + /// Value `i`, top-level and sub-parameters counted alike. + pub fn raw(&self, i: usize) -> Option { + (i < self.len).then(|| self.vals[i]) + } + + /// Parameter `i` counting only top-level values, 0 when absent. + pub fn get(&self, i: usize) -> u16 { + self.groups().nth(i).and_then(|g| g.first().copied()).unwrap_or(0) + } + + /// Parameter `i`, with 0 and absent both meaning `d`, as most sequences + /// read a count or a position. + pub fn count(&self, i: usize, d: u16) -> u16 { + match self.get(i) { + 0 => d, + v => v, + } + } + + /// Each top-level value with its sub-parameters. + pub fn groups(&self) -> Groups<'_> { + Groups { p: self, i: 0 } + } +} + +pub struct Groups<'a> { + p: &'a Params, + i: usize, +} + +impl<'a> Iterator for Groups<'a> { + type Item = &'a [u16]; + + fn next(&mut self) -> Option<&'a [u16]> { + let start = self.i; + if start >= self.p.len { + return None; + } + let mut end = start + 1; + while end < self.p.len && self.p.sub & (1 << end) != 0 { + end += 1; + } + self.i = end; + Some(&self.p.vals[start..end]) + } +} diff --git a/userland/nonos_vt/src/parser/advance.rs b/userland/nonos_vt/src/parser/advance.rs new file mode 100644 index 000000000..2068086f6 --- /dev/null +++ b/userland/nonos_vt/src/parser/advance.rs @@ -0,0 +1,62 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One byte through the machine: the transitions every state shares, then +//! the state's own. + +use super::handler::Handler; +use super::machine::{Parser, State}; + +impl Parser { + pub fn advance(&mut self, h: &mut H, b: u8) { + match b { + // CAN and SUB abandon whatever was being collected. + 0x18 | 0x1A => { + self.flush_utf8(h); + h.execute(b); + self.state = State::Ground; + return; + } + // ESC ends a string, dispatching it, and starts a new sequence. + 0x1B => { + match self.state { + State::Osc => self.end_osc(h), + State::DcsPass => self.end_dcs(h), + _ => {} + } + self.flush_utf8(h); + self.seq.clear(); + self.state = State::Escape; + return; + } + _ => {} + } + match self.state { + State::Ground => self.ground(h, b), + State::Escape => self.escape(h, b), + State::EscapeInter => self.escape_inter(h, b), + State::CsiEntry | State::CsiParam | State::CsiInter | State::CsiIgnore => { + self.csi_state(h, b) + } + State::DcsEntry | State::DcsParam | State::DcsInter | State::DcsIgnore => { + self.dcs_state(b) + } + State::DcsPass => self.dcs_pass(b), + State::Osc => self.osc_byte(h, b), + State::Ignored => {} + } + } +} diff --git a/userland/nonos_vt/src/parser/csi_states.rs b/userland/nonos_vt/src/parser/csi_states.rs new file mode 100644 index 000000000..e81358d5f --- /dev/null +++ b/userland/nonos_vt/src/parser/csi_states.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Collecting the parameters of a CSI sequence, up to its final byte. + +use super::handler::Handler; +use super::machine::{Parser, State}; + +impl Parser { + pub(super) fn csi_state(&mut self, h: &mut H, b: u8) { + let st = self.state; + match b { + 0x00..=0x1F => h.execute(b), + 0x7F => {} + 0x40..=0x7E => { + if st != State::CsiIgnore && !self.seq.truncated() { + self.seq.final_byte = b; + h.csi(&self.seq); + } + self.state = State::Ground; + } + _ if st == State::CsiIgnore => {} + 0x30..=0x39 if st != State::CsiInter => { + self.seq.params.digit(b - b'0'); + self.state = State::CsiParam; + } + b';' | b':' if st != State::CsiInter => { + self.seq.params.separator(b == b':'); + self.state = State::CsiParam; + } + // A private marker is only a marker as the first byte. + 0x3C..=0x3F if st == State::CsiEntry => { + self.seq.private = b; + self.state = State::CsiParam; + } + 0x20..=0x2F => { + self.seq.collect(b); + self.state = State::CsiInter; + } + // Anything else out of place: read to the final byte, then drop. + _ => self.state = State::CsiIgnore, + } + } +} diff --git a/userland/nonos_vt/src/parser/dcs_states.rs b/userland/nonos_vt/src/parser/dcs_states.rs new file mode 100644 index 000000000..2db8e5498 --- /dev/null +++ b/userland/nonos_vt/src/parser/dcs_states.rs @@ -0,0 +1,50 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Collecting the parameters of a DCS sequence, up to its final byte. + +use super::machine::{Parser, State}; + +impl Parser { + pub(super) fn dcs_state(&mut self, b: u8) { + let st = self.state; + match b { + 0x00..=0x1F | 0x7F => {} + 0x40..=0x7E => { + self.seq.final_byte = b; + self.state = if st == State::DcsIgnore { State::DcsIgnore } else { State::DcsPass }; + } + _ if st == State::DcsIgnore => {} + 0x30..=0x39 if st != State::DcsInter => { + self.seq.params.digit(b - b'0'); + self.state = State::DcsParam; + } + b';' | b':' if st != State::DcsInter => { + self.seq.params.separator(b == b':'); + self.state = State::DcsParam; + } + 0x3C..=0x3F if st == State::DcsEntry => { + self.seq.private = b; + self.state = State::DcsParam; + } + 0x20..=0x2F => { + self.seq.collect(b); + self.state = State::DcsInter; + } + _ => self.state = State::DcsIgnore, + } + } +} diff --git a/userland/nonos_vt/src/parser/escape.rs b/userland/nonos_vt/src/parser/escape.rs new file mode 100644 index 000000000..f6ea1a034 --- /dev/null +++ b/userland/nonos_vt/src/parser/escape.rs @@ -0,0 +1,66 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! After ESC: an escape sequence, or the introducer of a longer one. + +use super::handler::Handler; +use super::machine::{Parser, State}; + +impl Parser { + pub(super) fn escape(&mut self, h: &mut H, b: u8) { + match b { + 0x00..=0x1F => h.execute(b), + 0x20..=0x2F => { + self.seq.collect(b); + self.state = State::EscapeInter; + } + b'[' => self.state = State::CsiEntry, + b']' => self.start_string(State::Osc), + b'P' => self.start_string(State::DcsEntry), + b'X' | b'^' | b'_' => self.state = State::Ignored, + 0x30..=0x7E => { + self.seq.final_byte = b; + h.esc(&self.seq); + self.state = State::Ground; + } + 0x7F => {} + // Bytes past 0x7F are not part of any escape sequence. + _ => self.state = State::Ground, + } + } + + pub(super) fn escape_inter(&mut self, h: &mut H, b: u8) { + match b { + 0x00..=0x1F => h.execute(b), + 0x20..=0x2F => self.seq.collect(b), + 0x30..=0x7E => { + self.seq.final_byte = b; + if !self.seq.truncated() { + h.esc(&self.seq); + } + self.state = State::Ground; + } + 0x7F => {} + _ => self.state = State::Ground, + } + } + + pub(super) fn start_string(&mut self, state: State) { + self.buf.clear(); + self.buf_full = false; + self.state = state; + } +} diff --git a/userland/nonos_vt/src/parser/ground.rs b/userland/nonos_vt/src/parser/ground.rs new file mode 100644 index 000000000..bb6bcf9f9 --- /dev/null +++ b/userland/nonos_vt/src/parser/ground.rs @@ -0,0 +1,53 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The ground state: text, as UTF-8, and C0 controls. + +use super::handler::Handler; +use super::machine::Parser; +use crate::utf8::Step; + +impl Parser { + /// A character cut short by a control byte is shown as U+FFFD, not lost + /// silently and not merged with what follows. + pub(super) fn flush_utf8(&mut self, h: &mut H) { + if self.utf8.pending() { + self.utf8.reset(); + h.print(char::REPLACEMENT_CHARACTER); + } + } + + pub(super) fn ground(&mut self, h: &mut H, b: u8) { + if b < 0x20 { + self.flush_utf8(h); + h.execute(b); + return; + } + if b == 0x7F { + return; + } + match self.utf8.push(b) { + Step::Pending => {} + Step::Char(c) => h.print(c), + Step::Invalid { again } => { + h.print(char::REPLACEMENT_CHARACTER); + if again { + self.ground(h, b); + } + } + } + } +} diff --git a/userland/nonos_vt/src/parser/handler.rs b/userland/nonos_vt/src/parser/handler.rs new file mode 100644 index 000000000..f080593a1 --- /dev/null +++ b/userland/nonos_vt/src/parser/handler.rs @@ -0,0 +1,34 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What the parser reports as it recognises each piece of the byte stream. + +use super::seq::Seq; + +pub trait Handler { + /// A character to draw. + fn print(&mut self, c: char); + /// A C0 control byte. + fn execute(&mut self, b: u8); + /// `ESC [ ... final`. + fn csi(&mut self, seq: &Seq); + /// `ESC intermediates final`. + fn esc(&mut self, seq: &Seq); + /// `ESC ] ... BEL` or `ESC ] ... ESC \`. + fn osc(&mut self, data: &[u8]); + /// `ESC P ... final data ESC \`. + fn dcs(&mut self, seq: &Seq, data: &[u8]); +} diff --git a/userland/nonos_vt/src/parser/machine.rs b/userland/nonos_vt/src/parser/machine.rs new file mode 100644 index 000000000..aa9f10206 --- /dev/null +++ b/userland/nonos_vt/src/parser/machine.rs @@ -0,0 +1,66 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The parser's states and the transitions every state shares. + +use alloc::vec::Vec; + +use super::handler::Handler; +use super::seq::Seq; +use crate::utf8::Utf8; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub(super) enum State { + #[default] + Ground, + Escape, + EscapeInter, + CsiEntry, + CsiParam, + CsiInter, + CsiIgnore, + DcsEntry, + DcsParam, + DcsInter, + DcsPass, + DcsIgnore, + Osc, + /// SOS, PM and APC: read to the terminator and dropped. + Ignored, +} + +#[derive(Default)] +pub struct Parser { + pub(super) state: State, + pub(super) seq: Seq, + pub(super) utf8: Utf8, + /// OSC or DCS payload being collected. + pub(super) buf: Vec, + /// The payload outgrew its ceiling; it is dropped when it ends. + pub(super) buf_full: bool, +} + +impl Parser { + pub fn new() -> Parser { + Parser::default() + } + + pub fn feed(&mut self, h: &mut H, bytes: &[u8]) { + for &b in bytes { + self.advance(h, b); + } + } +} diff --git a/userland/nonos_vt/src/parser/mod.rs b/userland/nonos_vt/src/parser/mod.rs new file mode 100644 index 000000000..7058e38b9 --- /dev/null +++ b/userland/nonos_vt/src/parser/mod.rs @@ -0,0 +1,32 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The DEC control-sequence state machine, after Paul Williams' description +//! of the VT500 parser, with UTF-8 in the ground state. + +mod advance; +mod csi_states; +mod dcs_states; +mod escape; +mod ground; +mod handler; +mod machine; +mod seq; +mod strings; + +pub use handler::Handler; +pub use machine::Parser; +pub use seq::Seq; diff --git a/userland/nonos_vt/src/parser/seq.rs b/userland/nonos_vt/src/parser/seq.rs new file mode 100644 index 000000000..f8a4ec717 --- /dev/null +++ b/userland/nonos_vt/src/parser/seq.rs @@ -0,0 +1,61 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A control sequence as it was collected: private marker, parameters, +//! intermediate bytes and the final byte that names it. + +use crate::limits::MAX_INTERMEDIATES; +use crate::params::Params; + +#[derive(Clone, Default, Debug)] +pub struct Seq { + /// `?`, `>`, `=` or `<` straight after the introducer, else 0. + pub private: u8, + pub params: Params, + inter: [u8; MAX_INTERMEDIATES], + ninter: usize, + /// More intermediates than fit; the sequence is ignored. + pub overflow: bool, + pub final_byte: u8, +} + +impl Seq { + pub fn clear(&mut self) { + self.private = 0; + self.params.clear(); + self.ninter = 0; + self.overflow = false; + self.final_byte = 0; + } + + pub fn collect(&mut self, b: u8) { + if self.ninter < MAX_INTERMEDIATES { + self.inter[self.ninter] = b; + self.ninter += 1; + } else { + self.overflow = true; + } + } + + pub fn inter(&self) -> &[u8] { + &self.inter[..self.ninter] + } + + /// Whether the sequence carries anything it could not hold whole. + pub fn truncated(&self) -> bool { + self.overflow || self.params.overflow + } +} diff --git a/userland/nonos_vt/src/parser/strings.rs b/userland/nonos_vt/src/parser/strings.rs new file mode 100644 index 000000000..5dda09851 --- /dev/null +++ b/userland/nonos_vt/src/parser/strings.rs @@ -0,0 +1,63 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! OSC and DCS payloads: collected up to a ceiling and handed over whole +//! when they end, or dropped whole when they outgrew it. + +use super::handler::Handler; +use super::machine::{Parser, State}; +use crate::limits::{MAX_DCS, MAX_OSC}; + +impl Parser { + fn keep(&mut self, b: u8, max: usize) { + if self.buf.len() < max { + self.buf.push(b); + } else { + self.buf_full = true; + } + } + + pub(super) fn osc_byte(&mut self, h: &mut H, b: u8) { + match b { + 0x07 => { + self.end_osc(h); + self.state = State::Ground; + } + 0x00..=0x1F => {} + _ => self.keep(b, MAX_OSC), + } + } + + pub(super) fn end_osc(&mut self, h: &mut H) { + if !self.buf_full { + h.osc(&self.buf); + } + self.buf.clear(); + } + + pub(super) fn dcs_pass(&mut self, b: u8) { + if b != 0x7F { + self.keep(b, MAX_DCS); + } + } + + pub(super) fn end_dcs(&mut self, h: &mut H) { + if !self.buf_full && !self.seq.truncated() { + h.dcs(&self.seq, &self.buf); + } + self.buf.clear(); + } +} diff --git a/userland/nonos_vt/src/term/abs.rs b/userland/nonos_vt/src/term/abs.rs new file mode 100644 index 000000000..bb458f362 --- /dev/null +++ b/userland/nonos_vt/src/term/abs.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Absolute line numbers: every line the terminal ever had, counted from +//! its first, so a position in history survives scrolling. + +use super::state::Term; +use super::types::Pos; +use crate::line::Line; + +impl Term { + /// The oldest line still held, and the last line of the screen. + pub fn first_line(&self) -> u64 { + self.scrolled - self.scrollback.len() as u64 + } + + pub fn last_line(&self) -> u64 { + self.scrolled + self.rows as u64 - 1 + } + + pub fn line_at(&self, abs: u64) -> Option<&Line> { + if abs < self.first_line() { + return None; + } + if abs < self.scrolled { + return self.scrollback.get((abs - self.first_line()) as usize); + } + let y = usize::try_from(abs - self.scrolled).ok()?; + self.scr_ref().lines.get(y) + } + + /// The absolute line shown on visible row `row`. + pub fn abs_of_row(&self, row: usize) -> u64 { + if self.alt_active { + return self.scrolled + row as u64; + } + (self.scrolled + row as u64).saturating_sub(self.view as u64) + } + + pub fn cursor_pos(&self) -> Pos { + let s = self.scr_ref(); + Pos { line: self.scrolled + s.cur.y as u64, col: s.cur.x } + } +} diff --git a/userland/nonos_vt/src/term/alt.rs b/userland/nonos_vt/src/term/alt.rs new file mode 100644 index 000000000..9a4a43c42 --- /dev/null +++ b/userland/nonos_vt/src/term/alt.rs @@ -0,0 +1,59 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The alternate screen full-screen programs draw on, which leaves the +//! normal screen and its history untouched underneath. + +use super::state::Term; + +impl Term { + /// Switch to the alternate screen. The cursor carries over, as the two + /// screens share one in xterm. + pub(super) fn enter_alt(&mut self, clear: bool) { + if !self.alt_active { + self.alt.cur = self.primary.cur; + self.alt.reset_region(); + self.alt_active = true; + self.view = 0; + } + if clear { + let blank = self.alt.blank(); + for line in self.alt.lines.iter_mut() { + line.clear(blank); + } + } + self.touch_all(); + } + + pub(super) fn leave_alt(&mut self, clear_first: bool) { + if !self.alt_active { + return; + } + if clear_first { + let blank = self.alt.blank(); + for line in self.alt.lines.iter_mut() { + line.clear(blank); + } + } + self.primary.cur = self.alt.cur; + self.alt_active = false; + self.touch_all(); + } + + pub fn alt_active(&self) -> bool { + self.alt_active + } +} diff --git a/userland/nonos_vt/src/term/answer/dcs.rs b/userland/nonos_vt/src/term/answer/dcs.rs new file mode 100644 index 000000000..a0999fc06 --- /dev/null +++ b/userland/nonos_vt/src/term/answer/dcs.rs @@ -0,0 +1,58 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! DCS requests: DECRQSS for a setting, and XTGETTCAP for terminfo +//! capabilities. Editors ask these at start-up and wait for an answer, so +//! each is answered, as unknown when it is. + +use crate::parser::Seq; +use crate::term::state::Term; +use crate::term::types::CursorShape; + +impl Term { + pub(in crate::term) fn dcs_dispatch(&mut self, seq: &Seq, data: &[u8]) { + match (seq.private, seq.inter(), seq.final_byte) { + (0, [b'$'], b'q') => self.decrqss(data), + (0, [b'+'], b'q') => self.xtgettcap(data), + _ => {} + } + } + + fn decrqss(&mut self, what: &[u8]) { + let answer = match what { + b"r" => { + let s = self.scr_ref(); + Some(alloc::format!("{};{}r", s.top + 1, s.bot + 1)) + } + b" q" => { + let n = match (self.cursor_shape, self.modes.cursor_blink) { + (CursorShape::Block, true) => 1, + (CursorShape::Block, false) => 2, + (CursorShape::Underline, true) => 3, + (CursorShape::Underline, false) => 4, + (CursorShape::Bar, true) => 5, + (CursorShape::Bar, false) => 6, + }; + Some(alloc::format!("{n} q")) + } + _ => None, + }; + match answer { + Some(a) => self.reply_fmt(format_args!("\x1bP1$r{a}\x1b\\")), + None => self.reply(b"\x1bP0$r\x1b\\"), + } + } +} diff --git a/userland/nonos_vt/src/term/answer/mod.rs b/userland/nonos_vt/src/term/answer/mod.rs new file mode 100644 index 000000000..eff396953 --- /dev/null +++ b/userland/nonos_vt/src/term/answer/mod.rs @@ -0,0 +1,25 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Answers to what a program asks: identity, cursor position, modes, size +//! and terminfo. Many programs wait for these, so an unanswered question +//! can hang them. + +mod dcs; +mod reply; +mod report; +mod tcap; +mod winops; diff --git a/userland/nonos_vt/src/term/answer/reply.rs b/userland/nonos_vt/src/term/answer/reply.rs new file mode 100644 index 000000000..6f1a07ab5 --- /dev/null +++ b/userland/nonos_vt/src/term/answer/reply.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The answers queued for the host to write back to the program. + +use crate::limits::MAX_REPLY; +use crate::out::Out; +use crate::term::state::Term; +use alloc::vec::Vec; +use core::fmt::Write; + +impl Term { + /// Queue an answer. A program that never reads cannot grow the queue + /// past its ceiling; the answer that would is dropped whole. + pub(in crate::term) fn reply(&mut self, bytes: &[u8]) { + if self.replies.len() + bytes.len() <= MAX_REPLY { + self.replies.extend_from_slice(bytes); + } + } + + pub(in crate::term) fn reply_fmt(&mut self, args: core::fmt::Arguments) { + let mut buf = Vec::new(); + let _ = Out(&mut buf).write_fmt(args); + self.reply(&buf); + } + + /// Bytes the host must write back to the program, oldest first. + pub fn take_replies(&mut self) -> Vec { + core::mem::take(&mut self.replies) + } +} diff --git a/userland/nonos_vt/src/term/answer/report.rs b/userland/nonos_vt/src/term/answer/report.rs new file mode 100644 index 000000000..9610eced0 --- /dev/null +++ b/userland/nonos_vt/src/term/answer/report.rs @@ -0,0 +1,64 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Answers to what a program asks: identity, cursor position, modes and +//! size. Many programs wait for these, so an unanswered question can hang +//! them. Answers queue for the host to write back to the program. + +use crate::term::state::Term; + +impl Term { + /// DA1: a VT220 with ANSI colour. + pub(in crate::term) fn reply_da1(&mut self) { + self.reply(b"\x1b[?62;22c"); + } + + /// DA2: terminal type 1, version 10. + pub(in crate::term) fn reply_da2(&mut self) { + self.reply(b"\x1b[>1;10;0c"); + } + + pub(in crate::term) fn reply_version(&mut self) { + self.reply(b"\x1bP>|NONOS vt 0.1\x1b\\"); + } + + /// DSR 5 is status, DSR 6 the cursor position, 1-based and counted from + /// the region in origin mode. + pub(in crate::term) fn reply_status(&mut self, what: u16, dec: bool) { + match what { + 5 if !dec => self.reply(b"\x1b[0n"), + 6 => { + let s = self.scr_ref(); + let top = if self.modes.origin { s.top } else { 0 }; + let (row, col) = (s.cur.y - top.min(s.cur.y) + 1, s.cur.x + 1); + let q = if dec { "?" } else { "" }; + self.reply_fmt(format_args!("\x1b[{q}{row};{col}R")); + } + _ => {} + } + } + + /// DECRQM: 1 set, 2 reset, 0 not recognised. + pub(in crate::term) fn reply_mode(&mut self, mode: u16, dec: bool) { + let v = match self.mode_state(mode, dec) { + Some(true) => 1, + Some(false) => 2, + None => 0, + }; + let q = if dec { "?" } else { "" }; + self.reply_fmt(format_args!("\x1b[{q}{mode};{v}$y")); + } +} diff --git a/userland/nonos_vt/src/term/answer/tcap.rs b/userland/nonos_vt/src/term/answer/tcap.rs new file mode 100644 index 000000000..d01aa042b --- /dev/null +++ b/userland/nonos_vt/src/term/answer/tcap.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! XTGETTCAP: terminfo capabilities by name, hex encoded both ways. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::term::state::Term; + +fn hex_decode(s: &[u8]) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + let digit = |c: u8| (c as char).to_digit(16); + s.chunks(2).map(|p| Some((digit(p[0])? * 16 + digit(p[1])?) as u8)).collect() +} + +fn hex_encode(b: &[u8]) -> String { + const H: &[u8; 16] = b"0123456789ABCDEF"; + b.iter().flat_map(|&x| [H[(x >> 4) as usize] as char, H[(x & 15) as usize] as char]).collect() +} + +impl Term { + pub(in crate::term) fn xtgettcap(&mut self, data: &[u8]) { + for name in data.split(|&b| b == b';') { + let Some(cap) = hex_decode(name) else { continue }; + let value: Option<&[u8]> = match cap.as_slice() { + b"TN" | b"name" => Some(b"xterm-256color"), + b"Co" | b"colors" => Some(b"256"), + b"RGB" => Some(b"8/8/8"), + _ => None, + }; + let n = hex_encode(&cap); + match value { + Some(v) => { + let v = hex_encode(v); + self.reply_fmt(format_args!("\x1bP1+r{n}={v}\x1b\\")); + } + None => self.reply_fmt(format_args!("\x1bP0+r{n}\x1b\\")), + } + } + } +} diff --git a/userland/nonos_vt/src/term/answer/winops.rs b/userland/nonos_vt/src/term/answer/winops.rs new file mode 100644 index 000000000..bcc7ba047 --- /dev/null +++ b/userland/nonos_vt/src/term/answer/winops.rs @@ -0,0 +1,42 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! XTWINOPS: only size reports and the title stack. + +use crate::params::Params; +use crate::term::state::Term; + +impl Term { + /// XTWINOPS. Only size reports and the title stack: a program may not + /// move, resize, raise or iconify the window it runs in. + pub(in crate::term) fn window_op(&mut self, p: &Params) { + let (w, h) = self.cell_px; + match p.get(0) { + 14 if w > 0 => { + let (pw, ph) = (w as usize * self.cols, h as usize * self.rows); + self.reply_fmt(format_args!("\x1b[4;{ph};{pw}t")); + } + 16 if w > 0 => self.reply_fmt(format_args!("\x1b[6;{h};{w}t")), + 18 => { + let (rows, cols) = (self.rows, self.cols); + self.reply_fmt(format_args!("\x1b[8;{rows};{cols}t")); + } + 22 => self.push_title(), + 23 => self.pop_title(), + _ => {} + } + } +} diff --git a/userland/nonos_vt/src/term/control.rs b/userland/nonos_vt/src/term/control.rs new file mode 100644 index 000000000..2f809efd5 --- /dev/null +++ b/userland/nonos_vt/src/term/control.rs @@ -0,0 +1,54 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! C0 control bytes, and the line movements they share with ESC sequences. + +use super::state::Term; + +impl Term { + pub(super) fn control(&mut self, b: u8) { + match b { + 0x07 => self.bell = self.bell.saturating_add(1), + 0x08 => self.backspace(), + 0x09 => self.tab_forward(1), + 0x0A..=0x0C => { + if self.modes.newline { + self.carriage_return(); + } + self.index(); + } + 0x0D => self.carriage_return(), + 0x0E => self.charsets.gl = 1, + 0x0F => self.charsets.gl = 0, + _ => {} + } + } + + fn backspace(&mut self) { + let cur = &mut self.scr().cur; + if cur.pending_wrap { + cur.pending_wrap = false; + } else { + cur.x = cur.x.saturating_sub(1); + } + } + + pub(super) fn carriage_return(&mut self) { + let cur = &mut self.scr().cur; + cur.x = 0; + cur.pending_wrap = false; + } +} diff --git a/userland/nonos_vt/src/term/csi.rs b/userland/nonos_vt/src/term/csi.rs new file mode 100644 index 000000000..603f0d55d --- /dev/null +++ b/userland/nonos_vt/src/term/csi.rs @@ -0,0 +1,72 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! `ESC [` sequences, routed by private marker, intermediates and final. + +use super::state::Term; +use crate::parser::Seq; + +impl Term { + pub(super) fn csi_dispatch(&mut self, seq: &Seq) { + let p = &seq.params; + let n = |d: u16| p.count(0, d) as usize; + match (seq.private, seq.inter(), seq.final_byte) { + (0, [], b'@') => self.insert_chars(n(1)), + (0, [], b'A') => self.cursor_up(n(1)), + (0, [], b'B') | (0, [], b'e') => self.cursor_down(n(1)), + (0, [], b'C') | (0, [], b'a') => self.cursor_forward(n(1)), + (0, [], b'D') => self.cursor_back(n(1)), + (0, [], b'E') => self.cursor_line(n(1), true), + (0, [], b'F') => self.cursor_line(n(1), false), + (0, [], b'G') | (0, [], b'`') => self.cursor_column(n(1)), + (0, [], b'H') | (0, [], b'f') => { + self.cursor_to(p.count(0, 1) as usize, p.count(1, 1) as usize) + } + (0, [], b'I') => self.tab_forward(n(1)), + (0 | b'?', [], b'J') => self.erase_display(p.get(0)), + (0 | b'?', [], b'K') => self.erase_in_line(p.get(0)), + (0, [], b'L') => self.insert_lines(n(1)), + (0, [], b'M') => self.delete_lines(n(1)), + (0, [], b'P') => self.delete_chars(n(1)), + (0, [], b'S') => self.scroll_up(n(1)), + // Five parameters make it xterm's mouse highlight, not a scroll. + (0, [], b'T') if p.len() <= 1 => self.scroll_down(n(1)), + (0, [], b'X') => self.erase_chars(n(1)), + (0, [], b'Z') => self.tab_back(n(1)), + (0, [], b'b') => self.repeat_last(n(1)), + (0, [], b'c') if p.get(0) == 0 => self.reply_da1(), + (b'>', [], b'c') if p.get(0) == 0 => self.reply_da2(), + (0, [], b'd') => self.cursor_row(n(1)), + (0, [], b'g') => self.clear_tabs(p.get(0)), + (0, [], b'h') => self.set_ansi_modes(p, true), + (0, [], b'l') => self.set_ansi_modes(p, false), + (b'?', [], b'h') => self.set_dec_modes(p, true), + (b'?', [], b'l') => self.set_dec_modes(p, false), + (0, [], b'm') => self.sgr(p), + (0, [], b'n') => self.reply_status(p.get(0), false), + (b'?', [], b'n') => self.reply_status(p.get(0), true), + (0, [], b'r') => self.set_region(p.count(0, 1) as usize, p.count(1, 0) as usize), + (0, [], b's') => self.save_cursor(), + (0, [], b't') => self.window_op(p), + (0, [], b'u') => self.restore_cursor(), + (b'>', [], b'q') if p.get(0) == 0 => self.reply_version(), + (0 | b'?', [b'$'], b'p') => self.reply_mode(p.get(0), seq.private == b'?'), + (0, [b' '], b'q') => self.set_cursor_style(p.get(0)), + (0, [b'!'], b'p') => self.soft_reset(), + _ => {} + } + } +} diff --git a/userland/nonos_vt/src/term/csi_move.rs b/userland/nonos_vt/src/term/csi_move.rs new file mode 100644 index 000000000..d21be3444 --- /dev/null +++ b/userland/nonos_vt/src/term/csi_move.rs @@ -0,0 +1,67 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Cursor movement. Every move cancels a pending wrap. Relative moves stop +//! at the scroll region's edges when they start inside it, and absolute +//! rows count from the region when origin mode is on. + +use super::state::Term; + +impl Term { + pub(super) fn moved(&mut self, x: usize, y: usize) { + let (cols, rows) = (self.cols, self.rows); + let cur = &mut self.scr().cur; + cur.x = x.min(cols - 1); + cur.y = y.min(rows - 1); + cur.pending_wrap = false; + } + + pub(super) fn cursor_up(&mut self, n: usize) { + let s = self.scr_ref(); + let floor = if s.cur.y >= s.top { s.top } else { 0 }; + let (x, y) = (s.cur.x, s.cur.y.saturating_sub(n).max(floor)); + self.moved(x, y); + } + + pub(super) fn cursor_down(&mut self, n: usize) { + let s = self.scr_ref(); + let ceil = if s.cur.y <= s.bot { s.bot } else { self.rows - 1 }; + let (x, y) = (s.cur.x, s.cur.y.saturating_add(n).min(ceil)); + self.moved(x, y); + } + + pub(super) fn cursor_forward(&mut self, n: usize) { + let s = self.scr_ref(); + let (x, y) = (s.cur.x.saturating_add(n), s.cur.y); + self.moved(x, y); + } + + pub(super) fn cursor_back(&mut self, n: usize) { + let s = self.scr_ref(); + let (x, y) = (s.cur.x.saturating_sub(n), s.cur.y); + self.moved(x, y); + } + + /// CNL and CPL: down or up `n` lines, to the first column. + pub(super) fn cursor_line(&mut self, n: usize, down: bool) { + if down { + self.cursor_down(n); + } else { + self.cursor_up(n); + } + self.carriage_return(); + } +} diff --git a/userland/nonos_vt/src/term/csi_place.rs b/userland/nonos_vt/src/term/csi_place.rs new file mode 100644 index 000000000..23728165d --- /dev/null +++ b/userland/nonos_vt/src/term/csi_place.rs @@ -0,0 +1,67 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Absolute cursor positions and the scroll region, counted from the +//! region in origin mode. + +use super::state::Term; + +impl Term { + /// CHA and HPA, 1-based. + pub(super) fn cursor_column(&mut self, col: usize) { + let y = self.scr_ref().cur.y; + self.moved(col.saturating_sub(1), y); + } + + /// VPA, 1-based. + pub(super) fn cursor_row(&mut self, row: usize) { + let x = self.scr_ref().cur.x; + let y = self.origin_row(row); + self.moved(x, y); + } + + /// CUP, 1-based. + pub(super) fn cursor_to(&mut self, row: usize, col: usize) { + let y = self.origin_row(row); + self.moved(col.saturating_sub(1), y); + } + + pub(super) fn origin_row(&self, row: usize) -> usize { + let s = self.scr_ref(); + let r = row.saturating_sub(1); + if self.modes.origin { + s.top.saturating_add(r).min(s.bot) + } else { + r + } + } + + /// DECSTBM, 1-based and inclusive; 0 for the bottom means the last row. + /// An impossible region is ignored, as xterm ignores it. + pub(super) fn set_region(&mut self, top: usize, bot: usize) { + let rows = self.rows; + let bot = if bot == 0 { rows } else { bot.min(rows) }; + let top = top.max(1); + if top >= bot { + return; + } + let scr = self.scr(); + scr.top = top - 1; + scr.bot = bot - 1; + let home = if self.modes.origin { top - 1 } else { 0 }; + self.moved(0, home); + } +} diff --git a/userland/nonos_vt/src/term/cursor_style.rs b/userland/nonos_vt/src/term/cursor_style.rs new file mode 100644 index 000000000..77d4909bc --- /dev/null +++ b/userland/nonos_vt/src/term/cursor_style.rs @@ -0,0 +1,40 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! DECSCUSR: the cursor shape and whether it blinks. + +use super::state::Term; +use super::types::CursorShape; + +impl Term { + /// DECSCUSR: 0 and 1 blinking block, 2 steady block, 3 and 4 underline, + /// 5 and 6 bar, odd numbers blinking. + pub(super) fn set_cursor_style(&mut self, v: u16) { + let (shape, blink) = match v { + 0 | 1 => (CursorShape::Block, true), + 2 => (CursorShape::Block, false), + 3 => (CursorShape::Underline, true), + 4 => (CursorShape::Underline, false), + 5 => (CursorShape::Bar, true), + 6 => (CursorShape::Bar, false), + _ => return, + }; + self.cursor_shape = shape; + self.modes.cursor_blink = blink; + let y = self.scr_ref().cur.y; + self.touch(y); + } +} diff --git a/userland/nonos_vt/src/term/edit.rs b/userland/nonos_vt/src/term/edit.rs new file mode 100644 index 000000000..d07c0922d --- /dev/null +++ b/userland/nonos_vt/src/term/edit.rs @@ -0,0 +1,66 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Inserting and deleting characters and lines in place. + +use super::state::Term; +use crate::limits::MAX_REPEAT; +use crate::width::width; + +impl Term { + pub(super) fn insert_chars(&mut self, n: usize) { + let scr = self.scr(); + let (x, y) = (scr.cur.x, scr.cur.y); + let blank = scr.blank(); + scr.lines[y].insert_blanks(x, n, blank); + scr.cur.pending_wrap = false; + self.touch(y); + } + + pub(super) fn delete_chars(&mut self, n: usize) { + let scr = self.scr(); + let (x, y) = (scr.cur.x, scr.cur.y); + let blank = scr.blank(); + scr.lines[y].delete_cells(x, n, blank); + scr.cur.pending_wrap = false; + self.touch(y); + } + + /// ECH: blank `n` cells from the cursor without moving anything. + pub(super) fn erase_chars(&mut self, n: usize) { + let scr = self.scr(); + let (x, y) = (scr.cur.x, scr.cur.y); + let blank = scr.blank(); + let end = x.saturating_add(n); + scr.lines[y].erase(x, end, blank); + scr.cur.pending_wrap = false; + self.touch(y); + } + + /// REP: print the last character `n` more times. A screen's worth is + /// the most that can show, so more is not done. + pub(super) fn repeat_last(&mut self, n: usize) { + let Some(c) = self.last_char else { return }; + let w = width(c); + if w == 0 { + return; + } + let n = n.min(MAX_REPEAT).min(self.cols * self.rows); + for _ in 0..n { + self.put_glyph(c, w); + } + } +} diff --git a/userland/nonos_vt/src/term/erase.rs b/userland/nonos_vt/src/term/erase.rs new file mode 100644 index 000000000..346308f43 --- /dev/null +++ b/userland/nonos_vt/src/term/erase.rs @@ -0,0 +1,73 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! ED and EL. Erasing never moves the cursor, and erased cells keep the +//! pen's background, as xterm erases. + +use super::state::Term; + +impl Term { + pub(super) fn erase_in_line(&mut self, mode: u16) { + let cols = self.cols; + let scr = self.scr(); + let (x, y) = (scr.cur.x, scr.cur.y); + let blank = scr.blank(); + let line = &mut scr.lines[y]; + match mode { + 0 => { + line.erase(x, cols, blank); + line.wrapped = false; + } + 1 => line.erase(0, x + 1, blank), + 2 => line.clear(blank), + _ => return, + } + self.touch(y); + } + + pub(super) fn erase_display(&mut self, mode: u16) { + let (cols, rows) = (self.cols, self.rows); + let scr = self.scr(); + let (x, y) = (scr.cur.x, scr.cur.y); + let blank = scr.blank(); + match mode { + 0 => { + scr.lines[y].erase(x, cols, blank); + scr.lines[y].wrapped = false; + for line in &mut scr.lines[y + 1..rows] { + line.clear(blank); + } + } + 1 => { + for line in &mut scr.lines[..y] { + line.clear(blank); + } + scr.lines[y].erase(0, x + 1, blank); + } + 2 => { + for line in scr.lines.iter_mut() { + line.clear(blank); + } + } + 3 => { + self.clear_history(); + return; + } + _ => return, + } + self.touch_all(); + } +} diff --git a/userland/nonos_vt/src/term/esc.rs b/userland/nonos_vt/src/term/esc.rs new file mode 100644 index 000000000..e9002e00a --- /dev/null +++ b/userland/nonos_vt/src/term/esc.rs @@ -0,0 +1,66 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! `ESC` sequences that are not CSI, OSC or DCS. + +use super::state::Term; +use crate::charset::designate; +use crate::parser::Seq; + +impl Term { + pub(super) fn esc_dispatch(&mut self, seq: &Seq) { + let f = seq.final_byte; + match seq.inter() { + [] => match f { + b'7' => self.save_cursor(), + b'8' => self.restore_cursor(), + b'D' => self.index(), + b'E' => { + self.carriage_return(); + self.index(); + } + b'H' => self.set_tab(), + b'M' => self.reverse_index(), + b'c' => self.full_reset(), + b'=' => self.modes.keypad = true, + b'>' => self.modes.keypad = false, + b'N' => self.charsets.single = Some(2), + b'O' => self.charsets.single = Some(3), + b'Z' => self.reply_da1(), + _ => {} + }, + [b'('] => self.charsets.g[0] = designate(f), + [b')'] => self.charsets.g[1] = designate(f), + [b'*'] => self.charsets.g[2] = designate(f), + [b'+'] => self.charsets.g[3] = designate(f), + [b'#'] if f == b'8' => self.align_test(), + _ => {} + } + } + + /// DECALN: fill the screen with `E`, for lining up a display. + fn align_test(&mut self) { + let scr = self.scr(); + for line in scr.lines.iter_mut() { + line.clear(crate::cell::Cell { ch: 'E', ..crate::cell::Cell::BLANK }); + } + scr.reset_region(); + scr.cur.x = 0; + scr.cur.y = 0; + scr.cur.pending_wrap = false; + self.touch_all(); + } +} diff --git a/userland/nonos_vt/src/term/feed.rs b/userland/nonos_vt/src/term/feed.rs new file mode 100644 index 000000000..eeb368781 --- /dev/null +++ b/userland/nonos_vt/src/term/feed.rs @@ -0,0 +1,60 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Bytes from the program in, through the parser, onto the screen. + +use super::screen::Screen; +use super::state::Term; + +impl Term { + pub fn feed(&mut self, bytes: &[u8]) { + /* + * The parser is held outside the terminal while it runs, so it can + * hand the terminal to itself as the handler. + */ + let mut parser = core::mem::take(&mut self.parser); + parser.feed(self, bytes); + self.parser = parser; + } + + pub(super) fn scr(&mut self) -> &mut Screen { + if self.alt_active { + &mut self.alt + } else { + &mut self.primary + } + } + + pub(super) fn scr_ref(&self) -> &Screen { + if self.alt_active { + &self.alt + } else { + &self.primary + } + } + + pub(super) fn touch(&mut self, y: usize) { + if let Some(d) = self.dirty.get_mut(y) { + *d = true; + } + } + + pub(super) fn touch_all(&mut self) { + for d in self.dirty.iter_mut() { + *d = true; + } + } +} diff --git a/userland/nonos_vt/src/term/glyph.rs b/userland/nonos_vt/src/term/glyph.rs new file mode 100644 index 000000000..81cd49432 --- /dev/null +++ b/userland/nonos_vt/src/term/glyph.rs @@ -0,0 +1,72 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Putting a character into cells at the cursor, wrapping first when the +//! last column was already written. + +use super::state::Term; +use crate::cell::{attr, Cell}; + +impl Term { + pub(super) fn put_glyph(&mut self, c: char, w: usize) { + let cols = self.cols; + let autowrap = self.modes.autowrap; + if self.scr_ref().cur.pending_wrap { + self.scr().cur.pending_wrap = false; + self.wrap_to_next_line(); + } + /* + * A wide character never straddles the edge: it wraps whole, or is + * dropped when wrapping is off. + */ + if w == 2 && self.scr_ref().cur.x + 1 >= cols { + if !autowrap { + return; + } + self.wrap_to_next_line(); + } + let insert = self.modes.insert; + let scr = self.scr(); + let (x, y, pen) = (scr.cur.x, scr.cur.y, scr.cur.pen); + let blank = scr.blank(); + let line = &mut scr.lines[y]; + if insert { + line.insert_blanks(x, w, blank); + } + let base = Cell { + ch: c, + fg: pen.fg, + bg: pen.bg, + attr: pen.attr & attr::PEN, + link: pen.link, + mark: 0, + }; + if w == 2 { + let head = Cell { attr: base.attr | attr::WIDE, ..base }; + let tail = Cell { ch: ' ', attr: base.attr | attr::WIDE_TAIL, ..base }; + line.put_pair(x, head, tail); + } else { + line.put(x, base); + } + if x + w >= cols { + scr.cur.x = cols - 1; + scr.cur.pending_wrap = autowrap; + } else { + scr.cur.x = x + w; + } + self.touch(y); + } +} diff --git a/userland/nonos_vt/src/term/handler.rs b/userland/nonos_vt/src/term/handler.rs new file mode 100644 index 000000000..cf1cff7e1 --- /dev/null +++ b/userland/nonos_vt/src/term/handler.rs @@ -0,0 +1,47 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The parser's reports, each routed to the part of the terminal that acts +//! on it. + +use super::state::Term; +use crate::parser::{Handler, Seq}; + +impl Handler for Term { + fn print(&mut self, c: char) { + self.print_char(c); + } + + fn execute(&mut self, b: u8) { + self.control(b); + } + + fn csi(&mut self, seq: &Seq) { + self.csi_dispatch(seq); + } + + fn esc(&mut self, seq: &Seq) { + self.esc_dispatch(seq); + } + + fn osc(&mut self, data: &[u8]) { + self.osc_dispatch(data); + } + + fn dcs(&mut self, seq: &Seq, data: &[u8]) { + self.dcs_dispatch(seq, data); + } +} diff --git a/userland/nonos_vt/src/term/history.rs b/userland/nonos_vt/src/term/history.rs new file mode 100644 index 000000000..174020db1 --- /dev/null +++ b/userland/nonos_vt/src/term/history.rs @@ -0,0 +1,49 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Scrollback: lines that left the top of the normal screen. + +use super::state::Term; +use crate::line::Line; + +impl Term { + pub(super) fn push_history(&mut self, mut line: Line) { + self.scrolled = self.scrolled.saturating_add(1); + if self.scrollback_limit == 0 { + return; + } + line.trim(); + if self.scrollback.len() >= self.scrollback_limit { + self.scrollback.pop_front(); + } + self.scrollback.push_back(line); + if self.view > 0 { + /* + * Someone reading history keeps reading the same lines while + * output arrives below them. + */ + self.view = (self.view + 1).min(self.scrollback.len()); + } + } + + /// CSI 3 J: forget the scrollback. + pub(super) fn clear_history(&mut self) { + self.scrollback.clear(); + self.view = 0; + self.prompts.clear(); + self.touch_all(); + } +} diff --git a/userland/nonos_vt/src/term/index.rs b/userland/nonos_vt/src/term/index.rs new file mode 100644 index 000000000..0661aa802 --- /dev/null +++ b/userland/nonos_vt/src/term/index.rs @@ -0,0 +1,53 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Moving down or up a line, scrolling the region at its edge. + +use super::state::Term; + +impl Term { + /// Down a line, scrolling the region when the cursor is at its foot. + pub(super) fn index(&mut self) { + let rows = self.rows; + let scr = self.scr(); + scr.cur.pending_wrap = false; + if scr.cur.y == scr.bot { + self.scroll_up(1); + } else if scr.cur.y + 1 < rows { + scr.cur.y += 1; + } + } + + /// Up a line, scrolling the region down when the cursor is at its top. + pub(super) fn reverse_index(&mut self) { + let scr = self.scr(); + scr.cur.pending_wrap = false; + if scr.cur.y == scr.top { + self.scroll_down(1); + } else if scr.cur.y > 0 { + scr.cur.y -= 1; + } + } + + /// Carry on at the start of the next line, marking this one as running + /// on so copying and re-wrapping join them. + pub(super) fn wrap_to_next_line(&mut self) { + let y = self.scr_ref().cur.y; + self.scr().lines[y].wrapped = true; + self.scr().cur.x = 0; + self.index(); + } +} diff --git a/userland/nonos_vt/src/term/join.rs b/userland/nonos_vt/src/term/join.rs new file mode 100644 index 000000000..6f367d53a --- /dev/null +++ b/userland/nonos_vt/src/term/join.rs @@ -0,0 +1,49 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Drawing a mark over the character before it rather than in a cell of +//! its own. + +use super::state::Term; +use crate::cell::Cell; + +impl Term { + /// The cell the last character went into. + pub(super) fn prev_x(&self) -> Option { + let cur = self.scr_ref().cur; + let x = if cur.pending_wrap { cur.x } else { cur.x.checked_sub(1)? }; + let line = &self.scr_ref().lines[cur.y]; + if line.cell(x).is_tail() { + x.checked_sub(1) + } else { + Some(x) + } + } + + pub(super) fn prev_cell(&self) -> Option { + let x = self.prev_x()?; + let y = self.scr_ref().cur.y; + Some(self.scr_ref().lines[y].cell(x)) + } + + pub(super) fn attach_mark(&mut self, c: char) -> bool { + let Some(x) = self.prev_x() else { return false }; + let y = self.scr_ref().cur.y; + let ok = self.scr().lines[y].add_mark(x, c); + self.touch(y); + ok + } +} diff --git a/userland/nonos_vt/src/term/lines.rs b/userland/nonos_vt/src/term/lines.rs new file mode 100644 index 000000000..a112e377c --- /dev/null +++ b/userland/nonos_vt/src/term/lines.rs @@ -0,0 +1,63 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Inserting and deleting whole lines inside the scroll region. + +use super::state::Term; + +impl Term { + /// IL: open `n` blank lines at the cursor's row, pushing the rest of the + /// region down and off its foot. Outside the region it does nothing. + pub(super) fn insert_lines(&mut self, n: usize) { + let scr = self.scr(); + let (y, top, bot) = (scr.cur.y, scr.top, scr.bot); + if y < top || y > bot { + return; + } + let n = n.min(bot + 1 - y); + let blank = scr.blank(); + scr.lines[y..=bot].rotate_right(n); + for line in &mut scr.lines[y..y + n] { + line.clear(blank); + } + scr.cur.x = 0; + scr.cur.pending_wrap = false; + for r in y..=bot { + self.touch(r); + } + } + + /// DL: remove `n` lines at the cursor's row, pulling the rest of the + /// region up and blanking its foot. + pub(super) fn delete_lines(&mut self, n: usize) { + let scr = self.scr(); + let (y, top, bot) = (scr.cur.y, scr.top, scr.bot); + if y < top || y > bot { + return; + } + let n = n.min(bot + 1 - y); + let blank = scr.blank(); + scr.lines[y..=bot].rotate_left(n); + for line in &mut scr.lines[bot + 1 - n..=bot] { + line.clear(blank); + } + scr.cur.x = 0; + scr.cur.pending_wrap = false; + for r in y..=bot { + self.touch(r); + } + } +} diff --git a/userland/nonos_vt/src/term/logical.rs b/userland/nonos_vt/src/term/logical.rs new file mode 100644 index 000000000..37c3a4e0c --- /dev/null +++ b/userland/nonos_vt/src/term/logical.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A logical line: the physical lines one run of text wrapped across. + +use super::state::Term; +use super::types::Pos; +use alloc::vec::Vec; + +impl Term { + /// The logical line starting at `first`: its characters, each with the + /// position of its cell, and the absolute line after it. + pub(super) fn logical(&self, first: u64) -> (Vec<(char, Pos)>, u64) { + let mut chars = Vec::new(); + let mut n = first; + while let Some(line) = self.line_at(n) { + let len = if line.wrapped { line.len() } else { line.content_len() }; + for x in 0..len.min(self.cols) { + let c = line.cell(x); + if !c.is_tail() { + chars.push((c.ch, Pos { line: n, col: x })); + } + } + n += 1; + if !line.wrapped { + break; + } + } + (chars, n) + } +} diff --git a/userland/nonos_vt/src/term/mod.rs b/userland/nonos_vt/src/term/mod.rs new file mode 100644 index 000000000..3bf17e66a --- /dev/null +++ b/userland/nonos_vt/src/term/mod.rs @@ -0,0 +1,73 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The terminal: two screens, scrollback, modes, and everything a program +//! can ask of them. + +mod abs; +mod alt; +mod answer; +mod control; +mod csi; +mod csi_move; +mod csi_place; +mod cursor_style; +mod edit; +mod erase; +mod esc; +mod feed; +mod glyph; +mod handler; +mod history; +mod index; +mod join; +mod lines; +mod logical; +mod mode_state; +mod modes; +mod modes_type; +mod mouse_mode; +mod new; +mod osc; +mod pick; +mod print; +mod reflow; +mod reset; +mod resize; +mod save; +mod screen; +mod screen_modes; +mod scroll; +mod search; +mod search_match; +mod sgr; +mod sgr_attr; +mod sgr_colour; +mod soft_reset; +mod state; +mod tab_stops; +mod tabs; +mod text; +mod types; +mod view; +mod view_draw; +mod view_text; + +pub use modes_type::Modes; +pub use mouse_mode::MouseMode; +pub use screen::{Cursor, Screen}; +pub use state::Term; +pub use types::{ClipboardRequest, CursorShape, CursorView, Pos}; diff --git a/userland/nonos_vt/src/term/mode_state.rs b/userland/nonos_vt/src/term/mode_state.rs new file mode 100644 index 000000000..ab5035d1e --- /dev/null +++ b/userland/nonos_vt/src/term/mode_state.rs @@ -0,0 +1,55 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Whether a mode is set, for DECRQM. + +use super::mouse_mode::MouseMode; +use super::state::Term; + +impl Term { + /// Whether a mode is set: `Some(true)` set, `Some(false)` reset, `None` + /// not a mode this terminal knows. + pub(super) fn mode_state(&self, mode: u16, dec: bool) -> Option { + let m = &self.modes; + if !dec { + return match mode { + 4 => Some(m.insert), + 20 => Some(m.newline), + _ => None, + }; + } + Some(match mode { + 1 => m.cursor_keys, + 5 => m.reverse_video, + 6 => m.origin, + 7 => m.autowrap, + 12 => m.cursor_blink, + 25 => m.cursor_visible, + 66 => m.keypad, + 9 => m.mouse == MouseMode::Press, + 1000 => m.mouse == MouseMode::Click, + 1002 => m.mouse == MouseMode::Drag, + 1003 => m.mouse == MouseMode::Motion, + 1004 => m.focus, + 1006 => m.mouse_sgr, + 1007 => m.alt_scroll, + 2004 => m.bracketed_paste, + 2026 => m.sync, + 47 | 1047 | 1049 => self.alt_active, + _ => return None, + }) + } +} diff --git a/userland/nonos_vt/src/term/modes.rs b/userland/nonos_vt/src/term/modes.rs new file mode 100644 index 000000000..32c9ec9fb --- /dev/null +++ b/userland/nonos_vt/src/term/modes.rs @@ -0,0 +1,73 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! SM, RM, DECSET and DECRST, each applied to every parameter given. + +use super::mouse_mode::MouseMode; +use super::state::Term; +use crate::params::Params; + +impl Term { + pub(super) fn set_ansi_modes(&mut self, p: &Params, on: bool) { + for g in p.groups() { + match g[0] { + 4 => self.modes.insert = on, + 20 => self.modes.newline = on, + _ => {} + } + } + } + + pub(super) fn set_dec_modes(&mut self, p: &Params, on: bool) { + for g in p.groups() { + self.set_dec_mode(g[0], on); + } + } + + fn set_dec_mode(&mut self, mode: u16, on: bool) { + let m = &mut self.modes; + match mode { + 1 => m.cursor_keys = on, + 5 => { + m.reverse_video = on; + self.touch_all(); + } + 6 => { + m.origin = on; + let home = if on { self.scr_ref().top } else { 0 }; + let cur = &mut self.scr().cur; + cur.x = 0; + cur.y = home; + cur.pending_wrap = false; + } + 7 => m.autowrap = on, + 12 => m.cursor_blink = on, + 25 => m.cursor_visible = on, + 66 => m.keypad = on, + 9 => m.mouse = if on { MouseMode::Press } else { MouseMode::Off }, + 1000 => m.mouse = if on { MouseMode::Click } else { MouseMode::Off }, + 1002 => m.mouse = if on { MouseMode::Drag } else { MouseMode::Off }, + 1003 => m.mouse = if on { MouseMode::Motion } else { MouseMode::Off }, + 1004 => m.focus = on, + 1006 => m.mouse_sgr = on, + 1007 => m.alt_scroll = on, + 2004 => m.bracketed_paste = on, + 2026 => m.sync = on, + 47 | 1047 | 1048 | 1049 => self.set_screen_mode(mode, on), + _ => {} + } + } +} diff --git a/userland/nonos_vt/src/term/modes_type.rs b/userland/nonos_vt/src/term/modes_type.rs new file mode 100644 index 000000000..94169887d --- /dev/null +++ b/userland/nonos_vt/src/term/modes_type.rs @@ -0,0 +1,74 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The modes a program sets, which change how its output is drawn and +//! how keys and the pointer are reported to it. + +use super::mouse_mode::MouseMode; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Modes { + /// DECCKM: cursor keys send SS3 rather than CSI. + pub cursor_keys: bool, + /// DECKPAM: the keypad sends application sequences. + pub keypad: bool, + /// IRM: printing inserts rather than overwrites. + pub insert: bool, + /// LNM: line feed also returns the carriage, and Enter sends CR LF. + pub newline: bool, + /// DECOM: cursor positions count from the scroll region. + pub origin: bool, + /// DECAWM: printing past the last column wraps. + pub autowrap: bool, + /// DECSCNM: the whole screen is drawn inverted. + pub reverse_video: bool, + /// DECTCEM. + pub cursor_visible: bool, + pub cursor_blink: bool, + pub mouse: MouseMode, + /// ?1006: mouse reports in the SGR form, which has no coordinate limit. + pub mouse_sgr: bool, + /// ?1004: focus changes are reported. + pub focus: bool, + /// ?2004: pasted text is bracketed so a shell does not run it. + pub bracketed_paste: bool, + /// ?2026: the program is mid-frame; the host may hold the redraw. + pub sync: bool, + /// ?1007: the wheel sends cursor keys on the alternate screen. + pub alt_scroll: bool, +} + +impl Default for Modes { + fn default() -> Modes { + Modes { + cursor_keys: false, + keypad: false, + insert: false, + newline: false, + origin: false, + autowrap: true, + reverse_video: false, + cursor_visible: true, + cursor_blink: true, + mouse: MouseMode::Off, + mouse_sgr: false, + focus: false, + bracketed_paste: false, + sync: false, + alt_scroll: true, + } + } +} diff --git a/userland/nonos_vt/src/term/mouse_mode.rs b/userland/nonos_vt/src/term/mouse_mode.rs new file mode 100644 index 000000000..6621bed7d --- /dev/null +++ b/userland/nonos_vt/src/term/mouse_mode.rs @@ -0,0 +1,31 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Which pointer events a program asked to receive. + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum MouseMode { + #[default] + Off, + /// ?9: button presses only. + Press, + /// ?1000: presses and releases. + Click, + /// ?1002: and motion while a button is held. + Drag, + /// ?1003: and all motion. + Motion, +} diff --git a/userland/nonos_vt/src/term/new.rs b/userland/nonos_vt/src/term/new.rs new file mode 100644 index 000000000..0248f9d02 --- /dev/null +++ b/userland/nonos_vt/src/term/new.rs @@ -0,0 +1,71 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Making a terminal, and the full reset a program can ask for. + +use alloc::collections::VecDeque; +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; + +use super::modes_type::Modes; +use super::resize::clamp_size; +use super::screen::Screen; +use super::state::Term; +use super::tabs::default_tabs; +use super::types::CursorShape; +use crate::charset::Charsets; +use crate::color::Palette; +use crate::limits::MAX_SCROLLBACK; +use crate::parser::Parser; + +impl Term { + pub fn new(cols: usize, rows: usize, scrollback: usize) -> Term { + let (cols, rows) = clamp_size(cols, rows); + Term { + cols, + rows, + primary: Screen::new(cols, rows), + alt: Screen::new(cols, rows), + alt_active: false, + scrollback: VecDeque::new(), + scrollback_limit: scrollback.min(MAX_SCROLLBACK), + scrolled: 0, + view: 0, + modes: Modes::default(), + tabs: default_tabs(cols), + charsets: Charsets::default(), + last_char: None, + after_zwj: false, + parser: Parser::new(), + replies: Vec::new(), + title: String::new(), + title_stack: Vec::new(), + title_gen: 0, + cwd: None, + clipboard: None, + clipboard_reads_refused: 0, + bell: 0, + theme: Palette::xterm(), + palette: Palette::xterm(), + links: Vec::new(), + cursor_shape: CursorShape::Block, + prompts: VecDeque::new(), + dirty: vec![true; rows], + cell_px: (0, 0), + } + } +} diff --git a/userland/nonos_vt/src/term/osc/dispatch.rs b/userland/nonos_vt/src/term/osc/dispatch.rs new file mode 100644 index 000000000..65fb67603 --- /dev/null +++ b/userland/nonos_vt/src/term/osc/dispatch.rs @@ -0,0 +1,75 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Routing an OSC by its number, and the title it most often sets. + +use alloc::string::String; + +use crate::limits::{MAX_TITLE, MAX_TITLE_STACK}; +use crate::term::state::Term; + +/// Text a program sent, kept only as far as it is printable: a title or a +/// path cannot smuggle control bytes into the host's own display. +pub(in crate::term) fn clean_text(b: &[u8], max: usize) -> String { + let s = String::from_utf8_lossy(b); + s.chars().filter(|c| !c.is_control()).take(max).collect() +} + +impl Term { + pub(in crate::term) fn osc_dispatch(&mut self, data: &[u8]) { + let (num, rest) = match data.iter().position(|&b| b == b';') { + Some(i) => (&data[..i], &data[i + 1..]), + None => (data, &data[data.len()..]), + }; + let Some(num) = core::str::from_utf8(num).ok().and_then(|s| s.parse::().ok()) else { + return; + }; + match num { + 0 | 2 => self.set_title(clean_text(rest, MAX_TITLE)), + 4 => self.osc_palette(rest), + 7 => self.set_cwd(rest), + 8 => self.osc_link(rest), + 10..=12 => self.osc_dynamic(num, rest), + 52 => self.osc_clipboard(rest), + 104 => self.osc_reset_palette(rest), + 110 => self.palette.fg = self.theme.fg, + 111 => self.palette.bg = self.theme.bg, + 112 => self.palette.cursor = self.theme.cursor, + 133 => self.osc_prompt(rest), + _ => {} + } + if matches!(num, 4 | 10 | 11 | 104 | 110 | 111) { + self.touch_all(); + } + } + + fn set_title(&mut self, t: String) { + self.title = t; + self.title_gen = self.title_gen.wrapping_add(1); + } + + pub(in crate::term) fn push_title(&mut self) { + if self.title_stack.len() < MAX_TITLE_STACK { + self.title_stack.push(self.title.clone()); + } + } + + pub(in crate::term) fn pop_title(&mut self) { + if let Some(t) = self.title_stack.pop() { + self.set_title(t); + } + } +} diff --git a/userland/nonos_vt/src/term/osc/links.rs b/userland/nonos_vt/src/term/osc/links.rs new file mode 100644 index 000000000..b4cd0234a --- /dev/null +++ b/userland/nonos_vt/src/term/osc/links.rs @@ -0,0 +1,50 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! OSC 8 hyperlinks: text a program marked as pointing somewhere. The host +//! shows where before following one; the target is never followed here. + +use super::dispatch::clean_text; +use crate::limits::{MAX_LINK, MAX_LINKS}; +use crate::term::state::Term; + +impl Term { + /// `8;params;uri` starts a link, `8;;` ends it. + pub(in crate::term) fn osc_link(&mut self, rest: &[u8]) { + let Some(i) = rest.iter().position(|&b| b == b';') else { return }; + let uri = &rest[i + 1..]; + let link = if uri.is_empty() || uri.len() > MAX_LINK { 0 } else { self.intern_link(uri) }; + self.scr().cur.pen.link = link; + } + + fn intern_link(&mut self, uri: &[u8]) -> u16 { + let uri = clean_text(uri, MAX_LINK); + if let Some(i) = self.links.iter().position(|l| *l == uri) { + return (i + 1) as u16; + } + if self.links.len() >= MAX_LINKS { + return 0; + } + self.links.push(uri); + self.links.len() as u16 + } + + /// The target a cell's link names. + pub fn link_target(&self, link: u16) -> Option<&str> { + let i = (link as usize).checked_sub(1)?; + self.links.get(i).map(|s| s.as_str()) + } +} diff --git a/userland/nonos_vt/src/term/osc/mod.rs b/userland/nonos_vt/src/term/osc/mod.rs new file mode 100644 index 000000000..51c37442b --- /dev/null +++ b/userland/nonos_vt/src/term/osc/mod.rs @@ -0,0 +1,25 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! OSC: titles, colours, hyperlinks, the working directory, clipboard +//! requests and shell prompt marks. + +mod dispatch; +mod links; +mod osc_color; +mod osc_get; +mod osc_misc; +mod osc_palette; diff --git a/userland/nonos_vt/src/term/osc/osc_color.rs b/userland/nonos_vt/src/term/osc/osc_color.rs new file mode 100644 index 000000000..ac0fe88de --- /dev/null +++ b/userland/nonos_vt/src/term/osc/osc_color.rs @@ -0,0 +1,53 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Colour specifications in OSC 4, 10, 11 and 12: `rgb:r/g/b` with one to +//! four hex digits a channel, or `#rgb` to `#rrrrggggbbbb`. A `?` asks for +//! the current value, answered as xterm answers. + +use crate::term::state::Term; + +fn hex(s: &str) -> Option { + if s.is_empty() || s.len() > 4 { + return None; + } + let v = u32::from_str_radix(s, 16).ok()?; + // Scale to 8 bits: one digit repeats, more keep the high byte. + let max = (1u32 << (4 * s.len())) - 1; + Some(v * 255 / max) +} + +pub(in crate::term) fn parse_spec(spec: &[u8]) -> Option { + let s = core::str::from_utf8(spec).ok()?; + let (r, g, b) = if let Some(body) = s.strip_prefix("rgb:") { + let mut it = body.split('/'); + let c = (hex(it.next()?)?, hex(it.next()?)?, hex(it.next()?)?); + if it.next().is_some() { + return None; + } + c + } else { + let h = s.strip_prefix('#')?; + if h.is_empty() || h.len() % 3 != 0 || h.len() > 12 { + return None; + } + let n = h.len() / 3; + (hex(&h[..n])?, hex(&h[n..2 * n])?, hex(&h[2 * n..])?) + }; + Some((r << 16) | (g << 8) | b) +} + +impl Term {} diff --git a/userland/nonos_vt/src/term/osc/osc_get.rs b/userland/nonos_vt/src/term/osc/osc_get.rs new file mode 100644 index 000000000..4701fee7b --- /dev/null +++ b/userland/nonos_vt/src/term/osc/osc_get.rs @@ -0,0 +1,52 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What the host reads back of what programs set through OSC. + +use crate::term::state::Term; +use crate::term::types::ClipboardRequest; + +impl Term { + pub fn title(&self) -> &str { + &self.title + } + + /// Changes each time the title does, so the host redraws it only then. + pub fn title_generation(&self) -> u32 { + self.title_gen + } + + pub fn cwd(&self) -> Option<&str> { + self.cwd.as_deref() + } + + pub fn take_clipboard_request(&mut self) -> Option { + self.clipboard.take() + } + + pub fn clipboard_reads_refused(&self) -> u32 { + self.clipboard_reads_refused + } + + /// Bells since the last call. + pub fn take_bell(&mut self) -> u32 { + core::mem::take(&mut self.bell) + } + + pub fn prompt_lines(&self) -> impl Iterator + '_ { + self.prompts.iter().copied() + } +} diff --git a/userland/nonos_vt/src/term/osc/osc_misc.rs b/userland/nonos_vt/src/term/osc/osc_misc.rs new file mode 100644 index 000000000..17c4a792f --- /dev/null +++ b/userland/nonos_vt/src/term/osc/osc_misc.rs @@ -0,0 +1,68 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! OSC requests that are not titles or colours: the working directory, +//! the clipboard and shell prompt marks. + +use super::dispatch::clean_text; +use crate::limits::MAX_PROMPT_MARKS; +use crate::term::state::Term; +use crate::term::types::ClipboardRequest; +use alloc::string::String; +use alloc::vec::Vec; + +impl Term { + /// `file://host/path`: the host part is dropped, the path kept. + pub(in crate::term) fn set_cwd(&mut self, rest: &[u8]) { + let s = clean_text(rest, 1024); + let path = match s.strip_prefix("file://") { + Some(r) => r.find('/').map(|i| String::from(&r[i..])).unwrap_or_default(), + None => s, + }; + self.cwd = (!path.is_empty()).then_some(path); + } + + /// `52;targets;base64`. A `?` asks to read the clipboard; it is refused + /// and counted, because a program that can read the clipboard can read + /// whatever the user copied from anywhere else. + pub(in crate::term) fn osc_clipboard(&mut self, rest: &[u8]) { + let Some(i) = rest.iter().position(|&b| b == b';') else { return }; + let (targets, data) = (&rest[..i], &rest[i + 1..]); + if data == b"?" { + self.clipboard_reads_refused = self.clipboard_reads_refused.saturating_add(1); + return; + } + let ok = data.iter().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')); + if ok { + let targets = if targets.is_empty() { Vec::from(&b"s0"[..]) } else { targets.to_vec() }; + self.clipboard = Some(ClipboardRequest { targets, base64: data.to_vec() }); + } + } + + /// `133;A` marks where a prompt starts, so the host can jump between + /// commands. + pub(in crate::term) fn osc_prompt(&mut self, rest: &[u8]) { + if rest.first() == Some(&b'A') && !self.alt_active { + let line = self.scrolled + self.primary.cur.y as u64; + if self.prompts.back() != Some(&line) { + if self.prompts.len() >= MAX_PROMPT_MARKS { + self.prompts.pop_front(); + } + self.prompts.push_back(line); + } + } + } +} diff --git a/userland/nonos_vt/src/term/osc/osc_palette.rs b/userland/nonos_vt/src/term/osc/osc_palette.rs new file mode 100644 index 000000000..526140ed6 --- /dev/null +++ b/userland/nonos_vt/src/term/osc/osc_palette.rs @@ -0,0 +1,73 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Setting and reporting palette colours: OSC 4, 10 to 12 and 104. + +use super::osc_color::parse_spec; +use crate::term::state::Term; + +impl Term { + pub(in crate::term) fn reply_colour(&mut self, prefix: &str, c: u32) { + // Each channel as xterm reports it: sixteen bits, the byte repeated. + let ch = |shift: u32| ((c >> shift) & 0xFF) * 257; + let (r, g, b) = (ch(16), ch(8), ch(0)); + self.reply_fmt(format_args!("\x1b]{prefix};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\")); + } + + /// `4;index;spec[;index;spec...]`. + pub(in crate::term) fn osc_palette(&mut self, rest: &[u8]) { + let mut parts = rest.split(|&b| b == b';'); + while let (Some(i), Some(spec)) = (parts.next(), parts.next()) { + let Some(i) = core::str::from_utf8(i).ok().and_then(|s| s.parse::().ok()) else { + return; + }; + if spec == b"?" { + let c = self.palette.colors[i as usize]; + self.reply_colour(&alloc::format!("4;{i}"), c); + } else if let Some(c) = parse_spec(spec) { + self.palette.colors[i as usize] = c; + } + } + } + + /// 10 foreground, 11 background, 12 cursor. + pub(in crate::term) fn osc_dynamic(&mut self, num: u16, spec: &[u8]) { + let slot = match num { + 10 => &mut self.palette.fg, + 11 => &mut self.palette.bg, + _ => &mut self.palette.cursor, + }; + if spec == b"?" { + let c = *slot; + self.reply_colour(&alloc::format!("{num}"), c); + } else if let Some(c) = parse_spec(spec) { + *slot = c; + } + } + + /// 104 with no indices resets the whole palette to the theme. + pub(in crate::term) fn osc_reset_palette(&mut self, rest: &[u8]) { + if rest.is_empty() { + self.palette.colors = self.theme.colors; + return; + } + for i in rest.split(|&b| b == b';') { + if let Some(i) = core::str::from_utf8(i).ok().and_then(|s| s.parse::().ok()) { + self.palette.colors[i as usize] = self.theme.colors[i as usize]; + } + } + } +} diff --git a/userland/nonos_vt/src/term/pick.rs b/userland/nonos_vt/src/term/pick.rs new file mode 100644 index 000000000..b149e9323 --- /dev/null +++ b/userland/nonos_vt/src/term/pick.rs @@ -0,0 +1,66 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The word or the whole line a double or triple click picks. + +use super::state::Term; +use super::types::Pos; + +/// Characters a double click keeps together, so a path or a URL is picked +/// whole. +fn is_word(c: char) -> bool { + c.is_alphanumeric() || "_-./~:@%+#?&=".contains(c) +} + +impl Term { + /// The word under `p`, as the positions of its first and last cells. + pub fn word_at(&self, p: Pos) -> (Pos, Pos) { + let Some(line) = self.line_at(p.line) else { return (p, p) }; + let class = |x: usize| { + let c = line.cell(x); + if c.is_tail() { + None + } else { + Some(is_word(c.ch)) + } + }; + let here = class(p.col).unwrap_or(false); + let same = |x: usize| class(x).is_none_or(|w| w == here); + let mut l = p.col; + while l > 0 && same(l - 1) { + l -= 1; + } + let mut r = p.col; + while r + 1 < self.cols && same(r + 1) { + r += 1; + } + (Pos { line: p.line, col: l }, Pos { line: p.line, col: r }) + } + + /// The whole line `p` is on, following wraps both ways, for a triple + /// click. + pub fn line_bounds(&self, p: Pos) -> (Pos, Pos) { + let mut first = p.line; + while first > self.first_line() && self.line_at(first - 1).is_some_and(|l| l.wrapped) { + first -= 1; + } + let mut last = p.line; + while self.line_at(last).is_some_and(|l| l.wrapped) && last < self.last_line() { + last += 1; + } + (Pos { line: first, col: 0 }, Pos { line: last, col: self.cols - 1 }) + } +} diff --git a/userland/nonos_vt/src/term/print.rs b/userland/nonos_vt/src/term/print.rs new file mode 100644 index 000000000..c16a46b37 --- /dev/null +++ b/userland/nonos_vt/src/term/print.rs @@ -0,0 +1,58 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Drawing a character at the cursor. + +use super::state::Term; +use crate::width::{is_emoji_modifier, width, ZWJ}; + +impl Term { + pub(super) fn print_char(&mut self, c: char) { + let c = if (c as u32) < 0x80 { + self.charsets.map(c) + } else { + self.charsets.single = None; + c + }; + /* + * A joiner, a skin tone after an emoji, and nonspacing marks are + * drawn over the character before them, not in a cell of their own. + */ + if core::mem::take(&mut self.after_zwj) && self.attach_mark(c) { + return; + } + if c == ZWJ { + self.attach_mark(c); + self.after_zwj = true; + return; + } + if is_emoji_modifier(c) + && self.prev_cell().is_some_and(|p| p.is_wide()) + && self.attach_mark(c) + { + return; + } + match width(c) { + 0 => { + self.attach_mark(c); + } + w => { + self.put_glyph(c, w); + self.last_char = Some(c); + } + } + } +} diff --git a/userland/nonos_vt/src/term/reflow/gather.rs b/userland/nonos_vt/src/term/reflow/gather.rs new file mode 100644 index 000000000..59e6c8b22 --- /dev/null +++ b/userland/nonos_vt/src/term/reflow/gather.rs @@ -0,0 +1,56 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Taking the normal screen and its history apart into physical lines. + +use alloc::vec::Vec; + +use crate::line::Line; +use crate::term::Term; + +pub(super) struct Gathered { + pub(super) phys: Vec, + /// The cursor's physical line, and its offset into that line; one past + /// the last column when it was waiting to wrap. + pub(super) cur_phys: usize, + pub(super) cur_col: usize, + /// Where the old screen starts among the physical lines. + pub(super) old_top: usize, +} + +impl Term { + /// History then the screen down to its last used row, which the cursor + /// counts as using. Blank rows below are dropped. + pub(super) fn gather(&mut self) -> Gathered { + let cur = self.primary.cur; + let old_top = self.scrollback.len(); + let mut phys: Vec = self.scrollback.drain(..).collect(); + let used = self + .primary + .lines + .iter() + .rposition(|l| l.content_len() > 0 || l.wrapped) + .map_or(0, |i| i + 1) + .max(cur.y + 1); + phys.extend(self.primary.lines.drain(..).take(used)); + Gathered { + phys, + cur_phys: old_top + cur.y, + cur_col: cur.x + cur.pending_wrap as usize, + old_top, + } + } +} diff --git a/userland/nonos_vt/src/term/reflow/mod.rs b/userland/nonos_vt/src/term/reflow/mod.rs new file mode 100644 index 000000000..8488ec436 --- /dev/null +++ b/userland/nonos_vt/src/term/reflow/mod.rs @@ -0,0 +1,28 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Re-wrapping the normal screen and its history to a new width. +//! +//! Lines that ran on into one another are joined back into the text that +//! was written, then broken again at the new width, so a narrowed window +//! does not cut words off and a widened one does not leave them broken. The +//! cursor keeps its place in the text and, where it fits, its row. + +mod gather; +mod place; +mod placed; +mod rewrap; +mod rewrap_all; diff --git a/userland/nonos_vt/src/term/reflow/place.rs b/userland/nonos_vt/src/term/reflow/place.rs new file mode 100644 index 000000000..4f896f84f --- /dev/null +++ b/userland/nonos_vt/src/term/reflow/place.rs @@ -0,0 +1,68 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Choosing which re-wrapped lines are the screen, and putting everything +//! back. + +use alloc::vec::Vec; + +use super::placed::Placed; +use super::rewrap_all::rewrap_all; +use crate::cell::Cell; +use crate::line::Line; +use crate::term::Term; + +/// The first line of the new screen. The old top row stays on top unless +/// the cursor would fall off the foot. Growing while at the foot, as after +/// a command's output, brings back as many history lines as rows were +/// added. Whatever the rules choose, the cursor ends on screen. +fn screen_start(top_out: usize, at: &Placed, rows: usize, old_rows: usize, cur_y: usize) -> usize { + let mut start = top_out.min(at.line); + if rows > old_rows && cur_y + 1 == old_rows { + start = start.saturating_sub(rows - old_rows); + } + if at.line >= start + rows { + start = at.line + 1 - rows; + } + start +} + +impl Term { + pub(in crate::term) fn reflow_primary(&mut self, cols: usize, rows: usize) { + let base = self.first_line(); + let cur_y = self.primary.cur.y; + let g = self.gather(); + let (mut out, at, top_out) = rewrap_all(&g, cols, self.cols); + let start = screen_start(top_out, &at, rows, self.rows, cur_y); + let mut screen: Vec = out.drain(start..).take(rows).collect(); + screen.resize(rows, Line::new(cols, Cell::BLANK)); + for mut line in out { + line.trim(); + self.scrollback.push_back(line); + } + self.scrolled = base + self.scrollback.len() as u64; + while self.scrollback.len() > self.scrollback_limit { + self.scrollback.pop_front(); + } + let s = &mut self.primary; + s.lines = screen; + s.cur.y = at.line - start; + s.cur.x = at.col.min(cols - 1); + s.cur.pending_wrap = at.pending; + s.reset_region(); + self.prompts.clear(); + } +} diff --git a/userland/nonos_vt/src/term/reflow/placed.rs b/userland/nonos_vt/src/term/reflow/placed.rs new file mode 100644 index 000000000..be19937a6 --- /dev/null +++ b/userland/nonos_vt/src/term/reflow/placed.rs @@ -0,0 +1,25 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Where the cursor ended up after a re-wrap. + +/// Where the cursor ended up: output line, column, and whether it sits +/// just past the last column waiting to wrap. +pub(super) struct Placed { + pub(super) line: usize, + pub(super) col: usize, + pub(super) pending: bool, +} diff --git a/userland/nonos_vt/src/term/reflow/rewrap.rs b/userland/nonos_vt/src/term/reflow/rewrap.rs new file mode 100644 index 000000000..8c844aea0 --- /dev/null +++ b/userland/nonos_vt/src/term/reflow/rewrap.rs @@ -0,0 +1,73 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Breaking one logical line at a new width, keeping wide characters whole +//! and noting where the cursor lands. + +use alloc::vec::Vec; + +use super::placed::Placed; +use crate::cell::{attr, Cell}; +use crate::line::Line; + +/// Break one logical line of `cells` (each tagged with the physical line it +/// came from, for its marks) into lines `cols` wide. +pub(super) fn rewrap( + phys: &[Line], + cells: &[(usize, Cell)], + cols: usize, + cursor: Option, + out: &mut Vec, + placed: &mut Option, +) { + let mut line = Line::new(0, Cell::BLANK); + let mut col: usize = 0; + for (i, &(src, cell)) in cells.iter().enumerate() { + if cell.is_tail() { + if cursor == Some(i) && placed.is_none() { + *placed = + Some(Placed { line: out.len(), col: col.saturating_sub(1), pending: false }); + } + continue; + } + let w = if cell.is_wide() && cols >= 2 { 2 } else { 1 }; + if col + w > cols { + line.cells.resize(cols, Cell::BLANK); + line.wrapped = true; + out.push(core::mem::replace(&mut line, Line::new(0, Cell::BLANK))); + col = 0; + } + if cursor == Some(i) { + *placed = Some(Placed { line: out.len(), col, pending: false }); + } + let head = if w == 2 { cell } else { Cell { attr: cell.attr & !attr::WIDE, ..cell } }; + line.push_from(&phys[src], head); + if w == 2 { + let tail = (cell.attr & !attr::WIDE) | attr::WIDE_TAIL; + line.cells.push(Cell { ch: ' ', attr: tail, mark: 0, ..cell }); + } + col += w; + } + if cursor == Some(cells.len()) { + *placed = Some(if col >= cols { + Placed { line: out.len(), col: cols - 1, pending: true } + } else { + Placed { line: out.len(), col, pending: false } + }); + } + line.cells.resize(cols, Cell::BLANK); + out.push(line); +} diff --git a/userland/nonos_vt/src/term/reflow/rewrap_all.rs b/userland/nonos_vt/src/term/reflow/rewrap_all.rs new file mode 100644 index 000000000..4e8ec7123 --- /dev/null +++ b/userland/nonos_vt/src/term/reflow/rewrap_all.rs @@ -0,0 +1,61 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Every logical line of the gathered text, broken at the new width. + +use alloc::vec::Vec; + +use super::gather::Gathered; +use super::placed::Placed; +use super::rewrap::rewrap; +use crate::cell::Cell; +use crate::line::Line; + +/// The re-wrapped lines, where the cursor went, and the line the old +/// screen's first row became. +pub(super) fn rewrap_all(g: &Gathered, cols: usize, old_cols: usize) -> (Vec, Placed, usize) { + let phys = &g.phys; + let mut out: Vec = Vec::new(); + let mut placed: Option = None; + let mut top_out = 0; + let mut i = 0; + while i < phys.len() { + let start = i; + while phys[i].wrapped && i + 1 < phys.len() { + i += 1; + } + let end = i; + i += 1; + let mut cells: Vec<(usize, Cell)> = Vec::new(); + let mut cursor = None; + for (li, line) in phys.iter().enumerate().take(end + 1).skip(start) { + if li == g.cur_phys { + cursor = Some(cells.len() + g.cur_col); + } + let take = if li == end { line.content_len() } else { line.len() }; + cells.extend(line.cells[..take].iter().map(|&c| (li, c))); + } + if let Some(o) = cursor { + cells.resize(cells.len().max(o), (end, Cell::BLANK)); + } + if (start..=end).contains(&g.old_top) { + top_out = out.len() + (g.old_top - start) * old_cols / cols; + } + rewrap(phys, &cells, cols, cursor, &mut out, &mut placed); + } + let last = out.len().saturating_sub(1); + (out, placed.unwrap_or(Placed { line: last, col: 0, pending: false }), top_out) +} diff --git a/userland/nonos_vt/src/term/reset.rs b/userland/nonos_vt/src/term/reset.rs new file mode 100644 index 000000000..1fe54faf0 --- /dev/null +++ b/userland/nonos_vt/src/term/reset.rs @@ -0,0 +1,59 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The full reset a program can ask for, and the settings the host makes. + +use super::modes_type::Modes; +use super::screen::Screen; +use super::state::Term; +use super::tabs::default_tabs; +use super::types::CursorShape; +use crate::charset::Charsets; +use crate::color::Palette; + +impl Term { + /// RIS: both screens blank, every mode and colour back to its start. The + /// scrollback stays; it is the user's, not the program's. + pub(super) fn full_reset(&mut self) { + self.primary = Screen::new(self.cols, self.rows); + self.alt = Screen::new(self.cols, self.rows); + self.alt_active = false; + self.view = 0; + self.modes = Modes::default(); + self.tabs = default_tabs(self.cols); + self.charsets = Charsets::default(); + self.last_char = None; + self.after_zwj = false; + self.title.clear(); + self.title_stack.clear(); + self.title_gen = self.title_gen.wrapping_add(1); + self.palette = self.theme.clone(); + self.cursor_shape = CursorShape::Block; + self.touch_all(); + } + + /// Set the colours programs start from. Changes a program made are + /// dropped: the user chose a theme. + pub fn set_theme(&mut self, theme: Palette) { + self.theme = theme.clone(); + self.palette = theme; + self.touch_all(); + } + + pub fn set_cell_pixels(&mut self, w: u16, h: u16) { + self.cell_px = (w, h); + } +} diff --git a/userland/nonos_vt/src/term/resize.rs b/userland/nonos_vt/src/term/resize.rs new file mode 100644 index 000000000..8a5111959 --- /dev/null +++ b/userland/nonos_vt/src/term/resize.rs @@ -0,0 +1,72 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Changing the screen size. The normal screen re-wraps its text to the new +//! width; the alternate screen is cut or padded, since the program drawing +//! on it redraws when told of the new size. + +use alloc::vec; + +use super::state::Term; +use crate::cell::Cell; +use crate::limits::{MAX_COLS, MAX_ROWS, MIN_COLS, MIN_ROWS}; +use crate::line::Line; + +pub(super) fn clamp_size(cols: usize, rows: usize) -> (usize, usize) { + (cols.clamp(MIN_COLS, MAX_COLS), rows.clamp(MIN_ROWS, MAX_ROWS)) +} + +impl Term { + pub fn resize(&mut self, cols: usize, rows: usize) { + let (cols, rows) = clamp_size(cols, rows); + if cols == self.cols && rows == self.rows { + return; + } + self.reflow_primary(cols, rows); + self.resize_alt(cols, rows); + let old = self.tabs.len(); + self.tabs.resize(cols, false); + for x in old..cols { + self.tabs[x] = x % 8 == 0; + } + self.cols = cols; + self.rows = rows; + self.dirty = vec![true; rows]; + self.view = 0; + } + + fn resize_alt(&mut self, cols: usize, rows: usize) { + let s = &mut self.alt; + for line in s.lines.iter_mut() { + line.resize(cols, Cell::BLANK); + } + let len = s.lines.len(); + if rows < len { + // Keep the cursor's row on screen: lose lines above it first. + let above = (s.cur.y + 1).saturating_sub(rows).min(len - rows); + s.lines.drain(..above); + s.cur.y -= above; + s.lines.truncate(rows); + } + while s.lines.len() < rows { + s.lines.push(Line::new(cols, Cell::BLANK)); + } + s.cur.x = s.cur.x.min(cols - 1); + s.cur.y = s.cur.y.min(rows - 1); + s.cur.pending_wrap = false; + s.reset_region(); + } +} diff --git a/userland/nonos_vt/src/term/save.rs b/userland/nonos_vt/src/term/save.rs new file mode 100644 index 000000000..e354f8a67 --- /dev/null +++ b/userland/nonos_vt/src/term/save.rs @@ -0,0 +1,59 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! DECSC and DECRC: the cursor, its pen and the modes that go with it. + +use super::screen::Cursor; +use super::state::Term; +use crate::charset::Charsets; + +/// What DECSC saves and DECRC restores. +#[derive(Clone, Copy, Debug)] +pub struct Saved { + pub cursor: Cursor, + pub origin: bool, + pub autowrap: bool, + pub charsets: Charsets, +} + +impl Term { + pub(super) fn save_cursor(&mut self) { + let saved = Saved { + cursor: self.scr_ref().cur, + origin: self.modes.origin, + autowrap: self.modes.autowrap, + charsets: self.charsets, + }; + self.scr().saved = Some(saved); + } + + /// Restore what was saved, or the home position with a plain pen when + /// nothing was, as xterm does. + pub(super) fn restore_cursor(&mut self) { + let (cols, rows) = (self.cols, self.rows); + let saved = self.scr_ref().saved; + let (mut cur, origin, autowrap, charsets) = match saved { + Some(s) => (s.cursor, s.origin, s.autowrap, s.charsets), + None => (Cursor::default(), false, true, Charsets::default()), + }; + cur.x = cur.x.min(cols - 1); + cur.y = cur.y.min(rows - 1); + self.modes.origin = origin; + self.modes.autowrap = autowrap; + self.charsets = charsets; + self.scr().cur = cur; + } +} diff --git a/userland/nonos_vt/src/term/screen.rs b/userland/nonos_vt/src/term/screen.rs new file mode 100644 index 000000000..bfa772169 --- /dev/null +++ b/userland/nonos_vt/src/term/screen.rs @@ -0,0 +1,73 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One screen: its lines, its cursor and its scroll region. The terminal has +//! two, the normal one with scrollback and the alternate one without. + +use alloc::vec::Vec; + +use super::save::Saved; +use crate::cell::{Cell, Pen}; +use crate::line::Line; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Cursor { + pub x: usize, + pub y: usize, + pub pen: Pen, + /// The last column was just written. The next character wraps first; + /// anything that moves the cursor cancels it. This is why a program can + /// fill the bottom-right cell without the screen scrolling. + pub pending_wrap: bool, +} + +pub struct Screen { + pub lines: Vec, + pub cur: Cursor, + pub saved: Option, + /// Scroll region, inclusive rows. + pub top: usize, + pub bot: usize, +} + +impl Screen { + pub fn new(cols: usize, rows: usize) -> Screen { + let lines = (0..rows).map(|_| Line::new(cols, Cell::BLANK)).collect(); + Screen { lines, cur: Cursor::default(), saved: None, top: 0, bot: rows - 1 } + } + + pub fn rows(&self) -> usize { + self.lines.len() + } + + pub fn full_region(&self) -> bool { + self.top == 0 && self.bot + 1 == self.lines.len() + } + + pub fn reset_region(&mut self) { + self.top = 0; + self.bot = self.lines.len().saturating_sub(1); + } + + pub fn blank(&self) -> Cell { + Cell::erased(&self.cur.pen) + } + + pub fn line(&mut self) -> &mut Line { + let y = self.cur.y.min(self.lines.len() - 1); + &mut self.lines[y] + } +} diff --git a/userland/nonos_vt/src/term/screen_modes.rs b/userland/nonos_vt/src/term/screen_modes.rs new file mode 100644 index 000000000..6de515470 --- /dev/null +++ b/userland/nonos_vt/src/term/screen_modes.rs @@ -0,0 +1,62 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The DEC modes that switch screens or save the cursor: 47 and 1047 +//! switch, 1047 clearing the alternate screen on the way back, 1048 saves +//! and restores the cursor, and 1049 does both, as full-screen programs +//! use it. + +use super::state::Term; + +impl Term { + pub(super) fn set_screen_mode(&mut self, mode: u16, on: bool) { + match mode { + 47 => { + if on { + self.enter_alt(false) + } else { + self.leave_alt(false) + } + } + 1047 => { + if on { + self.enter_alt(false) + } else { + self.leave_alt(true) + } + } + 1048 => { + if on { + self.save_cursor() + } else { + self.restore_cursor() + } + } + 1049 => { + if on { + if !self.alt_active { + self.save_cursor(); + } + self.enter_alt(true); + } else if self.alt_active { + self.leave_alt(false); + self.restore_cursor(); + } + } + _ => {} + } + } +} diff --git a/userland/nonos_vt/src/term/scroll.rs b/userland/nonos_vt/src/term/scroll.rs new file mode 100644 index 000000000..78056b8c5 --- /dev/null +++ b/userland/nonos_vt/src/term/scroll.rs @@ -0,0 +1,67 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Scrolling the region, and history for the lines that leave the top. + +use super::state::Term; + +impl Term { + /// Move the region's lines up `n`, blank lines entering at its foot. On + /// the normal screen with a full-height region, the lines leaving the + /// top go to scrollback; anywhere else they are gone, which is what a + /// status line pinned outside the region needs. + pub(super) fn scroll_up(&mut self, n: usize) { + let to_history = !self.alt_active && self.scr_ref().top == 0; + let (top, bot) = (self.scr_ref().top, self.scr_ref().bot); + let n = n.min(bot + 1 - top); + if n == 0 { + return; + } + if to_history { + for i in 0..n { + let line = self.primary.lines[top + i].clone(); + self.push_history(line); + } + } + let scr = self.scr(); + let blank = scr.blank(); + scr.lines[top..=bot].rotate_left(n); + for line in &mut scr.lines[bot + 1 - n..=bot] { + line.clear(blank); + } + for y in top..=bot { + self.touch(y); + } + } + + /// Move the region's lines down `n`, blank lines entering at its top. + pub(super) fn scroll_down(&mut self, n: usize) { + let (top, bot) = (self.scr_ref().top, self.scr_ref().bot); + let n = n.min(bot + 1 - top); + if n == 0 { + return; + } + let scr = self.scr(); + let blank = scr.blank(); + scr.lines[top..=bot].rotate_right(n); + for line in &mut scr.lines[top..top + n] { + line.clear(blank); + } + for y in top..=bot { + self.touch(y); + } + } +} diff --git a/userland/nonos_vt/src/term/search.rs b/userland/nonos_vt/src/term/search.rs new file mode 100644 index 000000000..c0f24c076 --- /dev/null +++ b/userland/nonos_vt/src/term/search.rs @@ -0,0 +1,71 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Finding text in the screen and its history. A match may run across a +//! wrapped line end, as the text it matches did when it was written. + +use alloc::vec::Vec; + +use super::search_match::matches; +use super::state::Term; +use super::types::Pos; + +impl Term { + /// The logical lines, as the absolute line each starts on, in order. + fn logical_starts(&self) -> Vec { + let mut starts = Vec::new(); + let mut n = self.first_line(); + while n <= self.last_line() { + starts.push(n); + let (_, next) = self.logical(n); + n = next.max(n + 1); + } + starts + } + + /// The next match after `from` (or before it when `back`), wrapping + /// round the whole history once. Returns its first and last cells. + pub fn find(&self, needle: &str, from: Pos, back: bool, case: bool) -> Option<(Pos, Pos)> { + let needle: Vec = needle.chars().collect(); + let starts = self.logical_starts(); + if starts.is_empty() || needle.is_empty() { + return None; + } + let here = starts.iter().rposition(|&s| s <= from.line).unwrap_or(0); + let n = starts.len(); + for step in 0..=n { + let idx = if back { (here + n * 2 - step) % n } else { (here + step) % n }; + let (hay, _) = self.logical(starts[idx]); + let hits = matches(&hay, &needle, case); + let span = |i: usize| (hay[i].1, hay[i + needle.len() - 1].1); + let pick = if step == 0 { + if back { + hits.iter().rev().find(|&&i| hay[i].1 < from).copied() + } else { + hits.iter().find(|&&i| hay[i].1 > from).copied() + } + } else if back { + hits.last().copied() + } else { + hits.first().copied() + }; + if let Some(i) = pick { + return Some(span(i)); + } + } + None + } +} diff --git a/userland/nonos_vt/src/term/search_match.rs b/userland/nonos_vt/src/term/search_match.rs new file mode 100644 index 000000000..dc341afea --- /dev/null +++ b/userland/nonos_vt/src/term/search_match.rs @@ -0,0 +1,39 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Where a needle starts in a logical line's characters. + +use alloc::vec::Vec; + +use super::types::Pos; + +fn eq(a: char, b: char, case: bool) -> bool { + if case { + a == b + } else { + a.to_lowercase().eq(b.to_lowercase()) + } +} + +/// Every place `needle` starts in `hay`, by index. +pub(super) fn matches(hay: &[(char, Pos)], needle: &[char], case: bool) -> Vec { + if needle.is_empty() || needle.len() > hay.len() { + return Vec::new(); + } + (0..=hay.len() - needle.len()) + .filter(|&i| needle.iter().enumerate().all(|(k, &c)| eq(hay[i + k].0, c, case))) + .collect() +} diff --git a/userland/nonos_vt/src/term/sgr.rs b/userland/nonos_vt/src/term/sgr.rs new file mode 100644 index 000000000..09216f611 --- /dev/null +++ b/userland/nonos_vt/src/term/sgr.rs @@ -0,0 +1,61 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! SGR: the pen's colours and attributes. Colours read the same whether a +//! program writes `38;2;r;g;b`, `38:2:r:g:b` or `38:2::r:g:b`. + +use super::sgr_attr::plain; +use super::sgr_colour::{colour_from_group, colour_from_spread}; +use super::state::Term; +use crate::cell::Pen; +use crate::params::Params; + +impl Term { + pub(super) fn sgr(&mut self, p: &Params) { + let groups: alloc::vec::Vec<&[u16]> = p.groups().collect(); + let mut pen = self.scr_ref().cur.pen; + if groups.is_empty() { + pen = Pen { link: pen.link, ..Pen::default() }; + } + let mut i = 0; + while i < groups.len() { + let g = groups[i]; + i += 1; + if !matches!(g[0], 38 | 48 | 58) { + plain(&mut pen, g); + continue; + } + let colour = if g.len() > 1 { + colour_from_group(g) + } else { + // The semicolon form spreads over the next groups. + let (c, used) = colour_from_spread(&groups[i..]); + i += used; + c + }; + /* + * Underline colour (58) is read so its numbers are not taken for + * other attributes, and not drawn. + */ + match (g[0], colour) { + (38, Some(c)) => pen.fg = c, + (48, Some(c)) => pen.bg = c, + _ => {} + } + } + self.scr().cur.pen = pen; + } +} diff --git a/userland/nonos_vt/src/term/sgr_attr.rs b/userland/nonos_vt/src/term/sgr_attr.rs new file mode 100644 index 000000000..406bac138 --- /dev/null +++ b/userland/nonos_vt/src/term/sgr_attr.rs @@ -0,0 +1,58 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! SGR codes that set one attribute or one of the sixteen named colours. + +use crate::cell::{attr, Pen}; +use crate::color::Color; + +fn set_underline(pen: &mut Pen, style: u16) { + pen.attr = (pen.attr & !attr::UNDERLINE_MASK) | (style.min(5) << attr::UNDERLINE_SHIFT); +} + +/// Apply group `g`, a code with any sub-parameters. Codes this terminal +/// does not draw are read and ignored. +pub(super) fn plain(pen: &mut Pen, g: &[u16]) { + let code = g[0]; + match code { + 0 => *pen = Pen { link: pen.link, ..Pen::default() }, + 1 => pen.attr |= attr::BOLD, + 2 => pen.attr |= attr::DIM, + 3 => pen.attr |= attr::ITALIC, + 4 => set_underline(pen, g.get(1).copied().unwrap_or(1)), + 5 | 6 => pen.attr |= attr::BLINK, + 7 => pen.attr |= attr::INVERSE, + 8 => pen.attr |= attr::HIDDEN, + 9 => pen.attr |= attr::STRIKE, + 21 => set_underline(pen, 2), + 22 => pen.attr &= !(attr::BOLD | attr::DIM), + 23 => pen.attr &= !attr::ITALIC, + 24 => set_underline(pen, 0), + 25 => pen.attr &= !attr::BLINK, + 27 => pen.attr &= !attr::INVERSE, + 28 => pen.attr &= !attr::HIDDEN, + 29 => pen.attr &= !attr::STRIKE, + 30..=37 => pen.fg = Color::Indexed((code - 30) as u8), + 39 => pen.fg = Color::Default, + 40..=47 => pen.bg = Color::Indexed((code - 40) as u8), + 49 => pen.bg = Color::Default, + 53 => pen.attr |= attr::OVERLINE, + 55 => pen.attr &= !attr::OVERLINE, + 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8), + 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8), + _ => {} + } +} diff --git a/userland/nonos_vt/src/term/sgr_colour.rs b/userland/nonos_vt/src/term/sgr_colour.rs new file mode 100644 index 000000000..8476514b7 --- /dev/null +++ b/userland/nonos_vt/src/term/sgr_colour.rs @@ -0,0 +1,53 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Extended colours after 38, 48 and 58, in either notation. A component +//! past 255 makes the colour invalid, not wrapped. + +use crate::color::Color; + +fn byte(v: u16) -> Option { + u8::try_from(v).ok() +} + +/// From a colon group: `[38, 5, n]`, `[38, 2, r, g, b]` or +/// `[38, 2, space, r, g, b]`. +pub(super) fn colour_from_group(g: &[u16]) -> Option { + match g.get(1)? { + 5 => byte(*g.get(2)?).map(Color::Indexed), + 2 => { + let rgb = if g.len() >= 6 { &g[3..6] } else { g.get(2..5)? }; + Some(Color::Rgb(byte(rgb[0])?, byte(rgb[1])?, byte(rgb[2])?)) + } + _ => None, + } +} + +/// `5;n` or `2;r;g;b` spread over the groups after a 38 or 48. Returns the +/// colour and how many groups it took, so a malformed colour cannot swallow +/// what follows it beyond its own length. +pub(super) fn colour_from_spread(rest: &[&[u16]]) -> (Option, usize) { + let v = |i: usize| rest.get(i).map(|g| g[0]); + match v(0) { + Some(5) => (v(1).and_then(byte).map(Color::Indexed), 2.min(rest.len())), + Some(2) => { + let c = (|| Some(Color::Rgb(byte(v(1)?)?, byte(v(2)?)?, byte(v(3)?)?)))(); + (c, 4.min(rest.len())) + } + Some(_) => (None, 1), + None => (None, 0), + } +} diff --git a/userland/nonos_vt/src/term/soft_reset.rs b/userland/nonos_vt/src/term/soft_reset.rs new file mode 100644 index 000000000..2e77e253d --- /dev/null +++ b/userland/nonos_vt/src/term/soft_reset.rs @@ -0,0 +1,40 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! DECSTR: a soft reset. + +use super::state::Term; +use crate::cell::Pen; +use crate::charset::Charsets; + +impl Term { + /// DECSTR: modes and pen to their defaults, screen contents untouched. + pub(super) fn soft_reset(&mut self) { + let m = &mut self.modes; + m.insert = false; + m.origin = false; + m.autowrap = true; + m.cursor_visible = true; + m.keypad = false; + m.cursor_keys = false; + self.charsets = Charsets::default(); + let scr = self.scr(); + scr.reset_region(); + scr.cur.pen = Pen::default(); + scr.cur.pending_wrap = false; + scr.saved = None; + } +} diff --git a/userland/nonos_vt/src/term/state.rs b/userland/nonos_vt/src/term/state.rs new file mode 100644 index 000000000..b6bcce53f --- /dev/null +++ b/userland/nonos_vt/src/term/state.rs @@ -0,0 +1,71 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Everything the terminal holds. + +use alloc::collections::VecDeque; +use alloc::string::String; +use alloc::vec::Vec; + +use super::modes_type::Modes; +use super::screen::Screen; +use super::types::{ClipboardRequest, CursorShape}; +use crate::charset::Charsets; +use crate::color::Palette; +use crate::line::Line; +use crate::parser::Parser; + +pub struct Term { + pub(super) cols: usize, + pub(super) rows: usize, + pub(super) primary: Screen, + pub(super) alt: Screen, + pub(super) alt_active: bool, + pub(super) scrollback: VecDeque, + pub(super) scrollback_limit: usize, + /// Lines that ever left the top of the normal screen. The first line of + /// the normal screen is absolute line `scrolled`. + pub(super) scrolled: u64, + /// How many lines the view is scrolled back into history. + pub(super) view: usize, + pub modes: Modes, + pub(super) tabs: Vec, + pub(super) charsets: Charsets, + /// For REP, and for joining the character after a zero width joiner. + pub(super) last_char: Option, + pub(super) after_zwj: bool, + pub(super) parser: Parser, + pub(super) replies: Vec, + pub(super) title: String, + pub(super) title_stack: Vec, + pub(super) title_gen: u32, + pub(super) cwd: Option, + pub(super) clipboard: Option, + /// Clipboard reads a program asked for and was refused. + pub(super) clipboard_reads_refused: u32, + pub(super) bell: u32, + /// The theme the host set, and the palette programs may have changed. + pub(super) theme: Palette, + pub(super) palette: Palette, + pub(super) links: Vec, + pub(super) cursor_shape: CursorShape, + /// Absolute lines where a shell prompt started (OSC 133 A). + pub(super) prompts: VecDeque, + /// Rows to redraw. + pub(super) dirty: Vec, + /// Cell size in pixels, set by the host, for programs that ask. + pub(super) cell_px: (u16, u16), +} diff --git a/userland/nonos_vt/src/term/tab_stops.rs b/userland/nonos_vt/src/term/tab_stops.rs new file mode 100644 index 000000000..7853b8820 --- /dev/null +++ b/userland/nonos_vt/src/term/tab_stops.rs @@ -0,0 +1,42 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! HTS and TBC: setting and clearing tab stops. + +use super::state::Term; + +impl Term { + pub(super) fn set_tab(&mut self) { + let x = self.scr_ref().cur.x; + if let Some(t) = self.tabs.get_mut(x) { + *t = true; + } + } + + /// TBC: 0 clears the stop at the cursor, 3 clears them all. + pub(super) fn clear_tabs(&mut self, mode: u16) { + match mode { + 0 => { + let x = self.scr_ref().cur.x; + if let Some(t) = self.tabs.get_mut(x) { + *t = false; + } + } + 3 => self.tabs.iter_mut().for_each(|t| *t = false), + _ => {} + } + } +} diff --git a/userland/nonos_vt/src/term/tabs.rs b/userland/nonos_vt/src/term/tabs.rs new file mode 100644 index 000000000..c8b186d0d --- /dev/null +++ b/userland/nonos_vt/src/term/tabs.rs @@ -0,0 +1,60 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Tab stops: every eighth column until a program sets its own. + +use alloc::vec::Vec; + +use super::state::Term; + +pub(super) fn default_tabs(cols: usize) -> Vec { + (0..cols).map(|x| x > 0 && x % 8 == 0).collect() +} + +impl Term { + pub(super) fn tab_forward(&mut self, n: usize) { + let last = self.cols - 1; + let mut x = self.scr_ref().cur.x; + for _ in 0..n.min(self.cols) { + match (x + 1..self.cols).find(|&c| self.tabs[c]) { + Some(next) => x = next, + None => { + x = last; + break; + } + } + } + let cur = &mut self.scr().cur; + cur.x = x; + cur.pending_wrap = false; + } + + pub(super) fn tab_back(&mut self, n: usize) { + let mut x = self.scr_ref().cur.x; + for _ in 0..n.min(self.cols) { + match (0..x).rev().find(|&c| self.tabs[c]) { + Some(prev) => x = prev, + None => { + x = 0; + break; + } + } + } + let cur = &mut self.scr().cur; + cur.x = x; + cur.pending_wrap = false; + } +} diff --git a/userland/nonos_vt/src/term/text.rs b/userland/nonos_vt/src/term/text.rs new file mode 100644 index 000000000..7e31ea713 --- /dev/null +++ b/userland/nonos_vt/src/term/text.rs @@ -0,0 +1,74 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Reading text back out, for copying a selection. + +use alloc::string::String; + +use super::state::Term; +use super::types::Pos; +use crate::line::Line; + +fn push_cell(out: &mut String, line: &Line, x: usize) { + let c = line.cell(x); + if c.is_tail() { + return; + } + out.push(c.ch); + if let Some(m) = line.marks_of(&c) { + out.push_str(m); + } +} + +impl Term { + /// The text from `a` to `b`, both inclusive, in either order. Lines that + /// wrapped join without a break; other line ends lose trailing blanks + /// and become newlines. `block` takes the same columns from each line. + pub fn text_between(&self, a: Pos, b: Pos, block: bool) -> String { + let (s, e) = if a <= b { (a, b) } else { (b, a) }; + let (left, right) = (s.col.min(e.col), s.col.max(e.col)); + let mut out = String::new(); + let mut line_no = s.line; + while line_no <= e.line { + let Some(line) = self.line_at(line_no) else { + line_no += 1; + continue; + }; + let (from, to) = if block { + (left, right) + } else { + let from = if line_no == s.line { s.col } else { 0 }; + let to = if line_no == e.line { e.col } else { self.cols - 1 }; + (from, to) + }; + let mut piece = String::new(); + for x in from..=to.min(self.cols - 1) { + push_cell(&mut piece, line, x); + } + let runs_on = line.wrapped && !block && line_no != e.line; + if !runs_on { + let trimmed = piece.trim_end_matches(' ').len(); + piece.truncate(trimmed); + } + out.push_str(&piece); + if line_no != e.line && !runs_on { + out.push('\n'); + } + line_no += 1; + } + out + } +} diff --git a/userland/nonos_vt/src/term/types.rs b/userland/nonos_vt/src/term/types.rs new file mode 100644 index 000000000..3c696e076 --- /dev/null +++ b/userland/nonos_vt/src/term/types.rs @@ -0,0 +1,56 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The terminal's modes and the small types its public surface uses. + +use alloc::vec::Vec; + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum CursorShape { + #[default] + Block, + Underline, + Bar, +} + +/// Where and how the cursor is drawn on the visible rows. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct CursorView { + pub x: usize, + pub y: usize, + /// False when the program hid it or the view is scrolled away from it. + pub visible: bool, + pub shape: CursorShape, + pub blink: bool, +} + +/// A position in the whole history: an absolute line number, counted from +/// the first line this terminal ever had, and a column. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Default)] +pub struct Pos { + pub line: u64, + pub col: usize, +} + +/// OSC 52: a program asked to set a clipboard. The host decides whether to +/// honour it. Asking to read a clipboard is never answered. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ClipboardRequest { + /// The selection names the program gave, such as `c` or `p`. + pub targets: Vec, + /// The text, still base64 encoded as the program sent it. + pub base64: Vec, +} diff --git a/userland/nonos_vt/src/term/view.rs b/userland/nonos_vt/src/term/view.rs new file mode 100644 index 000000000..3105fb62d --- /dev/null +++ b/userland/nonos_vt/src/term/view.rs @@ -0,0 +1,72 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What the host draws: the visible rows, the cursor, which rows changed, +//! and each cell's final colours. + +use super::state::Term; +use crate::line::Line; + +impl Term { + pub fn cols(&self) -> usize { + self.cols + } + + pub fn rows(&self) -> usize { + self.rows + } + + pub fn history_len(&self) -> usize { + self.scrollback.len() + } + + pub fn view_offset(&self) -> usize { + self.view + } + + /// Row `row` of what is on view, history included when scrolled back. + pub fn visible_line(&self, row: usize) -> &Line { + let row = row.min(self.rows - 1); + if self.alt_active { + return &self.alt.lines[row]; + } + let sb = self.scrollback.len(); + let i = sb + row - self.view.min(sb); + if i < sb { + &self.scrollback[i] + } else { + &self.primary.lines[i - sb] + } + } + + /// Scroll the view: positive goes back into history. The alternate + /// screen has none. + pub fn scroll_view(&mut self, delta: isize) { + if self.alt_active { + return; + } + let max = self.scrollback.len() as isize; + let v = (self.view as isize).saturating_add(delta).clamp(0, max) as usize; + if v != self.view { + self.view = v; + self.touch_all(); + } + } + + pub fn scroll_to_bottom(&mut self) { + self.scroll_view(isize::MIN); + } +} diff --git a/userland/nonos_vt/src/term/view_draw.rs b/userland/nonos_vt/src/term/view_draw.rs new file mode 100644 index 000000000..572fad0f8 --- /dev/null +++ b/userland/nonos_vt/src/term/view_draw.rs @@ -0,0 +1,65 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What a frame needs besides the rows: the cursor, which rows changed, +//! and each cell's final colours. + +use super::state::Term; +use super::types::CursorView; +use crate::cell::{attr, Cell}; +use crate::color::{dim, Palette}; +use alloc::vec::Vec; + +impl Term { + pub fn cursor(&self) -> CursorView { + let s = self.scr_ref(); + CursorView { + x: s.cur.x, + y: s.cur.y, + visible: self.modes.cursor_visible && self.view == 0, + shape: self.cursor_shape, + blink: self.modes.cursor_blink, + } + } + + /// Which rows changed since the last call, then forgets. + pub fn take_dirty(&mut self) -> Vec { + let fresh = alloc::vec![false; self.rows]; + core::mem::replace(&mut self.dirty, fresh) + } + + pub fn palette(&self) -> &Palette { + &self.palette + } + + /// The colours a cell is drawn in, after bold brightening, dim, inverse, + /// hidden and reverse video. + pub fn cell_colors(&self, cell: &Cell) -> (u32, u32) { + let bold = cell.attr & attr::BOLD != 0; + let mut fg = self.palette.fg_of(cell.fg, bold); + let mut bg = self.palette.bg_of(cell.bg); + if cell.attr & attr::DIM != 0 { + fg = dim(fg); + } + if (cell.attr & attr::INVERSE != 0) != self.modes.reverse_video { + core::mem::swap(&mut fg, &mut bg); + } + if cell.attr & attr::HIDDEN != 0 { + fg = bg; + } + (fg, bg) + } +} diff --git a/userland/nonos_vt/src/term/view_text.rs b/userland/nonos_vt/src/term/view_text.rs new file mode 100644 index 000000000..49a6ef674 --- /dev/null +++ b/userland/nonos_vt/src/term/view_text.rs @@ -0,0 +1,49 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The visible rows as plain text, for self-tests, screen readers and +//! whatever else needs what is shown rather than how. + +use alloc::string::String; +use alloc::vec::Vec; + +use super::state::Term; + +impl Term { + /// Visible row `y` as text, marks included, trailing blanks dropped. + pub fn row_text(&self, y: usize) -> String { + let line = self.visible_line(y); + let mut s = String::new(); + for x in 0..self.cols { + let c = line.cell(x); + if c.is_tail() { + continue; + } + s.push(c.ch); + if let Some(m) = line.marks_of(&c) { + s.push_str(m); + } + } + let n = s.trim_end().len(); + s.truncate(n); + s + } + + /// Every visible row, as `row_text` gives it. + pub fn screen_text(&self) -> Vec { + (0..self.rows).map(|y| self.row_text(y)).collect() + } +} diff --git a/userland/nonos_vt/src/url.rs b/userland/nonos_vt/src/url.rs new file mode 100644 index 000000000..8d7b171f9 --- /dev/null +++ b/userland/nonos_vt/src/url.rs @@ -0,0 +1,68 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Finding URLs in text a program printed, so the host can offer to open +//! one. Only schemes a person would follow are recognised. + +const SCHEMES: [&str; 6] = ["https://", "http://", "file://", "ftp://", "ssh://", "git://"]; + +fn url_char(c: char) -> bool { + !c.is_whitespace() && !c.is_control() && !"<>\"'`{}|\\^".contains(c) +} + +/// Every URL in `text`, as char index ranges `[start, end)`. Trailing +/// punctuation is dropped unless it closes a bracket the URL opened. +pub fn find_urls(text: &[char]) -> alloc::vec::Vec<(usize, usize)> { + let mut out = alloc::vec::Vec::new(); + let mut i = 0; + while i < text.len() { + let hit = SCHEMES.iter().find(|s| { + let n = s.chars().count(); + i + n <= text.len() + && s.chars().zip(&text[i..i + n]).all(|(a, &b)| a.eq_ignore_ascii_case(&b)) + }); + let Some(s) = hit else { + i += 1; + continue; + }; + let start = i; + let mut end = i + s.len(); + while end < text.len() && url_char(text[end]) { + end += 1; + } + while end > start + s.len() { + let c = text[end - 1]; + let opens = |o: char| text[start..end].iter().filter(|&&x| x == o).count(); + let closes = |cl: char| text[start..end].iter().filter(|&&x| x == cl).count(); + let unbalanced = match c { + ')' => closes(')') > opens('('), + ']' => closes(']') > opens('['), + '.' | ',' | ';' | ':' | '!' | '?' => true, + _ => false, + }; + if unbalanced { + end -= 1; + } else { + break; + } + } + if end > start + s.len() { + out.push((start, end)); + } + i = end.max(i + 1); + } + out +} diff --git a/userland/nonos_vt/src/utf8/mod.rs b/userland/nonos_vt/src/utf8/mod.rs new file mode 100644 index 000000000..16c46eac6 --- /dev/null +++ b/userland/nonos_vt/src/utf8/mod.rs @@ -0,0 +1,23 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! UTF-8 decoding that survives a character split across two reads and never +//! accepts an overlong form, a surrogate or a value past U+10FFFF. + +mod push; +mod state; + +pub use state::{Step, Utf8}; diff --git a/userland/nonos_vt/src/utf8/push.rs b/userland/nonos_vt/src/utf8/push.rs new file mode 100644 index 000000000..791ddaad3 --- /dev/null +++ b/userland/nonos_vt/src/utf8/push.rs @@ -0,0 +1,58 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! One byte into the decoder. + +use super::state::{Step, Utf8}; + +impl Utf8 { + pub fn push(&mut self, b: u8) -> Step { + if self.need > 0 { + if b & 0xC0 != 0x80 { + self.reset(); + return Step::Invalid { again: true }; + } + self.cp = (self.cp << 6) | (b & 0x3F) as u32; + self.need -= 1; + if self.need > 0 { + return Step::Pending; + } + let (cp, min) = (self.cp, self.min); + self.reset(); + if cp < min || (0xD800..=0xDFFF).contains(&cp) { + return Step::Invalid { again: false }; + } + return match char::from_u32(cp) { + Some(c) => Step::Char(c), + None => Step::Invalid { again: false }, + }; + } + match b { + 0x00..=0x7F => Step::Char(b as char), + 0xC2..=0xDF => self.start(b & 0x1F, 1, 0x80), + 0xE0..=0xEF => self.start(b & 0x0F, 2, 0x800), + 0xF0..=0xF4 => self.start(b & 0x07, 3, 0x1_0000), + _ => Step::Invalid { again: false }, + } + } + + fn start(&mut self, bits: u8, need: u8, min: u32) -> Step { + self.cp = bits as u32; + self.need = need; + self.min = min; + Step::Pending + } +} diff --git a/userland/nonos_vt/src/utf8/state.rs b/userland/nonos_vt/src/utf8/state.rs new file mode 100644 index 000000000..562b04a07 --- /dev/null +++ b/userland/nonos_vt/src/utf8/state.rs @@ -0,0 +1,45 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The decoder's state between bytes. + +#[derive(Clone, Copy, Default, Debug)] +pub struct Utf8 { + pub(super) cp: u32, + pub(super) need: u8, + pub(super) min: u32, +} + +pub enum Step { + /// More bytes are needed. + Pending, + Char(char), + /// The sequence was malformed. When `again` is set, the byte that broke + /// it starts something new and must be fed again. + Invalid { + again: bool, + }, +} + +impl Utf8 { + pub fn pending(&self) -> bool { + self.need > 0 + } + + pub fn reset(&mut self) { + *self = Utf8::default(); + } +} diff --git a/userland/nonos_vt/src/width/check.rs b/userland/nonos_vt/src/width/check.rs new file mode 100644 index 000000000..1cee93905 --- /dev/null +++ b/userland/nonos_vt/src/width/check.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The lookup is a binary search, so each table must be sorted and its +//! ranges must not overlap, and the two halves of the zero table must meet +//! in order. + +use super::wide::WIDE; +use super::zero_high::ZERO_HIGH; +use super::zero_low::ZERO_LOW; + +fn sorted(t: &[(u32, u32)]) -> bool { + t.iter().all(|&(lo, hi)| lo <= hi) && t.windows(2).all(|w| w[0].1 < w[1].0) +} + +#[test] +fn tables_are_sorted_and_disjoint() { + assert!(sorted(ZERO_LOW) && sorted(ZERO_HIGH) && sorted(WIDE)); + assert!(ZERO_LOW.last().unwrap().1 < ZERO_HIGH[0].0); +} + +#[test] +fn known_widths() { + use super::lookup::width; + for (c, w) in + [('a', 1), ('\u{301}', 0), ('漢', 2), ('한', 2), ('🚀', 2), ('\u{fe0f}', 0), ('│', 1)] + { + assert_eq!(width(c), w, "{c:?}"); + } +} diff --git a/userland/nonos_vt/src/width/lookup.rs b/userland/nonos_vt/src/width/lookup.rs new file mode 100644 index 000000000..bc91f0dfb --- /dev/null +++ b/userland/nonos_vt/src/width/lookup.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Looking a character up in the width tables. + +use super::wide::WIDE; +use super::zero_high::ZERO_HIGH; +use super::zero_low::ZERO_LOW; + +fn within(table: &[(u32, u32)], c: u32) -> bool { + table + .binary_search_by(|&(lo, hi)| { + if hi < c { + core::cmp::Ordering::Less + } else if lo > c { + core::cmp::Ordering::Greater + } else { + core::cmp::Ordering::Equal + } + }) + .is_ok() +} + +pub fn width(c: char) -> usize { + let u = c as u32; + if u < 0x300 { + return 1; + } + if within(ZERO_LOW, u) || within(ZERO_HIGH, u) { + 0 + } else if within(WIDE, u) { + 2 + } else { + 1 + } +} + +/// Zero width joiner: the character after it joins the one before. +pub const ZWJ: char = '\u{200D}'; + +/// Skin tone modifiers, which join the emoji before them. +pub fn is_emoji_modifier(c: char) -> bool { + matches!(c as u32, 0x1F3FB..=0x1F3FF) +} diff --git a/userland/nonos_vt/src/width/mod.rs b/userland/nonos_vt/src/width/mod.rs new file mode 100644 index 000000000..7caacec15 --- /dev/null +++ b/userland/nonos_vt/src/width/mod.rs @@ -0,0 +1,30 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! How many columns a character takes: none for marks that combine with the +//! character before them, two for East Asian wide and emoji presentation, +//! else one. The tables follow Unicode 15 East Asian Width and general +//! category Mn, the same split wcwidth makes, so a program's idea of a +//! line's width and the screen's agree. + +#[cfg(test)] +mod check; +mod lookup; +mod wide; +mod zero_high; +mod zero_low; + +pub use lookup::{is_emoji_modifier, width, ZWJ}; diff --git a/userland/nonos_vt/src/width/wide.rs b/userland/nonos_vt/src/width/wide.rs new file mode 100644 index 000000000..714406fd6 --- /dev/null +++ b/userland/nonos_vt/src/width/wide.rs @@ -0,0 +1,40 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! East Asian Wide and Fullwidth, and emoji shown as emoji by default. + +#[rustfmt::skip] +pub(super) const WIDE: &[(u32, u32)] = &[ + (0x1100, 0x115F), (0x231A, 0x231B), (0x2329, 0x232A), (0x23E9, 0x23EC), (0x23F0, 0x23F0), + (0x23F3, 0x23F3), (0x25FD, 0x25FE), (0x2614, 0x2615), (0x2648, 0x2653), (0x267F, 0x267F), + (0x2693, 0x2693), (0x26A1, 0x26A1), (0x26AA, 0x26AB), (0x26BD, 0x26BE), (0x26C4, 0x26C5), + (0x26CE, 0x26CE), (0x26D4, 0x26D4), (0x26EA, 0x26EA), (0x26F2, 0x26F3), (0x26F5, 0x26F5), + (0x26FA, 0x26FA), (0x26FD, 0x26FD), (0x2705, 0x2705), (0x270A, 0x270B), (0x2728, 0x2728), + (0x274C, 0x274C), (0x274E, 0x274E), (0x2753, 0x2755), (0x2757, 0x2757), (0x2795, 0x2797), + (0x27B0, 0x27B0), (0x27BF, 0x27BF), (0x2B1B, 0x2B1C), (0x2B50, 0x2B50), (0x2B55, 0x2B55), + (0x2E80, 0x303E), (0x3041, 0x3247), (0x3250, 0x4DBF), (0x4E00, 0xA4CF), (0xA960, 0xA97F), + (0xAC00, 0xD7A3), (0xF900, 0xFAFF), (0xFE10, 0xFE19), (0xFE30, 0xFE6F), (0xFF00, 0xFF60), + (0xFFE0, 0xFFE6), (0x16FE0, 0x16FE4), (0x16FF0, 0x16FF1), (0x17000, 0x18CD5), (0x18D00, 0x18D08), + (0x1AFF0, 0x1B2FB), (0x1F004, 0x1F004), (0x1F0CF, 0x1F0CF), (0x1F18E, 0x1F18E), (0x1F191, 0x1F19A), + (0x1F200, 0x1F202), (0x1F210, 0x1F23B), (0x1F240, 0x1F248), (0x1F250, 0x1F251), (0x1F260, 0x1F265), + (0x1F300, 0x1F320), (0x1F32D, 0x1F335), (0x1F337, 0x1F37C), (0x1F37E, 0x1F393), (0x1F3A0, 0x1F3CA), + (0x1F3CF, 0x1F3D3), (0x1F3E0, 0x1F3F0), (0x1F3F4, 0x1F3F4), (0x1F3F8, 0x1F43E), (0x1F440, 0x1F440), + (0x1F442, 0x1F4FC), (0x1F4FF, 0x1F53D), (0x1F54B, 0x1F54E), (0x1F550, 0x1F567), (0x1F57A, 0x1F57A), + (0x1F595, 0x1F596), (0x1F5A4, 0x1F5A4), (0x1F5FB, 0x1F64F), (0x1F680, 0x1F6C5), (0x1F6CC, 0x1F6CC), + (0x1F6D0, 0x1F6D2), (0x1F6D5, 0x1F6D7), (0x1F6DC, 0x1F6DF), (0x1F6EB, 0x1F6EC), (0x1F6F4, 0x1F6FC), + (0x1F7E0, 0x1F7EB), (0x1F7F0, 0x1F7F0), (0x1F90C, 0x1F93A), (0x1F93C, 0x1F945), (0x1F947, 0x1F9FF), + (0x1FA70, 0x1FAFF), (0x20000, 0x2FFFD), (0x30000, 0x3FFFD), +]; diff --git a/userland/nonos_vt/src/width/zero_high.rs b/userland/nonos_vt/src/width/zero_high.rs new file mode 100644 index 000000000..7c576d7d3 --- /dev/null +++ b/userland/nonos_vt/src/width/zero_high.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Nonspacing marks and format characters from U+180B up. + +#[rustfmt::skip] +pub(super) const ZERO_HIGH: &[(u32, u32)] = &[ + (0x180B, 0x180F), (0x1885, 0x1886), (0x18A9, 0x18A9), (0x1920, 0x1922), (0x1927, 0x1928), + (0x1932, 0x1932), (0x1939, 0x193B), (0x1A17, 0x1A18), (0x1A1B, 0x1A1B), (0x1A56, 0x1A56), + (0x1A58, 0x1A60), (0x1A62, 0x1A62), (0x1A65, 0x1A6C), (0x1A73, 0x1A7F), (0x1AB0, 0x1ACE), + (0x1B00, 0x1B03), (0x1B34, 0x1B34), (0x1B36, 0x1B3A), (0x1B3C, 0x1B3C), (0x1B42, 0x1B42), + (0x1B6B, 0x1B73), (0x1B80, 0x1B81), (0x1BA2, 0x1BA5), (0x1BA8, 0x1BA9), (0x1BAB, 0x1BAD), + (0x1BE6, 0x1BE6), (0x1BE8, 0x1BE9), (0x1BED, 0x1BED), (0x1BEF, 0x1BF1), (0x1C2C, 0x1C33), + (0x1C36, 0x1C37), (0x1CD0, 0x1CD2), (0x1CD4, 0x1CE0), (0x1CE2, 0x1CE8), (0x1CED, 0x1CED), + (0x1CF4, 0x1CF4), (0x1CF8, 0x1CF9), (0x1DC0, 0x1DFF), (0x200B, 0x200F), (0x2028, 0x202E), + (0x2060, 0x2064), (0x20D0, 0x20F0), (0x2CEF, 0x2CF1), (0x2D7F, 0x2D7F), (0x2DE0, 0x2DFF), + (0x302A, 0x302D), (0x3099, 0x309A), (0xA66F, 0xA672), (0xA674, 0xA67D), (0xA69E, 0xA69F), + (0xA6F0, 0xA6F1), (0xA802, 0xA802), (0xA806, 0xA806), (0xA80B, 0xA80B), (0xA825, 0xA826), + (0xA82C, 0xA82C), (0xA8C4, 0xA8C5), (0xA8E0, 0xA8F1), (0xA8FF, 0xA8FF), (0xA926, 0xA92D), + (0xA947, 0xA951), (0xA980, 0xA982), (0xA9B3, 0xA9B3), (0xA9B6, 0xA9B9), (0xA9BC, 0xA9BD), + (0xA9E5, 0xA9E5), (0xAA29, 0xAA2E), (0xAA31, 0xAA32), (0xAA35, 0xAA36), (0xAA43, 0xAA43), + (0xAA4C, 0xAA4C), (0xAA7C, 0xAA7C), (0xAAB0, 0xAAB0), (0xAAB2, 0xAAB4), (0xAAB7, 0xAAB8), + (0xAABE, 0xAABF), (0xAAC1, 0xAAC1), (0xAAEC, 0xAAED), (0xAAF6, 0xAAF6), (0xABE5, 0xABE5), + (0xABE8, 0xABE8), (0xABED, 0xABED), (0xD7B0, 0xD7FF), (0xFB1E, 0xFB1E), (0xFE00, 0xFE0F), + (0xFE20, 0xFE2F), (0xFEFF, 0xFEFF), (0xFFF9, 0xFFFB), (0x101FD, 0x101FD), (0x10376, 0x1037A), + (0x10A01, 0x10A0F), (0x10A38, 0x10A3F), (0x11001, 0x11001), (0x11038, 0x11046), (0x1107F, 0x11081), + (0x110B3, 0x110B6), (0x110B9, 0x110BA), (0x11100, 0x11102), (0x11127, 0x1112B), (0x1112D, 0x11134), + (0x1D167, 0x1D169), (0x1D173, 0x1D182), (0x1D185, 0x1D18B), (0x1D1AA, 0x1D1AD), (0x1E000, 0x1E02A), + (0x1E8D0, 0x1E8D6), (0x1E944, 0x1E94A), (0xE0001, 0xE0001), (0xE0020, 0xE007F), (0xE0100, 0xE01EF), +]; diff --git a/userland/nonos_vt/src/width/zero_low.rs b/userland/nonos_vt/src/width/zero_low.rs new file mode 100644 index 000000000..db72e4fac --- /dev/null +++ b/userland/nonos_vt/src/width/zero_low.rs @@ -0,0 +1,45 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Nonspacing marks and format characters below U+180B, drawn over the +//! cell before them. + +#[rustfmt::skip] +pub(super) const ZERO_LOW: &[(u32, u32)] = &[ + (0x0300, 0x036F), (0x0483, 0x0489), (0x0591, 0x05BD), (0x05BF, 0x05BF), (0x05C1, 0x05C2), + (0x05C4, 0x05C5), (0x05C7, 0x05C7), (0x0610, 0x061A), (0x061C, 0x061C), (0x064B, 0x065F), + (0x0670, 0x0670), (0x06D6, 0x06DC), (0x06DF, 0x06E4), (0x06E7, 0x06E8), (0x06EA, 0x06ED), + (0x0711, 0x0711), (0x0730, 0x074A), (0x07A6, 0x07B0), (0x07EB, 0x07F3), (0x07FD, 0x07FD), + (0x0816, 0x0819), (0x081B, 0x0823), (0x0825, 0x0827), (0x0829, 0x082D), (0x0859, 0x085B), + (0x0898, 0x089F), (0x08CA, 0x08E1), (0x08E3, 0x0902), (0x093A, 0x093A), (0x093C, 0x093C), + (0x0941, 0x0948), (0x094D, 0x094D), (0x0951, 0x0957), (0x0962, 0x0963), (0x0981, 0x0981), + (0x09BC, 0x09BC), (0x09C1, 0x09C4), (0x09CD, 0x09CD), (0x09E2, 0x09E3), (0x09FE, 0x09FE), + (0x0A01, 0x0A02), (0x0A3C, 0x0A3C), (0x0A41, 0x0A51), (0x0A70, 0x0A71), (0x0A75, 0x0A75), + (0x0A81, 0x0A82), (0x0ABC, 0x0ABC), (0x0AC1, 0x0AC8), (0x0ACD, 0x0ACD), (0x0AE2, 0x0AE3), + (0x0AFA, 0x0AFF), (0x0B01, 0x0B01), (0x0B3C, 0x0B3C), (0x0B3F, 0x0B3F), (0x0B41, 0x0B44), + (0x0B4D, 0x0B56), (0x0B62, 0x0B63), (0x0B82, 0x0B82), (0x0BC0, 0x0BC0), (0x0BCD, 0x0BCD), + (0x0C00, 0x0C00), (0x0C04, 0x0C04), (0x0C3C, 0x0C3C), (0x0C3E, 0x0C40), (0x0C46, 0x0C56), + (0x0C62, 0x0C63), (0x0C81, 0x0C81), (0x0CBC, 0x0CBC), (0x0CCC, 0x0CCD), (0x0CE2, 0x0CE3), + (0x0D00, 0x0D01), (0x0D3B, 0x0D3C), (0x0D41, 0x0D44), (0x0D4D, 0x0D4D), (0x0D62, 0x0D63), + (0x0D81, 0x0D81), (0x0DCA, 0x0DCA), (0x0DD2, 0x0DD6), (0x0E31, 0x0E31), (0x0E34, 0x0E3A), + (0x0E47, 0x0E4E), (0x0EB1, 0x0EB1), (0x0EB4, 0x0EBC), (0x0EC8, 0x0ECE), (0x0F18, 0x0F19), + (0x0F35, 0x0F35), (0x0F37, 0x0F37), (0x0F39, 0x0F39), (0x0F71, 0x0F7E), (0x0F80, 0x0F84), + (0x0F86, 0x0F87), (0x0F8D, 0x0FBC), (0x0FC6, 0x0FC6), (0x102D, 0x1030), (0x1032, 0x1037), + (0x1039, 0x103A), (0x103D, 0x103E), (0x1058, 0x1059), (0x105E, 0x1060), (0x1071, 0x1074), + (0x1082, 0x1082), (0x1085, 0x1086), (0x108D, 0x108D), (0x109D, 0x109D), (0x1160, 0x11FF), + (0x135D, 0x135F), (0x1712, 0x1714), (0x1732, 0x1733), (0x1752, 0x1753), (0x1772, 0x1773), + (0x17B4, 0x17B5), (0x17B7, 0x17BD), (0x17C6, 0x17C6), (0x17C9, 0x17D3), (0x17DD, 0x17DD), +]; diff --git a/userland/nonos_vt/tests/input.rs b/userland/nonos_vt/tests/input.rs new file mode 100644 index 000000000..0f453cb65 --- /dev/null +++ b/userland/nonos_vt/tests/input.rs @@ -0,0 +1,60 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Keys, mouse and paste as a program reads them. + +use nonos_vt::input::{encode_key, Key, Mods}; +use nonos_vt::Modes; + +fn key(k: Key, m: Mods, modes: &Modes) -> Vec { + let mut v = Vec::new(); + encode_key(k, m, modes, &mut v); + v +} + +const NONE: Mods = Mods { shift: false, alt: false, ctrl: false }; +const CTRL: Mods = Mods { shift: false, alt: false, ctrl: true }; +const ALT: Mods = Mods { shift: false, alt: true, ctrl: false }; + +#[test] +fn cursor_keys_follow_the_mode() { + let mut m = Modes::default(); + assert_eq!(key(Key::Up, NONE, &m), b"\x1b[A"); + m.cursor_keys = true; + assert_eq!(key(Key::Up, NONE, &m), b"\x1bOA"); + assert_eq!(key(Key::Right, CTRL, &m), b"\x1b[1;5C"); +} + +#[test] +fn control_and_alt() { + let m = Modes::default(); + assert_eq!(key(Key::Char('c'), CTRL, &m), [3]); + assert_eq!(key(Key::Char('['), CTRL, &m), [0x1b]); + assert_eq!(key(Key::Char('x'), ALT, &m), b"\x1bx"); + assert_eq!(key(Key::Backspace, NONE, &m), [0x7f]); + assert_eq!(key(Key::Enter, NONE, &m), b"\r"); + assert_eq!(key(Key::Tab, Mods { shift: true, ..NONE }, &m), b"\x1b[Z"); +} + +#[test] +fn function_and_editing_keys() { + let m = Modes::default(); + assert_eq!(key(Key::F(1), NONE, &m), b"\x1bOP"); + assert_eq!(key(Key::F(5), NONE, &m), b"\x1b[15~"); + assert_eq!(key(Key::F(12), CTRL, &m), b"\x1b[24;5~"); + assert_eq!(key(Key::PageDown, NONE, &m), b"\x1b[6~"); + assert_eq!(key(Key::Delete, NONE, &m), b"\x1b[3~"); +} diff --git a/userland/nonos_vt/tests/input_pointer.rs b/userland/nonos_vt/tests/input_pointer.rs new file mode 100644 index 000000000..51d539acf --- /dev/null +++ b/userland/nonos_vt/tests/input_pointer.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Paste, mouse reports and motion, as a program reads them. + +use nonos_vt::input::{encode_mouse, encode_paste, Button, Mods, MouseEvent, MouseKind}; +use nonos_vt::{Modes, MouseMode}; + +const NONE: Mods = Mods { shift: false, alt: false, ctrl: false }; + +#[test] +fn paste_cannot_break_out_of_its_bracket() { + let m = Modes { bracketed_paste: true, ..Modes::default() }; + let mut v = Vec::new(); + encode_paste("ls\x1b[201~\nrm -rf ~\r\n", &m, &mut v); + assert_eq!(v, b"\x1b[200~ls[201~\rrm -rf ~\r\x1b[201~"); +} + +#[test] +fn mouse_reports_only_when_asked() { + let ev = + MouseEvent { kind: MouseKind::Press, button: Button::Left, col: 4, row: 2, mods: NONE }; + let mut v = Vec::new(); + assert!(!encode_mouse(&ev, &Modes::default(), &mut v)); + let m = Modes { mouse: MouseMode::Click, mouse_sgr: true, ..Modes::default() }; + assert!(encode_mouse(&ev, &m, &mut v)); + assert_eq!(v, b"\x1b[<0;5;3M"); + let m = Modes { mouse: MouseMode::Click, ..Modes::default() }; + let mut v = Vec::new(); + encode_mouse(&ev, &m, &mut v); + assert_eq!(v, [0x1b, b'[', b'M', 32, 37, 35]); +} + +#[test] +fn motion_without_a_button_needs_any_motion_mode() { + let ev = + MouseEvent { kind: MouseKind::Motion, button: Button::None, col: 0, row: 0, mods: NONE }; + let mut v = Vec::new(); + let drag = Modes { mouse: MouseMode::Drag, mouse_sgr: true, ..Modes::default() }; + assert!(!encode_mouse(&ev, &drag, &mut v)); + let any = Modes { mouse: MouseMode::Motion, mouse_sgr: true, ..Modes::default() }; + assert!(encode_mouse(&ev, &any, &mut v)); + assert_eq!(v, b"\x1b[<35;1;1M"); +} diff --git a/userland/nonos_vt/tests/mutate.rs b/userland/nonos_vt/tests/mutate.rs new file mode 100644 index 000000000..6ae8708da --- /dev/null +++ b/userland/nonos_vt/tests/mutate.rs @@ -0,0 +1,61 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Mutation fuzzing, deterministic and seeded, run as an ordinary test. +//! +//! Not coverage-guided: no libFuzzer is vendored in this tree. It builds +//! byte streams from the pieces hostile output is made of (control +//! sequences with random parameters and finals, strings, broken UTF-8, wide +//! and combining characters, raw noise), interleaves resizes, scrolling, +//! copying and searching, and after every step checks what must always +//! hold: the cursor on screen, every screen row as wide as the screen, the +//! reply queue under its ceiling. Debug builds trap on arithmetic overflow, +//! so an overflow anywhere fails the test. + +use nonos_vt::Term; + +#[path = "support/check.rs"] +mod check; +#[path = "support/pieces.rs"] +mod pieces; +#[path = "support/rng.rs"] +mod rng; + +use check::{check, poke}; +use pieces::piece; +use rng::Rng; + +#[test] +fn hostile_output_keeps_the_screen_sound() { + // A different seed explores different streams: NONOS_VT_SEED=n. + let seed = std::env::var("NONOS_VT_SEED").ok().and_then(|s| s.parse().ok()).unwrap_or(0x7E57); + let mut r = Rng::new(seed); + for round in 0..400 { + let mut t = Term::new(1 + (r.next() % 120) as usize, 1 + (r.next() % 50) as usize, 200); + for _ in 0..200 { + let mut bytes = Vec::new(); + for _ in 0..(r.next() % 8) { + piece(&mut r, &mut bytes); + } + // Feed in pieces, so sequences split across reads too. + let cut = (r.next() as usize) % (bytes.len() + 1); + t.feed(&bytes[..cut]); + t.feed(&bytes[cut..]); + poke(&mut t, &mut r, round); + check(&mut t); + } + } +} diff --git a/userland/nonos_vt/tests/noise.rs b/userland/nonos_vt/tests/noise.rs new file mode 100644 index 000000000..48bd3241b --- /dev/null +++ b/userland/nonos_vt/tests/noise.rs @@ -0,0 +1,41 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Pure noise: random bytes with no structure, with the host resizing, +//! scrolling and copying between them, which must never panic or leave the +//! screen unsound. + +#[path = "support/check.rs"] +mod check; +#[path = "support/rng.rs"] +mod rng; + +use check::{check, poke}; +use nonos_vt::Term; +use rng::Rng; + +#[test] +fn pure_noise_never_panics() { + let mut r = Rng::new(0xBAD); + let mut t = Term::new(80, 24, 1000); + for round in 0..20_000 { + let n = (r.next() % 64) as usize; + let bytes: Vec = (0..n).map(|_| (r.next() >> 56) as u8).collect(); + t.feed(&bytes); + poke(&mut t, &mut r, round); + check(&mut t); + } +} diff --git a/userland/nonos_vt/tests/parser.rs b/userland/nonos_vt/tests/parser.rs new file mode 100644 index 000000000..19f83d973 --- /dev/null +++ b/userland/nonos_vt/tests/parser.rs @@ -0,0 +1,61 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The parser on its own: what it reports for each kind of sequence, and +//! that malformed input ends in a known state. + +#[path = "support/log.rs"] +mod log; + +use log::run; + +#[test] +fn private_marker_and_params() { + assert_eq!(run(b"\x1b[?1049h"), ["csi?h[1049]"]); + assert_eq!(run(b"\x1b[>c"), ["csi>c"]); + assert_eq!(run(b"\x1b[1;5H"), ["csi\0H[1][5]"]); +} + +#[test] +fn colon_sub_parameters_group() { + assert_eq!(run(b"\x1b[38:2::1:2:3m"), ["csi\0m[38, 2, 0, 1, 2, 3]"]); + assert_eq!(run(b"\x1b[4:3m"), ["csi\0m[4, 3]"]); +} + +#[test] +fn escape_inside_a_sequence_starts_a_new_one() { + assert_eq!(run(b"\x1b[12\x1b[Aa"), ["csi\0A", "pa"]); +} + +#[test] +fn cancel_abandons_a_sequence() { + assert_eq!(run(b"\x1b[12\x18A"), ["x18", "pA"]); +} + +#[test] +fn a_misplaced_marker_ignores_the_sequence() { + assert_eq!(run(b"\x1b[1?2hz"), ["pz"]); +} + +#[test] +fn too_many_params_ignores_the_sequence() { + let mut s = b"\x1b[".to_vec(); + for _ in 0..40 { + s.extend_from_slice(b"1;"); + } + s.extend_from_slice(b"mz"); + assert_eq!(run(&s), ["pz"]); +} diff --git a/userland/nonos_vt/tests/parser_strings.rs b/userland/nonos_vt/tests/parser_strings.rs new file mode 100644 index 000000000..8a46196ce --- /dev/null +++ b/userland/nonos_vt/tests/parser_strings.rs @@ -0,0 +1,58 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! OSC, DCS and APC strings, oversized input, and UTF-8. + +#[path = "support/log.rs"] +mod log; +use log::{run, Log}; +use nonos_vt::parser::Parser; + +#[test] +fn osc_ends_on_bel_or_st() { + assert_eq!(run(b"\x1b]0;hi\x07x"), ["osc0;hi", "px"]); + assert_eq!(run(b"\x1b]2;yo\x1b\\x"), ["osc2;yo", "esc\\", "px"]); +} + +#[test] +fn dcs_payload_and_apc_swallowed() { + assert_eq!(run(b"\x1bP+q544e\x1b\\"), ["dcs+q544e", "esc\\"]); + assert_eq!(run(b"\x1b_Gsecret\x1b\\z"), ["esc\\", "pz"]); +} + +#[test] +fn oversized_osc_is_dropped_whole() { + let mut s = b"\x1b]0;".to_vec(); + s.extend(std::iter::repeat_n(b'a', 10_000)); + s.extend_from_slice(b"\x07z"); + assert_eq!(run(&s), ["pz"]); +} + +#[test] +fn utf8_split_across_feeds() { + let mut p = Parser::new(); + let mut l = Log::default(); + p.feed(&mut l, "é".as_bytes().split_at(1).0); + p.feed(&mut l, "é".as_bytes().split_at(1).1); + assert_eq!(l.0, ["pé"]); +} + +#[test] +fn utf8_cut_by_a_control_shows_a_replacement() { + assert_eq!(run(b"\xc3\n"), ["p\u{fffd}", "x0a"]); + assert_eq!(run(b"\xc0\xaf"), ["p\u{fffd}", "p\u{fffd}"]); + assert_eq!(run(b"\xed\xa0\x80"), ["p\u{fffd}"]); +} diff --git a/userland/nonos_vt/tests/reflow.rs b/userland/nonos_vt/tests/reflow.rs new file mode 100644 index 000000000..a748d677b --- /dev/null +++ b/userland/nonos_vt/tests/reflow.rs @@ -0,0 +1,55 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Resizing re-wraps text rather than cutting it. + +#[path = "support/term.rs"] +mod support; + +use support::term; + +#[test] +fn narrowing_wraps_and_widening_joins_again() { + let mut t = term(10, 3, "abcdefgh\r\nxy"); + t.resize(4, 4); + assert_eq!(t.screen_text(), ["abcd", "efgh", "xy", ""]); + t.resize(10, 3); + assert_eq!(t.screen_text(), ["abcdefgh", "xy", ""]); +} + +#[test] +fn the_cursor_keeps_its_place_in_the_text() { + let mut t = term(10, 3, "abcdefgh"); + assert_eq!((t.cursor().x, t.cursor().y), (8, 0)); + t.resize(4, 3); + assert_eq!((t.cursor().x, t.cursor().y), (3, 1)); + t.feed(b"Z"); + assert_eq!(t.screen_text(), ["abcd", "efgh", "Z"]); +} + +#[test] +fn a_wide_character_is_never_split() { + let mut t = term(6, 2, "abc漢"); + t.resize(4, 3); + assert_eq!(t.screen_text(), ["abc", "漢", ""]); +} + +#[test] +fn the_alternate_screen_is_cut_not_rewrapped() { + let mut t = term(8, 2, "\x1b[?1049habcdefgh"); + t.resize(4, 2); + assert_eq!(t.screen_text(), ["abcd", ""]); +} diff --git a/userland/nonos_vt/tests/reflow_rows.rs b/userland/nonos_vt/tests/reflow_rows.rs new file mode 100644 index 000000000..c41af36f9 --- /dev/null +++ b/userland/nonos_vt/tests/reflow_rows.rs @@ -0,0 +1,45 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Which rows a resize keeps on screen. + +#[path = "support/term.rs"] +mod support; +use support::term; + +#[test] +fn lines_pushed_off_the_top_go_to_history() { + let mut t = term(4, 2, "a\r\nb"); + t.resize(4, 1); + assert_eq!(t.screen_text(), ["b"]); + assert_eq!(t.history_len(), 1); +} + +#[test] +fn growing_at_the_foot_brings_history_back() { + let mut t = term(4, 2, "a\r\nb\r\nc"); + assert_eq!(t.history_len(), 1); + t.resize(4, 3); + assert_eq!(t.screen_text(), ["a", "b", "c"]); + assert_eq!((t.cursor().x, t.cursor().y), (1, 2)); +} + +#[test] +fn growing_mid_screen_opens_rows_below() { + let mut t = term(4, 3, "a\r\nb\r\nc\r\nd\x1b[1;1H"); + t.resize(4, 5); + assert_eq!(t.screen_text(), ["b", "c", "d", "", ""]); +} diff --git a/userland/nonos_vt/tests/refusals.rs b/userland/nonos_vt/tests/refusals.rs new file mode 100644 index 000000000..81ff9ee08 --- /dev/null +++ b/userland/nonos_vt/tests/refusals.rs @@ -0,0 +1,55 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What a program may ask for and does not get, and the ceilings that keep +//! a program from growing the terminal. + +#[path = "support/term.rs"] +mod support; +use nonos_vt::limits::MAX_REPLY; +use support::term; + +#[test] +fn a_program_that_never_reads_cannot_grow_the_queue() { + let mut t = term(80, 24, ""); + for _ in 0..10_000 { + t.feed(b"\x1b[c"); + } + assert!(t.take_replies().len() <= MAX_REPLY); +} + +#[test] +fn clipboard_reads_are_refused_and_writes_are_offered() { + let mut t = term(80, 24, "\x1b]52;c;?\x07"); + assert!(t.take_replies().is_empty()); + assert_eq!(t.clipboard_reads_refused(), 1); + t.feed(b"\x1b]52;c;aGk=\x07"); + let r = t.take_clipboard_request().unwrap(); + assert_eq!(r.base64, b"aGk="); +} + +#[test] +fn window_manipulation_is_ignored() { + // Move, resize and iconify: a program may not touch its window. + let mut t = term(80, 24, "\x1b[3;0;0t\x1b[8;1;1t\x1b[2t"); + assert!(t.take_replies().is_empty()); +} + +#[test] +fn titles_lose_control_bytes() { + let t = term(80, 24, "\x1b]0;a\u{9b}b\x07"); + assert_eq!(t.title(), "ab"); +} diff --git a/userland/nonos_vt/tests/report.rs b/userland/nonos_vt/tests/report.rs new file mode 100644 index 000000000..d0dfbf642 --- /dev/null +++ b/userland/nonos_vt/tests/report.rs @@ -0,0 +1,67 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Answers a program waits for, and requests that are refused. + +#[path = "support/term.rs"] +mod support; + +use support::term; + +fn replies(input: &str) -> String { + let mut t = term(80, 24, input); + String::from_utf8(t.take_replies()).unwrap() +} + +#[test] +fn identity_and_version() { + assert_eq!(replies("\x1b[c"), "\x1b[?62;22c"); + assert_eq!(replies("\x1b[>c"), "\x1b[>1;10;0c"); + assert!(replies("\x1b[>q").starts_with("\x1bP>|NONOS")); +} + +#[test] +fn cursor_position_is_one_based() { + assert_eq!(replies("\x1b[5;10H\x1b[6n"), "\x1b[5;10R"); + assert_eq!(replies("\x1b[5n"), "\x1b[0n"); +} + +#[test] +fn modes_are_reported() { + assert_eq!(replies("\x1b[?2004h\x1b[?2004$p"), "\x1b[?2004;1$y"); + assert_eq!(replies("\x1b[?25$p"), "\x1b[?25;1$y"); + assert_eq!(replies("\x1b[?9999$p"), "\x1b[?9999;0$y"); +} + +#[test] +fn size_is_reported() { + assert_eq!(replies("\x1b[18t"), "\x1b[8;24;80t"); +} + +#[test] +fn colours_are_reported() { + assert_eq!(replies("\x1b]11;?\x07"), "\x1b]11;rgb:0000/0000/0000\x1b\\"); + assert_eq!(replies("\x1b]4;1;#ff0000\x07\x1b]4;1;?\x07"), "\x1b]4;1;rgb:ffff/0000/0000\x1b\\"); +} + +#[test] +fn terminfo_queries_are_answered() { + // "TN" and an unknown "zz", hex encoded. + assert_eq!( + replies("\x1bP+q544e;7a7a\x1b\\"), + "\x1bP1+r544E=787465726D2D323536636F6C6F72\x1b\\\x1bP0+r7A7A\x1b\\" + ); +} diff --git a/userland/nonos_vt/tests/screen.rs b/userland/nonos_vt/tests/screen.rs new file mode 100644 index 000000000..c3d8c97ac --- /dev/null +++ b/userland/nonos_vt/tests/screen.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Printing, wrapping and history, against what xterm does for the +//! sequences full-screen tools rely on. + +#[path = "support/term.rs"] +mod support; + +use support::term; + +#[test] +fn the_bottom_right_cell_does_not_scroll() { + // vim and htop draw their last line into the last cell. + let t = term(4, 2, "ab\r\nwxyz"); + assert_eq!(t.screen_text(), ["ab", "wxyz"]); + assert_eq!((t.cursor().x, t.cursor().y), (3, 1)); +} + +#[test] +fn a_full_line_then_newline_adds_no_blank_line() { + let t = term(4, 3, "abcd\r\nef"); + assert_eq!(t.screen_text(), ["abcd", "ef", ""]); +} + +#[test] +fn printing_past_the_edge_wraps_and_marks_the_line() { + let t = term(4, 3, "abcdef"); + assert_eq!(t.screen_text(), ["abcd", "ef", ""]); + assert!(t.visible_line(0).wrapped); +} + +#[test] +fn autowrap_off_overwrites_the_last_column() { + let t = term(4, 2, "\x1b[?7labcdef"); + assert_eq!(t.screen_text(), ["abcf", ""]); +} + +#[test] +fn history_keeps_what_scrolls_off() { + let t = term(4, 2, "a\r\nb\r\nc\r\nd"); + assert_eq!(t.history_len(), 2); + assert_eq!(t.row_text(0), "c"); +} diff --git a/userland/nonos_vt/tests/screen_chars.rs b/userland/nonos_vt/tests/screen_chars.rs new file mode 100644 index 000000000..7005571b0 --- /dev/null +++ b/userland/nonos_vt/tests/screen_chars.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Wide and combining characters, and line drawing. + +#[path = "support/term.rs"] +mod support; +use support::term; + +#[test] +fn wide_characters_take_two_cells_and_wrap_whole() { + let t = term(5, 2, "ab漢字"); + assert_eq!(t.screen_text(), ["ab漢", "字"]); + assert!(t.visible_line(0).cell(2).is_wide()); + assert!(t.visible_line(0).cell(3).is_tail()); +} + +#[test] +fn overwriting_half_a_wide_character_blanks_the_other_half() { + let t = term(4, 1, "漢\x1b[1;2Hx"); + let line = t.visible_line(0); + assert_eq!(line.cell(0).ch, ' '); + assert!(!line.cell(0).is_wide()); + assert_eq!(line.cell(1).ch, 'x'); +} + +#[test] +fn combining_marks_join_the_character_before() { + let t = term(4, 1, "e\u{301}x"); + assert_eq!(t.row_text(0), "e\u{301}x"); + assert_eq!((t.cursor().x, t.cursor().y), (2, 0)); +} + +#[test] +fn emoji_joined_by_zwj_stay_in_one_cell_pair() { + let t = term(6, 1, "👩\u{200d}💻!"); + assert_eq!(t.visible_line(0).cell(2).ch, '!'); +} + +#[test] +fn dec_line_drawing() { + let t = term(4, 1, "\x1b(0lqk\x1b(Bq"); + assert_eq!(t.row_text(0), "┌─┐q"); +} diff --git a/userland/nonos_vt/tests/screen_colour.rs b/userland/nonos_vt/tests/screen_colour.rs new file mode 100644 index 000000000..f6bb8133f --- /dev/null +++ b/userland/nonos_vt/tests/screen_colour.rs @@ -0,0 +1,40 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! SGR colours and attributes as cells record them. + +#[path = "support/term.rs"] +mod support; +use nonos_vt::cell::attr; +use nonos_vt::color::Color; +use support::term; + +#[test] +fn sgr_colours_in_both_notations() { + let t = term(4, 1, "\x1b[38;2;1;2;3ma\x1b[38:2::4:5:6mb\x1b[48;5;200mc\x1b[1;4:3md"); + let l = t.visible_line(0); + assert_eq!(l.cell(0).fg, Color::Rgb(1, 2, 3)); + assert_eq!(l.cell(1).fg, Color::Rgb(4, 5, 6)); + assert_eq!(l.cell(2).bg, Color::Indexed(200)); + assert_ne!(l.cell(3).attr & attr::BOLD, 0); + assert_eq!(l.cell(3).underline(), nonos_vt::Underline::Curly); +} + +#[test] +fn a_malformed_colour_does_not_eat_what_follows() { + let t = term(4, 1, "\x1b[38;2;300;1;1;1ma"); + assert_ne!(t.visible_line(0).cell(0).attr & attr::BOLD, 0); +} diff --git a/userland/nonos_vt/tests/screen_cursor.rs b/userland/nonos_vt/tests/screen_cursor.rs new file mode 100644 index 000000000..2ce67f96d --- /dev/null +++ b/userland/nonos_vt/tests/screen_cursor.rs @@ -0,0 +1,72 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Saving the cursor, the alternate screen, tab stops, insert and origin +//! modes. + +#[path = "support/term.rs"] +mod support; +use support::term; + +#[test] +fn repeat_prints_the_last_character() { + let t = term(8, 1, "x\x1b[3b"); + assert_eq!(t.row_text(0), "xxxx"); +} + +#[test] +fn save_and_restore_the_cursor() { + let t = term(8, 3, "\x1b[2;3H\x1b7\x1b[H\x1b8X"); + assert_eq!(t.row_text(1), " X"); + let t = term(8, 3, "\x1b[2;3H\x1b[s\x1b[H\x1b[uY"); + assert_eq!(t.row_text(1), " Y"); +} + +#[test] +fn alternate_screen_restores_what_was_underneath() { + let t = term(8, 2, "shell\x1b[?1049hfull\x1b[?1049l"); + assert_eq!(t.screen_text(), ["shell", ""]); + assert_eq!((t.cursor().x, t.cursor().y), (5, 0)); + assert!(!t.alt_active()); +} + +#[test] +fn alternate_screen_output_never_reaches_history() { + let mut t = term(4, 2, "\x1b[?1049h"); + t.feed(b"1\r\n2\r\n3\r\n4\r\n"); + t.feed(b"\x1b[?1049l"); + assert_eq!(t.history_len(), 0); +} + +#[test] +fn tab_stops_set_and_cleared() { + let t = term(20, 1, "\ta"); + assert_eq!((t.cursor().x, t.cursor().y), (9, 0)); + let t = term(20, 1, "\x1b[3g\x1b[1;4H\x1bH\r\tb"); + assert_eq!(t.row_text(0), " b"); +} + +#[test] +fn insert_mode_shifts_the_line() { + let t = term(6, 1, "abc\x1b[1;2H\x1b[4hX"); + assert_eq!(t.row_text(0), "aXbc"); +} + +#[test] +fn origin_mode_counts_from_the_region() { + let t = term(4, 4, "\x1b[2;3r\x1b[?6h\x1b[1;1HZ"); + assert_eq!(t.row_text(1), "Z"); +} diff --git a/userland/nonos_vt/tests/screen_region.rs b/userland/nonos_vt/tests/screen_region.rs new file mode 100644 index 000000000..d677f772c --- /dev/null +++ b/userland/nonos_vt/tests/screen_region.rs @@ -0,0 +1,69 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The scroll region, line insertion and deletion, and erasing. + +#[path = "support/term.rs"] +mod support; +use support::term; + +#[test] +fn a_scroll_region_keeps_the_status_line() { + let t = term(6, 4, "\x1b[4;1Hstatus\x1b[1;3r\x1b[1;1Ha\r\nb\r\nc\r\nd"); + assert_eq!(t.screen_text(), ["b", "c", "d", "status"]); +} + +#[test] +fn reverse_index_at_the_top_scrolls_down() { + let t = term(4, 3, "one\r\ntwo\x1b[H\x1bMnew"); + assert_eq!(t.screen_text(), ["new", "one", "two"]); +} + +#[test] +fn insert_and_delete_lines_stay_inside_the_region() { + let t = term(4, 4, "a\r\nb\r\nc\r\nd\x1b[1;3r\x1b[2;1H\x1b[L"); + assert_eq!(t.screen_text(), ["a", "", "b", "d"]); + let t = term(4, 4, "a\r\nb\r\nc\r\nd\x1b[1;3r\x1b[1;1H\x1b[M"); + assert_eq!(t.screen_text(), ["b", "c", "", "d"]); +} + +#[test] +fn scroll_down_and_erase_chars() { + let t = term(4, 3, "a\r\nb\x1b[T"); + assert_eq!(t.screen_text(), ["", "a", "b"]); + let t = term(6, 1, "abcdef\x1b[1;2H\x1b[3X"); + assert_eq!(t.screen_text(), ["a ef"]); + assert_eq!((t.cursor().x, t.cursor().y), (1, 0)); +} + +#[test] +fn erase_to_start_includes_the_cursor_cell() { + let t = term(5, 1, "abcde\x1b[1;3H\x1b[1K"); + assert_eq!(t.row_text(0), " de"); +} + +#[test] +fn erase_display_leaves_the_cursor() { + let t = term(5, 2, "ab\r\ncd\x1b[2J"); + assert_eq!(t.screen_text(), ["", ""]); + assert_eq!((t.cursor().x, t.cursor().y), (2, 1)); +} + +#[test] +fn a_huge_scroll_count_is_bounded() { + let t = term(4, 3, "a\x1b[65535S"); + assert_eq!(t.screen_text(), ["", "", ""]); +} diff --git a/userland/nonos_vt/tests/support/check.rs b/userland/nonos_vt/tests/support/check.rs new file mode 100644 index 000000000..d8fdf4820 --- /dev/null +++ b/userland/nonos_vt/tests/support/check.rs @@ -0,0 +1,58 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What must hold after every step of a fuzz run, and the host actions a +//! run interleaves with output. + +use nonos_vt::limits::MAX_REPLY; +use nonos_vt::{Pos, Term}; + +use super::rng::Rng; + +pub fn check(t: &mut Term) { + let c = t.cursor(); + assert!(c.x < t.cols() && c.y < t.rows(), "cursor {c:?} off a {}x{}", t.cols(), t.rows()); + if t.view_offset() == 0 { + for y in 0..t.rows() { + assert_eq!(t.visible_line(y).len(), t.cols(), "row {y}"); + } + } + assert!(t.take_replies().len() <= MAX_REPLY); + assert_eq!(t.take_dirty().len(), t.rows()); +} + +fn pos(t: &Term, r: &mut Rng) -> Pos { + Pos { line: t.first_line() + r.next() % 300, col: (r.next() % 250) as usize } +} + +/// Now and then resize, scroll, copy or search, as a person would while +/// output streams in. +pub fn poke(t: &mut Term, r: &mut Rng, round: usize) { + match r.next() % 16 { + 0 => t.resize(1 + (r.next() % 200) as usize, 1 + (r.next() % 80) as usize), + 1 => t.scroll_view((r.next() % 64) as isize - 32), + 2 => { + let (a, b) = (pos(t, r), pos(t, r)); + let _ = t.text_between(a, b, r.next().is_multiple_of(2)); + let _ = t.word_at(a); + let _ = t.line_bounds(a); + } + 3 if round.is_multiple_of(8) => { + let _ = t.find("fox", t.cursor_pos(), r.next().is_multiple_of(2), false); + } + _ => {} + } +} diff --git a/userland/nonos_vt/tests/support/log.rs b/userland/nonos_vt/tests/support/log.rs new file mode 100644 index 000000000..67c3652b2 --- /dev/null +++ b/userland/nonos_vt/tests/support/log.rs @@ -0,0 +1,55 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A parser handler that writes down what it was told, one entry a +//! report, so a test can compare the sequence. + +use nonos_vt::parser::{Handler, Parser, Seq}; + +#[derive(Default)] +pub struct Log(pub Vec); + +impl Handler for Log { + fn print(&mut self, c: char) { + self.0.push(format!("p{c}")); + } + fn execute(&mut self, b: u8) { + self.0.push(format!("x{b:02x}")); + } + fn csi(&mut self, s: &Seq) { + let g: Vec = s.params.groups().map(|g| format!("{g:?}")).collect(); + let i = String::from_utf8_lossy(s.inter()).to_string(); + self.0.push(format!("csi{}{i}{}{}", s.private as char, s.final_byte as char, g.join(""))); + } + fn esc(&mut self, s: &Seq) { + let i = String::from_utf8_lossy(s.inter()).to_string(); + self.0.push(format!("esc{i}{}", s.final_byte as char)); + } + fn osc(&mut self, d: &[u8]) { + self.0.push(format!("osc{}", String::from_utf8_lossy(d))); + } + fn dcs(&mut self, s: &Seq, d: &[u8]) { + let i = String::from_utf8_lossy(s.inter()).to_string(); + self.0.push(format!("dcs{i}{}{}", s.final_byte as char, String::from_utf8_lossy(d))); + } +} + +pub fn run(bytes: &[u8]) -> Vec { + let mut p = Parser::new(); + let mut l = Log::default(); + p.feed(&mut l, bytes); + l.0 +} diff --git a/userland/nonos_vt/tests/support/pieces.rs b/userland/nonos_vt/tests/support/pieces.rs new file mode 100644 index 000000000..eeff0a51e --- /dev/null +++ b/userland/nonos_vt/tests/support/pieces.rs @@ -0,0 +1,67 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The pieces hostile output is built from: control sequences with random +//! parameters and finals, strings, broken UTF-8, wide and combining +//! characters, controls, text and raw noise. + +use super::rng::Rng; + +const FINALS: &[u8] = b"@ABCDEFGHIJKLMPSTXZ`abcdefghlmnqrstu"; + +pub fn piece(r: &mut Rng, out: &mut Vec) { + let n = |r: &mut Rng, m: u64| (r.next() % m) as usize; + match n(r, 12) { + 0..=2 => { + out.extend_from_slice(b"\x1b["); + if n(r, 3) == 0 { + out.push(b"?><="[n(r, 4)]); + } + for _ in 0..n(r, 6) { + let v = [0, 1, 2, 5, 7, 25, 38, 48, 1049, 2004, 65535, 999_999][n(r, 12)]; + out.extend_from_slice(v.to_string().as_bytes()); + out.push(if n(r, 4) == 0 { b':' } else { b';' }); + } + if n(r, 8) == 0 { + out.push(b" !$\"'"[n(r, 5)]); + } + out.push(FINALS[n(r, FINALS.len() as u64)]); + } + 3 => { + out.extend_from_slice(b"\x1b]"); + out.extend_from_slice( + ["0;t", "4;1;?", "8;;x", "52;c;?", "11;?", "133;A", "7;file:///p"][n(r, 7)] + .as_bytes(), + ); + out.extend_from_slice(if n(r, 2) == 0 { b"\x07" } else { b"\x1b\\" }); + } + 4 => out.extend_from_slice( + ["\x1bP+q544e\x1b\\", "\x1bP$qr\x1b\\", "\x1b_x\x1b\\"][n(r, 3)].as_bytes(), + ), + 5 => out.extend_from_slice( + ["\x1b7", "\x1b8", "\x1bM", "\x1bc", "\x1b(0", "\x1b#8"][n(r, 6)].as_bytes(), + ), + 6 => out.extend_from_slice("漢字👩\u{200d}💻e\u{301}\u{1f3fb}".as_bytes()), + 7 => out.extend_from_slice(&[0xC3, 0xE2, 0x82, 0xF0, 0x9F, 0xED, 0xA0][..n(r, 7) + 1]), + 8 => out.push([b'\n', b'\r', b'\t', 0x08, 0x0E, 0x0F, 0x18, 0x07][n(r, 8)]), + 9 => out.extend_from_slice(b"the quick brown fox "), + _ => { + for _ in 0..n(r, 16) { + out.push((r.next() >> 56) as u8); + } + } + } +} diff --git a/userland/nonos_vt/tests/support/rng.rs b/userland/nonos_vt/tests/support/rng.rs new file mode 100644 index 000000000..976070f44 --- /dev/null +++ b/userland/nonos_vt/tests/support/rng.rs @@ -0,0 +1,34 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! xorshift64*, the generator tools/nonos-zstd-vectors uses too. + +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Self { + Rng(seed.max(1)) + } + + pub fn next(&mut self) -> u64 { + let mut s = self.0; + s ^= s >> 12; + s ^= s << 25; + s ^= s >> 27; + self.0 = s; + s.wrapping_mul(0x2545_F491_4F6C_DD1D) + } +} diff --git a/userland/nonos_vt/tests/support/term.rs b/userland/nonos_vt/tests/support/term.rs new file mode 100644 index 000000000..5e79cecc7 --- /dev/null +++ b/userland/nonos_vt/tests/support/term.rs @@ -0,0 +1,25 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A terminal fed a string, as every screen test starts. + +use nonos_vt::Term; + +pub fn term(cols: usize, rows: usize, input: &str) -> Term { + let mut t = Term::new(cols, rows, 100); + t.feed(input.as_bytes()); + t +} diff --git a/userland/nonos_vt/tests/text.rs b/userland/nonos_vt/tests/text.rs new file mode 100644 index 000000000..3f1d59a42 --- /dev/null +++ b/userland/nonos_vt/tests/text.rs @@ -0,0 +1,62 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Selection text, word picking, search and URLs. + +#[path = "support/term.rs"] +mod support; + +use nonos_vt::url::find_urls; +use nonos_vt::Pos; +use support::term; + +fn p(line: u64, col: usize) -> Pos { + Pos { line, col } +} + +#[test] +fn a_wrapped_line_copies_as_one() { + let t = term(4, 3, "abcdef\r\ngh"); + assert_eq!(t.text_between(p(0, 0), p(2, 3), false), "abcdef\ngh"); +} + +#[test] +fn block_selection_takes_columns() { + let t = term(4, 2, "abcd\r\nefgh"); + assert_eq!(t.text_between(p(0, 1), p(1, 2), true), "bc\nfg"); +} + +#[test] +fn a_double_click_picks_a_path_whole() { + let t = term(20, 1, "cat /etc/hosts now"); + let (a, b) = t.word_at(p(0, 7)); + assert_eq!((a.col, b.col), (4, 13)); +} + +#[test] +fn search_finds_across_a_wrap_and_wraps_around() { + let t = term(4, 3, "xxhello"); + assert_eq!(t.find("hello", p(0, 0), false, true), Some((p(0, 2), p(1, 2)))); + assert_eq!(t.find("HELLO", p(1, 3), false, false), Some((p(0, 2), p(1, 2)))); + assert_eq!(t.find("nope", p(0, 0), false, true), None); +} + +#[test] +fn urls_drop_trailing_punctuation() { + let s: Vec = "see https://a.io/x_(y). and http://b".chars().collect(); + let found: Vec = find_urls(&s).iter().map(|&(a, b)| s[a..b].iter().collect()).collect(); + assert_eq!(found, ["https://a.io/x_(y)", "http://b"]); +} From 4e36246b0c3b0b610e265d302c194ae0f3fb48f4 Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Mon, 28 Sep 2026 12:56:51 +0000 Subject: [PATCH 08/29] app_skeleton: name the Insert and function keys the input drivers send The PS/2 keymap already emits Insert as 0x1209 and F1 to F12 as 0x1101 to 0x110C. Apps had no names for them, so none handled them. --- userland/app_skeleton/src/input/keys.rs | 4 ++++ userland/app_skeleton/src/input/mod.rs | 7 +++---- userland/app_skeleton/src/lib.rs | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/userland/app_skeleton/src/input/keys.rs b/userland/app_skeleton/src/input/keys.rs index 5023b38b6..d4c8573f2 100644 --- a/userland/app_skeleton/src/input/keys.rs +++ b/userland/app_skeleton/src/input/keys.rs @@ -29,4 +29,8 @@ pub const KEY_HOME: u32 = 0x1205; pub const KEY_END: u32 = 0x1206; pub const KEY_PAGE_UP: u32 = 0x1207; pub const KEY_PAGE_DOWN: u32 = 0x1208; +pub const KEY_INSERT: u32 = 0x1209; pub const KEY_DELETE: u32 = 0x120A; +/// F1; F2 to F12 follow in order. +pub const KEY_F1: u32 = 0x1101; +pub const KEY_F12: u32 = 0x110C; diff --git a/userland/app_skeleton/src/input/mod.rs b/userland/app_skeleton/src/input/mod.rs index 410bd6d17..14d61840e 100644 --- a/userland/app_skeleton/src/input/mod.rs +++ b/userland/app_skeleton/src/input/mod.rs @@ -22,9 +22,8 @@ mod modifiers; mod pointer; pub use event::InputEvent; -pub use keys::{ - KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_END, KEY_ENTER, KEY_ESC, KEY_HOME, KEY_LEFT, - KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, KEY_TAB, KEY_UP, -}; +pub use keys::{KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_END, KEY_ENTER, KEY_ESC, KEY_HOME}; +pub use keys::{KEY_F1, KEY_F12, KEY_INSERT}; +pub use keys::{KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, KEY_TAB, KEY_UP}; pub use kind::InputKind; pub use modifiers::{MOD_ALT, MOD_ALTGR, MOD_CAPS, MOD_CTRL, MOD_META, MOD_NUM, MOD_SHIFT}; diff --git a/userland/app_skeleton/src/lib.rs b/userland/app_skeleton/src/lib.rs index c494c98bc..97ff22bcd 100644 --- a/userland/app_skeleton/src/lib.rs +++ b/userland/app_skeleton/src/lib.rs @@ -32,8 +32,8 @@ pub use app::{App, AppManifest, EventOutcome, WindowKind}; pub use clients::clipboard::{clipboard_copy, clipboard_paste}; pub use input::{ InputEvent, InputKind, KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_END, KEY_ENTER, KEY_ESC, - KEY_HOME, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, KEY_TAB, KEY_UP, MOD_ALT, MOD_CAPS, - MOD_CTRL, MOD_META, MOD_NUM, MOD_SHIFT, + KEY_F1, KEY_F12, KEY_HOME, KEY_INSERT, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, + KEY_TAB, KEY_UP, MOD_ALT, MOD_CAPS, MOD_CTRL, MOD_META, MOD_NUM, MOD_SHIFT, }; pub use paint::font_advance; pub use paint::PaintBuffer; From bd52d02d31ee4e70aa00f84fe55b001121f9ca7f Mon Sep 17 00:00:00 2001 From: eKisNonos Date: Mon, 28 Sep 2026 12:57:18 +0000 Subject: [PATCH 09/29] terminal: run programs on an xterm-grade screen sized to the window The fixed 96x40 grid and its partial VT layer give way to nonos_vt. The screen follows the window size and zoom, re-wrapping on resize, with 3000 lines of history a tab on a 64 MiB heap, and draws bold, dim, every underline style, strikethrough, overline, inverse, hidden, wide and combining characters, and the cursor shape a program sets. A program in the foreground or on the alternate screen owns the body; the prompt returns when it ends, with any modes it left on reset, and output is read up to 64 KiB a tick instead of 256 bytes. A program reading raw gets keys as xterm sends them (taken as raw once it asks for the alternate screen, application cursor keys, bracketed paste or mouse reports, as there is no path for its tty settings yet); any other gets lines edited as a tty's canonical mode edits them. Ctrl+C interrupts, and a program's queries are answered on its stdin. The pointer selects text (double click a word or path, triple click a line, Alt for a block), or goes to a program that asked for mouse reports unless Shift is held. Ctrl+Shift+C copies, Ctrl+Shift+V pastes, Ctrl+Shift+F searches history across wrapped lines, and the wheel scrolls history or sends cursor keys on the alternate screen. Proofs for the line editing and selection added to terminal_line_proofs. --- userland/capsule_terminal/Cargo.lock | 5 + userland/capsule_terminal/Cargo.toml | 1 + .../src/command/output/feed_raw.rs | 9 + userland/capsule_terminal/src/event/clip.rs | 46 ++++ userland/capsule_terminal/src/event/cooked.rs | 58 +++++ .../capsule_terminal/src/event/cooked_kill.rs | 46 ++++ .../capsule_terminal/src/event/fg_cooked.rs | 58 +++++ .../capsule_terminal/src/event/fg_input.rs | 44 ++++ .../capsule_terminal/src/event/fg_keys.rs | 70 ++++++ .../capsule_terminal/src/event/find_bar.rs | 72 ++++++ .../capsule_terminal/src/event/find_seek.rs | 45 ++++ .../src/event/{fg_stdin.rs => interrupt.rs} | 37 +-- .../capsule_terminal/src/event/key_first.rs | 43 ++++ userland/capsule_terminal/src/event/keymap.rs | 52 ++++ userland/capsule_terminal/src/event/mod.rs | 14 +- .../capsule_terminal/src/event/on_ctrl.rs | 23 +- userland/capsule_terminal/src/event/on_key.rs | 17 +- .../src/event/paste_program.rs | 61 +++++ .../capsule_terminal/src/jobs/external.rs | 49 ++-- .../capsule_terminal/src/jobs/external_io.rs | 58 +++++ userland/capsule_terminal/src/jobs/mod.rs | 1 + userland/capsule_terminal/src/main.rs | 6 + .../src/paint/block_chrome.rs | 18 +- .../capsule_terminal/src/paint/compose.rs | 50 ++-- .../capsule_terminal/src/paint/draw_grid.rs | 95 -------- .../capsule_terminal/src/paint/geometry.rs | 57 +++++ .../capsule_terminal/src/paint/line_text.rs | 2 +- userland/capsule_terminal/src/paint/mod.rs | 8 +- .../{term/grid/cell.rs => paint/vt/area.rs} | 50 ++-- .../capsule_terminal/src/paint/vt/body.rs | 65 +++++ .../capsule_terminal/src/paint/vt/cell.rs | 52 ++++ .../capsule_terminal/src/paint/vt/cursor.rs | 47 ++++ .../capsule_terminal/src/paint/vt/deco.rs | 59 +++++ .../capsule_terminal/src/paint/vt/fill.rs | 47 ++++ .../capsule_terminal/src/paint/vt/find_bar.rs | 50 ++++ .../src/{term => paint}/vt/mod.rs | 23 +- .../capsule_terminal/src/paint/vt/rows.rs | 64 +++++ .../capsule_terminal/src/term/block/ops.rs | 4 +- .../capsule_terminal/src/term/dimensions.rs | 3 +- .../capsule_terminal/src/term/grid/alt.rs | 42 ---- .../capsule_terminal/src/term/grid/erase.rs | 69 ------ .../src/term/grid/move_cells.rs | 46 ---- .../capsule_terminal/src/term/grid/new.rs | 49 ---- .../capsule_terminal/src/term/grid/put.rs | 62 ----- .../capsule_terminal/src/term/grid/scroll.rs | 39 --- .../src/term/grid/scroll_region.rs | 57 ----- .../capsule_terminal/src/term/grid/types.rs | 54 ----- .../capsule_terminal/src/term/grid/view.rs | 32 --- .../capsule_terminal/src/term/grid/width.rs | 67 ----- userland/capsule_terminal/src/term/mod.rs | 3 +- .../src/term/scrollback/clear.rs | 3 +- .../src/term/scrollback/feed_raw.rs | 13 +- .../src/term/scrollback/fit.rs | 45 ++++ .../src/term/scrollback/jump_bottom.rs | 2 +- .../src/term/scrollback/mod.rs | 2 + .../src/term/scrollback/new.rs | 13 +- .../feed.rs => scrollback/program_ended.rs} | 31 ++- .../src/term/scrollback/push_dir_row.rs | 8 +- .../src/term/scrollback/push_raw.rs | 10 +- .../src/term/scrollback/push_styled.rs | 4 +- .../src/term/scrollback/scroll_down.rs | 3 +- .../src/term/scrollback/scroll_up.rs | 3 +- .../src/term/scrollback/types.rs | 11 +- userland/capsule_terminal/src/term/select.rs | 74 ++++++ .../capsule_terminal/src/term/state/mod.rs | 1 + .../capsule_terminal/src/term/state/new.rs | 3 + .../term/{grid/absline.rs => state/shade.rs} | 18 +- .../capsule_terminal/src/term/state/types.rs | 6 + .../src/term/terminal/app_impl_on_event.rs | 3 + .../src/term/terminal/app_impl_paint.rs | 16 ++ .../capsule_terminal/src/term/terminal/mod.rs | 1 + .../capsule_terminal/src/term/terminal/new.rs | 2 + .../src/term/terminal/pointer/cells.rs | 71 ++++++ .../term/{grid => terminal/pointer}/mod.rs | 26 +- .../src/term/terminal/pointer/press.rs | 57 +++++ .../src/term/terminal/pointer/report.rs | 63 +++++ .../src/term/terminal/pointer/select.rs | 52 ++++ .../pointer/state.rs} | 27 ++- .../src/term/terminal/pointer/wheel.rs | 52 ++++ .../src/term/terminal/selftest.rs | 228 ++++++------------ .../src/term/terminal/types.rs | 3 + .../capsule_terminal/src/term/vt/color.rs | 62 ----- .../src/term/vt/csi_cursor.rs | 67 ----- .../capsule_terminal/src/term/vt/csi_edit.rs | 52 ---- .../capsule_terminal/src/term/vt/decset.rs | 41 ---- .../capsule_terminal/src/term/vt/parser.rs | 182 -------------- userland/capsule_terminal/src/term/vt/sgr.rs | 102 -------- .../capsule_terminal/src/term/vt/state.rs | 83 ------- .../src/term/vt/utf8/finish.rs | 38 --- .../capsule_terminal/src/term/vt/utf8/mod.rs | 69 ------ .../src/term/vt/utf8/state.rs | 43 ---- userland/capsule_terminal/tests/utf8_host.rs | 108 --------- userland/capsule_terminal/tests/width_host.rs | 86 ------- userland/terminal_line_proofs/Cargo.lock | 7 + userland/terminal_line_proofs/Cargo.toml | 3 + .../terminal_line_proofs/src/cooked_tests.rs | 75 ++++++ userland/terminal_line_proofs/src/lib.rs | 10 + 97 files changed, 1981 insertions(+), 1927 deletions(-) create mode 100644 userland/capsule_terminal/src/event/clip.rs create mode 100644 userland/capsule_terminal/src/event/cooked.rs create mode 100644 userland/capsule_terminal/src/event/cooked_kill.rs create mode 100644 userland/capsule_terminal/src/event/fg_cooked.rs create mode 100644 userland/capsule_terminal/src/event/fg_input.rs create mode 100644 userland/capsule_terminal/src/event/fg_keys.rs create mode 100644 userland/capsule_terminal/src/event/find_bar.rs create mode 100644 userland/capsule_terminal/src/event/find_seek.rs rename userland/capsule_terminal/src/event/{fg_stdin.rs => interrupt.rs} (50%) create mode 100644 userland/capsule_terminal/src/event/key_first.rs create mode 100644 userland/capsule_terminal/src/event/keymap.rs create mode 100644 userland/capsule_terminal/src/event/paste_program.rs create mode 100644 userland/capsule_terminal/src/jobs/external_io.rs delete mode 100644 userland/capsule_terminal/src/paint/draw_grid.rs create mode 100644 userland/capsule_terminal/src/paint/geometry.rs rename userland/capsule_terminal/src/{term/grid/cell.rs => paint/vt/area.rs} (52%) create mode 100644 userland/capsule_terminal/src/paint/vt/body.rs create mode 100644 userland/capsule_terminal/src/paint/vt/cell.rs create mode 100644 userland/capsule_terminal/src/paint/vt/cursor.rs create mode 100644 userland/capsule_terminal/src/paint/vt/deco.rs create mode 100644 userland/capsule_terminal/src/paint/vt/fill.rs create mode 100644 userland/capsule_terminal/src/paint/vt/find_bar.rs rename userland/capsule_terminal/src/{term => paint}/vt/mod.rs (73%) create mode 100644 userland/capsule_terminal/src/paint/vt/rows.rs delete mode 100644 userland/capsule_terminal/src/term/grid/alt.rs delete mode 100644 userland/capsule_terminal/src/term/grid/erase.rs delete mode 100644 userland/capsule_terminal/src/term/grid/move_cells.rs delete mode 100644 userland/capsule_terminal/src/term/grid/new.rs delete mode 100644 userland/capsule_terminal/src/term/grid/put.rs delete mode 100644 userland/capsule_terminal/src/term/grid/scroll.rs delete mode 100644 userland/capsule_terminal/src/term/grid/scroll_region.rs delete mode 100644 userland/capsule_terminal/src/term/grid/types.rs delete mode 100644 userland/capsule_terminal/src/term/grid/view.rs delete mode 100644 userland/capsule_terminal/src/term/grid/width.rs create mode 100644 userland/capsule_terminal/src/term/scrollback/fit.rs rename userland/capsule_terminal/src/term/{grid/feed.rs => scrollback/program_ended.rs} (51%) create mode 100644 userland/capsule_terminal/src/term/select.rs rename userland/capsule_terminal/src/term/{grid/absline.rs => state/shade.rs} (67%) create mode 100644 userland/capsule_terminal/src/term/terminal/pointer/cells.rs rename userland/capsule_terminal/src/term/{grid => terminal/pointer}/mod.rs (73%) create mode 100644 userland/capsule_terminal/src/term/terminal/pointer/press.rs create mode 100644 userland/capsule_terminal/src/term/terminal/pointer/report.rs create mode 100644 userland/capsule_terminal/src/term/terminal/pointer/select.rs rename userland/capsule_terminal/src/term/{grid/scroll_view.rs => terminal/pointer/state.rs} (60%) create mode 100644 userland/capsule_terminal/src/term/terminal/pointer/wheel.rs delete mode 100644 userland/capsule_terminal/src/term/vt/color.rs delete mode 100644 userland/capsule_terminal/src/term/vt/csi_cursor.rs delete mode 100644 userland/capsule_terminal/src/term/vt/csi_edit.rs delete mode 100644 userland/capsule_terminal/src/term/vt/decset.rs delete mode 100644 userland/capsule_terminal/src/term/vt/parser.rs delete mode 100644 userland/capsule_terminal/src/term/vt/sgr.rs delete mode 100644 userland/capsule_terminal/src/term/vt/state.rs delete mode 100644 userland/capsule_terminal/src/term/vt/utf8/finish.rs delete mode 100644 userland/capsule_terminal/src/term/vt/utf8/mod.rs delete mode 100644 userland/capsule_terminal/src/term/vt/utf8/state.rs delete mode 100644 userland/capsule_terminal/tests/utf8_host.rs delete mode 100644 userland/capsule_terminal/tests/width_host.rs create mode 100644 userland/terminal_line_proofs/src/cooked_tests.rs diff --git a/userland/capsule_terminal/Cargo.lock b/userland/capsule_terminal/Cargo.lock index 19aa38d6c..155cef1b5 100644 --- a/userland/capsule_terminal/Cargo.lock +++ b/userland/capsule_terminal/Cargo.lock @@ -109,6 +109,7 @@ dependencies = [ "nonos_tls", "nonos_toolkit", "nonos_userland_libc", + "nonos_vt", ] [[package]] @@ -134,6 +135,10 @@ dependencies = [ "linked_list_allocator", ] +[[package]] +name = "nonos_vt" +version = "0.1.0" + [[package]] name = "owned_ttf_parser" version = "0.25.1" diff --git a/userland/capsule_terminal/Cargo.toml b/userland/capsule_terminal/Cargo.toml index d17892367..f76df1402 100644 --- a/userland/capsule_terminal/Cargo.toml +++ b/userland/capsule_terminal/Cargo.toml @@ -36,6 +36,7 @@ nonos_policy_proto = { path = "../policy_proto" } nonos_socket = { path = "../nonos_socket" } nonos_tls = { path = "../nonos_tls" } nonos_toolkit = { path = "../toolkit", default-features = false } +nonos_vt = { path = "../nonos_vt" } [profile.release] panic = "abort" diff --git a/userland/capsule_terminal/src/command/output/feed_raw.rs b/userland/capsule_terminal/src/command/output/feed_raw.rs index 90844b5f6..083fb30ad 100644 --- a/userland/capsule_terminal/src/command/output/feed_raw.rs +++ b/userland/capsule_terminal/src/command/output/feed_raw.rs @@ -20,4 +20,13 @@ impl<'a> Output<'a> { pub fn feed_raw(&mut self, bytes: &[u8]) { self.sb.feed_raw(bytes); } + + /// Answers the screen owes the program that wrote to it. + pub fn take_replies(&mut self) -> alloc::vec::Vec { + self.sb.vt.take_replies() + } + + pub fn program_ended(&mut self) { + self.sb.program_ended(); + } } diff --git a/userland/capsule_terminal/src/event/clip.rs b/userland/capsule_terminal/src/event/clip.rs new file mode 100644 index 000000000..9932ce85f --- /dev/null +++ b/userland/capsule_terminal/src/event/clip.rs @@ -0,0 +1,46 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Ctrl+Shift+C copies, Ctrl+Shift+V pastes, Ctrl+Shift+F searches. They +//! are taken before a running program sees the key, since without Shift +//! the same letters are the program's own. + +use crate::term::state::State; +use nonos_app_skeleton::{clipboard_copy, EventOutcome, InputEvent, MOD_CTRL, MOD_SHIFT}; + +pub fn clip_key(state: &mut State, event: &InputEvent) -> Option { + let both = MOD_CTRL | MOD_SHIFT; + if event.flags & both != both { + return None; + } + match char::from_u32(event.code)?.to_ascii_lowercase() { + 'c' if state.sel.is_some() => Some(copy_selection(state)), + 'v' if state.fg_running => Some(super::paste_program::paste_to_program(state)), + 'f' => Some(super::find_bar::open(state)), + _ => None, + } +} + +fn copy_selection(state: &mut State) -> EventOutcome { + let Some(sel) = state.sel else { return EventOutcome::Idle }; + let (a, b) = sel.ordered(); + let text = state.scrollback.vt.text_between(a, b, sel.block); + if clipboard_copy(text.as_bytes()).is_err() { + state.scrollback.push_line(b"copy: clipboard unavailable"); + return EventOutcome::Repaint; + } + EventOutcome::Idle +} diff --git a/userland/capsule_terminal/src/event/cooked.rs b/userland/capsule_terminal/src/event/cooked.rs new file mode 100644 index 000000000..e0d4ec1ba --- /dev/null +++ b/userland/capsule_terminal/src/event/cooked.rs @@ -0,0 +1,58 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The line a foreground program has not been sent yet, edited as a tty's +//! canonical mode edits it. Typed characters echo, Backspace erases the +//! last one from the screen as well as the line, Ctrl+U the whole line and +//! Ctrl+W the last word, and Enter sends the line with its newline. + +use alloc::vec::Vec; + +use nonos_vt::width::width; + +#[derive(Default)] +pub struct Cooked { + pub line: Vec, +} + +/// What an edit asks the terminal to do. +#[derive(Default)] +pub struct Effect { + /// Bytes to put on the screen. + pub echo: Vec, + /// Bytes to send to the program. + pub send: Vec, +} + +pub(super) fn erase(echo: &mut Vec, c: char) { + for _ in 0..width(c).max(1) { + echo.extend_from_slice(b"\x08 \x08"); + } +} + +impl Cooked { + pub fn char(&mut self, c: char, fx: &mut Effect) { + self.line.push(c); + let mut buf = [0u8; 4]; + fx.echo.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + + pub fn backspace(&mut self, fx: &mut Effect) { + if let Some(c) = self.line.pop() { + erase(&mut fx.echo, c); + } + } +} diff --git a/userland/capsule_terminal/src/event/cooked_kill.rs b/userland/capsule_terminal/src/event/cooked_kill.rs new file mode 100644 index 000000000..40fd3ef40 --- /dev/null +++ b/userland/capsule_terminal/src/event/cooked_kill.rs @@ -0,0 +1,46 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Killing back to a word or line start, and Enter, in the line a program +//! has not been sent yet. + +use super::cooked::{erase, Cooked, Effect}; + +impl Cooked { + pub fn kill_line(&mut self, fx: &mut Effect) { + while let Some(c) = self.line.pop() { + erase(&mut fx.echo, c); + } + } + + pub fn kill_word(&mut self, fx: &mut Effect) { + while self.line.last().is_some_and(|c| c.is_whitespace()) { + self.backspace(fx); + } + while self.line.last().is_some_and(|c| !c.is_whitespace()) { + self.backspace(fx); + } + } + + pub fn enter(&mut self, fx: &mut Effect) { + fx.echo.extend_from_slice(b"\n"); + for c in self.line.drain(..) { + let mut buf = [0u8; 4]; + fx.send.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + } + fx.send.push(b'\n'); + } +} diff --git a/userland/capsule_terminal/src/event/fg_cooked.rs b/userland/capsule_terminal/src/event/fg_cooked.rs new file mode 100644 index 000000000..3d84d350b --- /dev/null +++ b/userland/capsule_terminal/src/event/fg_cooked.rs @@ -0,0 +1,58 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Keys for a program reading lines: the line is edited here and sent on +//! Enter, as a tty's canonical mode does. + +use nonos_app_skeleton::EventOutcome; +use nonos_vt::input::{Key, Mods}; + +use super::cooked::Effect; +use super::fg_input::send; +use super::interrupt::interrupt; +use crate::term::state::State; + +pub(super) fn cooked(state: &mut State, key: Key, m: Mods) -> EventOutcome { + let mut fx = Effect::default(); + let lc = |c: char| c.to_ascii_lowercase(); + match key { + Key::Char(c) if m.ctrl => match lc(c) { + 'c' => return interrupt(state), + 'u' => state.cooked.kill_line(&mut fx), + 'w' => state.cooked.kill_word(&mut fx), + 'd' if state.cooked.line.is_empty() => { + /* + * There is no end-of-input for a NONOS program's stdin yet; + * saying so beats a key that silently does nothing. + */ + fx.echo.extend_from_slice(b"^D (end of input is not delivered to this program)\n"); + } + _ => return EventOutcome::Idle, + }, + Key::Char(c) => state.cooked.char(c, &mut fx), + Key::Tab => state.cooked.char('\t', &mut fx), + Key::Backspace => state.cooked.backspace(&mut fx), + Key::Enter => state.cooked.enter(&mut fx), + _ => return EventOutcome::Idle, + } + if !fx.echo.is_empty() { + state.scrollback.feed_raw(&fx.echo); + } + if !fx.send.is_empty() { + send(state, &fx.send); + } + EventOutcome::Repaint +} diff --git a/userland/capsule_terminal/src/event/fg_input.rs b/userland/capsule_terminal/src/event/fg_input.rs new file mode 100644 index 000000000..edaa8b1ba --- /dev/null +++ b/userland/capsule_terminal/src/event/fg_input.rs @@ -0,0 +1,44 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Whether the foreground program reads raw keys, and handing it bytes. + +use nonos_vt::{MouseMode, Term}; + +use crate::jobs::JobWork; +use crate::term::state::State; + +pub fn reads_raw(vt: &Term) -> bool { + let m = &vt.modes; + vt.alt_active() || m.cursor_keys || m.bracketed_paste || m.mouse != MouseMode::Off +} + +/// Queue bytes for the foreground program. False when it cannot take input. +pub fn send(state: &mut State, bytes: &[u8]) -> bool { + let Some(id) = state.jobs.foreground() else { return false }; + match state.jobs.get_mut(id).map(|j| &mut j.work) { + Some(JobWork::ExternalStage { in_buf, .. }) => { + in_buf.extend_from_slice(bytes); + true + } + _ => false, + } +} + +pub(super) fn takes_input(state: &State) -> bool { + let Some(id) = state.jobs.foreground() else { return false }; + matches!(state.jobs.get(id).map(|j| &j.work), Some(JobWork::ExternalStage { .. })) +} diff --git a/userland/capsule_terminal/src/event/fg_keys.rs b/userland/capsule_terminal/src/event/fg_keys.rs new file mode 100644 index 000000000..0853c1c64 --- /dev/null +++ b/userland/capsule_terminal/src/event/fg_keys.rs @@ -0,0 +1,70 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Keys while a program runs in the foreground. +//! +//! A program that drew a full screen reads keys raw, as the sequences +//! xterm sends. Any other program reads lines, edited here as a tty's +//! canonical mode edits them. Programs cannot yet say which they want: the +//! terminal has no path for a program's tty settings. Until it does, a +//! program is taken to read raw once it asks for the alternate screen, +//! application cursor keys, bracketed paste or mouse reports, which only +//! raw readers ask for. + +use nonos_app_skeleton::{EventOutcome, InputEvent}; +use nonos_vt::input::{encode_key, Key, Mods}; + +use super::fg_input::{reads_raw, send, takes_input}; +use super::interrupt::interrupt; +use super::keymap::key_of; +use crate::term::state::State; + +pub fn fg_key(state: &mut State, event: InputEvent) -> Option { + if !state.fg_running { + return None; + } + let Some((key, m)) = key_of(&event) else { return Some(EventOutcome::Idle) }; + // Shift with the page keys reads history, as in every terminal. + if m.shift && !m.ctrl && !state.scrollback.vt.alt_active() { + let page = state.scrollback.vt.rows().saturating_sub(2).max(1); + match key { + Key::PageUp => state.scrollback.scroll_up(page), + Key::PageDown => state.scrollback.scroll_down(page), + _ => return Some(raw_or_cooked(state, key, m)), + } + return Some(EventOutcome::Repaint); + } + Some(raw_or_cooked(state, key, m)) +} + +fn raw_or_cooked(state: &mut State, key: Key, m: Mods) -> EventOutcome { + if !takes_input(state) { + // A built-in job reads nothing; Ctrl+C still stops it. + if m.ctrl && key == Key::Char('c') { + return interrupt(state); + } + return EventOutcome::Idle; + } + state.scrollback.jump_bottom(); + if reads_raw(&state.scrollback.vt) { + let mut bytes = alloc::vec::Vec::new(); + if encode_key(key, m, &state.scrollback.vt.modes, &mut bytes) { + send(state, &bytes); + } + return EventOutcome::Repaint; + } + super::fg_cooked::cooked(state, key, m) +} diff --git a/userland/capsule_terminal/src/event/find_bar.rs b/userland/capsule_terminal/src/event/find_bar.rs new file mode 100644 index 000000000..3703f0ac6 --- /dev/null +++ b/userland/capsule_terminal/src/event/find_bar.rs @@ -0,0 +1,72 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The scrollback search bar. Typing searches back from the newest line, +//! Enter steps to the next older match and Shift+Enter to the newer one, +//! Alt+C toggles case, Esc closes. The view follows the match. + +use nonos_app_skeleton::{ + EventOutcome, InputEvent, KEY_BACKSPACE, KEY_ENTER, KEY_ESC, MOD_ALT, MOD_SHIFT, +}; + +use super::find_seek::{end, reveal, seek}; +use crate::term::select::Find; +use crate::term::state::State; + +const MAX_QUERY: usize = 256; + +pub fn open(state: &mut State) -> EventOutcome { + state.find = Some(Find::default()); + EventOutcome::Repaint +} + +pub fn key(state: &mut State, event: InputEvent) -> EventOutcome { + let Some(mut f) = state.find.take() else { return EventOutcome::Idle }; + let alt = event.flags & MOD_ALT != 0; + match event.code { + KEY_ESC => { + state.scrollback.jump_bottom(); + return EventOutcome::Repaint; + } + KEY_ENTER => { + let from = f.hit.map(|h| h.0).unwrap_or(end(state)); + f.hit = seek(state, &f, from, event.flags & MOD_SHIFT == 0).or(f.hit); + } + KEY_BACKSPACE => { + f.query.pop(); + f.hit = seek(state, &f, end(state), true); + } + c if alt && matches!(c, 0x43 | 0x63) => { + f.case = !f.case; + f.hit = seek(state, &f, end(state), true); + } + c => { + let Some(ch) = char::from_u32(c).filter(|ch| !ch.is_control()) else { + state.find = Some(f); + return EventOutcome::Idle; + }; + if f.query.len() < MAX_QUERY { + f.query.push(ch); + } + f.hit = seek(state, &f, end(state), true); + } + } + if let Some((a, _)) = f.hit { + reveal(state, a.line); + } + state.find = Some(f); + EventOutcome::Repaint +} diff --git a/userland/capsule_terminal/src/event/find_seek.rs b/userland/capsule_terminal/src/event/find_seek.rs new file mode 100644 index 000000000..1c487c631 --- /dev/null +++ b/userland/capsule_terminal/src/event/find_seek.rs @@ -0,0 +1,45 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Where a search starts, the next match from a place, and bringing a +//! match into view. + +use nonos_vt::Pos; + +use crate::term::select::Find; +use crate::term::state::State; + +/// Just past the newest cell, so a backward search starts at the bottom. +pub(super) fn end(state: &State) -> Pos { + Pos { line: state.scrollback.vt.last_line() + 1, col: 0 } +} + +pub(super) fn seek(state: &State, f: &Find, from: Pos, older: bool) -> Option<(Pos, Pos)> { + if f.query.is_empty() { + return None; + } + state.scrollback.vt.find(&f.query, from, older, f.case) +} + +/// Scroll history so `line` sits mid-screen. +pub(super) fn reveal(state: &mut State, line: u64) { + let vt = &mut state.scrollback.vt; + let rows = vt.rows() as u64; + let bottom = vt.cursor_pos().line; + let back = (bottom + rows / 2).saturating_sub(line + rows); + vt.scroll_to_bottom(); + vt.scroll_view(back.min(isize::MAX as u64) as isize); +} diff --git a/userland/capsule_terminal/src/event/fg_stdin.rs b/userland/capsule_terminal/src/event/interrupt.rs similarity index 50% rename from userland/capsule_terminal/src/event/fg_stdin.rs rename to userland/capsule_terminal/src/event/interrupt.rs index 6c200f3e5..17bec7158 100644 --- a/userland/capsule_terminal/src/event/fg_stdin.rs +++ b/userland/capsule_terminal/src/event/interrupt.rs @@ -14,31 +14,32 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +//! Ctrl+C: stop the foreground job, or drop the line being typed. + use nonos_app_skeleton::EventOutcome; +use nonos_libc::mk_kill; -use crate::command::output::Output; use crate::jobs::JobWork; use crate::term::state::State; -// A key typed while a foreground job runs: if that job is an interactive -// external capsule, queue the byte on its stdin buffer (the on_tick pump feeds -// it to the child via mk_proc_input) and echo it locally so the typist sees -// what they entered. Any other foreground job swallows the key exactly as -// Task 13's gate did, returning `Idle`. -pub fn forward(state: &mut State, byte: u8) -> EventOutcome { - let mut fed = false; - if let Some(id) = state.jobs.foreground() { - if let Some(job) = state.jobs.get_mut(id) { - if let JobWork::ExternalStage { in_buf, .. } = &mut job.work { - in_buf.push(byte); - fed = true; +const SIGINT: u64 = 2; + +pub fn interrupt(state: &mut State) -> EventOutcome { + if state.fg_running { + if let Some(id) = state.jobs.foreground() { + if let Some(job) = state.jobs.get_mut(id) { + if let JobWork::ExternalStage { pid, .. } = job.work { + let _ = mk_kill(pid as u64, SIGINT); + } + job.cancel = true; } } - } - if fed { - Output::new(&mut state.scrollback).feed_raw(&[byte]); - EventOutcome::Repaint + state.cooked.line.clear(); } else { - EventOutcome::Idle + state.line.clear(); + state.history.reset_cursor(); } + state.scrollback.push_line(b"^C"); + state.scrollback.jump_bottom(); + EventOutcome::Repaint } diff --git a/userland/capsule_terminal/src/event/key_first.rs b/userland/capsule_terminal/src/event/key_first.rs new file mode 100644 index 000000000..fac5a3742 --- /dev/null +++ b/userland/capsule_terminal/src/event/key_first.rs @@ -0,0 +1,43 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! What a key reaches before the shell's line editor: copy, paste and +//! search, an open search bar, and a running program. + +use nonos_app_skeleton::{EventOutcome, InputEvent}; + +use crate::term::state::State; + +pub fn key_first(state: &mut State, event: InputEvent) -> Option { + if let Some(out) = super::clip::clip_key(state, &event) { + return Some(out); + } + if state.find.is_some() { + return Some(super::find_bar::key(state, event)); + } + // Typing moves on from whatever was picked. + state.sel = None; + /* + * A running program gets the keys first, Ctrl ones included: a full + * screen program binds them itself. + */ + super::fg_keys::fg_key(state, event) +} + +/// A page of history: the screen less two lines of overlap. +pub fn page(state: &State) -> usize { + state.scrollback.vt.rows().saturating_sub(2).max(1) +} diff --git a/userland/capsule_terminal/src/event/keymap.rs b/userland/capsule_terminal/src/event/keymap.rs new file mode 100644 index 000000000..e187771e2 --- /dev/null +++ b/userland/capsule_terminal/src/event/keymap.rs @@ -0,0 +1,52 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A key press as the window reports it, as the key a terminal program +//! reads. + +use nonos_app_skeleton::{ + InputEvent, KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_END, KEY_ENTER, KEY_ESC, KEY_F1, KEY_F12, + KEY_HOME, KEY_INSERT, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, KEY_TAB, KEY_UP, + MOD_ALT, MOD_CTRL, MOD_SHIFT, +}; +use nonos_vt::input::{Key, Mods}; + +pub fn mods(flags: u16) -> Mods { + Mods { shift: flags & MOD_SHIFT != 0, alt: flags & MOD_ALT != 0, ctrl: flags & MOD_CTRL != 0 } +} + +pub fn key_of(event: &InputEvent) -> Option<(Key, Mods)> { + let m = mods(event.flags); + let key = match event.code { + KEY_ENTER => Key::Enter, + KEY_TAB => Key::Tab, + KEY_BACKSPACE | 0x7F => Key::Backspace, + KEY_ESC => Key::Escape, + KEY_UP => Key::Up, + KEY_DOWN => Key::Down, + KEY_LEFT => Key::Left, + KEY_RIGHT => Key::Right, + KEY_HOME => Key::Home, + KEY_END => Key::End, + KEY_PAGE_UP => Key::PageUp, + KEY_PAGE_DOWN => Key::PageDown, + KEY_INSERT => Key::Insert, + KEY_DELETE => Key::Delete, + c @ KEY_F1..=KEY_F12 => Key::F((c - KEY_F1 + 1) as u8), + c => Key::Char(char::from_u32(c).filter(|ch| !ch.is_control())?), + }; + Some((key, m)) +} diff --git a/userland/capsule_terminal/src/event/mod.rs b/userland/capsule_terminal/src/event/mod.rs index 22318e810..1c73669c5 100644 --- a/userland/capsule_terminal/src/event/mod.rs +++ b/userland/capsule_terminal/src/event/mod.rs @@ -16,9 +16,19 @@ mod accept_suggestion; mod bool_to_outcome; +mod clip; pub(crate) mod complete; +pub(crate) mod cooked; +mod cooked_kill; mod copy_line; -mod fg_stdin; +mod fg_cooked; +mod fg_input; +mod fg_keys; +mod find_bar; +mod find_seek; +mod interrupt; +mod key_first; +pub(crate) mod keymap; mod on_ctrl; mod on_down; mod on_enter; @@ -28,9 +38,11 @@ mod on_printable; mod on_tab; mod on_up; mod paste_clipboard; +mod paste_program; pub(crate) mod search; pub(crate) mod search_edit; mod search_place; +pub use fg_input::send as send_to_program; pub use on_enter::on_enter; pub use on_event::on_event; diff --git a/userland/capsule_terminal/src/event/on_ctrl.rs b/userland/capsule_terminal/src/event/on_ctrl.rs index 1dc20535d..8740ef08c 100644 --- a/userland/capsule_terminal/src/event/on_ctrl.rs +++ b/userland/capsule_terminal/src/event/on_ctrl.rs @@ -15,18 +15,14 @@ // along with this program. If not, see . use nonos_app_skeleton::{EventOutcome, KEY_LEFT, KEY_RIGHT, MOD_SHIFT}; -use nonos_libc::mk_kill; use super::accept_suggestion::accept_suggestion; use super::bool_to_outcome::bool_to_outcome; use super::copy_line::copy_line; use super::paste_clipboard::paste_clipboard; use super::search::{search_cancel, search_step}; -use crate::jobs::JobWork; use crate::term::state::State; -const SIGINT: u64 = 2; - const CTRL_A: u32 = 0x41; const CTRL_C: u32 = 0x43; const CTRL_E: u32 = 0x45; @@ -73,24 +69,7 @@ pub fn on_ctrl(state: &mut State, code: u32, flags: u16) -> Option state.scrollback.jump_bottom(); Some(EventOutcome::Repaint) } - CTRL_C | CTRL_C_LO => { - if state.fg_running { - if let Some(id) = state.jobs.foreground() { - if let Some(job) = state.jobs.get_mut(id) { - if let JobWork::ExternalStage { pid, .. } = job.work { - let _ = mk_kill(pid as u64, SIGINT); - } - job.cancel = true; - } - } - } else { - state.line.clear(); - state.history.reset_cursor(); - } - state.scrollback.push_line(b"^C"); - state.scrollback.jump_bottom(); - Some(EventOutcome::Repaint) - } + CTRL_C | CTRL_C_LO => Some(super::interrupt::interrupt(state)), CTRL_U | CTRL_U_LO => { state.line.kill_line(); Some(EventOutcome::Repaint) diff --git a/userland/capsule_terminal/src/event/on_key.rs b/userland/capsule_terminal/src/event/on_key.rs index 1447accbd..91c26d8f9 100644 --- a/userland/capsule_terminal/src/event/on_key.rs +++ b/userland/capsule_terminal/src/event/on_key.rs @@ -26,24 +26,17 @@ use super::on_enter::on_enter; use super::on_printable::on_printable; use super::on_tab::on_tab; use super::on_up::on_up; -use crate::term::dimensions::VISIBLE_ROWS; use crate::term::state::State; pub fn on_key(state: &mut State, event: InputEvent) -> EventOutcome { + if let Some(out) = super::key_first::key_first(state, event) { + return out; + } if event.flags & MOD_CTRL != 0 { if let Some(out) = on_ctrl(state, event.code, event.flags) { return out; } } - if state.fg_running && event.flags & MOD_CTRL == 0 { - match event.code { - KEY_ENTER => return super::fg_stdin::forward(state, b'\n'), - code if (0x20..=0x7E).contains(&code) => { - return super::fg_stdin::forward(state, code as u8) - } - _ => {} - } - } match event.code { // Clears the line. Esc used to close the window, so one stray press // took the scrollback, history and working directory with it. Closing @@ -72,11 +65,11 @@ pub fn on_key(state: &mut State, event: InputEvent) -> EventOutcome { KEY_UP => on_up(state), KEY_DOWN => on_down(state), KEY_PAGE_UP => { - state.scrollback.scroll_up(VISIBLE_ROWS - 2); + state.scrollback.scroll_up(super::key_first::page(state)); EventOutcome::Repaint } KEY_PAGE_DOWN => { - state.scrollback.scroll_down(VISIBLE_ROWS - 2); + state.scrollback.scroll_down(super::key_first::page(state)); EventOutcome::Repaint } KEY_TAB => on_tab(state), diff --git a/userland/capsule_terminal/src/event/paste_program.rs b/userland/capsule_terminal/src/event/paste_program.rs new file mode 100644 index 000000000..1f04e1cac --- /dev/null +++ b/userland/capsule_terminal/src/event/paste_program.rs @@ -0,0 +1,61 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A paste into a running program: bracketed and stripped of escapes for a +//! raw reader, typed line by line for a line reader. + +use alloc::string::String; +use alloc::vec; + +use nonos_app_skeleton::{clipboard_paste, EventOutcome}; +use nonos_vt::input::encode_paste; + +use super::cooked::Effect; +use super::fg_input::{reads_raw, send}; +use crate::term::state::State; + +/// Largest paste taken at once. +const PASTE_MAX: usize = 16 * 1024; + +pub(super) fn paste_to_program(state: &mut State) -> EventOutcome { + let mut buf = vec![0u8; PASTE_MAX]; + let n = match clipboard_paste(&mut buf) { + Ok(n) => n.min(buf.len()), + Err(_) => { + state.scrollback.push_line(b"paste: clipboard unavailable"); + return EventOutcome::Repaint; + } + }; + let text = String::from_utf8_lossy(&buf[..n]).into_owned(); + if reads_raw(&state.scrollback.vt) { + let mut bytes = alloc::vec::Vec::new(); + encode_paste(&text, &state.scrollback.vt.modes, &mut bytes); + send(state, &bytes); + return EventOutcome::Repaint; + } + // A line reader gets the paste as if typed: each line sent on its end. + let mut fx = Effect::default(); + for c in text.chars() { + match c { + '\r' | '\n' => state.cooked.enter(&mut fx), + c if c.is_control() && c != '\t' => {} + c => state.cooked.char(c, &mut fx), + } + } + state.scrollback.feed_raw(&fx.echo); + send(state, &fx.send); + EventOutcome::Repaint +} diff --git a/userland/capsule_terminal/src/jobs/external.rs b/userland/capsule_terminal/src/jobs/external.rs index 4f18afbf2..ac03dd3d6 100644 --- a/userland/capsule_terminal/src/jobs/external.rs +++ b/userland/capsule_terminal/src/jobs/external.rs @@ -14,52 +14,43 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use nonos_libc::{mk_proc_input, mk_proc_output, mk_wait}; +use alloc::vec::Vec; + +use nonos_libc::{mk_proc_output, mk_wait}; use crate::command::output::Output; +use super::external_io::{drain_remaining, feed_stdin, TICK_BUDGET}; use crate::jobs::JobProgress; const ERRNO_TIMEDOUT: i64 = -110; -const MAX_PROC_INPUT: usize = 1024 * 1024; - pub fn step_external( pid: u32, - in_buf: &[u8], + in_buf: &mut Vec, in_cursor: &mut usize, out: &mut Output<'_>, ) -> JobProgress { feed_stdin(pid, in_buf, in_cursor); let mut buf = [0u8; 256]; - let n = mk_proc_output(pid, buf.as_mut_ptr(), buf.len()); - if n > 0 { - out.feed_raw(&buf[..(n as usize).min(buf.len())]); + let mut taken = 0; + while taken < TICK_BUDGET { + let n = mk_proc_output(pid, buf.as_mut_ptr(), buf.len()); + if n <= 0 { + break; + } + let n = (n as usize).min(buf.len()); + out.feed_raw(&buf[..n]); + taken += n; } + /* + * What the program asked the terminal (its cursor position, its + * identity) is answered on its stdin, as a tty answers. + */ + in_buf.extend_from_slice(&out.take_replies()); let status = mk_wait(pid as u64, 0); if status == ERRNO_TIMEDOUT { return JobProgress::Running; } drain_remaining(pid, out, &mut buf); + out.program_ended(); JobProgress::Done(status as i32) } - -fn feed_stdin(pid: u32, in_buf: &[u8], in_cursor: &mut usize) { - if *in_cursor >= in_buf.len() { - return; - } - let pending = &in_buf[*in_cursor..]; - let chunk = &pending[..pending.len().min(MAX_PROC_INPUT)]; - let sent = mk_proc_input(pid as u64, chunk.as_ptr(), chunk.len() as u64); - if sent > 0 { - *in_cursor += (sent as usize).min(chunk.len()); - } -} - -fn drain_remaining(pid: u32, out: &mut Output<'_>, buf: &mut [u8; 256]) { - loop { - let m = mk_proc_output(pid, buf.as_mut_ptr(), buf.len()); - if m <= 0 { - break; - } - out.feed_raw(&buf[..(m as usize).min(buf.len())]); - } -} diff --git a/userland/capsule_terminal/src/jobs/external_io.rs b/userland/capsule_terminal/src/jobs/external_io.rs new file mode 100644 index 000000000..1ff941914 --- /dev/null +++ b/userland/capsule_terminal/src/jobs/external_io.rs @@ -0,0 +1,58 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Moving bytes between the terminal and a running program: its stdin +//! queue in, its output out, each bounded a tick. + +use alloc::vec::Vec; + +use nonos_libc::{mk_proc_input, mk_proc_output}; + +use crate::command::output::Output; + +const MAX_PROC_INPUT: usize = 1024 * 1024; +/// Output taken from a program in one tick. A program printing fast used to +/// be read 256 bytes a tick, about 8 KiB a second; this keeps a frame +/// bounded while letting a build log or a scan scroll at full speed. +pub(super) const TICK_BUDGET: usize = 64 * 1024; + +pub(super) fn feed_stdin(pid: u32, in_buf: &mut Vec, in_cursor: &mut usize) { + if *in_cursor >= in_buf.len() { + // All of it went: start the buffer over rather than let it grow. + in_buf.clear(); + *in_cursor = 0; + return; + } + let pending = &in_buf[*in_cursor..]; + let chunk = &pending[..pending.len().min(MAX_PROC_INPUT)]; + let sent = mk_proc_input(pid as u64, chunk.as_ptr(), chunk.len() as u64); + if sent > 0 { + *in_cursor += (sent as usize).min(chunk.len()); + } +} + +pub(super) fn drain_remaining(pid: u32, out: &mut Output<'_>, buf: &mut [u8; 256]) { + let mut taken = 0; + while taken < TICK_BUDGET { + let m = mk_proc_output(pid, buf.as_mut_ptr(), buf.len()); + if m <= 0 { + break; + } + let m = (m as usize).min(buf.len()); + out.feed_raw(&buf[..m]); + taken += m; + } +} diff --git a/userland/capsule_terminal/src/jobs/mod.rs b/userland/capsule_terminal/src/jobs/mod.rs index 3662d5c5c..4b434c459 100644 --- a/userland/capsule_terminal/src/jobs/mod.rs +++ b/userland/capsule_terminal/src/jobs/mod.rs @@ -17,6 +17,7 @@ mod classify; mod env; mod external; +mod external_io; mod pipeline_job; mod pump; mod reap; diff --git a/userland/capsule_terminal/src/main.rs b/userland/capsule_terminal/src/main.rs index 2c1c4c841..e7e63ee71 100644 --- a/userland/capsule_terminal/src/main.rs +++ b/userland/capsule_terminal/src/main.rs @@ -40,6 +40,12 @@ use nonos_app_skeleton::run; /// It must not be called from Rust code. #[no_mangle] pub unsafe extern "C" fn _start() -> ! { + /* + * Each tab keeps a screen and its history. The default 16 MiB heap left + * room for a few hundred lines; this holds thousands in every tab. + */ + const TERMINAL_HEAP: usize = 64 * 1024 * 1024; + let _ = nonos_libc::heap_init_sized(TERMINAL_HEAP); #[cfg(feature = "nonos-autorun-selftest")] { term::terminal::selftest::main() diff --git a/userland/capsule_terminal/src/paint/block_chrome.rs b/userland/capsule_terminal/src/paint/block_chrome.rs index be1549490..52c69ee07 100644 --- a/userland/capsule_terminal/src/paint/block_chrome.rs +++ b/userland/capsule_terminal/src/paint/block_chrome.rs @@ -19,8 +19,8 @@ use nonos_app_skeleton::PaintBuffer; use super::block_meta::draw_meta; use super::metrics::Metrics; use super::shade::elevate; +use super::vt::Area; use crate::term::block::Status; -use crate::term::dimensions::VISIBLE_ROWS; use crate::term::state::State; use crate::term::theme::types::Theme; @@ -30,20 +30,22 @@ const STRIPE_GAP: u32 = 8; pub fn draw_block_chrome( state: &State, fb: &mut PaintBuffer, - ox: u32, - oy: u32, - max_y: u32, - max_x: u32, + a: &Area, + top: u64, m: &Metrics, t: &Theme, ) { - let g = &state.scrollback.grid; - for row in 0..VISIBLE_ROWS { + let (ox, oy, max_x, max_y) = (a.x, a.y, a.max_x, a.max_y); + let last = state.scrollback.vt.cursor_pos().line; + for row in 0.. { let y = crate::layout::row_top(row as u32, oy, m.lh); if y + m.lh > max_y { break; } - let abs = g.abs_of_visible_row(row); + let abs = top + row as u64; + if abs > last { + break; + } let (idx, status) = match block_for(state, abs) { Some(v) => v, None => continue, diff --git a/userland/capsule_terminal/src/paint/compose.rs b/userland/capsule_terminal/src/paint/compose.rs index 2ce84a2b7..8d6ad5fcd 100644 --- a/userland/capsule_terminal/src/paint/compose.rs +++ b/userland/capsule_terminal/src/paint/compose.rs @@ -17,16 +17,14 @@ use nonos_app_skeleton::PaintBuffer; use super::block_chrome::draw_block_chrome; -use super::constants::{BODY_PAD_TOP, HEADER_H, TEXT_LEFT}; -use super::draw_grid::{draw_grid, draw_grid_cursor}; use super::draw_input_line::draw_input_line; use super::fetch::draw_fetch; -use super::footer::{draw_footer, footer_h}; +use super::footer::draw_footer; +use super::geometry::geometry; use super::header::draw_header; -use super::metrics::Metrics; use super::rail_left; -use crate::layout::limits::LEFT_RAIL_W; -use crate::layout::{compute, Chrome, Layout, Rails}; +use super::vt::{draw_find_bar, draw_vt, Area, Frame, Rows}; +use crate::layout::Layout; use crate::palette::{Index, Palette}; use crate::rail::Rail; use crate::term::prefs::types::Project; @@ -66,34 +64,30 @@ pub fn paint( ) -> Layout { fb.clear(t.bg); draw_header(state, fb, t); - let m = Metrics::new(fb, font_scale); - let chrome = Chrome { - titlebar_h: HEADER_H, - tabstrip_h: 0, - body_pad_top: BODY_PAD_TOP, - footer_h: footer_h(), - text_left: TEXT_LEFT, - row_h: m.lh, - }; - // The rail is off unless it was asked for. A terminal that opens with a - // quarter of the window given to charts is a dashboard that happens to - // accept commands; the grid is the window, and the telemetry is there for - // whoever wants it. - let left = if rail_open { LEFT_RAIL_W } else { 0 }; - let l = compute(fb.width, fb.height, &chrome, Rails { left }); + let (l, m, chrome) = geometry(fb, font_scale, rail_open); let text_x = l.body.x + chrome.text_left; let text_r = (l.body.x + l.body.w).saturating_sub(chrome.text_left); - let alt = state.scrollback.grid.alternate; - if alt { - draw_grid(&state.scrollback.grid, fb, text_x, l.body.y, l.footer.y, text_r, m, t); - draw_grid_cursor(&state.scrollback.grid, fb, text_x, l.body.y, m, t); + let vt = &state.scrollback.vt; + /* + * A program in the foreground, or on the alternate screen, owns the + * whole body and is drawn as its screen; the prompt returns at its end. + */ + let owned = vt.alt_active() || state.fg_running; + let bottom = if owned { l.footer.y } else { l.input.y }; + let area = Area { x: text_x, y: l.body.y, max_x: text_r, max_y: bottom }; + let f = Frame { vt, area, m, t }; + if owned { + draw_vt(&f, fb, Rows::Screen, state.shade()); } else if state.fresh { draw_fetch(state, fb, text_x, l.body.y, text_r, t); } else { - draw_block_chrome(state, fb, text_x, l.body.y, l.input.y, text_r, &m, t); - draw_grid(&state.scrollback.grid, fb, text_x, l.body.y, l.input.y, text_r, m, t); + let rows = Rows::Shell { rows: (l.body.h / m.lh.max(1)) as usize, back: vt.view_offset() }; + draw_block_chrome(state, fb, &f.area, rows.first(vt), &m, t); + draw_vt(&f, fb, rows, state.shade()); } - if !alt { + if let Some(find) = &state.find { + draw_find_bar(find, fb, l.input, m, t); + } else if !owned { draw_input_line(state, fb, l.input, m, t); } draw_footer(fb, t); diff --git a/userland/capsule_terminal/src/paint/draw_grid.rs b/userland/capsule_terminal/src/paint/draw_grid.rs deleted file mode 100644 index a9c20f8c5..000000000 --- a/userland/capsule_terminal/src/paint/draw_grid.rs +++ /dev/null @@ -1,95 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//! Draw the character grid with crisp monospace TrueType glyphs. Each cell is -//! laid out on the measured advance and painted with `text_ttf_mono`, so the -//! body reads like a real terminal rather than a scaled bitmap. - -use nonos_app_skeleton::PaintBuffer; - -use super::metrics::Metrics; -use crate::term::dimensions::{COLS, VISIBLE_ROWS}; -use crate::term::grid::cell::{F_REVERSE, F_WIDE_TAIL}; -use crate::term::grid::types::Grid; -use crate::term::theme::types::Theme; -use crate::term::vt::color::DEFAULT_BG; - -fn glyph(fb: &mut PaintBuffer, x: u32, y: u32, ch: char, argb: u32, px: f32) { - // Blanks and control characters have no glyph. Everything else is handed - // to the face, which covers far more than ASCII and draws .notdef for - // what it does not have, so an unmapped character is visibly missing - // rather than silently absent. - if ch == ' ' || (ch as u32) < 0x20 || ch as u32 == 0x7f { - return; - } - let mut buf = [0u8; 4]; - let s = ch.encode_utf8(&mut buf); - let _ = fb.text_ttf_mono(x as i32, y as i32, s, argb, px); -} - -pub fn draw_grid_cursor(g: &Grid, fb: &mut PaintBuffer, ox: u32, oy: u32, m: Metrics, t: &Theme) { - if !g.cursor_visible { - return; - } - let x = ox + g.x as u32 * m.adv; - let y = oy + g.y as u32 * m.lh; - fb.fill_rect(x, y, m.adv, m.lh, t.accent); - glyph(fb, x, y, g.cells[Grid::idx(g.x, g.y)].ch, t.bg, m.px); -} - -pub fn draw_grid( - g: &Grid, - fb: &mut PaintBuffer, - ox: u32, - oy: u32, - max_y: u32, - max_x: u32, - m: Metrics, - t: &Theme, -) { - for row in 0..VISIBLE_ROWS { - let y = crate::layout::row_top(row as u32, oy, m.lh); - if y + m.lh > max_y { - break; - } - let rowcells = g.visible_row(row); - for (col, cell) in rowcells.iter().enumerate().take(COLS) { - let x = ox + col as u32 * m.adv; - if x + m.adv > max_x { - break; - } - let has_bg = cell.bg != DEFAULT_BG; - let mut fg = cell.fg; - // A default (transparent) background resolves to the terminal - // backdrop when it needs to become a visible colour, so reverse - // video and explicit fills both read correctly. - let mut bg = if has_bg { cell.bg } else { t.bg }; - let reverse = cell.flags & F_REVERSE != 0; - if reverse { - core::mem::swap(&mut fg, &mut bg); - } - if has_bg || reverse { - fb.fill_rect(x, y, m.adv, m.lh, bg); - } - // The right half of a wide character carries the background and - // nothing else. Its glyph was drawn by the cell before it, which - // had both columns to draw into. - if cell.flags & F_WIDE_TAIL == 0 { - glyph(fb, x, y, cell.ch, fg, m.px); - } - } - } -} diff --git a/userland/capsule_terminal/src/paint/geometry.rs b/userland/capsule_terminal/src/paint/geometry.rs new file mode 100644 index 000000000..cc5f3005b --- /dev/null +++ b/userland/capsule_terminal/src/paint/geometry.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Where the body and its cells fall in a window of a given size and zoom, +//! for the painter and for sizing the screen before painting. + +use nonos_app_skeleton::PaintBuffer; + +use super::constants::{BODY_PAD_TOP, HEADER_H, TEXT_LEFT}; +use super::footer::footer_h; +use super::metrics::Metrics; +use crate::layout::limits::LEFT_RAIL_W; +use crate::layout::{compute, Chrome, Layout, Rails}; + +pub fn geometry(fb: &PaintBuffer, font_scale: u32, rail_open: bool) -> (Layout, Metrics, Chrome) { + let m = Metrics::new(fb, font_scale); + let chrome = Chrome { + titlebar_h: HEADER_H, + tabstrip_h: 0, + body_pad_top: BODY_PAD_TOP, + footer_h: footer_h(), + text_left: TEXT_LEFT, + row_h: m.lh, + }; + /* + * The rail is off unless it was asked for. A terminal that opens with a + * quarter of the window given to charts is a dashboard that happens to + * accept commands; the grid is the window, and the telemetry is there for + * whoever wants it. + */ + let left = if rail_open { LEFT_RAIL_W } else { 0 }; + (compute(fb.width, fb.height, &chrome, Rails { left }), m, chrome) +} + +/// The cells the body holds at this window size and zoom: columns between +/// the text margins, rows over the body and the prompt row together, which +/// a program in the foreground draws on. +pub fn grid_size(fb: &PaintBuffer, font_scale: u32, rail_open: bool) -> (usize, usize, Metrics) { + let (l, m, chrome) = geometry(fb, font_scale, rail_open); + let width = l.body.w.saturating_sub(2 * chrome.text_left); + let cols = (width / m.adv.max(1)) as usize; + let rows = ((l.body.h + l.input.h) / m.lh.max(1)) as usize; + (cols.max(2), rows.max(1), m) +} diff --git a/userland/capsule_terminal/src/paint/line_text.rs b/userland/capsule_terminal/src/paint/line_text.rs index bee5f5b85..3e0a0d98a 100644 --- a/userland/capsule_terminal/src/paint/line_text.rs +++ b/userland/capsule_terminal/src/paint/line_text.rs @@ -20,8 +20,8 @@ use nonos_app_skeleton::PaintBuffer; use super::line_chars::chars_of; use super::syntax::Part; -use crate::term::grid::width::char_width; use crate::term::theme::types::Theme; +use nonos_vt::width::width as char_width; // Draw text as crisp monospace, advancing by the columns each character // occupies. What is typed has to render the same as what the grid shows, or a diff --git a/userland/capsule_terminal/src/paint/mod.rs b/userland/capsule_terminal/src/paint/mod.rs index 4cea5556b..b3bd880de 100644 --- a/userland/capsule_terminal/src/paint/mod.rs +++ b/userland/capsule_terminal/src/paint/mod.rs @@ -19,7 +19,6 @@ mod block_meta; mod compose; mod constants; mod draw_cursor; -mod draw_grid; mod draw_input_line; mod fetch; pub(crate) mod fetch_banner; @@ -28,11 +27,12 @@ mod fetch_uptime; mod fetch_version; mod fit_text; mod footer; +mod geometry; mod header; mod line_chars; mod line_text; mod line_window; -mod metrics; +pub(crate) mod metrics; mod palette; mod palette_row; mod prompt; @@ -65,6 +65,10 @@ pub mod tab_pill; pub mod tokens; mod tool_icon; pub mod toolbar; +mod vt; pub use compose::paint_tabs; +pub use constants::TEXT_LEFT; +pub use geometry::grid_size; pub use tab_bar::draw_tab_bar; +pub use vt::{Rows, Shade}; diff --git a/userland/capsule_terminal/src/term/grid/cell.rs b/userland/capsule_terminal/src/paint/vt/area.rs similarity index 52% rename from userland/capsule_terminal/src/term/grid/cell.rs rename to userland/capsule_terminal/src/paint/vt/area.rs index 58e2797fc..c3fd43826 100644 --- a/userland/capsule_terminal/src/term/grid/cell.rs +++ b/userland/capsule_terminal/src/paint/vt/area.rs @@ -14,29 +14,35 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub const F_BOLD: u8 = 1; -pub const F_UNDERLINE: u8 = 2; -pub const F_REVERSE: u8 = 4; -/// The right half of a character drawn two columns wide. It holds no glyph -/// of its own; the cell before it drew both halves. -pub const F_WIDE_TAIL: u8 = 8; +//! Where the body is drawn, with what, and what is shaded over it. -#[derive(Clone, Copy)] -pub struct Cell { - pub ch: char, - // Full ARGB foreground and background, so 24-bit colour is preserved. - pub fg: u32, - pub bg: u32, - pub flags: u8, +use nonos_vt::{Pos, Term}; + +use crate::paint::metrics::Metrics; +use crate::term::select::Selection; +use crate::term::theme::types::Theme; + +pub const OPAQUE: u32 = 0xFF00_0000; + +/// What is shaded over the text. +#[derive(Clone, Copy, Default)] +pub struct Shade { + pub selection: Option, + pub found: Option<(Pos, Pos)>, +} + +/// The rectangle the body may draw in. +pub struct Area { + pub x: u32, + pub y: u32, + pub max_x: u32, + pub max_y: u32, } -impl Cell { - pub const fn blank() -> Cell { - Cell { - ch: ' ', - fg: crate::term::vt::color::DEFAULT_FG, - bg: crate::term::vt::color::DEFAULT_BG, - flags: 0, - } - } +/// One frame's drawing context. +pub struct Frame<'a> { + pub vt: &'a Term, + pub area: Area, + pub m: Metrics, + pub t: &'a Theme, } diff --git a/userland/capsule_terminal/src/paint/vt/body.rs b/userland/capsule_terminal/src/paint/vt/body.rs new file mode 100644 index 000000000..fec455270 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/body.rs @@ -0,0 +1,65 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The body's rows: colours after every attribute, wide characters across +//! two cells, and the selection and search match shaded. + +use nonos_app_skeleton::PaintBuffer; +use nonos_vt::Line; + +use super::area::{Frame, Shade}; +use super::cell::text; +use super::cursor::draw_cursor; +use super::deco::decorations; +use super::fill::background; +use super::rows::Rows; + +pub fn draw_vt(f: &Frame, fb: &mut PaintBuffer, rows: Rows, shade: Shade) { + let top = rows.first(f.vt); + for i in 0.. { + let y = crate::layout::row_top(i as u32, f.area.y, f.m.lh); + if y + f.m.lh > f.area.max_y { + break; + } + let Some((abs, line)) = rows.get(f.vt, top, i) else { break }; + draw_row(f, fb, line, abs, y, shade); + draw_cursor(f, fb, rows, abs, y); + } +} + +fn draw_row(f: &Frame, fb: &mut PaintBuffer, line: &Line, abs: u64, y: u32, shade: Shade) { + let m = f.m; + for col in 0..f.vt.cols() { + let x = f.area.x + col as u32 * m.adv; + if x + m.adv > f.area.max_x { + break; + } + let cell = line.cell(col); + let (fg, bg) = f.vt.cell_colors(&cell); + if let Some(bg) = background(f, &cell, bg, abs, col, shade) { + fb.fill_rect(x, y, m.adv, m.lh, bg); + } + /* + * The right half of a wide character carries only its background; + * the head drew the glyph across both cells. + */ + if !cell.is_tail() { + let w = if cell.is_wide() { 2 * m.adv } else { m.adv }; + text(fb, line, &cell, x, y, fg, m); + decorations(fb, &cell, x, y, w, fg, m); + } + } +} diff --git a/userland/capsule_terminal/src/paint/vt/cell.rs b/userland/capsule_terminal/src/paint/vt/cell.rs new file mode 100644 index 000000000..f6eb3960d --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/cell.rs @@ -0,0 +1,52 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A cell's glyph and the marks drawn over it. + +use nonos_app_skeleton::PaintBuffer; +use nonos_vt::cell::attr; +use nonos_vt::{Cell, Line}; + +use super::area::OPAQUE; +use crate::paint::metrics::Metrics; + +pub fn glyph(fb: &mut PaintBuffer, x: u32, y: u32, ch: char, argb: u32, px: f32) { + /* + * Blanks and controls have no glyph. Anything else goes to the face, + * which draws .notdef for what it lacks, so a missing character shows. + */ + if ch == ' ' || ch == '\u{a0}' || ch.is_control() { + return; + } + let mut buf = [0u8; 4]; + let _ = fb.text_ttf_mono(x as i32, y as i32, ch.encode_utf8(&mut buf), argb, px); +} + +/// Draw the text of `cell` at `(x, y)` in `fg`. Bold is drawn twice a pixel +/// apart: the face has no bold weight. It has no italic either, so italic +/// text is drawn upright. +pub fn text(fb: &mut PaintBuffer, line: &Line, cell: &Cell, x: u32, y: u32, fg: u32, m: Metrics) { + let fg = OPAQUE | fg; + glyph(fb, x, y, cell.ch, fg, m.px); + if cell.attr & attr::BOLD != 0 { + glyph(fb, x + 1, y, cell.ch, fg, m.px); + } + if let Some(marks) = line.marks_of(cell) { + for c in marks.chars() { + glyph(fb, x, y, c, fg, m.px); + } + } +} diff --git a/userland/capsule_terminal/src/paint/vt/cursor.rs b/userland/capsule_terminal/src/paint/vt/cursor.rs new file mode 100644 index 000000000..8d059129c --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/cursor.rs @@ -0,0 +1,47 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The cursor, in the shape the program set: block, underline or bar. + +use nonos_app_skeleton::PaintBuffer; +use nonos_vt::CursorShape; + +use super::area::{Frame, OPAQUE}; +use super::cell::glyph; +use super::rows::Rows; + +/// Draw the cursor if it is on the row at `abs`, drawn at `y`. The shell +/// view draws its own cursor in the prompt instead. +pub fn draw_cursor(f: &Frame, fb: &mut PaintBuffer, rows: Rows, abs: u64, y: u32) { + let c = f.vt.cursor(); + if !matches!(rows, Rows::Screen) || !c.visible || f.vt.abs_of_row(c.y) != abs { + return; + } + let (m, x) = (f.m, f.area.x + c.x as u32 * f.m.adv); + if x + m.adv > f.area.max_x { + return; + } + let colour = OPAQUE | f.vt.palette().cursor; + match c.shape { + CursorShape::Block => { + fb.fill_rect(x, y, m.adv, m.lh, colour); + let under = f.vt.visible_line(c.y).cell(c.x); + glyph(fb, x, y, under.ch, OPAQUE | f.t.bg, m.px); + } + CursorShape::Underline => fb.fill_rect(x, y + m.lh.saturating_sub(2), m.adv, 2, colour), + CursorShape::Bar => fb.fill_rect(x, y, 2, m.lh, colour), + } +} diff --git a/userland/capsule_terminal/src/paint/vt/deco.rs b/userland/capsule_terminal/src/paint/vt/deco.rs new file mode 100644 index 000000000..761305bc8 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/deco.rs @@ -0,0 +1,59 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Lines drawn across a cell: the underline styles, strikethrough and +//! overline. + +use nonos_app_skeleton::PaintBuffer; +use nonos_vt::cell::attr; +use nonos_vt::{Cell, Underline}; + +use super::area::OPAQUE; +use crate::paint::metrics::Metrics; + +fn curly(fb: &mut PaintBuffer, x: u32, base: u32, w: u32, fg: u32) { + let pts: alloc::vec::Vec<(i32, i32)> = (0..=w / 2) + .map(|i| { + let py = if i % 2 == 0 { base.saturating_sub(1) } else { base + 1 }; + ((x + 2 * i) as i32, py as i32) + }) + .collect(); + fb.polyline(&pts, fg); +} + +pub fn decorations(fb: &mut PaintBuffer, cell: &Cell, x: u32, y: u32, w: u32, fg: u32, m: Metrics) { + let fg = OPAQUE | fg; + let base = y + m.lh.saturating_sub(3); + match cell.underline() { + Underline::None => {} + Underline::Single => fb.fill_rect(x, base, w, 1, fg), + Underline::Double => { + fb.fill_rect(x, base.saturating_sub(2), w, 1, fg); + fb.fill_rect(x, base, w, 1, fg); + } + Underline::Curly => curly(fb, x, base, w, fg), + Underline::Dotted => (0..w).step_by(2).for_each(|i| fb.fill_rect(x + i, base, 1, 1, fg)), + Underline::Dashed => { + (0..w).step_by(5).for_each(|i| fb.fill_rect(x + i, base, 3.min(w - i), 1, fg)) + } + } + if cell.attr & attr::STRIKE != 0 { + fb.fill_rect(x, y + m.lh / 2, w, 1, fg); + } + if cell.attr & attr::OVERLINE != 0 { + fb.fill_rect(x, y + 1, w, 1, fg); + } +} diff --git a/userland/capsule_terminal/src/paint/vt/fill.rs b/userland/capsule_terminal/src/paint/vt/fill.rs new file mode 100644 index 000000000..be519d7ba --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/fill.rs @@ -0,0 +1,47 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The fill behind a cell: the selection, a search match, its own +//! background, or none where the theme's backdrop shows through. + +use nonos_toolkit::paint::mixer::mix; +use nonos_vt::cell::attr; +use nonos_vt::{Cell, Color}; + +use super::area::{Frame, Shade, OPAQUE}; +use crate::term::select::in_span; + +/// The fill behind a cell, or none where the theme's backdrop shows. +pub(super) fn background( + f: &Frame, + cell: &Cell, + bg: u32, + abs: u64, + col: usize, + shade: Shade, +) -> Option { + if shade.selection.is_some_and(|s| s.contains(abs, col)) { + return Some(OPAQUE | mix(f.t.bg, f.t.accent, 90)); + } + if in_span(shade.found, abs, col) { + return Some(OPAQUE | mix(f.t.bg, f.t.run, 150)); + } + let plain = cell.bg == Color::Default && cell.attr & attr::INVERSE == 0; + if plain && !f.vt.modes.reverse_video { + return None; + } + Some(OPAQUE | bg) +} diff --git a/userland/capsule_terminal/src/paint/vt/find_bar.rs b/userland/capsule_terminal/src/paint/vt/find_bar.rs new file mode 100644 index 000000000..5b489699d --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/find_bar.rs @@ -0,0 +1,50 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! The search bar, drawn where the prompt goes while a search is open. + +use nonos_app_skeleton::PaintBuffer; + +use crate::layout::Rect; +use crate::paint::constants::TEXT_LEFT; +use crate::paint::metrics::Metrics; +use crate::paint::shade::elevate; +use crate::term::select::Find; +use crate::term::theme::types::Theme; + +pub fn draw_find_bar(f: &Find, fb: &mut PaintBuffer, r: Rect, m: Metrics, t: &Theme) { + let bar_x = r.x + TEXT_LEFT / 2; + let bar_y = r.y.saturating_sub(3); + fb.fill_rect(bar_x, bar_y, r.w.saturating_sub(TEXT_LEFT), m.lh + 4, elevate(t.bg, 12)); + fb.fill_rect(bar_x, bar_y, 2, m.lh + 4, t.run); + let x = (r.x + TEXT_LEFT) as i32; + let label = "find "; + let _ = fb.text_ttf_mono(x, r.y as i32, label, t.dim, m.px); + let qx = x + (label.len() as u32 * m.adv) as i32; + let _ = fb.text_ttf_mono(qx, r.y as i32, &f.query, t.fg, m.px); + let state = match (f.query.is_empty(), f.hit.is_some()) { + (true, _) => "type to search history", + (false, true) => "Enter older, Shift+Enter newer, Esc close", + (false, false) => "no match", + }; + let case = if f.case { " case" } else { "" }; + let hint_x = qx + ((f.query.chars().count() as u32 + 3) * m.adv) as i32; + let _ = fb.text_ttf_mono(hint_x, r.y as i32, state, t.dim, m.px); + if !case.is_empty() { + let cx = hint_x + (state.len() as u32 * m.adv) as i32; + let _ = fb.text_ttf_mono(cx, r.y as i32, case, t.accent, m.px); + } +} diff --git a/userland/capsule_terminal/src/term/vt/mod.rs b/userland/capsule_terminal/src/paint/vt/mod.rs similarity index 73% rename from userland/capsule_terminal/src/term/vt/mod.rs rename to userland/capsule_terminal/src/paint/vt/mod.rs index a112cf417..6bb765f95 100644 --- a/userland/capsule_terminal/src/term/vt/mod.rs +++ b/userland/capsule_terminal/src/paint/vt/mod.rs @@ -14,11 +14,18 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub mod color; -pub mod csi_cursor; -pub mod csi_edit; -pub mod decset; -pub mod parser; -pub mod sgr; -pub mod state; -pub mod utf8; +//! Drawing the terminal body from the screen and its history. + +mod area; +mod body; +mod cell; +mod cursor; +mod deco; +mod fill; +mod find_bar; +mod rows; + +pub use area::{Area, Frame, Shade}; +pub use body::draw_vt; +pub use find_bar::draw_find_bar; +pub use rows::Rows; diff --git a/userland/capsule_terminal/src/paint/vt/rows.rs b/userland/capsule_terminal/src/paint/vt/rows.rs new file mode 100644 index 000000000..1bcbd7cf0 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/rows.rs @@ -0,0 +1,64 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Which lines fill the body. The shell draws its history ending at the +//! last line written, so output sits just above the prompt. A program in +//! the foreground, or on the alternate screen, owns the whole body and is +//! drawn as its screen. + +use nonos_vt::{Line, Term}; + +#[derive(Clone, Copy)] +pub enum Rows { + /// Screen rows `0..n`, as the program laid them out. + Screen, + /// `n` lines of history ending at the last line written, `back` lines + /// back into history. + Shell { rows: usize, back: usize }, +} + +impl Rows { + /// The absolute line of the first row drawn. + pub fn first(&self, vt: &Term) -> u64 { + match *self { + Rows::Screen => vt.abs_of_row(0), + Rows::Shell { rows, back } => { + let cur = vt.cursor_pos(); + let written = vt.line_at(cur.line).is_some_and(|l| l.content_len() > 0); + let last = + if cur.col > 0 || written { cur.line } else { cur.line.saturating_sub(1) }; + let top = (last + 1).saturating_sub(rows as u64).saturating_sub(back as u64); + top.max(vt.first_line()) + } + } + } + + /// Row `i` of the body, given `top` from `first`: its absolute line + /// and the line itself. + pub fn get<'a>(&self, vt: &'a Term, top: u64, i: usize) -> Option<(u64, &'a Line)> { + match self { + Rows::Screen => (i < vt.rows()).then(|| (vt.abs_of_row(i), vt.visible_line(i))), + Rows::Shell { .. } => { + let abs = top + i as u64; + let cur = vt.cursor_pos().line; + if abs > cur { + return None; + } + vt.line_at(abs).map(|l| (abs, l)) + } + } + } +} diff --git a/userland/capsule_terminal/src/term/block/ops.rs b/userland/capsule_terminal/src/term/block/ops.rs index 9cd23c1fa..db26f3783 100644 --- a/userland/capsule_terminal/src/term/block/ops.rs +++ b/userland/capsule_terminal/src/term/block/ops.rs @@ -21,7 +21,7 @@ const MAX_BLOCKS: usize = 256; impl State { pub fn open_block(&mut self, ts: [u8; 8]) { - let start_abs = self.scrollback.grid.current_abs_line(); + let start_abs = self.scrollback.vt.cursor_pos().line; self.blocks.push(Block { start_abs, ts, status: Status::Running, dur_ms: 0 }); if self.blocks.len() > MAX_BLOCKS { self.blocks.remove(0); @@ -36,7 +36,7 @@ impl State { } pub fn evict_blocks(&mut self) { - let base = self.scrollback.grid.abs_base(); + let base = self.scrollback.vt.first_line(); let keep = self.blocks.iter().position(|b| b.start_abs >= base).unwrap_or(self.blocks.len()); if keep > 1 { diff --git a/userland/capsule_terminal/src/term/dimensions.rs b/userland/capsule_terminal/src/term/dimensions.rs index 831a21765..bd009fab1 100644 --- a/userland/capsule_terminal/src/term/dimensions.rs +++ b/userland/capsule_terminal/src/term/dimensions.rs @@ -15,7 +15,8 @@ // along with this program. If not, see . pub const COLS: usize = 96; -pub const SCROLLBACK_ROWS: usize = 256; +/// History lines each tab keeps. +pub const SCROLLBACK_ROWS: usize = 3000; pub const VISIBLE_ROWS: usize = 40; pub const HISTORY_DEPTH: usize = 32; pub const MIN_FONT_SCALE: u32 = 1; diff --git a/userland/capsule_terminal/src/term/grid/alt.rs b/userland/capsule_terminal/src/term/grid/alt.rs deleted file mode 100644 index bf7a16bdf..000000000 --- a/userland/capsule_terminal/src/term/grid/alt.rs +++ /dev/null @@ -1,42 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use crate::term::dimensions::VISIBLE_ROWS; -use crate::term::grid::types::Grid; - -impl Grid { - pub fn enter_alt(&mut self, clear: bool) { - if !self.alternate { - core::mem::swap(&mut self.cells, &mut self.alt); - self.alternate = true; - } - // A fresh screen starts with a full-screen scroll region. - self.scroll_top = 0; - self.scroll_bot = VISIBLE_ROWS - 1; - if clear { - self.clear(); - } - } - - pub fn leave_alt(&mut self) { - if self.alternate { - core::mem::swap(&mut self.cells, &mut self.alt); - self.alternate = false; - } - self.scroll_top = 0; - self.scroll_bot = VISIBLE_ROWS - 1; - } -} diff --git a/userland/capsule_terminal/src/term/grid/erase.rs b/userland/capsule_terminal/src/term/grid/erase.rs deleted file mode 100644 index 4bf7713d3..000000000 --- a/userland/capsule_terminal/src/term/grid/erase.rs +++ /dev/null @@ -1,69 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use crate::term::dimensions::{COLS, VISIBLE_ROWS}; -use crate::term::grid::types::Grid; - -impl Grid { - pub fn erase_line(&mut self, mode: u8) { - let blank = self.blank_cell(); - let (start, end) = match mode { - 0 => (self.x, COLS), - 1 => (0, self.x), - _ => (0, COLS), - }; - for x in start..end { - self.cells[Grid::idx(x, self.y)] = blank; - } - } - pub fn erase_display(&mut self, mode: u8) { - let blank = self.blank_cell(); - match mode { - 0 => { - for x in self.x..COLS { - self.cells[Grid::idx(x, self.y)] = blank; - } - for y in (self.y + 1)..VISIBLE_ROWS { - for x in 0..COLS { - self.cells[Grid::idx(x, y)] = blank; - } - } - } - 1 => { - for y in 0..self.y { - for x in 0..COLS { - self.cells[Grid::idx(x, y)] = blank; - } - } - for x in 0..self.x { - self.cells[Grid::idx(x, self.y)] = blank; - } - } - _ => { - self.clear(); - } - } - } - pub fn clear(&mut self) { - let blank = self.blank_cell(); - for i in 0..self.cells.len() { - self.cells[i] = blank; - } - self.x = 0; - self.y = 0; - self.view_offset = 0; - } -} diff --git a/userland/capsule_terminal/src/term/grid/move_cells.rs b/userland/capsule_terminal/src/term/grid/move_cells.rs deleted file mode 100644 index 5da67135c..000000000 --- a/userland/capsule_terminal/src/term/grid/move_cells.rs +++ /dev/null @@ -1,46 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use crate::term::dimensions::{COLS, VISIBLE_ROWS}; -use crate::term::grid::types::Grid; - -impl Grid { - pub fn move_cells( - &mut self, - from_x: usize, - from_y: usize, - to_x: usize, - to_y: usize, - w: usize, - h: usize, - ) { - for ry in 0..h { - let fy = from_y + ry; - let ty = to_y + ry; - if fy >= VISIBLE_ROWS || ty >= VISIBLE_ROWS { - continue; - } - let fw = w.min(COLS.saturating_sub(from_x)); - let tw = fw.min(COLS.saturating_sub(to_x)); - if tw == 0 { - continue; - } - let src = Grid::idx(from_x, fy); - let dst = Grid::idx(to_x, ty); - self.cells.copy_within(src..src + tw, dst); - } - } -} diff --git a/userland/capsule_terminal/src/term/grid/new.rs b/userland/capsule_terminal/src/term/grid/new.rs deleted file mode 100644 index 32114f7b4..000000000 --- a/userland/capsule_terminal/src/term/grid/new.rs +++ /dev/null @@ -1,49 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use alloc::vec; - -use crate::term::dimensions::{COLS, SCROLLBACK_ROWS, VISIBLE_ROWS}; -use crate::term::grid::cell::Cell; -use crate::term::grid::types::Grid; -use crate::term::vt::color::{DEFAULT_BG, DEFAULT_FG}; -use crate::term::vt::parser::Parser; -use crate::term::vt::utf8::Utf8; - -impl Grid { - pub fn new() -> Grid { - Grid { - cells: vec![Cell::blank(); COLS * VISIBLE_ROWS], - alt: vec![Cell::blank(); COLS * VISIBLE_ROWS], - history: vec![Cell::blank(); COLS * SCROLLBACK_ROWS], - hist_head: 0, - hist_count: 0, - view_offset: 0, - alternate: false, - cursor_visible: true, - x: 0, - y: 0, - fg: DEFAULT_FG, - bg: DEFAULT_BG, - flags: 0, - parser: Parser::new(), - utf8: Utf8::default(), - total_scrolled: 0, - scroll_top: 0, - scroll_bot: VISIBLE_ROWS - 1, - } - } -} diff --git a/userland/capsule_terminal/src/term/grid/put.rs b/userland/capsule_terminal/src/term/grid/put.rs deleted file mode 100644 index 442df9c31..000000000 --- a/userland/capsule_terminal/src/term/grid/put.rs +++ /dev/null @@ -1,62 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use crate::term::dimensions::{COLS, VISIBLE_ROWS}; -use crate::term::grid::cell::{Cell, F_WIDE_TAIL}; -use crate::term::grid::types::Grid; -use crate::term::grid::width::char_width; - -impl Grid { - pub fn blank_cell(&self) -> Cell { - Cell { ch: ' ', fg: self.fg, bg: self.bg, flags: 0 } - } - pub fn put_char(&mut self, c: char) { - let w = char_width(c); - // A character drawn two columns wide cannot straddle the edge, so it - // wraps whole rather than being split across two lines. - if self.x + w > COLS { - self.x = 0; - self.line_feed(); - } - let i = Grid::idx(self.x, self.y); - self.cells[i] = Cell { ch: c, fg: self.fg, bg: self.bg, flags: self.flags }; - if w == 2 { - // The right half holds no glyph. It is still a cell, so that - // erasing, scrolling and background fills treat the pair as the - // two columns it occupies. - let tail = Grid::idx(self.x + 1, self.y); - self.cells[tail] = - Cell { ch: ' ', fg: self.fg, bg: self.bg, flags: self.flags | F_WIDE_TAIL }; - } - self.x += w; - if self.x >= COLS { - self.x = 0; - self.line_feed(); - } - } - pub fn line_feed(&mut self) { - if self.y == self.scroll_bot { - // At the foot of the scroll region the window shifts and the cursor - // stays put; elsewhere it just steps down within the screen. - self.scroll_region_up(); - } else if self.y + 1 < VISIBLE_ROWS { - self.y += 1; - } - } - pub fn carriage_return(&mut self) { - self.x = 0; - } -} diff --git a/userland/capsule_terminal/src/term/grid/scroll.rs b/userland/capsule_terminal/src/term/grid/scroll.rs deleted file mode 100644 index 06d97b4a6..000000000 --- a/userland/capsule_terminal/src/term/grid/scroll.rs +++ /dev/null @@ -1,39 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use crate::term::dimensions::{COLS, SCROLLBACK_ROWS, VISIBLE_ROWS}; -use crate::term::grid::types::Grid; - -impl Grid { - pub fn scroll_up_one(&mut self) { - let slot = (self.hist_head + self.hist_count) % SCROLLBACK_ROWS; - let dst = slot * COLS; - for x in 0..COLS { - self.history[dst + x] = self.cells[Grid::idx(x, 0)]; - } - if self.hist_count < SCROLLBACK_ROWS { - self.hist_count += 1; - } else { - self.hist_head = (self.hist_head + 1) % SCROLLBACK_ROWS; - } - self.total_scrolled += 1; - self.cells.copy_within(COLS..VISIBLE_ROWS * COLS, 0); - let blank = self.blank_cell(); - for x in 0..COLS { - self.cells[Grid::idx(x, VISIBLE_ROWS - 1)] = blank; - } - } -} diff --git a/userland/capsule_terminal/src/term/grid/scroll_region.rs b/userland/capsule_terminal/src/term/grid/scroll_region.rs deleted file mode 100644 index 8859af5b3..000000000 --- a/userland/capsule_terminal/src/term/grid/scroll_region.rs +++ /dev/null @@ -1,57 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use crate::term::dimensions::{COLS, VISIBLE_ROWS}; -use crate::term::grid::types::Grid; - -impl Grid { - /// DECSTBM: set the vertical scrolling region to the inclusive 0-based rows - /// `[top, bot]`. An inverted or out-of-range window resets to the full - /// screen, matching xterm. Per the spec the cursor homes to the top-left of - /// the new region afterwards. - pub fn set_scroll_region(&mut self, top: usize, bot: usize) { - if top < bot && bot < VISIBLE_ROWS { - self.scroll_top = top; - self.scroll_bot = bot; - } else { - self.scroll_top = 0; - self.scroll_bot = VISIBLE_ROWS - 1; - } - self.x = 0; - self.y = self.scroll_top; - } - - /// Scroll the active region up by one line. A full-screen region preserves - /// its top line into scrollback (the normal terminal history path); a - /// partial region simply drops its top line, so a pinned status bar outside - /// the window is left untouched. - pub fn scroll_region_up(&mut self) { - if self.scroll_top == 0 && self.scroll_bot == VISIBLE_ROWS - 1 { - self.scroll_up_one(); - return; - } - let (top, bot) = (self.scroll_top, self.scroll_bot); - for y in top..bot { - let dst = y * COLS; - let src = (y + 1) * COLS; - self.cells.copy_within(src..src + COLS, dst); - } - let blank = self.blank_cell(); - for x in 0..COLS { - self.cells[Grid::idx(x, bot)] = blank; - } - } -} diff --git a/userland/capsule_terminal/src/term/grid/types.rs b/userland/capsule_terminal/src/term/grid/types.rs deleted file mode 100644 index a535c2571..000000000 --- a/userland/capsule_terminal/src/term/grid/types.rs +++ /dev/null @@ -1,54 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use alloc::vec::Vec; - -use crate::term::dimensions::COLS; -use crate::term::grid::cell::Cell; -use crate::term::vt::parser::Parser; -use crate::term::vt::utf8::Utf8; - -pub struct Grid { - pub cells: Vec, - pub alt: Vec, - pub history: Vec, - pub hist_head: usize, - pub hist_count: usize, - pub view_offset: usize, - pub alternate: bool, - pub cursor_visible: bool, - pub x: usize, - pub y: usize, - pub fg: u32, - pub bg: u32, - pub flags: u8, - pub parser: Parser, - /// Rebuilds characters from the bytes they arrive in. It belongs to the - /// grid because a character can be split across two feeds. - pub utf8: Utf8, - pub total_scrolled: u64, - // DECSTBM vertical scroll region, inclusive 0-based rows. Defaults to the - // whole screen; full-screen TUIs set a smaller window so a status line at - // the top or bottom stays put while the body scrolls. - pub scroll_top: usize, - pub scroll_bot: usize, -} - -impl Grid { - pub fn idx(x: usize, y: usize) -> usize { - y * COLS + x - } -} diff --git a/userland/capsule_terminal/src/term/grid/view.rs b/userland/capsule_terminal/src/term/grid/view.rs deleted file mode 100644 index cf37a8557..000000000 --- a/userland/capsule_terminal/src/term/grid/view.rs +++ /dev/null @@ -1,32 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -use crate::term::dimensions::{COLS, SCROLLBACK_ROWS, VISIBLE_ROWS}; -use crate::term::grid::cell::Cell; -use crate::term::grid::types::Grid; - -impl Grid { - pub fn visible_row(&self, i: usize) -> &[Cell] { - let ci = (self.hist_count + i).saturating_sub(self.view_offset); - if ci < self.hist_count { - let slot = (self.hist_head + ci) % SCROLLBACK_ROWS; - &self.history[slot * COLS..slot * COLS + COLS] - } else { - let live = (ci - self.hist_count).min(VISIBLE_ROWS - 1); - &self.cells[live * COLS..live * COLS + COLS] - } - } -} diff --git a/userland/capsule_terminal/src/term/grid/width.rs b/userland/capsule_terminal/src/term/grid/width.rs deleted file mode 100644 index 369003239..000000000 --- a/userland/capsule_terminal/src/term/grid/width.rs +++ /dev/null @@ -1,67 +0,0 @@ -// NONOS Operating System -// Copyright (C) 2026 NONOS Contributors -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -//! How many columns a character takes. -//! -//! A grid assumes every character is one cell wide. That holds for Latin and -//! stops holding the moment anything else arrives: a CJK ideograph or an -//! emoji is drawn two columns wide, and treating it as one puts the next -//! character on top of its right half. Every column after it on that line is -//! then wrong, and so is anything that counts columns to place a cursor. - -/// Ranges drawn two columns wide. -/// -/// Taken from the East Asian Wide and Fullwidth classes, which is where the -/// double width ones live. Ranges rather than a table per codepoint: the -/// blocks are contiguous and a table would be tens of kilobytes to say the -/// same thing. -const WIDE: &[(u32, u32)] = &[ - (0x1100, 0x115F), // Hangul Jamo initial consonants - (0x2E80, 0x303E), // CJK radicals, Kangxi, CJK symbols - (0x3041, 0x33FF), // Kana, Bopomofo, Hangul compatibility, enclosed - (0x3400, 0x4DBF), // CJK unified ideographs extension A - (0x4E00, 0x9FFF), // CJK unified ideographs - (0xA000, 0xA4CF), // Yi syllables and radicals - (0xA960, 0xA97F), // Hangul Jamo extended A - (0xAC00, 0xD7A3), // Hangul syllables - (0xF900, 0xFAFF), // CJK compatibility ideographs - (0xFE10, 0xFE19), // Vertical forms - (0xFE30, 0xFE6F), // CJK compatibility forms, small form variants - (0xFF00, 0xFF60), // Fullwidth forms - (0xFFE0, 0xFFE6), // Fullwidth signs - (0x1F300, 0x1F64F), // Symbols, pictographs, emoticons - (0x1F900, 0x1F9FF), // Supplemental symbols and pictographs - (0x20000, 0x3FFFD), // CJK unified ideographs, later extensions -]; - -/// Columns `ch` occupies. -/// -/// Never zero. Combining marks are the case that would justify zero, and -/// composing them onto the character before is work this grid does not do, -/// so they are given their own cell instead: a decomposed accent shows as a -/// separate mark rather than being silently dropped. -pub fn char_width(ch: char) -> usize { - let cp = ch as u32; - for &(lo, hi) in WIDE { - if cp < lo { - break; - } - if cp <= hi { - return 2; - } - } - 1 -} diff --git a/userland/capsule_terminal/src/term/mod.rs b/userland/capsule_terminal/src/term/mod.rs index 227020050..3fe2b1cf1 100644 --- a/userland/capsule_terminal/src/term/mod.rs +++ b/userland/capsule_terminal/src/term/mod.rs @@ -20,7 +20,6 @@ pub mod context; pub mod cwd; pub mod dimensions; pub mod dur; -pub mod grid; pub mod history; pub mod identity; pub mod line; @@ -30,10 +29,10 @@ pub mod prompt; pub mod rtc; pub mod scrollback; pub mod search; +pub mod select; pub mod state; pub mod terminal; pub mod theme; pub mod util; -pub mod vt; pub use terminal::Terminal; diff --git a/userland/capsule_terminal/src/term/scrollback/clear.rs b/userland/capsule_terminal/src/term/scrollback/clear.rs index c995a26bf..df8cadfe4 100644 --- a/userland/capsule_terminal/src/term/scrollback/clear.rs +++ b/userland/capsule_terminal/src/term/scrollback/clear.rs @@ -17,7 +17,8 @@ use super::types::Scrollback; impl Scrollback { + /// Blank the screen and forget its history, cursor home. pub fn clear(&mut self) { - self.grid.clear(); + self.vt.feed(b"\x1b[0m\x1b[H\x1b[2J\x1b[3J"); } } diff --git a/userland/capsule_terminal/src/term/scrollback/feed_raw.rs b/userland/capsule_terminal/src/term/scrollback/feed_raw.rs index d5e5ec159..f930dfb06 100644 --- a/userland/capsule_terminal/src/term/scrollback/feed_raw.rs +++ b/userland/capsule_terminal/src/term/scrollback/feed_raw.rs @@ -17,7 +17,18 @@ use super::types::Scrollback; impl Scrollback { + /// Bytes a program or the shell wrote, through output processing. pub fn feed_raw(&mut self, bytes: &[u8]) { - self.grid.feed(bytes); + if !self.onlcr { + self.vt.feed(bytes); + return; + } + let mut rest = bytes; + while let Some(i) = rest.iter().position(|&b| b == b'\n') { + self.vt.feed(&rest[..i]); + self.vt.feed(b"\r\n"); + rest = &rest[i + 1..]; + } + self.vt.feed(rest); } } diff --git a/userland/capsule_terminal/src/term/scrollback/fit.rs b/userland/capsule_terminal/src/term/scrollback/fit.rs new file mode 100644 index 000000000..06d934410 --- /dev/null +++ b/userland/capsule_terminal/src/term/scrollback/fit.rs @@ -0,0 +1,45 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use nonos_vt::Palette; + +use super::types::Scrollback; +use crate::term::theme::types::Theme; + +impl Scrollback { + /// Match the screen to the window. A resize re-wraps what is there. + pub fn fit(&mut self, cols: usize, rows: usize, cell_w: u32, cell_h: u32) { + self.vt.resize(cols, rows); + self.vt.set_cell_pixels( + cell_w.min(u16::MAX as u32) as u16, + cell_h.min(u16::MAX as u32) as u16, + ); + } + + /// Program colours start from the window's theme. Only a change of + /// theme resets them, so a colour a program set survives a repaint. + pub fn follow_theme(&mut self, index: u16, t: &Theme) { + if self.theme_of == Some(index) { + return; + } + let mut p = Palette::xterm(); + p.fg = t.fg & 0x00FF_FFFF; + p.bg = t.bg & 0x00FF_FFFF; + p.cursor = t.accent & 0x00FF_FFFF; + self.vt.set_theme(p); + self.theme_of = Some(index); + } +} diff --git a/userland/capsule_terminal/src/term/scrollback/jump_bottom.rs b/userland/capsule_terminal/src/term/scrollback/jump_bottom.rs index caad121f5..3cab442b9 100644 --- a/userland/capsule_terminal/src/term/scrollback/jump_bottom.rs +++ b/userland/capsule_terminal/src/term/scrollback/jump_bottom.rs @@ -18,6 +18,6 @@ use super::types::Scrollback; impl Scrollback { pub fn jump_bottom(&mut self) { - self.grid.jump_view_bottom(); + self.vt.scroll_to_bottom(); } } diff --git a/userland/capsule_terminal/src/term/scrollback/mod.rs b/userland/capsule_terminal/src/term/scrollback/mod.rs index 0b5462932..253a804c0 100644 --- a/userland/capsule_terminal/src/term/scrollback/mod.rs +++ b/userland/capsule_terminal/src/term/scrollback/mod.rs @@ -17,8 +17,10 @@ mod capture; mod clear; mod feed_raw; +mod fit; mod jump_bottom; mod new; +mod program_ended; mod push_dir_row; mod push_error; mod push_line; diff --git a/userland/capsule_terminal/src/term/scrollback/new.rs b/userland/capsule_terminal/src/term/scrollback/new.rs index 06252a3c9..a8407102f 100644 --- a/userland/capsule_terminal/src/term/scrollback/new.rs +++ b/userland/capsule_terminal/src/term/scrollback/new.rs @@ -14,11 +14,20 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +use nonos_vt::Term; + use super::types::Scrollback; -use crate::term::grid::types::Grid; +use crate::term::dimensions::{COLS, SCROLLBACK_ROWS, VISIBLE_ROWS}; impl Scrollback { + /// The size here is only where it starts: the paint resizes it to the + /// window before anything is drawn. pub fn new() -> Self { - Self { capture: None, grid: Grid::new() } + Self { + capture: None, + vt: Term::new(COLS, VISIBLE_ROWS, SCROLLBACK_ROWS), + onlcr: true, + theme_of: None, + } } } diff --git a/userland/capsule_terminal/src/term/grid/feed.rs b/userland/capsule_terminal/src/term/scrollback/program_ended.rs similarity index 51% rename from userland/capsule_terminal/src/term/grid/feed.rs rename to userland/capsule_terminal/src/term/scrollback/program_ended.rs index 5f3d85705..813b13b7d 100644 --- a/userland/capsule_terminal/src/term/grid/feed.rs +++ b/userland/capsule_terminal/src/term/scrollback/program_ended.rs @@ -14,23 +14,22 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::term::grid::types::Grid; -use crate::term::vt::parser::Parser; -use crate::term::vt::state::VtState; +use super::types::Scrollback; -impl Grid { - pub fn feed(&mut self, bytes: &[u8]) { - let mut parser = core::mem::replace(&mut self.parser, Parser::new()); - // Both are taken out for the borrow and put back after. A character - // can be split across two feeds, so the decoder has to survive one. - let mut utf8 = core::mem::take(&mut self.utf8); - { - let mut vt = VtState { g: self, utf8: &mut utf8 }; - for &b in bytes { - parser.advance(&mut vt, b); - } +impl Scrollback { + /// A program ended. Whatever modes it left on are turned off, as `reset` + /// would: a full-screen program that crashed must not leave the shell on + /// its alternate screen, hide the cursor, or keep sending mouse reports + /// into the prompt. Output that stopped mid-line gets its line ended, so + /// the next command's output starts at the left. + pub fn program_ended(&mut self) { + self.vt.feed( + b"\x1b[?1049l\x1b[?1l\x1b[?2004l\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\ + \x1b[?1004l\x1b[?25h\x1b[0m\x1b(B", + ); + if self.vt.cursor().x > 0 { + self.vt.feed(b"\r\n"); } - self.parser = parser; - self.utf8 = utf8; + self.onlcr = true; } } diff --git a/userland/capsule_terminal/src/term/scrollback/push_dir_row.rs b/userland/capsule_terminal/src/term/scrollback/push_dir_row.rs index fc1ffced4..212f2e53f 100644 --- a/userland/capsule_terminal/src/term/scrollback/push_dir_row.rs +++ b/userland/capsule_terminal/src/term/scrollback/push_dir_row.rs @@ -30,9 +30,9 @@ impl Scrollback { self.push_line(&joined); return; } - self.grid.feed(plain); - self.grid.feed(b"\x1b[94m"); - self.grid.feed(name); - self.grid.feed(b"\x1b[0m\n"); + self.feed_raw(plain); + self.feed_raw(b"\x1b[94m"); + self.feed_raw(name); + self.feed_raw(b"\x1b[0m\n"); } } diff --git a/userland/capsule_terminal/src/term/scrollback/push_raw.rs b/userland/capsule_terminal/src/term/scrollback/push_raw.rs index ca071c378..cb364edaa 100644 --- a/userland/capsule_terminal/src/term/scrollback/push_raw.rs +++ b/userland/capsule_terminal/src/term/scrollback/push_raw.rs @@ -21,13 +21,13 @@ impl Scrollback { pub(super) fn push_raw(&mut self, line: &[u8], role: Role) { match role { Role::Error => { - self.grid.feed(b"\x1b[31m"); - self.grid.feed(line); - self.grid.feed(b"\x1b[0m\n"); + self.feed_raw(b"\x1b[31m"); + self.feed_raw(line); + self.feed_raw(b"\x1b[0m\n"); } Role::Normal => { - self.grid.feed(line); - self.grid.feed(b"\n"); + self.feed_raw(line); + self.feed_raw(b"\n"); } } } diff --git a/userland/capsule_terminal/src/term/scrollback/push_styled.rs b/userland/capsule_terminal/src/term/scrollback/push_styled.rs index be559d0f1..ab9a17cc1 100644 --- a/userland/capsule_terminal/src/term/scrollback/push_styled.rs +++ b/userland/capsule_terminal/src/term/scrollback/push_styled.rs @@ -29,7 +29,7 @@ impl Scrollback { self.push_line(plain); return; } - self.grid.feed(styled); - self.grid.feed(b"\x1b[0m\n"); + self.feed_raw(styled); + self.feed_raw(b"\x1b[0m\n"); } } diff --git a/userland/capsule_terminal/src/term/scrollback/scroll_down.rs b/userland/capsule_terminal/src/term/scrollback/scroll_down.rs index 116ed6444..45d52fc17 100644 --- a/userland/capsule_terminal/src/term/scrollback/scroll_down.rs +++ b/userland/capsule_terminal/src/term/scrollback/scroll_down.rs @@ -17,7 +17,8 @@ use super::types::Scrollback; impl Scrollback { + /// Forward toward the live screen by `lines`. pub fn scroll_down(&mut self, lines: usize) { - self.grid.scroll_view_down(lines); + self.vt.scroll_view(-(lines.min(isize::MAX as usize) as isize)); } } diff --git a/userland/capsule_terminal/src/term/scrollback/scroll_up.rs b/userland/capsule_terminal/src/term/scrollback/scroll_up.rs index b7e093d46..9a7d28a3b 100644 --- a/userland/capsule_terminal/src/term/scrollback/scroll_up.rs +++ b/userland/capsule_terminal/src/term/scrollback/scroll_up.rs @@ -17,7 +17,8 @@ use super::types::Scrollback; impl Scrollback { + /// Back into history by `lines`. pub fn scroll_up(&mut self, lines: usize) { - self.grid.scroll_view_up(lines); + self.vt.scroll_view(lines.min(isize::MAX as usize) as isize); } } diff --git a/userland/capsule_terminal/src/term/scrollback/types.rs b/userland/capsule_terminal/src/term/scrollback/types.rs index eff6bd4a6..1c157353d 100644 --- a/userland/capsule_terminal/src/term/scrollback/types.rs +++ b/userland/capsule_terminal/src/term/scrollback/types.rs @@ -16,9 +16,16 @@ use alloc::vec::Vec; -use crate::term::grid::types::Grid; +use nonos_vt::Term; pub struct Scrollback { pub(super) capture: Option>>, - pub grid: Grid, + /// The screen and its history. + pub vt: Term, + /// Output processing a tty does by default: a line feed also returns + /// the carriage, since the shell and most programs end lines with `\n` + /// alone. A program that turns output processing off gets bare feeds. + pub onlcr: bool, + /// The theme the palette was last taken from. + pub(super) theme_of: Option, } diff --git a/userland/capsule_terminal/src/term/select.rs b/userland/capsule_terminal/src/term/select.rs new file mode 100644 index 000000000..e0b362f6d --- /dev/null +++ b/userland/capsule_terminal/src/term/select.rs @@ -0,0 +1,74 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! A span of the screen and its history picked with the pointer, and the +//! match a scrollback search is on. + +use nonos_vt::Pos; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Selection { + /// Where the press landed, and where the pointer is now. + pub anchor: Pos, + pub head: Pos, + /// Alt held: a rectangle of columns rather than a run of text. + pub block: bool, +} + +impl Selection { + pub fn ordered(&self) -> (Pos, Pos) { + if self.anchor <= self.head { + (self.anchor, self.head) + } else { + (self.head, self.anchor) + } + } + + pub fn contains(&self, line: u64, col: usize) -> bool { + let (a, b) = self.ordered(); + if line < a.line || line > b.line { + return false; + } + if self.block { + let (l, r) = (a.col.min(b.col), a.col.max(b.col)); + return col >= l && col <= r; + } + let from = if line == a.line { a.col } else { 0 }; + let to = if line == b.line { b.col } else { usize::MAX }; + col >= from && col <= to + } +} + +/// A search through the screen and its history. +#[derive(Clone, Default, Debug)] +pub struct Find { + pub query: alloc::string::String, + /// The match on view, first and last cells. + pub hit: Option<(Pos, Pos)>, + /// Upper and lower case count as different. + pub case: bool, +} + +/// A span to shade: a search match. +pub fn in_span(span: Option<(Pos, Pos)>, line: u64, col: usize) -> bool { + match span { + Some((a, b)) => { + let p = Pos { line, col }; + p >= a && p <= b + } + None => false, + } +} diff --git a/userland/capsule_terminal/src/term/state/mod.rs b/userland/capsule_terminal/src/term/state/mod.rs index 9c23ee850..f457515c4 100644 --- a/userland/capsule_terminal/src/term/state/mod.rs +++ b/userland/capsule_terminal/src/term/state/mod.rs @@ -15,6 +15,7 @@ // along with this program. If not, see . mod new; +mod shade; mod types; pub use types::State; diff --git a/userland/capsule_terminal/src/term/state/new.rs b/userland/capsule_terminal/src/term/state/new.rs index 110db7207..9dfadad74 100644 --- a/userland/capsule_terminal/src/term/state/new.rs +++ b/userland/capsule_terminal/src/term/state/new.rs @@ -44,6 +44,9 @@ impl State { jobs: JobTable::new(), fg_running: false, fg_started_ms: 0, + sel: None, + find: None, + cooked: crate::event::cooked::Cooked::default(), } } } diff --git a/userland/capsule_terminal/src/term/grid/absline.rs b/userland/capsule_terminal/src/term/state/shade.rs similarity index 67% rename from userland/capsule_terminal/src/term/grid/absline.rs rename to userland/capsule_terminal/src/term/state/shade.rs index c89adb1e8..a5f66c814 100644 --- a/userland/capsule_terminal/src/term/grid/absline.rs +++ b/userland/capsule_terminal/src/term/state/shade.rs @@ -14,18 +14,12 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::term::grid::types::Grid; +use super::types::State; +use crate::paint::Shade; -impl Grid { - pub fn current_abs_line(&self) -> u64 { - self.total_scrolled + self.y as u64 - } - - pub fn abs_base(&self) -> u64 { - self.total_scrolled - self.hist_count as u64 - } - - pub fn abs_of_visible_row(&self, row: usize) -> u64 { - self.total_scrolled + row as u64 - self.view_offset as u64 +impl State { + /// What the body shades over its text: the selection and the search hit. + pub fn shade(&self) -> Shade { + Shade { selection: self.sel, found: self.find.as_ref().and_then(|f| f.hit) } } } diff --git a/userland/capsule_terminal/src/term/state/types.rs b/userland/capsule_terminal/src/term/state/types.rs index cad5fdd14..27557c831 100644 --- a/userland/capsule_terminal/src/term/state/types.rs +++ b/userland/capsule_terminal/src/term/state/types.rs @@ -59,4 +59,10 @@ pub struct State { // close_block and last_status to the job's reap in the on_tick pump. pub fg_running: bool, pub fg_started_ms: i64, + /// Text picked with the pointer, if any. + pub sel: Option, + /// The scrollback search: what is typed, and the match it is on. + pub find: Option, + /// The line editing a foreground program gets while it reads lines. + pub cooked: crate::event::cooked::Cooked, } diff --git a/userland/capsule_terminal/src/term/terminal/app_impl_on_event.rs b/userland/capsule_terminal/src/term/terminal/app_impl_on_event.rs index 88bb96190..5b133987d 100644 --- a/userland/capsule_terminal/src/term/terminal/app_impl_on_event.rs +++ b/userland/capsule_terminal/src/term/terminal/app_impl_on_event.rs @@ -34,6 +34,9 @@ impl Terminal { if let Some(outcome) = self.rail_click(event) { return outcome; } + if let Some(outcome) = self.body_pointer(event) { + return outcome; + } let outcome = on_event(self.cur(), event); self.drain_chrome_req(); outcome diff --git a/userland/capsule_terminal/src/term/terminal/app_impl_paint.rs b/userland/capsule_terminal/src/term/terminal/app_impl_paint.rs index d3eecfe18..7531269e6 100644 --- a/userland/capsule_terminal/src/term/terminal/app_impl_paint.rs +++ b/userland/capsule_terminal/src/term/terminal/app_impl_paint.rs @@ -29,6 +29,12 @@ impl Terminal { } self.width = fb.width; let theme = crate::term::theme::profiles::by_index(self.theme); + let rail_open = self.prefs.rails & RAIL_VISIBLE != 0; + let (cols, rows, m) = crate::paint::grid_size(fb, self.font_scale, rail_open); + let theme_ix = self.theme; + let sb = &mut self.cur().scrollback; + sb.fit(cols, rows, m.adv, m.lh); + sb.follow_theme(theme_ix, theme); let l = crate::paint::paint_tabs( &self.tabs, self.active, @@ -42,6 +48,16 @@ impl Terminal { &self.palette, self.prefs.rails & RAIL_VISIBLE != 0, ); + let s = self.cur_ref(); + let owned = s.scrollback.vt.alt_active() || s.fg_running; + self.cells = Some(super::pointer::CellGeom { + x: l.body.x + crate::paint::TEXT_LEFT, + y: l.body.y, + adv: m.adv, + lh: m.lh, + shell_rows: (l.body.h / m.lh.max(1)) as usize, + owned, + }); self.layout = Some(l); } } diff --git a/userland/capsule_terminal/src/term/terminal/mod.rs b/userland/capsule_terminal/src/term/terminal/mod.rs index 8769ffdb5..0473afdc7 100644 --- a/userland/capsule_terminal/src/term/terminal/mod.rs +++ b/userland/capsule_terminal/src/term/terminal/mod.rs @@ -25,6 +25,7 @@ mod new; mod palette_act; mod palette_key; mod palette_pick; +mod pointer; mod rail_click; mod rail_wheel; #[cfg(feature = "nonos-autorun-selftest")] diff --git a/userland/capsule_terminal/src/term/terminal/new.rs b/userland/capsule_terminal/src/term/terminal/new.rs index ba19cde5e..4f1c1007c 100644 --- a/userland/capsule_terminal/src/term/terminal/new.rs +++ b/userland/capsule_terminal/src/term/terminal/new.rs @@ -41,6 +41,8 @@ impl Terminal { rail_scroll: 0, layout: None, palette: Palette::new(), + cells: None, + ptr: Default::default(), } } } diff --git a/userland/capsule_terminal/src/term/terminal/pointer/cells.rs b/userland/capsule_terminal/src/term/terminal/pointer/cells.rs new file mode 100644 index 000000000..73338a349 --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/cells.rs @@ -0,0 +1,71 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Where the body's cells were drawn in the last paint, so a pointer event +//! can be read back as a cell and a line. + +use nonos_vt::{Pos, Term}; + +use crate::paint::Rows; + +#[derive(Clone, Copy)] +pub struct CellGeom { + pub x: u32, + pub y: u32, + pub adv: u32, + pub lh: u32, + /// Rows of the body a shell drawing uses; the screen owns all rows. + pub shell_rows: usize, + pub owned: bool, +} + +impl CellGeom { + pub fn rows(&self, vt: &Term) -> Rows { + if self.owned { + Rows::Screen + } else { + Rows::Shell { rows: self.shell_rows, back: vt.view_offset() } + } + } + + /// The cell under `(px, py)`, clamped to the screen, and the row it is. + pub fn cell_at(&self, vt: &Term, px: i32, py: i32) -> (usize, usize) { + let col = ((px - self.x as i32).max(0) as u32 / self.adv.max(1)) as usize; + let row = ((py - self.y as i32).max(0) as u32 / self.lh.max(1)) as usize; + let max_row = if self.owned { vt.rows() } else { self.shell_rows }; + (col.min(vt.cols() - 1), row.min(max_row.saturating_sub(1))) + } + + pub fn pos_at(&self, vt: &Term, px: i32, py: i32) -> Pos { + let (col, row) = self.cell_at(vt, px, py); + let rows = self.rows(vt); + let line = match rows { + Rows::Screen => vt.abs_of_row(row), + Rows::Shell { .. } => rows.first(vt) + row as u64, + }; + Pos { line, col } + } + + pub fn inside(&self, vt: &Term, px: i32, py: i32) -> bool { + let w = vt.cols() as u32 * self.adv; + let rows = if self.owned { vt.rows() } else { self.shell_rows }; + let h = rows as u32 * self.lh; + px >= self.x as i32 + && py >= self.y as i32 + && px < (self.x + w) as i32 + && py < (self.y + h) as i32 + } +} diff --git a/userland/capsule_terminal/src/term/grid/mod.rs b/userland/capsule_terminal/src/term/terminal/pointer/mod.rs similarity index 73% rename from userland/capsule_terminal/src/term/grid/mod.rs rename to userland/capsule_terminal/src/term/terminal/pointer/mod.rs index fb9ef8f21..6cacbb244 100644 --- a/userland/capsule_terminal/src/term/grid/mod.rs +++ b/userland/capsule_terminal/src/term/terminal/pointer/mod.rs @@ -14,17 +14,15 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -pub mod absline; -pub mod alt; -pub mod cell; -pub mod erase; -pub mod feed; -pub mod move_cells; -pub mod new; -pub mod put; -pub mod scroll; -pub mod scroll_region; -pub mod scroll_view; -pub mod types; -pub mod view; -pub mod width; +//! The pointer over the body: selecting text, and for a program that asked +//! for them, mouse reports. Shift held selects even then, as xterm does. + +mod cells; +mod press; +mod report; +mod select; +mod state; +mod wheel; + +pub use cells::CellGeom; +pub use state::Pointer; diff --git a/userland/capsule_terminal/src/term/terminal/pointer/press.rs b/userland/capsule_terminal/src/term/terminal/pointer/press.rs new file mode 100644 index 000000000..82dd34123 --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/press.rs @@ -0,0 +1,57 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Choosing text: a press starts, a drag extends, a double click takes a +//! word and a triple click a line, and Alt makes it a block. + +use crate::term::select::Selection; +use crate::term::terminal::Terminal; +use nonos_app_skeleton::{InputEvent, MOD_ALT}; +use nonos_vt::Pos; + +/// Presses closer together than this count as a double or triple click. +const MULTI_CLICK_NS: u64 = 400_000_000; + +impl Terminal { + pub(super) fn press(&mut self, event: &InputEvent, pos: Pos) { + let p = &mut self.ptr; + let again = + event.timestamp_ns.saturating_sub(p.last_ns) < MULTI_CLICK_NS && p.last_pos == pos; + p.clicks = if again { p.clicks % 3 + 1 } else { 1 }; + (p.last_ns, p.last_pos, p.start) = (event.timestamp_ns, pos, pos); + p.block = event.flags & MOD_ALT != 0; + p.selecting = true; + let clicks = p.clicks; + let state = self.cur(); + let vt = &state.scrollback.vt; + let span = match clicks { + 2 => Some(vt.word_at(pos)), + 3 => Some(vt.line_bounds(pos)), + _ => None, + }; + state.sel = span.map(|(a, b)| Selection { anchor: a, head: b, block: false }); + } + + pub(super) fn drag(&mut self, pos: Pos) { + let (start, block, clicks) = (self.ptr.start, self.ptr.block, self.ptr.clicks); + let state = self.cur(); + if pos == start && state.sel.is_none() { + return; + } + let anchor = state.sel.map(|x| x.anchor).filter(|_| clicks > 1).unwrap_or(start); + state.sel = Some(Selection { anchor, head: pos, block }); + } +} diff --git a/userland/capsule_terminal/src/term/terminal/pointer/report.rs b/userland/capsule_terminal/src/term/terminal/pointer/report.rs new file mode 100644 index 000000000..7fff56af4 --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/report.rs @@ -0,0 +1,63 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Pointer events for a foreground program that asked for mouse reports. + +use nonos_app_skeleton::{InputEvent, InputKind, MOD_SHIFT}; +use nonos_vt::input::{encode_mouse, Button, MouseEvent, MouseKind}; +use nonos_vt::MouseMode; + +use super::select::{BTN_LEFT, BTN_RIGHT}; +use crate::event::keymap::mods; +use crate::event::send_to_program; +use crate::term::terminal::Terminal; + +impl Terminal { + /// Hand the event to the program when it asked for mouse reports and + /// Shift is not held. True when the program took it. + pub(super) fn program_mouse(&mut self, event: &InputEvent) -> bool { + let Some(g) = self.cells else { return false }; + let state = self.cur_ref(); + let vt = &state.scrollback.vt; + if !state.fg_running || vt.modes.mouse == MouseMode::Off || event.flags & MOD_SHIFT != 0 { + return false; + } + let (col, row) = g.cell_at(vt, event.x, event.y); + let (kind, button) = match event.kind { + InputKind::ButtonDown | InputKind::ButtonUp => { + let b = match event.code { + BTN_LEFT => Button::Left, + BTN_RIGHT => Button::Right, + _ => Button::Middle, + }; + let down = event.kind == InputKind::ButtonDown; + self.ptr.held = down.then_some(b); + (if down { MouseKind::Press } else { MouseKind::Release }, b) + } + InputKind::PointerAbs => (MouseKind::Motion, self.ptr.held.unwrap_or(Button::None)), + InputKind::Wheel if event.delta_y > 0 => (MouseKind::Press, Button::WheelUp), + InputKind::Wheel => (MouseKind::Press, Button::WheelDown), + _ => return false, + }; + let ev = MouseEvent { kind, button, col, row, mods: mods(event.flags) }; + let mut bytes = alloc::vec::Vec::new(); + let state = self.cur(); + if encode_mouse(&ev, &state.scrollback.vt.modes, &mut bytes) { + send_to_program(state, &bytes); + } + true + } +} diff --git a/userland/capsule_terminal/src/term/terminal/pointer/select.rs b/userland/capsule_terminal/src/term/terminal/pointer/select.rs new file mode 100644 index 000000000..838e12db5 --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/select.rs @@ -0,0 +1,52 @@ +// NONOS Operating System +// Copyright (C) 2026 NONOS Contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +//! Routing pointer events over the body: to the program, the wheel, or +//! choosing text. + +use nonos_app_skeleton::{EventOutcome, InputEvent, InputKind}; + +use crate::term::terminal::Terminal; + +pub(super) const BTN_LEFT: u32 = 1; +pub(super) const BTN_RIGHT: u32 = 2; + +impl Terminal { + pub(crate) fn body_pointer(&mut self, event: InputEvent) -> Option { + let g = self.cells?; + let kinds = + [InputKind::ButtonDown, InputKind::ButtonUp, InputKind::PointerAbs, InputKind::Wheel]; + if !kinds.contains(&event.kind) { + return None; + } + let inside = g.inside(&self.cur_ref().scrollback.vt, event.x, event.y); + if !inside && !self.ptr.selecting { + return None; + } + if self.program_mouse(&event) { + return Some(EventOutcome::Idle); + } + let pos = g.pos_at(&self.cur_ref().scrollback.vt, event.x, event.y); + match event.kind { + InputKind::Wheel => return Some(self.wheel(event)), + InputKind::ButtonDown if event.code == BTN_LEFT => self.press(&event, pos), + InputKind::PointerAbs if self.ptr.selecting => self.drag(pos), + InputKind::ButtonUp if event.code == BTN_LEFT => self.ptr.selecting = false, + _ => return Some(EventOutcome::Idle), + } + Some(EventOutcome::Repaint) + } +} diff --git a/userland/capsule_terminal/src/term/grid/scroll_view.rs b/userland/capsule_terminal/src/term/terminal/pointer/state.rs similarity index 60% rename from userland/capsule_terminal/src/term/grid/scroll_view.rs rename to userland/capsule_terminal/src/term/terminal/pointer/state.rs index 1657a8d9c..95f8dd226 100644 --- a/userland/capsule_terminal/src/term/grid/scroll_view.rs +++ b/userland/capsule_terminal/src/term/terminal/pointer/state.rs @@ -14,16 +14,21 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -use crate::term::grid::types::Grid; +//! What the pointer is in the middle of. -impl Grid { - pub fn scroll_view_up(&mut self, lines: usize) { - self.view_offset = (self.view_offset + lines).min(self.hist_count); - } - pub fn scroll_view_down(&mut self, lines: usize) { - self.view_offset = self.view_offset.saturating_sub(lines); - } - pub fn jump_view_bottom(&mut self) { - self.view_offset = 0; - } +use nonos_vt::input::Button; +use nonos_vt::Pos; + +#[derive(Default)] +pub struct Pointer { + /// A left-button drag is choosing text: from where, and as a block. + pub selecting: bool, + pub start: Pos, + pub block: bool, + /// Counting presses in one place for double and triple clicks. + pub clicks: u8, + pub last_ns: u64, + pub last_pos: Pos, + /// The button held, for motion reports to a program. + pub held: Option