Add mdview markdown viewer, reorganize tests/examples and libc layout

- Split tests/ (libc feature tests) and examples/ (real apps); shared
  app.mk in repo root, was examples/example.mk
- libc/io/* split into libc/{conio,env,errno,file,mouse,string,sys,
  time,video}/ — clearer module boundaries
- New examples/mdview/: markdown viewer (Phases 1-5 + light nested
  lists). Headers (H1-H4), HR, ulist/olist/quote with nesting via
  leading spaces, fenced code blocks, inline emphasis (bold/italic/
  underscore/code), wrap/unwrap mode with soft wrap (F2), horizontal
  pan (← →) with '>' truncation indicator
- libc additions: scroll() in conio (ESTEX SCROLL), strlwr/strupr,
  gets() test
- Makefile updates across tests/ for the new shared app.mk path

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 22:23:36 +03:00
parent b851e22fa6
commit 737c974400
104 changed files with 2485 additions and 223 deletions
+527
View File
@@ -0,0 +1,527 @@
# План: текстовый Markdown viewer для Sprinter (`examples/mdview`)
## Context
Тестовая крупная задача — проверить нашу libc на нетривиальном interactive-приложении (полноэкранный UI, файловый I/O, парсер). Параллельно даст хороший showcase платформы и поможет вытащить недоделки в conio/io. Конечная цель: viewer для `.md` файлов с подсветкой синтаксиса, навигацией по тексту и постраничным скроллингом.
**Ограничения v1 (зафиксированы пользователем):**
- POSIX file API (`open/read/lseek/close`); FILE*/fread/fgets — не использовать
- Максимум 16 KB на один файл (одна EMM-страница); многостраничный режим — в v2
- Подсветка через цвет: **размер заголовка** → цвет шрифта; **bold/italic** → цвет фона (моноширинный фонт без жирного/курсивного начертания)
---
## Архитектура
### Раскладка экрана (80×32, текст mode 0x03)
```
Row 0: ┃ mdview.md L 1-30 / 142 21% ┃ ← status bar (BG=blue, FG=white)
Row 1: ┃ ┃
... ┃ document viewport (30 rows) ┃
Row 30: ┃ ┃
Row 31: ┃ F1 Help F10 Exit┃ ← menu bar (BG=blue, FG=cyan)
```
- Viewport = 30 строк × 80 столбцов.
- Status / menu рендерятся через `wrchar()` (без авто-скролла), viewport — через `LOCATE` + посимвольный `wrchar()` (тоже без авто-скролла, ставит и char и attr за один call).
### Память
Memory mode: **`small`** — DSS отводит под наш образ **два банка (W1 + W2)** = 32 KB суммарно (CODE в W1, DATA + STACK + HEAP в W2). Этого должно хватить чтобы НЕ заводить `__banked` функции. W3 остаётся полностью свободным для маппинга больших буферов:
```
W0 (0x0000-0x3FFF): ESTEX (system, untouchable)
W1 (0x4000-0x7FFF): CODE (small mode page 1)
W2 (0x8000-0xBFFF): DATA + STACK + HEAP (small mode page 2)
W3 (0xC000-0xFFFF): paged window — переключается между EMM-страницами:
├── file page: исходный текст .md файла (16 KB)
└── cache page: кэш отформатированных строк (Phase 3+)
```
Почему small + W3:
- 32 KB на код+данные с большим запасом → нет банкинга
- W3 — стандартный paged window, под него у нас уже есть `bank_io_w3` API
- v2-расширение (multi-page файлы >16 KB): просто `mem_alloc_pages(N)` и переключение страниц в W3 через `sprinter_page_w3()`
Статики (в W2):
- `line_offset[MAX_LINES]` — uint16_t смещение каждой строки в файловой странице (4 KB на 2048 строк)
- `cache_tag[CACHE_N]` — uint16_t тег слота (200 байт на 100 слотов, появляется в Phase 3)
- `filename[64]`, `top_line`, `total_lines`, `file_size`, `file_blk`, `cache_blk` — единицы байт
**FILE_BUF**: `((char*)0xC000)` — фиксированная адресация в W3 после маппинга нужной страницы.
### Поток данных
```
main → open() → read() chunks 1KB → write to W1 (mapped EMM page) → close()
→ index_lines() (одно сканирование, заполняет line_offset[])
→ render_viewport() + main loop { getkey(); handle(); render_status(); render_viewport() }
```
**Тонкость с `read()`**: ESTEX READ записывает по dst-указателю в адресном пространстве вызывающего. Поскольку мы маппим EMM-страницу в W3 (0xC000) ДО вызова read(), указатель 0xC000+offset валиден. Если выяснится что BIOS трогает W3 во время read (графический видеобуфер по умолчанию в W3 при графических режимах, но в текстовом — должен быть свободен) — fallback: читать в 1 KB буфер в W2 и копировать в W3 через `bank_write_w3()`.
---
## Реализация — поэтапная
### Phase 1 — Plain text viewer (MVP)
**Что работает:**
- Загрузка файла (`open/read/close`) в W1-страницу
- Индексация строк (LF / CRLF разделители)
- Status bar: имя файла, L N-M / Total, процент скроллинга
- Menu bar: `F1 Help F10 Exit`
- Навигация: ↑/↓ (1 строка), PgUp/PgDn (30 строк), Home/End (начало/конец), Esc/F10 (выход), F1 (help screen)
- Обрезка строк длиннее 80 символов (без word-wrap)
- Цвета: текст белый на чёрном; status/menu — белый на синем
**Критичные файлы:**
- `examples/mdview/mdview.c` — main, key loop, rendering, indexing (one-file MVP)
- `examples/mdview/Makefile`
- `examples/mdview/SAMPLE.MD` — тестовый markdown файл
### Phase 2 — Headers и горизонтальная линия
**MD фичи:**
- `# H1` → ярко-жёлтый (COLOR_YELLOW = 14) на чёрном
- `## H2` → ярко-голубой (COLOR_LBLUE = 11)
- `### H3` → ярко-зелёный (COLOR_LGREEN = 10)
- `#### H4+` → серый (COLOR_GREY = 8)
- `---` / `***` на отдельной строке → линия 0xC4 (горизонтальная рамка ASCII) во всю ширину
### Phase 3 — Inline emphasis
**Парсер inline (per-line, runs в одну строку):**
- `**bold**` → ATTR_TEXT_BOLD
- `*italic*` → ATTR_TEXT_ITALIC
- `_underscore_` → ATTR_TEXT_UNDERSORE
- `` `code` `` → ATTR_TEXT_CODE
- Маркеры `**`/`*`/`_`/`` ` `` НЕ рендерятся (съедаются)
State machine: один активный стиль одновременно (без вложенности); конфликтующий маркер при чужом активном стиле уходит литералом. Состояние сбрасывается на каждой строке.
> **Кэш отформатированных строк** — отложен в самый конец, см. "Phase ∞: оптимизации".
> Скорости текущего наивного рендера хватает на 80×30 = 2400 wrchar / кадр; PgUp/PgDn визуально мгновенен.
### Phase 4 — Block elements
- Маркированные списки: `- foo`, `* foo`, `+ foo` → префикс `•` (0x07) + пробел; цвет маркера ярче основного
- Нумерованные списки: `1. foo`, `2. foo` → как есть (число оставляем)
- Blockquote: строки с `> ` → префикс `│` (0xB3) серого цвета, остальной текст слегка приглушённый
- Fenced code blocks: `` ``` `` открывает/закрывает блок; все строки между — bg=серый, моноширинно (без inline-парсинга)
- Indented code blocks (4+ пробелов): аналогично fenced, но без явного маркера
**Light nested lists (v1 — реализовано):**
- `classify_line()` пропускает leading spaces перед ulist/olist/quote маркером,
возвращает `content_off` после маркера → `content_off - p_start` = indent + marker
бит в visible col.
- `render_line()` рисует leading-spaces в `ATTR_TEXT`, потом маркер на сдвинутой
позиции (col = indent). Marker всё ещё фиксирован при горизонтальном pan'е.
- HR / header / fence delim остаются строго col-0 (CommonMark разрешает до 3
ведущих пробелов для них — упростили).
- Tab-indent → не распознаётся как nesting (только spaces).
**Phase 4-full — полная поддержка вложенности (deferred):**
- **Tab-indent**: считать tab = 4 пробела для определения уровня.
- **Quote nesting** (`> > foo`): каждый `>` подряд = +1 уровень, каждый рисуется
отдельным `│` в `ATTR_QUOTE_MARKER` (визуальная "лестница" слева).
- **Hanging indent в wrap-continuation**: когда `- some very long bullet text
that wraps...` — continuation seg должен начинаться от content-col (после
маркера), а не от col 0. Сейчас continuation идёт от col 0 (v1 simplification).
Требует хранить `marker_width` per логическая строка (8 бит) или re-classify
first seg при рендере continuation.
- **Lazy continuation**: строки без маркера, но с правильным indent под
предыдущим bullet'ом, должны считаться продолжением того bullet'а
(визуально — общий attr).
- **Strict CommonMark indent rules**: вложенный пункт должен быть на indent
≥ content_col родителя, иначе считается breakout. Нужен мини-stack
активных списков при индексации.
### Phase 5 — Wrap / Unwrap длинных строк
Дефолт: **wrap on**. F2 переключает; в меню-баре подпись отражает действие
("Unwrap" когда wrap включён, "Wrap" когда выключен).
**v1 — реализовано:**
- Один массив `line_offset[2048]` хранит ВИДИМЫЕ сегменты (а не логические
строки); биты 0..13 — байтовое смещение, бит 15 — CONT-флаг continuation.
- Wrap-режим: soft wrap на последнем пробеле ≤ 80; hard fallback если
пробела нет.
- Маркеры эмфазиса (`**`/`*`/`_`/`` ` ``) и header-префиксы (`#`/`##`/...) не
учитываются в visible-col при поиске точки переноса.
- "Специальные" логические строки не wrap'аются вообще (одна seg-запись на
логическую строку): fence delim, table row (header/separator/body), HR.
- F2 toggle сохраняет визуальную позицию через `top_offset` в FILE_BUF.
- Bitmaps (`in_code`/`in_table`/`is_tab_hdr`) перестраиваются вместе с
сегментами, индексируются seg-индексом, биты ставятся только на первом
seg'е логической строки.
- Continuation-сегменты рендерятся в стиле "v1: плоско" — plain text,
никаких markdown-классификаций; padding до конца строки `ATTR_TEXT`.
**v1.5 — отложено для полной картины wrap:**
- **Hanging indent**: continuation от ulist/olist/quote должен выравниваться
под content, а не от col 0. Требует хранить marker_width per логическая
строка ИЛИ re-classify первого seg'а при рендере continuation.
- **Наследование base_attr**: continuation от header'а должен сохранять
цвет; continuation от code body — фон ATTR_TEXT_CODE. Требует хранить
1 байт `base_attr` per seg ИЛИ lookup первого seg'а.
- **Inline emphasis через границу**: эмфазис, открытый в первом seg'е и не
закрытый, должен продолжаться во втором. Требует хранить emph state per
seg (3 бита).
- Compact way: добавить параллельный массив `seg_meta[MAX_SEGS]` по 1 байту
— пакует marker_width (4 бита) + emph_state (3 бита) + base_attr_idx
(4 бита из таблицы → нужен 2-байтовый seg_meta).
- **Hpan для длинных строк**: если wrap выключен, добавить ←/→ для
горизонтального скролла >80 cols. Общий механизм с tables (Phase 6).
**РЕАЛИЗОВАНО** (light) — `viewport_x` + полный re-render на каждое ←/→.
- **Ускорение hpan через ESTEX WINCOPY/WINREST** (deferred): сейчас pan
делает полный `render_viewport()` = 30 строк × 80 wrchar. Можно
скопировать существующее содержимое viewport'а на N cols влево/вправо
через win-copy, потом рендерить только узкую полосу справа/слева
(HPAN_STEP cols × 30 rows ≈ 240 wrchar вместо 2400). ESTEX SCROLL
горизонталь не поддерживает — нужна именно WINCOPY-операция или
rdchar/wrchar loop. Активировать когда ощутится тормоз; сейчас на
типовом markdown'е не заметно.
**v2 — отдельная фича, мимо wrap:**
- Toggle подсветки целиком (F3?)
- Search по тексту (Ctrl+F / F4)
- Links `[text](url)` → синий подчёркнутый text, url прячется
- Images `![alt](path)` → `[IMG: alt]`
### Phase 7 — Links и поиск (post-v1)
- `[text](url)` → отрисовать только `text` с ярко-синим FG (визуально подчёркнутое)
- `![alt](path)` → `[IMG: alt]` в скобках
- Search по тексту (F3 / Ctrl+F): инкрементальный, подсветка совпадений
### Phase 8 — F8 Raw / Render toggle
Переключатель режима отображения: при включённом Raw показывается исходный
текст файла как есть — все markdown-маркеры (`#`, `**`, `_`, `` ` ``, `|`,
`>`, `-`, etc.) рендерятся литералами с `ATTR_TEXT`, без классификации.
Полезно когда:
- нужно увидеть точную разметку (отлаживание .md, скриншоты, копирование)
- markdown-классификатор ошибся и хочется увидеть оригинал
- хочется быстро сравнить "до/после" рендера
**Поведение:**
- F8 переключает `render_mode` (1=render, 0=raw); меню показывает обратное
действие ("Raw" когда сейчас render, "Render" когда сейчас raw) — той же
логикой что F2/Wrap/Unwrap.
- В Raw режиме: `render_line()` идёт по короткому пути — никакого
`classify_line`, `is_fence_delim`, `is_code_body`, inline-эмфазиса; просто
байтовый дамп FILE_BUF от seg-offset до next-seg/EOL с tab-expansion и
ATTR_TEXT.
- Раздельно от F2: оба режима независимы (можно Raw+Wrap, Raw+Truncate,
Render+Wrap, Render+Truncate). Wrap-логика в `index_lines` работает в
обоих случаях одинаково (опирается на визуальные колонки независимо от
раскраски).
- Статус-бар: добавить индикатор `[R]` / `[V]` (Raw / View) или текстом
`RAW` рядом с именем файла.
**Минимальная реализация:**
- Один новый static `uint8_t render_mode = 1;`
- В `render_line()`: на самом верху `if (!render_mode) { … raw render … return; }`
- В `render_menu()`: добавить ярлык F8 рядом с F2.
- В главном цикле: `case KEY_F8: toggle_render(); break;`
- `toggle_render()` отличается от `toggle_wrap()` тем, что НЕ перестраивает
`line_offset[]` (wrap-сегментация не меняется), только перерендерит экран.
---
## API-новинки в libc (минимальные)
### `getkey()` — extended key reader (в libc)
Текущий `getch()` теряет scan code расширенных клавиш (возвращает только E=ASCII). Добавляем **сразу в `libc/conio/conio.c`** новую функцию рядом с `getch()`:
```c
// Returns scan in high byte, ASCII in low byte.
// Extended keys (arrows, F-keys, PgUp/PgDn, Home/End): ASCII=0, scan code в high byte.
// Plain keys: ASCII в low byte; high byte содержит positional scan (бит 7 = Ctrl/Alt/Shift modifier).
uint16_t getkey(void) __naked {
__asm
ld c, #0x30 ; ESTEX WAITKEY
rst #0x10 ; A=ASCII, D=scan, E=ASCII
ld e, a ; ensure E=ASCII even if E clobbered
ret ; SDCC __sdcccall(1): возврат uint16_t в DE (D=scan, E=ASCII)
__endasm;
}
```
Дополнительно — **в `libc/include/conio.h`** прописать прототип и константы scan-кодов:
```c
uint16_t getkey(void);
/* Scan codes for getkey() high byte when ASCII=0 (extended keys). */
#define KEY_F1 0x0E
#define KEY_F2 0x0F
#define KEY_F3 0x10
#define KEY_F4 0x11
#define KEY_F5 0x12
#define KEY_F6 0x13
#define KEY_F7 0x14
#define KEY_F8 0x15
#define KEY_F9 0x16
#define KEY_F10 0x17
#define KEY_F11 0x18
#define KEY_F12 0x19
#define KEY_END 0x24
#define KEY_DOWN 0x25
#define KEY_PGDN 0x26
#define KEY_LEFT 0x27
#define KEY_RIGHT 0x29
#define KEY_HOME 0x2A
#define KEY_UP 0x2B
#define KEY_PGUP 0x2C
#define KEY_INS 0x23
#define KEY_DEL 0x22
```
**Скан-коды (из docs/converted/ProgrammerManual.txt:2143-2323):**
| Клавиша | scan | Клавиша | scan |
|---|---|---|---|
| F1 | 0x0E | Up | 0x2B |
| F10 | 0x17 | Down | 0x25 |
| F11 | 0x18 | Left | 0x27 |
| F12 | 0x19 | Right | 0x29 |
| | | PgUp | 0x2C |
| | | PgDn | 0x26 |
| | | Home | 0x2A |
| | | End | 0x24 |
### Что **переиспользуем** из существующей libc
- `open/read/lseek/close` — `libc/io/{open,read,lseek}.c` (POSIX wrappers)
- `mem_alloc_pages/mem_free_block/mem_get_page` — `libc/mem/mem_alloc.c`
- `sprinter_page_w3()` — inline `__sfr` write в `libc/include/sprinter.h:113`
- `bank_read_w3/bank_write_w3` — `libc/mem/bank_io_w3.c` (для fallback или v2 multi-page)
- `wrchar(x, y, ch, attr)` — `libc/conio/conio.c:476` (без auto-scroll, идеально для viewport)
- `clrscr_attr(attr)` — `libc/conio/conio.c:395`
- `gotoxy/wherex/wherey` — `libc/conio/conio.c:412-462` (если нужно)
- `kbhit()` — `libc/conio/conio.c:22` (для non-blocking опроса, опционально)
- `dec16/dec8` — `libc/stdio/dec_print.c` (для status bar: текущая строка / total / %)
- `COLOR(fg, bg)` макрос — `libc/include/conio.h:152`
- Цветовые константы `COLOR_*` — `libc/include/conio.h:145`
- `strlen/memcpy/memset` — z80.lib (НЕ переписывать)
---
## Структура исходников
```
examples/mdview/
├── Makefile # стандартный pattern (см. examples/cat/Makefile)
├── mdview.c # Phase 1: всё в одном файле (main, keys, indexing, render)
├── SAMPLE.MD # тестовый markdown
└── README.md # описание и controls
```
После Phase 3 раскидать по модулям (если суммарный размер > ~6KB):
```
mdview.c — main loop, status/menu bars, key dispatch
mdrender.c — line rendering with MD inline parser
mdindex.c — file load + line indexing
```
### Сборка
```makefile
PROJ ?= ../..
SPRINTER_CC := $(PROJ)/bin/sprinter-cc
mdview.exe: mdview.c
$(SPRINTER_CC) --memory small -o $@ mdview.c
```
`--memory small`: код+данные в W2 (DSS даёт нужное число страниц); файл — отдельная EMM-страница в W3.
---
## Структура mdview.c (Phase 1, эскиз)
```c
#include <stdint.h>
#include <stdio.h>
#include <conio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sprinter.h>
#include <sprinter_mem.h>
#define VIEW_TOP 1
#define VIEW_BOT 30 // inclusive
#define VIEW_H 30
#define SCREEN_W 80
#define MAX_LINES 2048
#define FILE_BUF ((char*)0xC000) /* W3 — EMM page mapped here */
#define TAB_STOP 4
#define ATTR_TEXT COLOR(COLOR_WHITE, COLOR_BLACK)
#define ATTR_BAR COLOR(COLOR_WHITE, COLOR_BLUE)
#define ATTR_MENU_K COLOR(COLOR_YELLOW, COLOR_BLUE)
#define ATTR_MENU_T COLOR(COLOR_WHITE, COLOR_BLUE)
static uint16_t line_off[MAX_LINES];
static uint16_t n_lines;
static uint16_t top_line;
static uint16_t file_size;
static uint8_t file_blk;
static char filename[64];
static int load_file(const char *path); // open, alloc EMM page, map W3, read, close
static void index_lines(void); // scan FILE_BUF, fill line_off[]
static void render_status(void); // row 0
static void render_menu(void); // row 31
static void render_line(uint16_t idx, uint8_t row); // one line @ row
static void render_viewport(void); // VIEW_H lines starting from top_line
static void scroll_up(uint16_t n);
static void scroll_down(uint16_t n);
static void help_screen(void); // F1
int main(int argc, char **argv) {
if (argc < 2) { puts("Usage: mdview <file.md>"); return 1; }
if (load_file(argv[1]) < 0) { puts("load error"); return 1; }
index_lines();
clrscr_attr(ATTR_TEXT);
render_menu();
render_status();
render_viewport();
for (;;) {
uint16_t k = getkey();
uint8_t ascii = k & 0xFF;
uint8_t scan = (k >> 8) & 0x7F; // strip mod bit
if (ascii) {
if (ascii == 0x1B) break; // Esc → exit
continue;
}
switch (scan) {
case KEY_F10: goto exit;
case KEY_F1: help_screen(); break;
case KEY_UP: scroll_up(1); break;
case KEY_DOWN: scroll_down(1); break;
case KEY_PGUP: scroll_up(VIEW_H); break;
case KEY_PGDN: scroll_down(VIEW_H); break;
case KEY_HOME: top_line = 0;
render_viewport(); break;
case KEY_END: /* clamp to last viewport */ break;
}
render_status();
}
exit:
mem_free_block(file_blk);
clrscr_attr(ATTR_TEXT);
return 0;
}
```
---
## Verification
### Phase 1
1. Подготовить `SAMPLE.MD` ~5 KB (заголовки, абзацы, списки) — рендериться будет plain.
2. `cd examples/mdview && make`
3. `python make_disk.py mdview.exe SAMPLE.MD → mc.img && ./run_mame.sh`
4. Проверить:
- Status bar показывает `SAMPLE.MD L 1-30 / N X%`
- Menu bar внизу
- ↑/↓: 1 строка
- PgUp/PgDn: 30 строк, корректное clamp на границах
- Home: top_line=0
- End: top_line = total_lines - VIEW_H
- F1: показывает help, любая клавиша возвращает
- F10 / Esc: выход, экран очищен
5. Edge cases: пустой файл, файл из одной строки, файл с очень длинной строкой (>80), CRLF и LF mixed.
### Phase 2-4
Расширять `SAMPLE.MD` с фичами по мере добавления, визуально верифицировать в MAME. Скриншот-сравнение опционально.
### Регрессии
- Никаких изменений в libc на Phase 1 кроме (потенциально) добавления `getkey()` в `libc/conio/conio.c` — если так, прогнать `examples/conio2` и `examples/filetest` чтобы убедиться что ничего не сломалось.
---
## Решения по неоднозначностям
1. **Word-wrap vs truncate**: v1 = truncate (просто). v2 — F2 toggle wrap.
2. **Горизонтальный скроллинг**: v1 — нет; v2 — `←/→` сдвиг viewport по столбцам.
3. **Tab handling**: преобразование при рендере, **tabstop = 4** (стандарт MD). Оригинал в W3 не трогаем.
4. **UTF-8**: рендерим байты как есть. Если файл в CP866 — отрисуется кириллицей через системный фонт. UTF-8 — пока не поддерживаем (визуально будет каша на не-ASCII символах; детект и предупреждение — в v2).
---
### Phase 6 — Полный layout таблиц (deferred)
Сейчас (Phase 4-tables) таблицы рисуются "декоративно" — пайпы и тире
заменяются на box-drawing chars, но ширины колонок берутся как есть из
исходника. Цель Phase 6 — пересчитать таблицу в нормализованный вид:
- **Pre-scan таблицы**: пройти все строки одного table-блока, найти
максимальную ширину каждой колонки (с учётом съеденных inline-маркеров —
визуальный размер, не байтовый).
- **Re-emit в буфере**: при загрузке файла (или при первой встрече таблицы)
переписать строки в FILE_BUF так, чтобы все ячейки одной колонки имели
одинаковую ширину; добавить top/bottom рамки (`┌─┬─┐` / `└─┴─┘`) как
синтетические строки. Это позволит сохранить 1:1 соответствие "логическая
строка → одна viewport row" без специальной логики при рендере.
- **Память**: re-emit может УВЕЛИЧИТЬ файл за счёт padding и доп.рамок.
Если буфер близок к 16KB — отрезать таблицу и пометить её overflow'ом.
- **Горизонтальный скроллинг**: если итоговая ширина таблицы (или любой
строки) > SCREEN_W = 80 — добавить ←/→ для horizontal pan. Это будет
общий механизм для длинных строк (см. также wrap mode), не только таблиц.
- **Выравнивание из separator-row**: `:-` → left, `-:` → right, `:-:` →
center; учитывать при padding'е содержимого ячейки.
- **Шаги реализации**:
1. Walking pass по фенсам/таблицам прямо в `index_lines()` — собрать
extents всех таблиц.
2. Для каждой таблицы — определить ширины колонок.
3. Решение: rewrite-in-buffer (проще для рендера, но мутирует исходник)
vs render-time layout (cleaner, но требует отдельной структуры
описания layout'а на каждую таблицу).
4. Hpan: общий `viewport_x_offset` для всего экрана, или отдельный
"широкий режим" только внутри таблиц.
> Не блокирующая фича. Запускать когда станет понятен типовой источник
> markdown-файлов (узкие читалки → достаточно текущего декоратора;
> широкие README с большими таблицами → нужен полный layout).
---
### Phase ∞ — Кэш рендеренных строк (low priority)
Отложено: текущая скорость более чем достаточна. Активировать если появится
сценарий, где видна задержка PgUp/PgDn (например, при тяжёлом inline-парсере
v2 с UTF-8 / linkifier / таблицами).
**Кэш отформатированных строк** (W3, отдельная EMM-страница):
```
Cache layout (16 KB EMM page, всего 16000 байт используется):
slot 0: 80 chars + 80 attrs = 160 bytes @ offset 0
...
slot 99: 80 chars + 80 attrs = 160 bytes @ offset 15840
Cache tags (W2 static): uint16_t cache_tag[100] = 200 bytes
cache_tag[i] = line_id, или 0xFFFF = invalid
```
Стратегия — **direct map (no LRU)**: `slot = line_id % 100`. Коллизия → вытеснение.
**Batched viewport render**: 2 page-swap'а на ВЕСЬ viewport (cache → file → cache),
не 60 как при наивной реализации.
При сборке: `cache_blk = mem_alloc_pages(1)` после `file_blk`; `mem_free_block` на exit.
---
## Что отложено в v2
- Файлы >16 KB: многостраничное хранение через `mem_alloc_pages(N)`, ленивая загрузка страниц в W3 при доступе.
- Search (Find / Find next) — F3 / F4.
- Toggle highlight on/off — F2.
- Word wrap.
- Image alt-text rendering.
- Tables.