tty: the grid was 80x24 on a 160x50 screen, and wrapping was eager

Three symptoms, two causes, both in the terminal layer.

**The grid and TIOCGWINSZ disagreed about the size.** vt_init() sizes every
grid from gfx_cols()/gfx_rows(), and it runs at main.c:75 -- while the display
driver attaches its scanout from the PCI bus walk at main.c:99. So the grids
were the 80x24 fallback, and TIOCGWINSZ, which computed its own answer live,
said 160x50.

That one disagreement produced all three things visible in the report: text
stopped two thirds of the way down the screen, `ls` laid out 160 columns into
an 80-column grid so words split across the wrap ("vers" / "ion"), and zsh's
cursor tracking was wrong on every line.

Fixed in two places, because either alone would leave the trap set:
gfx_attach_scanout() now calls vt_resize(), so the grids learn the real screen
the moment there is one; and TIOCGWINSZ reports v->cols/v->rows rather than
measuring again. One number, from the terminal being asked.

**Wrapping was eager.** A glyph printed in the last column moved the cursor to
the next line immediately. Real terminals set a pending-wrap flag and wrap when
the *next* glyph arrives -- which is why a line exactly as wide as the screen
takes one row and not two, and why a program can write the bottom-right cell
without scrolling.

zsh's partial-line marker depends on that rule directly: it writes exactly
COLUMNS-1 spaces and expects to still be on the line. With eager wrap it left a
reverse-video '#' above every prompt, which is the hashtag in the report. Both
are gone.

Verified by screendump: `ls /proc` now fits one line with "version" intact, no
markers, and output fills the screen to the last row. schfs-check passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ7Qx8E1q1M1bbCq8QLHS2
This commit is contained in:
Nexoniarz 2026-09-13 12:49:13 +02:00
commit a5b0d5a0ef
5 changed files with 137 additions and 25 deletions

View file

@ -109,6 +109,30 @@ keyboard driver names it and stops.
Input goes to whichever terminal is on screen. That is what makes a terminal a Input goes to whichever terminal is on screen. That is what makes a terminal a
terminal: the keyboard follows the eyes. 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 ## What it does not do
- **No scrollback.** The grid is exactly the visible screen. Shift+PageUp does - **No scrollback.** The grid is exactly the visible screen. Shift+PageUp does

View file

