Files
Sprinter-SDCC/examples/mdview/PLAN.md
T
snark13 737c974400 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>
2026-06-04 22:23:36 +03:00

30 KiB
Raw Blame History

План: текстовый 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():

// 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-кодов:

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/closelibc/io/{open,read,lseek}.c (POSIX wrappers)
  • mem_alloc_pages/mem_free_block/mem_get_pagelibc/mem/mem_alloc.c
  • sprinter_page_w3() — inline __sfr write в libc/include/sprinter.h:113
  • bank_read_w3/bank_write_w3libc/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/whereylibc/conio/conio.c:412-462 (если нужно)
  • kbhit()libc/conio/conio.c:22 (для non-blocking опроса, опционально)
  • dec16/dec8libc/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

Сборка

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, эскиз)

#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.