SchOS/docs/architecture/terminals.md
Nexoniarz 91a6d440d6 tty: Alt+1..6 switches terminals, and AltGr is left alone
Alt+F1..F6 was wrong in three separate ways, and the third is the one that
matters most here.

  Alt+F4            the host window manager closes the QEMU window
  Alt+F2            GNOME and KDE open a run dialog
  Ctrl+Alt+F1..F6   the host switches its *own* virtual terminals
  AltGr+anything    Polish diacritics -- a c e l n o s x z with their marks --
                    and on some setups AltGr is delivered as Ctrl+Alt
  Ctrl+Shift+key    widely bound inside applications
  Scroll Lock       not present on many keyboards

Alt+1..6 survives all of it: the number row, so no function key is involved,
and left Alt, so AltGr is untouched.

Right Alt is tracked as its own modifier bit now. It used to share ALT, which
is harmless for typing -- neither is used to look up a character -- and wrong
for chords, because it made AltGr indistinguishable from Alt.

And the test is an equality rather than a mask:

    if ((shift & (CTL | SHIFT | ALT | RALT)) == ALT && ...)

`shift & ALT` fired on Ctrl+Alt+1 and AltGr+1 as well, so a chord aimed at the
host -- or an ordinary Polish keystroke -- would have switched terminals
behind your back. That was a real bug in what shipped yesterday.

Verified with QEMU's sendkey, comparing screendump hashes:

    start (tty1)   86cee2284878
    Ctrl+Alt+2     86cee2284878   identical -- did not fire
    AltGr+2        86cee2284878   identical -- did not fire
    Alt+2          114562804a78   switched
    Alt+1          86cee2284878   back, pixel-identical to the start

schfs-check passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
2026-09-13 12:56:34 +02:00

179 lines
7.8 KiB
Markdown