@ -79,6 +79,12 @@ struct vt {
struct vtcell *cells; // cols*rows, row-major, into vt_storage struct vtcell *cells; // cols*rows, row-major, into vt_storage
int cx, cy; // cursor, in cells int cx, cy; // cursor, in cells
// Deferred wrap, the rule every real terminal follows and none of them
// document well: a glyph printed in the last column does NOT move the
// cursor to the next line. It sets this instead, and the wrap happens when
// the *next* glyph arrives. Anything that moves the cursor deliberately --
// CR, LF, backspace, an escape sequence -- clears it.
int wrap_pending;
int saved_cx, saved_cy; // DECSC/DECRC int saved_cx, saved_cy; // DECSC/DECRC
uint8 fg, bg; // current SGR colours uint8 fg, bg; // current SGR colours
@ -118,6 +124,7 @@ struct vt {
extern int vt_active; extern int vt_active;
void vt_init(void); void vt_init(void);
void vt_resize(void); // a scanout arrived; re-measure
struct vt *vt_get(int i); // 0-based, clamped, never null struct vt *vt_get(int i); // 0-based, clamped, never null
struct vt *vt_current(void); struct vt *vt_current(void);
int vt_switch(int i); // 0-based; repaints. -1 if out of range int vt_switch(int i); // 0-based; repaints. -1 if out of range

View file

@ -21,6 +21,7 @@
#include "device.h" #include "device.h"
#include "display.h" #include "display.h"
#include "gfx.h" #include "gfx.h"
#include "vt.h"
#define BACKSPACE 0x100 #define BACKSPACE 0x100
#define CHAR_HEIGHT 16 #define CHAR_HEIGHT 16
@ -47,6 +48,12 @@ gfx_attach_scanout(const struct fb_info *info)
{ {
scanout = *info; scanout = *info;
fb = info->ptr; fb = info->ptr;
// Tell the terminals how big the screen actually is. They were sized before
// this driver existed -- the PCI bus is walked long after the terminals are
// set up -- so until this call every grid is the 80x24 fallback. See
// vt_resize().
vt_resize();
} }
const struct fb_info * const struct fb_info *

View file

@ -118,18 +118,29 @@ termios_defaults(struct termios *t)
t->c_ospeed = 15; t->c_ospeed = 15;
} }
void // The screen size, clamped to what a grid can hold.
vt_init(void) //
// 80x24 when there is no framebuffer yet -- which is the usual case when
// vt_init runs, because the display driver is found on the PCI bus and the
// terminals are set up before the bus is walked. vt_resize() corrects it the
// moment a scanout arrives.
static void
measure(int *out_cols, int *out_rows)
{ {
int cols = gfx_cols(), rows = gfx_rows(); int cols = gfx_cols(), rows = gfx_rows();
// No framebuffer (serial-only boot): 80x24 is a stated default rather than
// a measurement of nothing. The grids still work and still scroll; nobody
// is looking at them.
if (cols <= 0) cols = 80; if (cols <= 0) cols = 80;
if (rows <= 0) rows = 24; if (rows <= 0) rows = 24;
if (cols > VT_MAXCOLS) cols = VT_MAXCOLS; if (cols > VT_MAXCOLS) cols = VT_MAXCOLS;
if (rows > VT_MAXROWS) rows = VT_MAXROWS; if (rows > VT_MAXROWS) rows = VT_MAXROWS;
*out_cols = cols;
*out_rows = rows;
}
void
vt_init(void)
{
int cols, rows;
measure(&cols, &rows);
for (int i = 0; i < NVT; i++) { for (int i = 0; i < NVT; i++) {
struct vt *v = &vts[i]; struct vt *v = &vts[i];
@ -153,6 +164,42 @@ vt_init(void)
vt_ready = 1; vt_ready = 1;
} }
// The real screen has arrived. Re-measure and re-lay the grids.
//
// This exists because of the order the kernel comes up in: terminals are
// initialised before the PCI bus is walked, so the display driver attaches its
// scanout *after* vt_init has already sized every grid from a framebuffer that
// was not there yet. Without this the grids stayed 80x24 on a 160x50 screen --
// text stopped two thirds of the way down, `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.
//
// Content is not preserved. Reflowing a grid into a different width is a real
// problem and there is nothing here worth reflowing: this runs during boot,
// before console_takeover(), when every grid is still blank.
void
vt_resize(void)
{
int cols, rows;
if (!vt_ready)
return; // vt_init has not run; it will measure itself
measure(&cols, &rows);
if (cols == vts[0].cols && rows == vts[0].rows)
return; // same screen, nothing to do
for (int i = 0; i < NVT; i++) {
struct vt *v = &vts[i];
v->cols = cols;
v->rows = rows;
if (v->cx >= cols) v->cx = cols - 1;
if (v->cy >= rows) v->cy = rows - 1;
for (int r = 0; r < rows; r++)
blank_row(v, r);
}
vt_repaint(vt_current());
}
struct vt * struct vt *
vt_get(int i) vt_get(int i)
{ {
@ -219,6 +266,7 @@ vt_cursor_set(struct vt *v, int col, int row)
if (row >= v->rows) row = v->rows - 1; if (row >= v->rows) row = v->rows - 1;
v->cx = col; v->cx = col;
v->cy = row; v->cy = row;
v->wrap_pending = 0; // an explicit move is not a wrap
} }
void void
@ -239,21 +287,38 @@ put_blank_at(struct vt *v, int col, int row)
show(v, col, row); show(v, col, row);
} }
// Take the deferred wrap, if one is owed. Called before anything that needs
// the cursor to be at a real position.
static void
wrap_now(struct vt *v)
{
if (!v->wrap_pending)
return;
v->wrap_pending = 0;
v->cx = 0;
v->cy++;
while (v->cy >= v->rows)
vt_scroll(v);
}
void void
vt_putc(struct vt *v, int glyph) vt_putc(struct vt *v, int glyph)
{ {
switch (glyph) { switch (glyph) {
case '\n': case '\n':
v->wrap_pending = 0;
v->cx = 0; v->cx = 0;
v->cy++; v->cy++;
break; break;
case '\r': case '\r':
v->wrap_pending = 0;
v->cx = 0; v->cx = 0;
break; break;
case '\b': case '\b':
case 0x7f: case 0x7f:
v->wrap_pending = 0;
if (v->cx > 0) { if (v->cx > 0) {
v->cx--; v->cx--;
put_blank_at(v, v->cx, v->cy); put_blank_at(v, v->cx, v->cy);
@ -265,10 +330,12 @@ vt_putc(struct vt *v, int glyph)
// rather than skipping over stale characters. Without a case here '\t' // rather than skipping over stale characters. Without a case here '\t'
// reaches the font as glyph 9, which in CP437 is a hollow circle: pressing // reaches the font as glyph 9, which in CP437 is a hollow circle: pressing
// Tab printed a circle. // Tab printed a circle.
wrap_now(v);
do { do {
put_blank_at(v, v->cx, v->cy); put_blank_at(v, v->cx, v->cy);
v->cx++; v->cx++;
} while (v->cx % 8 != 0 && v->cx < v->cols); } while (v->cx % 8 != 0 && v->cx < v->cols);
if (v->cx >= v->cols) { v->cx = v->cols - 1; v->wrap_pending = 1; }
break; break;
default: { default: {
@ -282,21 +349,25 @@ vt_putc(struct vt *v, int glyph)
return; return;
if (glyph < 0 || glyph > 255) if (glyph < 0 || glyph > 255)
glyph = '?'; glyph = '?';
wrap_now(v); // the previous glyph filled the line
struct vtcell *k = cell_at(v, v->cx, v->cy); struct vtcell *k = cell_at(v, v->cx, v->cy);
k->ch = (uint8)glyph; k->ch = (uint8)glyph;
k->fg = v->fg; k->fg = v->fg;
k->bg = v->bg; k->bg = v->bg;
k->attr = (uint8)v->attr; k->attr = (uint8)v->attr;
show(v, v->cx, v->cy); show(v, v->cx, v->cy);
// Filling the last column does not move to the next line. It owes a wrap,
// and pays it when the next glyph arrives -- which is what makes a line
// that is exactly as wide as the screen take one row instead of two, and
// what lets a program write to the bottom-right cell without scrolling.
if (v->cx + 1 >= v->cols)
v->wrap_pending = 1;
else
v->cx++; v->cx++;
break; break;
} }
} }
if (v->cx >= v->cols) {
v->cx = 0;
v->cy++;
}
while (v->cy >= v->rows) while (v->cy >= v->rows)
vt_scroll(v); vt_scroll(v);
} }
@ -339,6 +410,7 @@ vt_clear(struct vt *v)
for (int r = 0; r < v->rows; r++) for (int r = 0; r < v->rows; r++)
blank_row(v, r); blank_row(v, r);
v->cx = v->cy = 0; v->cx = v->cy = 0;
v->wrap_pending = 0;
vt_repaint(v); vt_repaint(v);
} }

