diff --git a/abi/syscalls.toml b/abi/syscalls.toml index 210a70b848..b84d3fca67 100644 --- a/abi/syscalls.toml +++ b/abi/syscalls.toml @@ -41,6 +41,7 @@ ENODEV = -19 ENOTDIR = -20 EISDIR = -21 EINVAL = -22 +ENOTTY = -25 ETXTBSY = -26 ERANGE = -34 ENOSYS = -38 @@ -118,6 +119,8 @@ MTMS = 0x534D544D MTRN = 0x4E52544D MTRT = 0x5452544D MTSP = 0x5053544D +MTTQ = 0x5154544D +MTTY = 0x5954544D MWAT = 0x5441574D CRND = 0x444E5243 CHSH = 0x48534843 @@ -662,7 +665,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] @@ -803,6 +806,18 @@ caps = ["IPC"] args = [{name="entry",type="u64",dir="in"},{name="stack",type="u64",dir="in"}] ret = {type="i64"} +[desc.MTTQ] +nr = 0x5154544D +caps = ["valid_token"] +args = [{name="fd",type="u64",dir="in"}] +ret = {type="i64"} + +[desc.MTTY] +nr = 0x5954544D +caps = ["IPC"] +args = [{name="pid",type="u64",dir="in"},{name="streams",type="u64",dir="in"},{name="cols",type="u64",dir="in"},{name="rows",type="u64",dir="in"}] +ret = {type="i64"} + [desc.MWAT] nr = 0x5441574D caps = ["IPC"] diff --git a/mk/20-build.mk b/mk/20-build.mk index 893340e796..288ac0eb19 100644 --- a/mk/20-build.mk +++ b/mk/20-build.mk @@ -327,6 +327,11 @@ $(NONOS_STD_PAL_STAMP): $(NONOS_STD_PAL_SRCS) | $(TARGET_DIR)/.nonos-toolchain.s @echo "Applying NONOS std platform layer to rust-src..." @PATH="$(HOME)/.cargo/bin:$$PATH" RUSTUP_TOOLCHAIN=$(TOOLCHAIN) \ toolchain/nonos-std/apply.sh + @# -Zbuild-std fingerprints the sysroot crates by version, not by their + @# sources, so a target dir that built std before the layer changed keeps + @# linking the old std and the tool ships without the new behaviour. The + @# layer just changed, so every such cache is stale: drop them. + @rm -rf userland/upstream-src/*/target userland/capsule_std_proof/target @mkdir -p $(TARGET_DIR) @touch $@ @@ -1187,10 +1192,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 diff --git a/src/memory/paging/manager/api/address_space.rs b/src/memory/paging/manager/api/address_space.rs index 7d0950a489..99be15ac61 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 61b0321857..7eea355707 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 2ae992d8ed..20ad4e12bc 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) } diff --git a/src/process/exit/finalize.rs b/src/process/exit/finalize.rs index 6fdcd9e1bb..200f4681b3 100644 --- a/src/process/exit/finalize.rs +++ b/src/process/exit/finalize.rs @@ -35,6 +35,7 @@ pub(super) fn finalize_teardown(pid: Pid) { let _ = crate::ipc::nonos_inbox::unregister_for_pid(pid); } + crate::syscall::microkernel::tty_table::forget(pid); crate::process::clear_interrupt_context(pid); crate::process::clear_fpu_state(pid); crate::process::core::init::reparent_orphans(pid); diff --git a/src/process/foreign/thread.rs b/src/process/foreign/thread.rs index fd3d230a74..f46ea39a5d 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/abi/registry/mk.rs b/src/syscall/abi/registry/mk.rs index 1433658157..e4e0f99950 100644 --- a/src/syscall/abi/registry/mk.rs +++ b/src/syscall/abi/registry/mk.rs @@ -114,6 +114,8 @@ pub(super) const ENTRIES: &[AbiEntry] = &[ e(b"MAPL", SyscallNumber::MkAppLaunch, "MkAppLaunch"), e(b"MAIS", SyscallNumber::MkAppInstallStatus, "MkAppInstallStatus"), e(b"MTRN", SyscallNumber::MkToolRun, "MkToolRun"), + e(b"MTTY", SyscallNumber::MkTtySet, "MkTtySet"), + e(b"MTTQ", SyscallNumber::MkTtyQuery, "MkTtyQuery"), e(b"MSOW", SyscallNumber::MkStdoutWrite, "MkStdoutWrite"), e(b"MSWR", SyscallNumber::MkStoreWrite, "MkStoreWrite"), e(b"MCVF", SyscallNumber::MkCapsuleVerify, "MkCapsuleVerify"), diff --git a/src/syscall/contract/cap_table/mk.rs b/src/syscall/contract/cap_table/mk.rs index fbbb0d2826..11a51e1764 100644 --- a/src/syscall/contract/cap_table/mk.rs +++ b/src/syscall/contract/cap_table/mk.rs @@ -199,6 +199,13 @@ pub(super) fn check(caps: &CapabilityToken, number: SyscallNumber) -> Option caps.can_ipc(), + /* + * Saying what a child's streams are on is part of driving its stdio, + * so it needs what running the child needed. Asking about one's own + * streams reveals nothing about anyone else. + */ + SyscallNumber::MkTtySet => caps.can_ipc(), + SyscallNumber::MkTtyQuery => true, _ => return None, }) diff --git a/src/syscall/dispatch/router/microkernel_ops.rs b/src/syscall/dispatch/router/microkernel_ops.rs index c158454b6b..9b97a9cdfc 100644 --- a/src/syscall/dispatch/router/microkernel_ops.rs +++ b/src/syscall/dispatch/router/microkernel_ops.rs @@ -106,6 +106,8 @@ pub(super) fn matches(nr: SyscallNumber) -> bool { | MkAppLaunch | MkAppInstallStatus | MkToolRun + | MkTtySet + | MkTtyQuery ) } diff --git a/src/syscall/microkernel/dispatch/process.rs b/src/syscall/microkernel/dispatch/process.rs index 18e11d28c1..bea6847f6f 100644 --- a/src/syscall/microkernel/dispatch/process.rs +++ b/src/syscall/microkernel/dispatch/process.rs @@ -52,6 +52,7 @@ use crate::syscall::microkernel::time::{ sys_time_adjust, sys_time_millis, sys_time_monotonic, sys_time_rtc, }; use crate::syscall::microkernel::tool_run::sys_tool_run; +use crate::syscall::microkernel::tty::{sys_tty_query, sys_tty_set}; use crate::syscall::microkernel::wait::sys_wait; pub(super) fn handle(nr: u64, a: Args) -> Option { @@ -95,7 +96,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), @@ -112,6 +113,8 @@ pub(super) fn handle(nr: u64, a: Args) -> Option { SYS_DEV_ROOT_CONFIRM => sys_dev_root_confirm(a.a0), SYS_SPAWN_INSTANCE => sys_spawn_instance(a.a0, a.a1), SYS_TOOL_RUN => sys_tool_run(a.a0, a.a1, a.a2, a.a3), + SYS_TTY_SET => sys_tty_set(a.a0, a.a1, a.a2, a.a3), + SYS_TTY_QUERY => sys_tty_query(a.a0), _ => return None, }) } diff --git a/src/syscall/microkernel/errnos.rs b/src/syscall/microkernel/errnos.rs index fdd743208a..88f82abf7d 100644 --- a/src/syscall/microkernel/errnos.rs +++ b/src/syscall/microkernel/errnos.rs @@ -29,6 +29,7 @@ pub const ERRNO_BUSY: i64 = -16; pub const ERRNO_EXIST: i64 = -17; pub const ERRNO_NODEV: i64 = -19; pub const ERRNO_INVAL: i64 = -22; +pub const ERRNO_NOTTY: i64 = -25; pub const ERRNO_NOSYS: i64 = -38; pub const ERRNO_NOTSUP: i64 = -95; pub const ERRNO_TIMEDOUT: i64 = -110; diff --git a/src/syscall/microkernel/kill.rs b/src/syscall/microkernel/kill.rs index 5274cf1f23..8ab3ac8bc6 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/src/syscall/microkernel/mod.rs b/src/syscall/microkernel/mod.rs index fe29cae341..9b2e9c77c6 100644 --- a/src/syscall/microkernel/mod.rs +++ b/src/syscall/microkernel/mod.rs @@ -66,6 +66,8 @@ pub mod stdout_write; pub mod store_write; pub mod time; pub mod tool_run; +pub mod tty; +pub mod tty_table; pub mod wait; pub use attest::sys_attest_status; diff --git a/src/syscall/microkernel/numbers.rs b/src/syscall/microkernel/numbers.rs index 630aef93c3..4a60fe30fa 100644 --- a/src/syscall/microkernel/numbers.rs +++ b/src/syscall/microkernel/numbers.rs @@ -144,3 +144,5 @@ pub const SYS_SPAWN_INSTANCE: u64 = tag4(b"MSPI"); // Run a baked, attested command-line tool by name, parented to the caller so // it can drive the tool's stdin and stdout. Gated on the IPC capability. pub const SYS_TOOL_RUN: u64 = tag4(b"MTRN"); +pub const SYS_TTY_SET: u64 = tag4(b"MTTY"); +pub const SYS_TTY_QUERY: u64 = tag4(b"MTTQ"); diff --git a/src/syscall/microkernel/tty.rs b/src/syscall/microkernel/tty.rs new file mode 100644 index 0000000000..f256a4f9f8 --- /dev/null +++ b/src/syscall/microkernel/tty.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 . + +//! `MkTtySet` and `MkTtyQuery`: how a program learns whether it is writing +//! to a person or to a pipe. A launcher that renders a child's output +//! knows which of the child's streams reach its screen; the child asks +//! before choosing colour, columns and a pager. + +use super::errnos::{ERRNO_INVAL, ERRNO_NOTTY, ERRNO_PERM}; +use super::tty_table::{self, Tty, STREAMS_ALL}; +use crate::process::{current_pid, get_parent_pid}; + +/// `MkTtySet(pid, streams, cols, rows)`. Only the parent of `pid` may say +/// what its streams are on, the same rule that lets it drain them; anyone +/// else would be telling a stranger's program it has a screen. +pub fn sys_tty_set(pid: u64, streams: u64, cols: u64, rows: u64) -> i64 { + let fits = |v: u64| v <= u16::MAX as u64; + if pid == 0 || pid > u32::MAX as u64 || streams > STREAMS_ALL as u64 || !fits(cols) || !fits(rows) + { + return ERRNO_INVAL; + } + let caller = current_pid().unwrap_or(0); + if caller == 0 || get_parent_pid(pid as u32) != Some(caller) { + return ERRNO_PERM; + } + tty_table::set(pid as u32, Tty { streams: streams as u8, cols: cols as u16, rows: rows as u16 }); + 0 +} + +/// `MkTtyQuery(fd)`. For the caller's stdin, stdout or stderr on a +/// terminal, its size as `rows << 16 | cols`; ENOTTY for anything else. +pub fn sys_tty_query(fd: u64) -> i64 { + if fd > 2 { + return ERRNO_NOTTY; + } + let Some(pid) = current_pid() else { return ERRNO_NOTTY }; + match tty_table::get(pid) { + Some(t) if t.streams & (1 << fd) != 0 => ((t.rows as i64) << 16) | t.cols as i64, + _ => ERRNO_NOTTY, + } +} diff --git a/src/syscall/microkernel/tty_table.rs b/src/syscall/microkernel/tty_table.rs new file mode 100644 index 0000000000..bdc373813c --- /dev/null +++ b/src/syscall/microkernel/tty_table.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 . + +//! Which of a process's standard streams reach a terminal, and the size of +//! that terminal in cells. Set by the parent that drains the process's +//! output, read by the process, forgotten when it exits. + +use alloc::collections::BTreeMap; +use spin::Mutex; + +/// Bit 0 stdin, bit 1 stdout, bit 2 stderr. +pub const STREAMS_ALL: u8 = 0b111; + +#[derive(Clone, Copy)] +pub struct Tty { + pub streams: u8, + pub cols: u16, + pub rows: u16, +} + +static TABLE: Mutex> = Mutex::new(BTreeMap::new()); + +/// Record what `pid`'s streams are on. No streams at all clears the entry, +/// which is what a stage whose output is redirected to a file gets. +pub fn set(pid: u32, tty: Tty) { + let mut table = TABLE.lock(); + if tty.streams == 0 { + table.remove(&pid); + } else { + table.insert(pid, tty); + } +} + +pub fn get(pid: u32) -> Option { + TABLE.lock().get(&pid).copied() +} + +/// Called as a process is finalized, so a pid reused later starts with no +/// terminal it never had. +pub fn forget(pid: u32) { + TABLE.lock().remove(&pid); +} diff --git a/src/syscall/numbers/defs.rs b/src/syscall/numbers/defs.rs index 7ffad31ca0..862a9c0ef3 100644 --- a/src/syscall/numbers/defs.rs +++ b/src/syscall/numbers/defs.rs @@ -80,6 +80,8 @@ pub enum SyscallNumber { MkDevRootRequest = tag4(b"MDRQ"), MkDevRootConfirm = tag4(b"MDRC"), MkToolRun = tag4(b"MTRN"), + MkTtySet = tag4(b"MTTY"), + MkTtyQuery = tag4(b"MTTQ"), MkCapGrant = tag4(b"MCGT"), MkCapRevoke = tag4(b"MCRV"), MkCapCheck = tag4(b"MCCK"), diff --git a/toolchain/nonos-std/README.md b/toolchain/nonos-std/README.md index 46755ac219..3460aa4f29 100644 --- a/toolchain/nonos-std/README.md +++ b/toolchain/nonos-std/README.md @@ -24,6 +24,7 @@ returns `Unsupported` loudly; nothing pretends. | heap (`alloc`) | real | dlmalloc over `MMAP`, spin-locked (thread-safe) | | `println!` / stdout / stderr | real | `MDBG` serial sink, mirrored to `proc.` inbox | | stdin | real | blocking read of this process's kernel stdin channel (`MSRD`), fed by a launcher (the terminal); no EOF-on-close yet | +| `IsTerminal` | real | `MTTQ`: true for a standard stream the launcher said reaches its screen (`MTTY`); a stage feeding a pipe or a file, or a process no terminal started, gets false | | `args` | real | `MKAR` | | env vars | real, process-local | in-process map; nothing is inherited across spawns yet | | `current_dir` | fixed `/` | capsules see the VFS from its root; `chdir` unsupported | diff --git a/toolchain/nonos-std/apply.sh b/toolchain/nonos-std/apply.sh index 3c15a44d09..35b38b0a9b 100755 --- a/toolchain/nonos-std/apply.sh +++ b/toolchain/nonos-std/apply.sh @@ -15,6 +15,7 @@ SYS="$STD/src/sys" # 1. copy the platform modules cp "$HERE/sys/alloc/nonos.rs" "$SYS/alloc/nonos.rs" cp "$HERE/sys/io/error/nonos.rs" "$SYS/io/error/nonos.rs" +cp "$HERE/sys/io/is_terminal/nonos.rs" "$SYS/io/is_terminal/nonos.rs" cp "$HERE/sys/random/nonos.rs" "$SYS/random/nonos.rs" cp "$HERE/sys/stdio/nonos.rs" "$SYS/stdio/nonos.rs" cp "$HERE/sys/args/nonos.rs" "$SYS/args/nonos.rs" @@ -72,6 +73,11 @@ ARM_PAL = ' target_vendor = "nonos" => {\n mod nonos;\n pub use insert_before(f"{sysdir}/alloc/mod.rs", ' any(\n target_family = "unix",', ARM_BARE, 'mod nonos') insert_before(f"{sysdir}/io/error/mod.rs", ' target_os = "hermit" => {', ARM, 'target_vendor = "nonos"') +# IsTerminal: the arm sits inside `mod is_terminal`, one level deeper. +ARM_TTY = (' target_vendor = "nonos" => {\n mod nonos;\n' + ' pub use nonos::*;\n }\n') +insert_before(f"{sysdir}/io/mod.rs", ' target_os = "hermit" => {\n mod hermit;', ARM_TTY, + 'target_vendor = "nonos" => {\n mod nonos;') insert_before(f"{sysdir}/random/mod.rs", ' // Tier 1\n', ARM_FILL, 'target_vendor = "nonos"') insert_before(f"{sysdir}/stdio/mod.rs", ' any(target_family = "unix"', ARM, 'target_vendor = "nonos"') insert_before(f"{sysdir}/fs/mod.rs", ' any(target_family = "unix", target_os = "wasi") => {', ARM_IMP, 'target_vendor = "nonos"') diff --git a/toolchain/nonos-std/sys/io/is_terminal/nonos.rs b/toolchain/nonos-std/sys/io/is_terminal/nonos.rs new file mode 100644 index 0000000000..ee68ed84b8 --- /dev/null +++ b/toolchain/nonos-std/sys/io/is_terminal/nonos.rs @@ -0,0 +1,33 @@ +// NONOS std PAL: IsTerminal asks the kernel (MTTQ) whether this process's +// stdin, stdout or stderr reaches a terminal. The launcher that renders the +// process's output says so when it starts it, and says nothing for a stage +// whose output feeds a pipe or a file, so a program picks colour and columns +// for a person and plain bytes for a pipe, as it would on any other system. + +use crate::os::fd::{AsFd, AsRawFd}; + +const fn tag4(b: &[u8; 4]) -> i64 { + (b[0] as i64) | ((b[1] as i64) << 8) | ((b[2] as i64) << 16) | ((b[3] as i64) << 24) +} + +const N_MK_TTY_QUERY: i64 = tag4(b"MTTQ"); + +pub fn is_terminal(fd: &impl AsFd) -> bool { + let fd = fd.as_fd().as_raw_fd(); + // Only the three standard streams can reach a terminal; files and + // sockets sit in the descriptor table above them. + if !(0..=2).contains(&fd) { + return false; + } + let r: i64; + unsafe { + core::arch::asm!( + "syscall", + inout("rax") N_MK_TTY_QUERY => r, + in("rdi") fd as u64, + out("rcx") _, + out("r11") _, + ); + } + r >= 0 +} diff --git a/userland/app_skeleton/src/app/behavior.rs b/userland/app_skeleton/src/app/behavior.rs index 32d84781cb..f67e25b269 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/input/keys.rs b/userland/app_skeleton/src/input/keys.rs index 5023b38b6e..d4c8573f2a 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 410bd6d17e..14d61840eb 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 c494c98bc9..c6ed3d5418 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_ALTGR, MOD_CAPS, MOD_CTRL, MOD_META, MOD_NUM, MOD_SHIFT, }; pub use paint::font_advance; pub use paint::PaintBuffer; diff --git a/userland/app_skeleton/src/runner/drain_ipc.rs b/userland/app_skeleton/src/runner/drain_ipc.rs index 9d13fbfeef..234269ea69 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, diff --git a/userland/capsule_linux/src/linux/call/spawn/clone.rs b/userland/capsule_linux/src/linux/call/spawn/clone.rs index 3ecdb0caed..e9c8ddfa89 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/capsule_linux/src/linux/serve/family_reap.rs b/userland/capsule_linux/src/linux/serve/family_reap.rs index 16b102a885..831a95f57b 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; diff --git a/userland/capsule_terminal/Cargo.lock b/userland/capsule_terminal/Cargo.lock index 19aa38d6c4..155cef1b5b 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 d17892367b..f76df1402f 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/builtin/echo.rs b/userland/capsule_terminal/src/command/builtin/echo.rs index a02077ea7c..c70ef0cb01 100644 --- a/userland/capsule_terminal/src/command/builtin/echo.rs +++ b/userland/capsule_terminal/src/command/builtin/echo.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . use crate::command::output::Output; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; use crate::term::util::copy_into; pub fn run(out: &mut Output<'_>, argv: &[&[u8]]) { @@ -23,7 +23,7 @@ pub fn run(out: &mut Output<'_>, argv: &[&[u8]]) { out.writeln(b""); return; } - let mut buf = [0u8; COLS]; + let mut buf = [0u8; LINE_MAX]; let mut n = 0; for (i, arg) in argv[1..].iter().enumerate() { if i > 0 && n < buf.len() { diff --git a/userland/capsule_terminal/src/command/builtin/help.rs b/userland/capsule_terminal/src/command/builtin/help.rs index 8b5be463ec..062fe627c1 100644 --- a/userland/capsule_terminal/src/command/builtin/help.rs +++ b/userland/capsule_terminal/src/command/builtin/help.rs @@ -20,30 +20,18 @@ use crate::command::output::Output; -const GROUPS: &[(&[u8], &[u8])] = &[ - (b"files", b"ls tree cat cd pwd mkdir touch rm rmdir mv cp stat find du"), - (b"text", b"head tail grep wc echo sort uniq cut nl tac rev"), - (b"system", b"capsules service ps kill sys id whoami date uptime battery about"), - (b"net", b"ping ifconfig nslookup curl nym"), - (b"apps", b"market install pkg git nox"), -]; - -const DEEPER: &[(&[u8], &[u8])] = &[ - (b"help keys", b"editing, history, tabs and view keys"), - (b"help shell", b"pipes, redirects, jobs and aliases"), - (b"help ", b"what one command takes"), -]; +use super::help_layout::{plain_row, DEEPER, DEEPER_PAD, GROUPS, GROUP_PAD, INTRO, PAGE_PAD}; pub fn run(out: &mut Output<'_>) { - out.writeln(b"Type a command and press Enter. Tab completes."); + out.writeln(INTRO); out.writeln(b""); for (name, list) in GROUPS { - row(out, name, list, 9); + row(out, name, list, GROUP_PAD); } super::help_tools::tools(out); out.writeln(b""); for (name, what) in DEEPER { - row(out, name, what, 13); + row(out, name, what, DEEPER_PAD); } } @@ -55,17 +43,14 @@ pub fn topic(out: &mut Output<'_>, name: &[u8]) -> bool { _ => return false, }; for (name, what) in lines { - row(out, name, what, 10); + row(out, name, what, PAGE_PAD); } true } /// A label in the accent colour, padded to `pad`, then the text. pub(super) fn row(out: &mut Output<'_>, name: &[u8], text: &[u8], pad: usize) { - let mut plain = alloc::vec![b' '; 2]; - plain.extend_from_slice(name); - plain.resize(2 + pad.max(name.len() + 1), b' '); - plain.extend_from_slice(text); + let plain = plain_row(name, text, pad); let mut styled = alloc::vec::Vec::with_capacity(plain.len() + 12); styled.extend_from_slice(b" \x1b[36m"); styled.extend_from_slice(name); diff --git a/userland/capsule_terminal/src/command/builtin/help_layout.rs b/userland/capsule_terminal/src/command/builtin/help_layout.rs new file mode 100644 index 0000000000..26d4c01220 --- /dev/null +++ b/userland/capsule_terminal/src/command/builtin/help_layout.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 . + +/* + * What `help` shows and how its rows are laid out, with nothing else in the + * file, so the proofs render the same rows the terminal prints. + */ + +use alloc::vec::Vec; + +/// The width `help` has to fit: what every terminal opens at. +pub const WIDTH: usize = 80; +/// Where the text column starts, after the label, in each table. +pub const GROUP_PAD: usize = 9; +pub const DEEPER_PAD: usize = 13; +pub const PAGE_PAD: usize = 10; + +pub const INTRO: &[u8] = b"Type a command and press Enter. Tab completes."; + +pub const GROUPS: &[(&[u8], &[u8])] = &[ + (b"files", b"ls tree cat cd pwd mkdir touch rm rmdir mv cp"), + (b"disk", b"stat find du"), + (b"text", b"head tail grep wc echo sort uniq cut nl tac rev"), + (b"system", b"capsules service ps kill sys battery about"), + (b"session", b"id whoami date uptime"), + (b"net", b"ping ifconfig nslookup curl nym"), + (b"apps", b"market install pkg git nox"), +]; + +pub const DEEPER: &[(&[u8], &[u8])] = &[ + (b"help keys", b"editing, history, tabs, selection and search keys"), + (b"help shell", b"pipes, redirects, jobs and aliases"), + (b"help ", b"what one command takes"), +]; + +/// A row as plain text: indent, the label padded to `pad`, then the text. +pub fn plain_row(name: &[u8], text: &[u8], pad: usize) -> Vec { + let mut plain = alloc::vec![b' '; 2]; + plain.extend_from_slice(name); + plain.resize(2 + pad.max(name.len() + 1), b' '); + plain.extend_from_slice(text); + plain +} diff --git a/userland/capsule_terminal/src/command/builtin/help_pages.rs b/userland/capsule_terminal/src/command/builtin/help_pages.rs index 11be511256..0d22754d51 100644 --- a/userland/capsule_terminal/src/command/builtin/help_pages.rs +++ b/userland/capsule_terminal/src/command/builtin/help_pages.rs @@ -16,12 +16,16 @@ //! The deeper help pages: keys and shell syntax. -pub const KEYS: [(&[u8], &[u8]); 8] = [ - (b"move", b"Ctrl-A start Ctrl-E end Ctrl-Left/Right by word"), - (b"cut", b"Ctrl-W word Ctrl-K to end Ctrl-U line Ctrl-Y put back Ctrl-D delete"), +pub const KEYS: [(&[u8], &[u8]); 12] = [ + (b"move", b"Ctrl-A start Ctrl-E end Ctrl-F a char Alt-B/F a word"), + (b"cut", b"Ctrl-W word back Alt-D word on Ctrl-K to end Ctrl-U line"), + (b"put", b"Ctrl-Y puts back the last cut Ctrl-D or Ctrl-H delete a character"), (b"complete", b"Tab completes a command or a path"), - (b"recall", b"Up/Down walk history Ctrl-R search it Ctrl-C abandon the line"), + (b"recall", b"Up/Down or Ctrl-P/N walk history Ctrl-R search it Ctrl-C abandon"), (b"history", b"!! the last command !n the nth !text the last starting with text"), + (b"select", b"drag double-click a word triple-click a line Alt+drag a block"), + (b"clipboard", b"Ctrl+Shift+C copy the selection Ctrl+Shift+V paste"), + (b"search", b"Ctrl+Shift+F find in scrollback Enter older Shift+Enter newer"), (b"tabs", b"Ctrl+Shift+T new Ctrl+Shift+W close Ctrl+PgUp/PgDn switch"), (b"view", b"Ctrl-B side rail Ctrl+= / Ctrl+- font size Ctrl-L clear"), (b"theme", b"theme or profile to change colours"), diff --git a/userland/capsule_terminal/src/command/builtin/help_tools.rs b/userland/capsule_terminal/src/command/builtin/help_tools.rs index 5583439203..3bc3af2591 100644 --- a/userland/capsule_terminal/src/command/builtin/help_tools.rs +++ b/userland/capsule_terminal/src/command/builtin/help_tools.rs @@ -25,17 +25,6 @@ use crate::command::output::Output; /// nothing else on screen says they exist, and a tool nobody can discover may /// as well not be installed. pub fn tools(out: &mut Output<'_>) { - let mut list = alloc::vec::Vec::with_capacity(96); - for (typed, _) in super::tool::TOOLS { - // Two spaces between names, matching the groups above. A name that - // would not fit is dropped rather than wrapped: the list is a pointer - // to what exists, not the manual. - if list.len() + typed.len() + 2 > 84 { - break; - } - list.extend_from_slice(typed); - list.extend_from_slice(b" "); - } - // Styled and aligned like every other group. - super::help::row(out, b"tools", &list, 9); + let list = super::tool_list::tool_list(super::tool::TOOLS); + super::help::row(out, b"tools", &list, super::help_layout::GROUP_PAD); } diff --git a/userland/capsule_terminal/src/command/builtin/history_cmd.rs b/userland/capsule_terminal/src/command/builtin/history_cmd.rs index 3ab105dfb7..d08c4468a5 100644 --- a/userland/capsule_terminal/src/command/builtin/history_cmd.rs +++ b/userland/capsule_terminal/src/command/builtin/history_cmd.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . use crate::command::output::Output; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; use crate::term::history::History; use crate::term::util::format_u64; @@ -24,7 +24,7 @@ pub fn run(out: &mut Output<'_>, history: &History, _argv: &[&[u8]]) { let mut numbuf = [0u8; 4]; let nn = format_u64(i as u64, &mut numbuf); let p = nn.min(3); - let mut line = [0u8; COLS]; + let mut line = [0u8; LINE_MAX]; let mut o = 0; for k in 0..p { line[o] = numbuf[nn - p + k]; @@ -34,7 +34,7 @@ pub fn run(out: &mut Output<'_>, history: &History, _argv: &[&[u8]]) { line[o + 1] = b' '; o += 2; let body = history.get(i); - let take = body.len().min(COLS - o); + let take = body.len().min(LINE_MAX - o); line[o..o + take].copy_from_slice(&body[..take]); out.writeln(&line[..o + take]); } diff --git a/userland/capsule_terminal/src/command/builtin/mod.rs b/userland/capsule_terminal/src/command/builtin/mod.rs index 6246b43d9e..29368e7806 100644 --- a/userland/capsule_terminal/src/command/builtin/mod.rs +++ b/userland/capsule_terminal/src/command/builtin/mod.rs @@ -25,6 +25,7 @@ pub mod exit_check; pub mod fs; pub mod git; pub mod help; +mod help_layout; pub mod help_one; mod help_pages; mod help_tools; @@ -39,6 +40,8 @@ pub mod receipt; pub mod service; pub mod theme; pub mod tool; +mod tool_list; +mod tool_refused; pub mod version; pub mod which; pub mod whoami; diff --git a/userland/capsule_terminal/src/command/builtin/nox/exec.rs b/userland/capsule_terminal/src/command/builtin/nox/exec.rs index c294783c53..96b2bd4ac0 100644 --- a/userland/capsule_terminal/src/command/builtin/nox/exec.rs +++ b/userland/capsule_terminal/src/command/builtin/nox/exec.rs @@ -39,6 +39,7 @@ pub fn run(state: &mut State, args: &[&[u8]]) -> bool { let argv = argv_blob(stem, &args[1..]); match call_installer(stem, &argv) { Ok(pid) => { + crate::jobs::tty::attach(state, pid); let work = JobWork::ExternalStage { pid, in_buf: Vec::new(), in_cursor: 0 }; let _ = submit(state, stem, false, work); state.fg_running = true; diff --git a/userland/capsule_terminal/src/command/builtin/nox/install/job.rs b/userland/capsule_terminal/src/command/builtin/nox/install/job.rs index 2d1a6f7c3b..0b46696224 100644 --- a/userland/capsule_terminal/src/command/builtin/nox/install/job.rs +++ b/userland/capsule_terminal/src/command/builtin/nox/install/job.rs @@ -27,7 +27,7 @@ const DEADLINE_MS: i64 = 5000; // final flush of whatever is still buffered. Holds the progress cursor // (elapsed start, whether any output has been seen) between slices. pub struct InstallJob { - pid: u32, + pub(crate) pid: u32, start: i64, saw_output: bool, } diff --git a/userland/capsule_terminal/src/command/builtin/nox/install/run.rs b/userland/capsule_terminal/src/command/builtin/nox/install/run.rs index 8805cfe7ba..762e03a64b 100644 --- a/userland/capsule_terminal/src/command/builtin/nox/install/run.rs +++ b/userland/capsule_terminal/src/command/builtin/nox/install/run.rs @@ -37,6 +37,7 @@ pub fn run(state: &mut State, args: &[&[u8]]) -> bool { let argv = argv_blob(stem, &args[1..]); match call_installer(stem, &argv) { Ok(new_pid) => { + crate::jobs::tty::attach(state, new_pid); emit_ok(state, stem, new_pid); debug_marker(b"[TERMINAL-INSTALL] load ok\n"); drain_output(state, new_pid); diff --git a/userland/capsule_terminal/src/command/builtin/tool.rs b/userland/capsule_terminal/src/command/builtin/tool.rs index 3574506e39..6e393f926b 100644 --- a/userland/capsule_terminal/src/command/builtin/tool.rs +++ b/userland/capsule_terminal/src/command/builtin/tool.rs @@ -15,13 +15,11 @@ use crate::term::state::State; /// The installed command-line tools: what you type, and the service it runs. /// -/// The two are not always the same word. ripgrep installs as `rg`, which is the -/// name its users have in their fingers, while its capsule serves -/// `tool.ripgrep`. Mapping the pair here lets a tool keep the name it is known -/// by without renaming its service, and both spellings can reach it. -/// -/// Kept in step with `userland/apps.list` and the std tool capsules the desktop -/// profile bakes. +/// The typed name comes first because it need not be the service's: a tool +/// can keep the name its users have in their fingers without renaming its +/// capsule. Every service here is one `userland/apps.list` registers, which +/// terminal_line_proofs checks, so a name on this list always has a program +/// behind it. pub const TOOLS: &[(&[u8], &[u8])] = &[ (b"grex", b"grex"), (b"dotenv-linter", b"dotenv-linter"), @@ -30,8 +28,6 @@ pub const TOOLS: &[(&[u8], &[u8])] = &[ (b"tokei", b"tokei"), (b"huniq", b"huniq"), (b"csview", b"csview"), - (b"rg", b"ripgrep"), - (b"ripgrep", b"ripgrep"), ]; // `install` is absent for the same reason as `sd`: the builtin that installs @@ -65,10 +61,10 @@ pub fn prepare(state: &mut State, args: &[&[u8]]) -> Option { let argv = argv_blob(args); let rc = mk_tool_run(&service, &argv); if rc < 0 { - state.scrollback.push_error(b"tool: launch failed"); - state.last_status = 1; + super::tool_refused::refused(state, name, rc); return None; } + crate::jobs::tty::attach(state, rc as u32); Some(InstallJob::new(rc as u32)) } diff --git a/userland/capsule_terminal/src/command/builtin/tool_list.rs b/userland/capsule_terminal/src/command/builtin/tool_list.rs new file mode 100644 index 0000000000..f9bf930785 --- /dev/null +++ b/userland/capsule_terminal/src/command/builtin/tool_list.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 tools row of `help`, kept apart from the tool table that spawns them +//! so the proofs can build it. + +use alloc::vec::Vec; + +use super::help_layout::{GROUP_PAD, WIDTH}; + +/* + * The tools, one name for each: two names for one service run the same + * program, and listing both says there are two. A name that would push + * the row past the width is left out rather than wrapped; the list points + * at what exists. + */ +pub fn tool_list(tools: &[(&[u8], &[u8])]) -> Vec { + let room = WIDTH - 2 - GROUP_PAD; + let mut list: Vec = Vec::with_capacity(room); + for (i, (typed, service)) in tools.iter().enumerate() { + if tools[..i].iter().any(|(_, s)| s == service) { + continue; + } + let gap = if list.is_empty() { 0 } else { 2 }; + if list.len() + gap + typed.len() > room { + break; + } + list.resize(list.len() + gap, b' '); + list.extend_from_slice(typed); + } + list +} diff --git a/userland/capsule_terminal/src/command/builtin/tool_refused.rs b/userland/capsule_terminal/src/command/builtin/tool_refused.rs new file mode 100644 index 0000000000..2907ddd753 --- /dev/null +++ b/userland/capsule_terminal/src/command/builtin/tool_refused.rs @@ -0,0 +1,33 @@ +// 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 terminal says when the kernel will not start a tool. + +use crate::term::state::State; + +const ERRNO_NOENT: i64 = -2; + +/// Which tool the kernel would not start, and why, in its own words. +pub fn refused(state: &mut State, name: &[u8], rc: i64) { + let why: &[u8] = match rc { + ERRNO_NOENT => b": not installed in this build", + _ => b": the kernel refused to start it", + }; + let mut line = name.to_vec(); + line.extend_from_slice(why); + state.scrollback.push_error(&line); + state.last_status = 1; +} diff --git a/userland/capsule_terminal/src/command/output/feed_raw.rs b/userland/capsule_terminal/src/command/output/feed_raw.rs index 90844b5f69..083fb30ad2 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 0000000000..9932ce85fc --- /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 0000000000..e0d4ec1baf --- /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 0000000000..40fd3ef402 --- /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 0000000000..3d84d350be --- /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 0000000000..edaa8b1ba3 --- /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 0000000000..0853c1c647 --- /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 0000000000..3703f0ac65 --- /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 0000000000..1c487c6311 --- /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 6c200f3e50..17bec71589 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 0000000000..fac5a37426 --- /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 0000000000..e187771e2b --- /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 22318e8102..4ef83892d2 100644 --- a/userland/capsule_terminal/src/event/mod.rs +++ b/userland/capsule_terminal/src/event/mod.rs @@ -16,21 +16,35 @@ 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; mod on_event; mod on_key; +mod on_nav; mod on_printable; mod on_tab; mod on_up; mod paste_clipboard; +mod paste_program; +mod readline; 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 1dc20535d9..8740ef08c0 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_enter.rs b/userland/capsule_terminal/src/event/on_enter.rs index 0e89b68098..5e97499544 100644 --- a/userland/capsule_terminal/src/event/on_enter.rs +++ b/userland/capsule_terminal/src/event/on_enter.rs @@ -21,7 +21,7 @@ use crate::command; use crate::jobs; use crate::term::context::context_line; use crate::term::cwd::home_var; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; use crate::term::identity::{hostname, USER}; use crate::term::prompt::PROMPT_BYTES; use crate::term::state::State; @@ -34,20 +34,20 @@ pub fn on_enter(state: &mut State) -> EventOutcome { state.fresh = false; let started = mk_time_millis(); state.open_block(crate::term::rtc::rtc_hms()); - let mut ctx = [0u8; COLS]; + let mut ctx = [0u8; LINE_MAX]; let cn = context_line(USER, hostname(), state.cwd.as_bytes(), home_var(state), &mut ctx); state.scrollback.push_line(&ctx[..cn]); // A `!` form is resolved before anything else sees the line, so what is // echoed, recorded in history and run are all the same text. Expanding // later would put one command on screen and another through the parser. - let mut entered = [0u8; COLS]; + let mut entered = [0u8; LINE_MAX]; let n; match crate::term::history::expand(state.line.as_bytes(), &state.history) { // The expansion is what gets echoed, which is the whole safety of the // feature: the reader sees the command that is about to run, not the // shorthand they typed for it. Some(Ok(line)) => { - n = line.len().min(COLS); + n = line.len().min(LINE_MAX); entered[..n].copy_from_slice(&line[..n]); } Some(Err(_)) => { @@ -67,7 +67,7 @@ pub fn on_enter(state: &mut State) -> EventOutcome { entered[..n].copy_from_slice(body); } } - let mut echo = [0u8; COLS + 8]; + let mut echo = [0u8; LINE_MAX + 8]; let mut k = 0; k += copy_into(&mut echo[k..], PROMPT_BYTES); k += copy_into(&mut echo[k..], &entered[..n]); diff --git a/userland/capsule_terminal/src/event/on_key.rs b/userland/capsule_terminal/src/event/on_key.rs index 1447accbd5..6eedd3c76d 100644 --- a/userland/capsule_terminal/src/event/on_key.rs +++ b/userland/capsule_terminal/src/event/on_key.rs @@ -15,8 +15,8 @@ // along with this program. If not, see . use nonos_app_skeleton::{ - EventOutcome, InputEvent, 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_CTRL, + EventOutcome, InputEvent, KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_ENTER, KEY_ESC, KEY_TAB, + KEY_UP, MOD_CTRL, }; use super::bool_to_outcome::bool_to_outcome; @@ -26,23 +26,22 @@ 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) - } - _ => {} - } + if let Some(out) = super::readline::readline_key(state, event.code, event.flags) { + return out; + } + if let Some(out) = super::on_nav::on_nav(state, event.code) { + return out; } match event.code { // Clears the line. Esc used to close the window, so one stray press @@ -59,26 +58,8 @@ pub fn on_key(state: &mut State, event: InputEvent) -> EventOutcome { } KEY_BACKSPACE => bool_to_outcome(state.line.backspace()), KEY_DELETE => bool_to_outcome(state.line.delete()), - KEY_LEFT => bool_to_outcome(state.line.move_left()), - KEY_RIGHT => bool_to_outcome(state.line.move_right()), - KEY_HOME => { - state.line.move_home(); - EventOutcome::Repaint - } - KEY_END => { - state.line.move_end(); - EventOutcome::Repaint - } KEY_UP => on_up(state), KEY_DOWN => on_down(state), - KEY_PAGE_UP => { - state.scrollback.scroll_up(VISIBLE_ROWS - 2); - EventOutcome::Repaint - } - KEY_PAGE_DOWN => { - state.scrollback.scroll_down(VISIBLE_ROWS - 2); - EventOutcome::Repaint - } KEY_TAB => on_tab(state), code if (0x20..=0x7E).contains(&code) => on_printable(state, code as u8), _ => EventOutcome::Idle, diff --git a/userland/capsule_terminal/src/event/on_nav.rs b/userland/capsule_terminal/src/event/on_nav.rs new file mode 100644 index 0000000000..48e4799fbb --- /dev/null +++ b/userland/capsule_terminal/src/event/on_nav.rs @@ -0,0 +1,51 @@ +// 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 along the line and through history: the arrows, Home and End on + * the line, Page Up and Page Down on the scrollback. + */ + +use nonos_app_skeleton::{ + EventOutcome, KEY_END, KEY_HOME, KEY_LEFT, KEY_PAGE_DOWN, KEY_PAGE_UP, KEY_RIGHT, +}; + +use super::bool_to_outcome::bool_to_outcome; +use crate::term::state::State; + +pub fn on_nav(state: &mut State, code: u32) -> Option { + Some(match code { + KEY_LEFT => bool_to_outcome(state.line.move_left()), + KEY_RIGHT => bool_to_outcome(state.line.move_right()), + KEY_HOME => { + state.line.move_home(); + EventOutcome::Repaint + } + KEY_END => { + state.line.move_end(); + EventOutcome::Repaint + } + KEY_PAGE_UP => { + state.scrollback.scroll_up(super::key_first::page(state)); + EventOutcome::Repaint + } + KEY_PAGE_DOWN => { + state.scrollback.scroll_down(super::key_first::page(state)); + EventOutcome::Repaint + } + _ => return None, + }) +} diff --git a/userland/capsule_terminal/src/event/paste_clipboard.rs b/userland/capsule_terminal/src/event/paste_clipboard.rs index 74814a43f1..4bd16b40f6 100644 --- a/userland/capsule_terminal/src/event/paste_clipboard.rs +++ b/userland/capsule_terminal/src/event/paste_clipboard.rs @@ -16,11 +16,11 @@ use nonos_app_skeleton::{clipboard_paste, EventOutcome}; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; use crate::term::state::State; pub fn paste_clipboard(state: &mut State) -> EventOutcome { - let mut buf = [0u8; COLS]; + let mut buf = [0u8; LINE_MAX]; let n = match clipboard_paste(&mut buf) { Ok(n) => n.min(buf.len()), Err(_) => return EventOutcome::Idle, @@ -45,7 +45,7 @@ pub fn paste_clipboard(state: &mut State) -> EventOutcome { changed = true; } } - // The input line holds COLS bytes, so anything longer cannot fit. Say so + // The input line holds LINE_MAX bytes, so anything longer cannot fit. Say so // instead of leaving a silently shortened command on the prompt. if multiline { state.scrollback.push_line(b"paste: first line only"); 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 0000000000..1f04e1cac2 --- /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/event/readline.rs b/userland/capsule_terminal/src/event/readline.rs new file mode 100644 index 0000000000..17625747df --- /dev/null +++ b/userland/capsule_terminal/src/event/readline.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 readline keys a shell user types without thinking, beyond the ones + * on_ctrl owns: Ctrl-F and Ctrl-H step and rub out a character, Ctrl-P and + * Ctrl-N walk history, and Alt with B, F and D moves and cuts by word. + * Ctrl-B is the rail's, taken before the line sees it. + */ + +use nonos_app_skeleton::{EventOutcome, MOD_ALT, MOD_ALTGR, MOD_CTRL}; + +use super::bool_to_outcome::bool_to_outcome; +use crate::term::state::State; + +pub fn readline_key(state: &mut State, code: u32, flags: u16) -> Option { + /* AltGr is how some layouts type characters: a key with it is text. */ + if flags & MOD_ALTGR != 0 { + return None; + } + let letter = char::from_u32(code)?.to_ascii_lowercase(); + if flags & MOD_CTRL != 0 { + return match letter { + 'f' => Some(bool_to_outcome(state.line.move_right())), + 'h' => Some(bool_to_outcome(state.line.backspace())), + 'p' => Some(super::on_up::on_up(state)), + 'n' => Some(super::on_down::on_down(state)), + _ => unbound(code), + }; + } + if flags & MOD_ALT != 0 { + return match letter { + 'b' => Some(bool_to_outcome(state.line.move_word_left())), + 'f' => Some(bool_to_outcome(state.line.move_word_right())), + 'd' => Some(bool_to_outcome(state.line.delete_word_right())), + _ => unbound(code), + }; + } + None +} + +/* + * A chord no binding took: typing its letter would put text on the line + * that nobody typed, so a printable one is swallowed. Named keys go on to + * the line editor, which gives Ctrl-Home and the like their plain meaning. + */ +fn unbound(code: u32) -> Option { + (0x20..=0x7E).contains(&code).then_some(EventOutcome::Idle) +} diff --git a/userland/capsule_terminal/src/jobs/external.rs b/userland/capsule_terminal/src/jobs/external.rs index 4f18afbf25..ac03dd3d68 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 0000000000..1ff941914e --- /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 3662d5c5cf..58631c876b 100644 --- a/userland/capsule_terminal/src/jobs/mod.rs +++ b/userland/capsule_terminal/src/jobs/mod.rs @@ -17,11 +17,13 @@ mod classify; mod env; mod external; +mod external_io; mod pipeline_job; mod pump; mod reap; mod submit; mod table; +pub mod tty; mod work; pub use classify::{is_job_command, is_store_tool, Verdict}; diff --git a/userland/capsule_terminal/src/jobs/tty.rs b/userland/capsule_terminal/src/jobs/tty.rs new file mode 100644 index 0000000000..5ece144861 --- /dev/null +++ b/userland/capsule_terminal/src/jobs/tty.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 . + +//! Telling a program that its output reaches this screen, and how big the +//! screen is. The kernel answers the program's own is-it-a-terminal +//! question from this, so it picks colour and columns for a person. A +//! stage that feeds a pipe or a file is never told, and writes plain bytes. + +use nonos_libc::{mk_tty_set, TTY_STDERR, TTY_STDIN, TTY_STDOUT}; + +use super::JobWork; +use crate::term::state::State; + +/// The program `pid` reads the keyboard and writes to this screen. A kernel +/// without the call leaves it believing it writes to a pipe, which is where +/// it started, so the answer is not checked. +pub fn attach(state: &State, pid: u32) { + let vt = &state.scrollback.vt; + let (cols, rows) = (vt.cols().min(u16::MAX as usize), vt.rows().min(u16::MAX as usize)); + let _ = mk_tty_set(pid, TTY_STDIN | TTY_STDOUT | TTY_STDERR, cols as u16, rows as u16); +} + +/// The screen changed size: the program in front hears the new one the +/// next time it asks. +pub fn resized(state: &State) { + let Some(id) = state.jobs.foreground() else { return }; + let pid = match state.jobs.get(id).map(|j| &j.work) { + Some(JobWork::ExternalStage { pid, .. }) => *pid, + Some(JobWork::InstallDrain(job)) => job.pid, + _ => return, + }; + attach(state, pid); +} diff --git a/userland/capsule_terminal/src/main.rs b/userland/capsule_terminal/src/main.rs index 2c1c4c8419..e7e63ee712 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 be1549490c..52c69ee07d 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 2ce84a2b7a..8d6ad5fcd1 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 a9c20f8c5a..0000000000 --- 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 0000000000..cc5f3005b7 --- /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 bee5f5b85e..3e0a0d98a4 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 4cea5556b4..b3bd880dea 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/paint/vt/area.rs b/userland/capsule_terminal/src/paint/vt/area.rs new file mode 100644 index 0000000000..c3fd438269 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/area.rs @@ -0,0 +1,48 @@ +// 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 is drawn, with what, and what is shaded over it. + +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, +} + +/// 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 0000000000..fec455270c --- /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/box_arms.rs b/userland/capsule_terminal/src/paint/vt/box_arms.rs new file mode 100644 index 0000000000..7d2bf05e87 --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/box_arms.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 . + +/* + * Which arms a box-drawing character has, and how heavy each is. Tables + * that tools print with these characters only join up when the lines run + * to the edges of the cell, which a font's glyph does not: the line gap + * leaves a break between rows. + */ + +/// An arm's weight: none, light, heavy or double. +pub const NONE: u8 = 0; +pub const LIGHT: u8 = 1; +pub const HEAVY: u8 = 2; +pub const DOUBLE: u8 = 3; + +/// Up, down, left and right arms of `ch`, or None when it is not one of +/// the line characters drawn here. +pub fn arms(ch: char) -> Option<[u8; 4]> { + let (l, h, d) = (LIGHT, HEAVY, DOUBLE); + Some(match ch { + '─' => [0, 0, l, l], + '━' => [0, 0, h, h], + '│' => [l, l, 0, 0], + '┃' => [h, h, 0, 0], + '┌' | '╭' => [0, l, 0, l], + '┐' | '╮' => [0, l, l, 0], + '└' | '╰' => [l, 0, 0, l], + '┘' | '╯' => [l, 0, l, 0], + '├' => [l, l, 0, l], + '┤' => [l, l, l, 0], + '┬' => [0, l, l, l], + '┴' => [l, 0, l, l], + '┼' => [l, l, l, l], + '┏' => [0, h, 0, h], + '┓' => [0, h, h, 0], + '┗' => [h, 0, 0, h], + '┛' => [h, 0, h, 0], + '┣' => [h, h, 0, h], + '┫' => [h, h, h, 0], + '┳' => [0, h, h, h], + '┻' => [h, 0, h, h], + '╋' => [h, h, h, h], + '═' => [0, 0, d, d], + '║' => [d, d, 0, 0], + '╔' => [0, d, 0, d], + '╗' => [0, d, d, 0], + '╚' => [d, 0, 0, d], + '╝' => [d, 0, d, 0], + '╠' => [d, d, 0, d], + '╣' => [d, d, d, 0], + '╦' => [0, d, d, d], + '╩' => [d, 0, d, d], + '╬' => [d, d, d, d], + _ => return None, + }) +} diff --git a/userland/capsule_terminal/src/paint/vt/box_draw.rs b/userland/capsule_terminal/src/paint/vt/box_draw.rs new file mode 100644 index 0000000000..86a7669f5f --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/box_draw.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 . + +//! Box-drawing characters stroked across the whole cell. + +use nonos_app_skeleton::PaintBuffer; + +use super::box_arms::{arms, DOUBLE, HEAVY, NONE}; + +/// Stroke `ch` over the cell at `(x, y)` sized `w` by `h`. False when it +/// is not a line character, so the caller draws the glyph instead. +pub fn stroke(fb: &mut PaintBuffer, ch: char, x: u32, y: u32, w: u32, h: u32, argb: u32) -> bool { + let Some([up, down, left, right]) = arms(ch) else { return false }; + let t = (w / 8).max(1); + let (cx, cy) = (x + w / 2, y + h / 2); + for (arm, across, first) in + [(up, false, true), (down, false, false), (left, true, true), (right, true, false)] + { + if arm == NONE { + continue; + } + let k = if arm == HEAVY { 2 * t } else { t }; + /* + * An arm runs from the cell's edge to just past the centre line, by + * half its own width, so the arms of a cross meet with no notch. A + * double arm is two strokes either side of the centre. + */ + let gap = if arm == DOUBLE { t + 1 } else { 0 }; + let past = k / 2 + gap + k % 2; + let sides: &[i32] = if arm == DOUBLE { &[-1, 1] } else { &[0] }; + for &side in sides { + let lane = |c: u32| (c as i32 + side * gap as i32) as u32 - k / 2; + if across { + let (a, b) = if first { (x, cx + past) } else { (cx - past, x + w) }; + fb.fill_rect(a, lane(cy), b - a, k, argb); + } else { + let (a, b) = if first { (y, cy + past) } else { (cy - past, y + h) }; + fb.fill_rect(lane(cx), a, k, b - a, argb); + } + } + } + true +} 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 0000000000..64c3907f6d --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/cell.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 . + +//! 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; + // Line characters run to the cell's edges so tables join between rows. + if super::box_draw::stroke(fb, cell.ch, x, y, m.adv, m.lh, fg) { + return; + } + 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 0000000000..8d059129c9 --- /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 0000000000..761305bc89 --- /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 0000000000..be519d7ba6 --- /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 0000000000..5b489699d7 --- /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/paint/vt/mod.rs b/userland/capsule_terminal/src/paint/vt/mod.rs new file mode 100644 index 0000000000..4388a76dfb --- /dev/null +++ b/userland/capsule_terminal/src/paint/vt/mod.rs @@ -0,0 +1,33 @@ +// 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 the terminal body from the screen and its history. + +mod area; +mod body; +mod box_arms; +mod box_draw; +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 0000000000..1bcbd7cf0e --- /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 9cd23c1fa6..db26f37836 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 831a217656..1f154b5a7b 100644 --- a/userland/capsule_terminal/src/term/dimensions.rs +++ b/userland/capsule_terminal/src/term/dimensions.rs @@ -15,7 +15,11 @@ // along with this program. If not, see . pub const COLS: usize = 96; -pub const SCROLLBACK_ROWS: usize = 256; +/// The longest command line the shell takes. Wider than any screen: a +/// command wraps on screen, it is not cut off. +pub const LINE_MAX: usize = 1024; +/// 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/erase.rs b/userland/capsule_terminal/src/term/grid/erase.rs deleted file mode 100644 index 4bf7713d3c..0000000000 --- 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 5da67135c4..0000000000 --- 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 32114f7b49..0000000000 --- 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 442df9c315..0000000000 --- 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_region.rs b/userland/capsule_terminal/src/term/grid/scroll_region.rs deleted file mode 100644 index 8859af5b37..0000000000 --- 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 a535c25713..0000000000 --- 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/width.rs b/userland/capsule_terminal/src/term/grid/width.rs deleted file mode 100644 index 3690032397..0000000000 --- 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/history/new.rs b/userland/capsule_terminal/src/term/history/new.rs index 57fbd2b9ad..5ff9e2b8d3 100644 --- a/userland/capsule_terminal/src/term/history/new.rs +++ b/userland/capsule_terminal/src/term/history/new.rs @@ -15,12 +15,12 @@ // along with this program. If not, see . use super::types::History; -use crate::term::dimensions::{COLS, HISTORY_DEPTH}; +use crate::term::dimensions::{LINE_MAX, HISTORY_DEPTH}; impl History { pub const fn new() -> Self { Self { - entries: [[0; COLS]; HISTORY_DEPTH], + entries: [[0; LINE_MAX]; HISTORY_DEPTH], lengths: [0; HISTORY_DEPTH], count: 0, cursor: None, diff --git a/userland/capsule_terminal/src/term/history/push.rs b/userland/capsule_terminal/src/term/history/push.rs index 042a4bef21..17cc5e5248 100644 --- a/userland/capsule_terminal/src/term/history/push.rs +++ b/userland/capsule_terminal/src/term/history/push.rs @@ -15,7 +15,7 @@ // along with this program. If not, see . use super::types::History; -use crate::term::dimensions::{COLS, HISTORY_DEPTH}; +use crate::term::dimensions::{LINE_MAX, HISTORY_DEPTH}; impl History { pub fn push(&mut self, line: &[u8]) { @@ -37,7 +37,7 @@ impl History { self.count = HISTORY_DEPTH - 1; } let slot = self.count; - let n = line.len().min(COLS); + let n = line.len().min(LINE_MAX); self.entries[slot][..n].copy_from_slice(&line[..n]); self.lengths[slot] = n; self.count += 1; diff --git a/userland/capsule_terminal/src/term/history/types.rs b/userland/capsule_terminal/src/term/history/types.rs index 16563b38df..b685e96b0f 100644 --- a/userland/capsule_terminal/src/term/history/types.rs +++ b/userland/capsule_terminal/src/term/history/types.rs @@ -14,10 +14,10 @@ // 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, HISTORY_DEPTH}; +use crate::term::dimensions::{LINE_MAX, HISTORY_DEPTH}; pub struct History { - pub(super) entries: [[u8; COLS]; HISTORY_DEPTH], + pub(super) entries: [[u8; LINE_MAX]; HISTORY_DEPTH], pub(super) lengths: [usize; HISTORY_DEPTH], pub(super) count: usize, pub(super) cursor: Option, diff --git a/userland/capsule_terminal/src/term/line/delete_word_right.rs b/userland/capsule_terminal/src/term/line/delete_word_right.rs new file mode 100644 index 0000000000..97fee9fa14 --- /dev/null +++ b/userland/capsule_terminal/src/term/line/delete_word_right.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 . + +use super::types::Line; + +impl Line { + /* + * Delete the word after the cursor (Alt-D): the run Alt-F would step + * over, word first and then the gap, so the two keys agree. + */ + pub fn delete_word_right(&mut self) -> bool { + let mut end = self.cursor; + while end < self.len && self.buf[end] != b' ' { + end += 1; + } + while end < self.len && self.buf[end] == b' ' { + end += 1; + } + let removed = end - self.cursor; + if removed == 0 { + return false; + } + let mut cut = [0u8; super::types::KILL_CAP]; + cut[..removed].copy_from_slice(&self.buf[self.cursor..end]); + self.hold_killed(&cut[..removed]); + self.buf.copy_within(end..self.len, self.cursor); + self.len -= removed; + true + } +} diff --git a/userland/capsule_terminal/src/term/line/insert.rs b/userland/capsule_terminal/src/term/line/insert.rs index a7c117dc06..f754e9dd5a 100644 --- a/userland/capsule_terminal/src/term/line/insert.rs +++ b/userland/capsule_terminal/src/term/line/insert.rs @@ -15,11 +15,11 @@ // along with this program. If not, see . use super::types::Line; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; impl Line { pub fn insert(&mut self, byte: u8) -> bool { - if self.len >= COLS { + if self.len >= LINE_MAX { return false; } if self.cursor < self.len { diff --git a/userland/capsule_terminal/src/term/line/mod.rs b/userland/capsule_terminal/src/term/line/mod.rs index 2cb1be106f..93ce43d4a5 100644 --- a/userland/capsule_terminal/src/term/line/mod.rs +++ b/userland/capsule_terminal/src/term/line/mod.rs @@ -19,6 +19,7 @@ mod backspace; mod clear; mod delete; mod delete_word; +mod delete_word_right; mod insert; mod kill_ring; mod kill_to_end; diff --git a/userland/capsule_terminal/src/term/line/new.rs b/userland/capsule_terminal/src/term/line/new.rs index f8f5046ac3..9e8446242e 100644 --- a/userland/capsule_terminal/src/term/line/new.rs +++ b/userland/capsule_terminal/src/term/line/new.rs @@ -15,12 +15,12 @@ // along with this program. If not, see . use super::types::Line; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; impl Line { pub const fn new() -> Self { Self { - buf: [0; COLS], + buf: [0; LINE_MAX], len: 0, cursor: 0, killed: [0; super::types::KILL_CAP], diff --git a/userland/capsule_terminal/src/term/line/replace.rs b/userland/capsule_terminal/src/term/line/replace.rs index 16500c7410..78236903cb 100644 --- a/userland/capsule_terminal/src/term/line/replace.rs +++ b/userland/capsule_terminal/src/term/line/replace.rs @@ -15,11 +15,11 @@ // along with this program. If not, see . use super::types::Line; -use crate::term::dimensions::COLS; +use crate::term::dimensions::LINE_MAX; impl Line { pub fn replace(&mut self, src: &[u8]) { - let n = src.len().min(COLS); + let n = src.len().min(LINE_MAX); self.buf[..n].copy_from_slice(&src[..n]); self.len = n; self.cursor = n; diff --git a/userland/capsule_terminal/src/term/line/types.rs b/userland/capsule_terminal/src/term/line/types.rs index 71f24f5506..b56841e020 100644 --- a/userland/capsule_terminal/src/term/line/types.rs +++ b/userland/capsule_terminal/src/term/line/types.rs @@ -14,14 +14,14 @@ // 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; +use crate::term::dimensions::LINE_MAX; -/// How much cut text is held for Ctrl-Y. A line cannot exceed COLS, so a -/// buffer that size can always hold whatever a single kill removed. -pub const KILL_CAP: usize = COLS; +/// How much cut text is held for Ctrl-Y. A line cannot exceed LINE_MAX, so +/// a buffer that size can always hold whatever a single kill removed. +pub const KILL_CAP: usize = LINE_MAX; pub struct Line { - pub buf: [u8; COLS], + pub buf: [u8; LINE_MAX], pub len: usize, pub cursor: usize, /// The last text a kill key removed, waiting to be yanked back. diff --git a/userland/capsule_terminal/src/term/mod.rs b/userland/capsule_terminal/src/term/mod.rs index 2270200503..3fe2b1cf1c 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 c995a26bf2..df8cadfe47 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 d5e5ec1599..f930dfb068 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 0000000000..63f99cae44 --- /dev/null +++ b/userland/capsule_terminal/src/term/scrollback/fit.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 . + +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. + /// True when the size in cells changed. + pub fn fit(&mut self, cols: usize, rows: usize, cell_w: u32, cell_h: u32) -> bool { + let changed = (cols, rows) != (self.vt.cols(), self.vt.rows()); + 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, + ); + changed + } + + /// 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.colors[..16].copy_from_slice(crate::term::theme::ansi::base16_for(t.bg)); + 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 caad121f5f..3cab442b94 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 0b5462932b..253a804c0e 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 06252a3c93..a8407102fa 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/scrollback/program_ended.rs b/userland/capsule_terminal/src/term/scrollback/program_ended.rs new file mode 100644 index 0000000000..813b13b7d8 --- /dev/null +++ b/userland/capsule_terminal/src/term/scrollback/program_ended.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 . + +use super::types::Scrollback; + +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.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 fc1ffced45..212f2e53f9 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 ca071c3780..cb364edaaa 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 be559d0f11..ab9a17cc17 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 116ed64443..45d52fc17c 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 b7e093d467..9a7d28a3b2 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 eff6bd4a69..1c157353d8 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 0000000000..e0b362f6db --- /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 9c23ee850b..f457515c47 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 110db72079..9dfadad74d 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/scroll_view.rs b/userland/capsule_terminal/src/term/state/shade.rs similarity index 66% rename from userland/capsule_terminal/src/term/grid/scroll_view.rs rename to userland/capsule_terminal/src/term/state/shade.rs index 1657a8d9cf..a5f66c814c 100644 --- a/userland/capsule_terminal/src/term/grid/scroll_view.rs +++ b/userland/capsule_terminal/src/term/state/shade.rs @@ -14,16 +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 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; +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 cad5fdd145..27557c8312 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 88bb961900..5b133987d9 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 d3eecfe18c..f0bcd3b98e 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,15 @@ 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; + let resized = sb.fit(cols, rows, m.adv, m.lh); + sb.follow_theme(theme_ix, theme); + if resized { + crate::jobs::tty::resized(self.cur_ref()); + } let l = crate::paint::paint_tabs( &self.tabs, self.active, @@ -42,6 +51,17 @@ 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, + pad: crate::paint::TEXT_LEFT, + 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 8769ffdb5f..0473afdc75 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 ba19cde5e0..4f1c1007ce 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 0000000000..4bb0977bb2 --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/cells.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 . + +//! 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::Term; + +use crate::paint::Rows; + +#[derive(Clone, Copy)] +pub struct CellGeom { + pub x: u32, + pub y: u32, + pub adv: u32, + pub lh: u32, + /// The margin drawn around the text, which a press may land in. + pub pad: u32, + /// Rows of the body a shell drawing uses; the screen owns all rows. + pub shell_rows: usize, + pub owned: bool, +} + +impl CellGeom { + pub(super) fn body_rows(&self, vt: &Term) -> usize { + if self.owned { + vt.rows() + } else { + self.shell_rows + } + } + + pub fn rows(&self, vt: &Term) -> Rows { + if self.owned { + Rows::Screen + } else { + Rows::Shell { rows: self.shell_rows, back: vt.view_offset() } + } + } +} diff --git a/userland/capsule_terminal/src/term/terminal/pointer/cells_hit.rs b/userland/capsule_terminal/src/term/terminal/pointer/cells_hit.rs new file mode 100644 index 0000000000..281cfc859d --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/cells_hit.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 . + +//! Reading a pointer position back as a cell. + +use nonos_vt::{Pos, Term}; + +use super::cells::CellGeom; +use crate::paint::Rows; + +impl CellGeom { + /// 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; + (col.min(vt.cols() - 1), row.min(self.body_rows(vt).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 } + } + + /* + * Over the text or the margin around it: a press a few pixels short of + * the first column is aimed at it, and cell_at clamps it there. + */ + pub fn inside(&self, vt: &Term, px: i32, py: i32) -> bool { + let w = vt.cols() as u32 * self.adv; + let h = self.body_rows(vt) as u32 * self.lh; + let left = self.x as i32 - self.pad as i32; + px >= left + && py >= self.y as i32 + && px < (self.x + w + self.pad) as i32 + && py < (self.y + h) as i32 + } +} diff --git a/userland/capsule_terminal/src/term/terminal/pointer/mod.rs b/userland/capsule_terminal/src/term/terminal/pointer/mod.rs new file mode 100644 index 0000000000..fb0184752b --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/mod.rs @@ -0,0 +1,29 @@ +// 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 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 cells_hit; +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 0000000000..82dd34123b --- /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 0000000000..7fff56af41 --- /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 0000000000..838e12db54 --- /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/terminal/pointer/state.rs b/userland/capsule_terminal/src/term/terminal/pointer/state.rs new file mode 100644 index 0000000000..95f8dd2262 --- /dev/null +++ b/userland/capsule_terminal/src/term/terminal/pointer/state.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 pointer is in the middle of. + +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