<!-- SPDX-License-Identifier: Apache-2.0 -->
# Virtual terminals
Six of them, `/dev/tty1` through `/dev/tty6`, reached with **Alt+1 .. Alt+6**
or with `chvt N`. Each one is an independent terminal: its own screen, its own
cursor, its own line discipline, its own foreground process group and its own
session. Two shells on two terminals are two unrelated processes that happen to
share a keyboard and a screen.
A seventh is deliberately not bound. That is where a display server goes.
---
## What makes switching possible
A backing store, and nothing else.
Before this, the console drew straight into the framebuffer and scrolled by
moving pixels. That works exactly as long as nothing else ever wants the
screen: there was nowhere to put terminal 2 while terminal 1 was visible, and
nothing to repaint terminal 1 from when it came back.
So each terminal owns a grid of cells and the framebuffer shows whichever grid
is active. `vt_switch()` is three lines — set the active index, repaint from
the grid, return — because nothing has to be *saved* on the way out. The
outgoing terminal's grid has been current all along.
The rule the whole layer is built on:
> **A terminal does not know whether it is visible.**
Writes land in the grid, the cursor moves, the grid scrolls, all of it
unconditionally. Drawing is a separate question, asked once, at the bottom of
`kernel/dev/vt.c`, answered by comparing against `vt_active`. Every alternative
spreads "am I on screen" through the escape parser, and then a terminal
switched away from mid-sequence comes back wrong.
## A cell
Four bytes:
| | |
|---|---|
| `ch` | the glyph the font draws — **not** a codepoint |
| `fg`, `bg` | palette indices, 0-15 ANSI plus two theme defaults |
| `attr` | reverse video; room for underline and the rest |
`ch` stores the glyph rather than the codepoint because the console maps UTF-8
to CP437 before it gets here, and that is what `include/font.h` contains.
Storing the codepoint would mean re-running the mapping on every repaint and
getting a different answer for anything the font cannot draw.
The colours are indices rather than RGB because a cell holding two 32-bit
colours would be twelve bytes instead of four, and the grids are static: six
terminals at 240x75 is 432 KB of BSS, paid once at link time rather than failed
to pay at run time. There is no contiguous multi-page allocator to ask.
## What is per-terminal
Everything that used to be a single global:
| Was | Now |
|---|---|
| `int tty_fg_pgrp = 1;` | `v->fg_pgrp`, one per terminal |
| one `struct termios tio` | `v->tio` |
| one 256-byte input ring | `v->in` |
| one escape-parser state | `v->esc_*` |
| one UTF-8 decoder | `v->u8_*` |
| one cursor, in pixels, in `gfx.c` | `v->cx`, `v->cy`, in cells |
`TIOCSCTTY` used to be `return 0` — it claimed to make a terminal the caller's
controlling terminal and did nothing, because there was one terminal and
nothing to choose between. It records `proc->ctty` now, which is what
`/dev/tty` resolves through.
## The devices
| Path | Minor | What |
|---|---|---|
| `/dev/console` | 1 | the **active** terminal, so kernel messages follow a switch |
| `/dev/tty` | 10 | the caller's **controlling** terminal, via `proc->ctty` |
| `/dev/tty1`..`tty6` | 11-16 | that terminal, whether or not it is on screen |
`/dev/tty` was an alias for `/dev/console` with the same major and minor. They
are different questions and now have different answers.
## The ioctls
Linux's numbers, because they do Linux's job.
| | |
|---|---|
| `VT_ACTIVATE` | switch. Argument is a 1-based number **by value**, not a pointer |
| `VT_WAITACTIVE` | returns immediately: `vt_switch` repaints synchronously, so there is nothing left to wait for |
| `VT_GETSTATE` | active terminal and a bitmask of those in use |
| `VT_OPENQRY` | the first terminal nobody has open |
| `VT_GETMODE`/`VT_SETMODE` | accepted and ignored — the kernel drives switching and there is no process mode to set |
## The keyboard
`kbd.c` recognises Alt+1..6 and returns `KEY_ALT_F1 + n`, which is above
0xFF on purpose: every value that fits in a byte is already either a character
or a UTF-8 continuation byte, and a chord that collided with one would be typed
into the terminal instead of switching away from it.
What the chord *means* is decided in `console.c`, where the terminals are. The
keyboard driver names it and stops.
### Why the number row, and why left Alt
Everything else was already spoken for:
| | |
|---|---|
| `Alt+F4` | the host window manager closes the QEMU window |
| `Alt+F2` | GNOME and KDE open a run dialog |
| `Ctrl+Alt+F1..F6` | the host switches its **own** virtual terminals |
| `AltGr+`anything | Polish diacritics — ą ć ę ł ń ó ś ź ż — and on some setups AltGr *is* Ctrl+Alt |
| `Ctrl+Shift+`key | widely bound inside applications |
| Scroll Lock | not present on many keyboards |
`Alt+1..6` survives all of it.
The modifier test is an **equality**, not a mask:
```c
if ((shift & (CTL | SHIFT | ALT | RALT)) == ALT && ...)
```
`shift & ALT` would fire on `Ctrl+Alt+1` and on `AltGr+1` too, so a chord aimed
at the host — or an ordinary Polish keystroke — would switch terminals behind
your back. Right Alt is tracked as its own bit (`RALT`) for exactly this
reason; it used to share `ALT`, which is harmless for typing, because neither
is used to look up a character, and wrong for chords.
Input goes to whichever terminal is on screen. That is what makes a terminal a
terminal: the keyboard follows the eyes.
## Two rules that are easy to get wrong
**The grid is the only source of truth for the size.** `TIOCGWINSZ` reports
`v->cols`/`v->rows`, not a fresh `gfx_cols()`/`gfx_rows()`. When it computed
its own answer there were two, and they disagreed: the grids are sized at
`vt_init()`, which runs before the display driver is found on the PCI bus, so
they were 80x24 while `TIOCGWINSZ` said 160x50. Text stopped two thirds of the
way down the screen, `ls` formatted for a width the grid did not have and split
words across the wrap, and zsh's cursor tracking was wrong on every line --
three symptoms, one disagreement. `vt_resize()` now corrects the grids when a
scanout arrives, and the ioctl reads them.
**A glyph in the last column does not wrap.** It sets `wrap_pending`, and the
wrap happens when the *next* glyph arrives. Every real terminal behaves this
way and none of them document it clearly. Wrapping immediately makes a line
exactly as wide as the screen take two rows instead of one, and makes it
impossible to write the bottom-right cell without scrolling -- and zsh's
partial-line marker depends on the rule directly: it writes exactly
`COLUMNS - 1` spaces and expects the cursor to still be on that line. With
immediate wrap, a reverse-video `#` appeared above every prompt.
Anything that moves the cursor deliberately -- CR, LF, backspace, an escape
sequence -- clears the debt.
## What it does not do
- **No scrollback.** The grid is exactly the visible screen. Shift+PageUp does
nothing because there is nothing above the top row to show.
- **No reflow on resize.** The geometry is read once at `vt_init()`. Nothing
resizes a framebuffer console today; a compositor will, and that is when this
has to grow.
- **No process-mode switching.** `VT_SETMODE` is accepted and ignored. A
display server that wants to veto a switch — which is what `VT_PROCESS` is
for — cannot yet, and will need to before Schcom owns Alt+F7.
- **No terminal past 6.** Linux allows 63. Six is what the chords reach and
what the grids are sized for.
- **Only 240x75 of any screen.** Larger framebuffers work; the grid is clamped
and the remainder stays background. Raising `VT_MAXCOLS`/`VT_MAXROWS` is the
fix, and costs BSS.
- **No UTF-8 the font lacks.** CP437 has ~256 glyphs. Anything else draws as
`?`, which is deliberate: drawing a *different* character would be worse.