View file

@ -1061,22 +1061,24 @@ addr_t sys_ioctl(void)
if (request == TIOCGWINSZ) { if (request == TIOCGWINSZ) {
struct winsize *ws = (struct winsize*)user_ptr(argp, sizeof(*ws)); struct winsize *ws = (struct winsize*)user_ptr(argp, sizeof(*ws));
if (!ws) return -EFAULT; if (!ws) return -EFAULT;
// The console's real geometry, not a VT100's. // The terminal's own grid, and nothing else.
// //
// This used to answer 24x80 unconditionally -- the size of a terminal // This used to answer 24x80 unconditionally -- the size of a terminal
// SchOS has never drawn. Every program that lays out a screen asks // SchOS has never drawn -- and was then fixed to compute the answer
// this one question first: a line editor to decide where to wrap, `ls` // live from gfx_cols()/gfx_rows(). That was a second source of truth,
// to pick a column count, fastfetch to place its logo beside the text. // and it disagreed with the first: the grids are sized once at
// At 1024x768 with the 8x16 font the console is 128x48, so all of them // vt_init(), which runs before the display driver is found on the PCI
// were formatting for a screen a third the area of the real one, and // bus, so they were 80x24 while this reported 160x50. Programs
// each looked like a separate bug in a separate program. // formatted for a screen the grid did not have -- `ls` split words
int cols = gfx_cols(), rows = gfx_rows(); // across the wrap, text stopped two thirds of the way down, and zsh's
// Serial-only boot, with no framebuffer attached: 80x24 is then a // cursor tracking was wrong on every line.
// stated default rather than a wrong measurement. //
ws->ws_col = cols > 0 ? (uint16)cols : 80; // One number, from the terminal being asked. They cannot disagree.
ws->ws_row = rows > 0 ? (uint16)rows : 24; struct vt *v = vt_for_minor(f->ip->minor);
ws->ws_xpixel = (uint16)gfx_width(); ws->ws_col = (uint16)v->cols;
ws->ws_ypixel = (uint16)gfx_height(); ws->ws_row = (uint16)v->rows;
ws->ws_xpixel = (uint16)(v->cols * gfx_char_w());
ws->ws_ypixel = (uint16)(v->rows * gfx_char_h());
return 0; return 0;
} }
// The console's size is a property of the framebuffer, so a request to // The console's size is a property of the framebuffer, so a request to