From eab5a2d6ac0ad1af1c5a10fc5ed6ec78065ea4eb Mon Sep 17 00:00:00 2001 From: Alexander Petrov Date: Tue, 23 Jun 2026 22:41:32 +0300 Subject: [PATCH] mdview2: add render-cache markdown viewer (Phases 0-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New examples/mdview2 — render-cache version of mdview: each logical line is rendered into an EMM (char,attr) buffer once during file load (interleaved with index_lines()), then scrolling draws straight from the cache via ESTEX WINREST, with no re-parsing or fb() access in the steady state. Horizontal scroll still uses the old live-render path (Phase 5, not yet migrated). Format and budget were derisked empirically first (tests/winrest): confirmed ESTEX WINCOPY/WINREST buffer layout and measured EMM free space, both folded into the implementation. Co-Authored-By: Claude Sonnet 4.6 --- examples/mdview2/Makefile | 42 + examples/mdview2/README.MD | 133 ++ examples/mdview2/docs/mdview2-plan.md | 164 ++ examples/mdview2/mdview2.c | 2539 +++++++++++++++++++++++++ tests/winrest/Makefile | 5 + tests/winrest/winrest.c | 151 ++ 6 files changed, 3034 insertions(+) create mode 100644 examples/mdview2/Makefile create mode 100644 examples/mdview2/README.MD create mode 100644 examples/mdview2/docs/mdview2-plan.md create mode 100644 examples/mdview2/mdview2.c create mode 100644 tests/winrest/Makefile create mode 100644 tests/winrest/winrest.c diff --git a/examples/mdview2/Makefile b/examples/mdview2/Makefile new file mode 100644 index 0000000..56c3a22 --- /dev/null +++ b/examples/mdview2/Makefile @@ -0,0 +1,42 @@ +# Build mdview2.exe — Markdown viewer for Sprinter (render-cache version). +# +# Фаза 0: скаффолдинг — копия mdview.c как baseline, без изменений +# логики. План — docs/mdview2-plan.md. +# +# small memory mode: code in W1, data/stack/heap in W2 (32 KB total). +# W3 stays free for the file buffer (EMM-mapped). + +PROJ_ROOT := $(abspath $(CURDIR)/../..) +EXAMPLE := mdview2 +MEMORY := small + +include $(PROJ_ROOT)/app.mk + +# ------------------------------------------------------------------ +# Образ дискеты: только mdview2.exe + README.MD (перекодированный +# из UTF-8 в CP866 — рабочую кодировку Sprinter). +# +# README.MD хранится в репозитории в UTF-8; iconv -c конвертирует +# его в CP866, отбрасывая символы без аналога в целевой кодировке. +# Результат кладётся в .disk_tmp/README.MD, чтобы make_disk.py +# использовал правильное имя файла на диске. +# ------------------------------------------------------------------ +DISK_TMP := .disk_tmp +README_DISK := $(DISK_TMP)/README.MD + +$(DISK_TMP): + mkdir -p $@ + +$(README_DISK): README.MD | $(DISK_TMP) + iconv -c -f UTF-8 -t CP866 README.MD > $@ + +floppy: $(EXAMPLE).exe $(README_DISK) + python3 $(MAKE_DISK) $(FLOPPY_IMG) $(EXAMPLE).exe $(README_DISK) + @echo + @echo "Floppy ready: $(FLOPPY_IMG)" + @echo "Run: cd $(MAME_DIR) && ./run_mame.sh" + +clean: + rm -rf .sprinter-cc-* $(EXAMPLE).exe $(DISK_TMP) + +.PHONY: all clean floppy run diff --git a/examples/mdview2/README.MD b/examples/mdview2/README.MD new file mode 100644 index 0000000..f0d40d8 --- /dev/null +++ b/examples/mdview2/README.MD @@ -0,0 +1,133 @@ +# MDView — Просмотрщик Markdown для Sprinter + +**MDView** — программа для просмотра документов в формате *Markdown* на компьютере +Sprinter (процессор Z80). Документ хранится в отдельном W3 окне и не занимает +основную RAM программы. + +## Возможности + +- Документы до **128 КБ** (8 страниц EMM по 16 КБ каждая) +- До **16 384** экранных строк в индексе +- Автоматический перенос слов по ширине экрана (80 столбцов) +- Горизонтальный сдвиг для широких строк (блоки кода, таблицы) +- Статус-бар: имя файла, диапазон строк, процент прокрутки +- Спиннер в строке состояния во время загрузки и индексации +- Поддержка «мягкого» склеивания строк в абзацах и цитатах + +## Запуск + +``` +mdview [имя_файла.md] +``` + +Если имя файла не задано, загружается `README.MD`. + +## Управление + +``` +Клавиша Действие +───────────── ──────────────────────────────────────── +Up Down Прокрутка на одну строку вверх / вниз +PgUp PgDn Прокрутка на страницу (30 строк) +Home Начало документа +End Конец документа +Left Right Горизонтальный сдвиг (только nowrap-строки) +F1 Окно справки +F10 / Esc Выход из программы +``` + +## Синтаксис Markdown + +### Заголовки + +Поддерживаются уровни H1–H4. Уровни H5 и H6 отображаются как H4. + + # Заголовок первого уровня + ## Заголовок второго уровня + ### Заголовок третьего уровня + #### Заголовок четвёртого уровня + +### Текстовое форматирование + +**Жирный текст** выделяется двойными звёздочками: `**текст**` + +*Курсив* выделяется одиночными звёздочками `*текст*` или знаком подчёркивания `_текст_` + +`Встроенный код` обозначается обратными кавычками + +~~Зачёркнутый текст~~ — двойные тильды: `~~текст~~` + +### Ненумерованный список + +Маркеры `-`, `*` или `+`: + +- Первый пункт списка +- Второй пункт списка +- Третий пункт с достаточно длинным текстом, который при необходимости + будет перенесён на следующую строку с сохранением отступа + +### Нумерованный список + +1. Первый элемент +2. Второй элемент +3. Третий элемент + +### Цитата + +> Блок цитаты начинается с символа `>`. Несколько последовательных +> строк одной цитаты склеиваются в единый абзац с автоматическим +> переносом слов. + +### Блок кода (verbatim) + +Блок кода заключается в тройные обратные кавычки. Внутри блока +текст отображается «как есть» без разбора Markdown: + +``` +#include +#include + +int main(void) { + puts("Hello, Sprinter!"); + return 0; +} +``` + +### Горизонтальная линия + +Три или более символов `---`, `***` или `___` на отдельной строке: + +--- + +## Технические характеристики + +- **Платформа:** Sprinter, процессор Z80 @ 21 МГц +- **Кодировка:** CP866 (DOS Cyrillic) +- **Максимальный размер файла:** 128 КБ +- **Максимальное число строк в индексе:** 16 384 +- **Режим памяти:** small + - Код программы, cтек, данные, куча — окнa W1-W2 (32 КБ, адреса 0x4000–0xBFFF). + - Буфер файла — страницы EMM, отображаемые в W3 (0xC000–0xFFFF) + +## TODO + +1. **Увеличение размера документов.** Снять лимит 128 КБ: Достаточно + разрешить работать с большим кол-вом страниц памяти, пока оттестированно + на работе с 8-мю страницами по 16Кб. + +2. **Форматированные таблицы.** Разбирать строки вида `| ячейка | ячейка |` + с автоматическим выравниванием столбцов и отрисовкой разделительных + линий (строки `|---|---|`). На текущий момент таблицы отображаются + как обычные nowrap-строки без выравнивания. + +3. **Поддержка кодировок CP1251 и UTF-8.** Автоопределение кодировки + по BOM, явное указание через аргумент командной строки (`--encoding cp1251`), + возможность переключения кодировки во время просмотра (`F8`). + Нужно прежде всего для документов на русском языке; CP866 кодировака уже поддерживается. + +4. **Ускорение рендеринга.** Кэш строк экрана. Оптимизация цикла вывода + символов через BIOS WRCHAR (пакетный вывод, DMA). + +--- + +*MDView v0.2 · (c) 2026 Петров А.Г.* diff --git a/examples/mdview2/docs/mdview2-plan.md b/examples/mdview2/docs/mdview2-plan.md new file mode 100644 index 0000000..6bfa8a9 --- /dev/null +++ b/examples/mdview2/docs/mdview2-plan.md @@ -0,0 +1,164 @@ +# mdview2 — план: рендер-кэш вместо живого парсинга на каждый скролл + +Контекст и обоснование — см. обсуждение 2026-06-23 (после оптимизации mdview через +``, см. `mdview-модель-документа-и-рендеринг.md`). Идея: вместо того +чтобы при каждом скролле повторно идти в файл (`fb()`, W3-банкинг) и гонять +markdown inline-парсер (`handle_inline_marker`/emphasis state machine), один раз +отрендерить каждую логическую строку в готовый байтовый буфер и дальше выводить +его на экран напрямую — без парсинга, без обращения к исходному файлу. + +Ключевые подтверждённые факты (эмпирически в MAME, не из документации): + +- **Формат буфера ESTEX `WINCOPY`(59h)/`WINREST`(5Ah)** — пара байт `(char, attr)` + на ячейку, по строкам, шаг строки = `width*2`, без паддинга. Можно генерировать + самим, не вызывая `WINCOPY`. `WINREST` копирует сразу `H` строк одним вызовом. + Детали ABI и регистров — см. `tests/winrest/winrest.c`. +- **Бюджет EMM**: 256 страниц (4 МБ) total, 215 (3440 КБ) free на старте программы. + Файл+индекс в худшем случае (128 КБ файл) съедают 16 страниц (256 КБ) — остаётся + ≈199 страниц (3184 КБ). Кэш всего документа целиком (даже худший случай: + 16384 строк × 160 байт = 2.6 МБ) укладывается без LRU/частичного кэша. + +## Единый формат строки (пересмотрено 2026-06-23) + +Исходно планировалось два типа строк (тип 1 — многоцветные, помещающиеся; +тип 2 — nowrap/code, один стиль, чистый текст для экономии памяти). От этого +деления отказались: строки таблиц (`IF_NOWRAP`, но не `IF_CODE`) всё равно +проходят inline-парсер и могут содержать несколько атрибутов (bold/italic в +ячейках) — предположение "один стиль" для них неверно. Бюджет EMM +([[sprinter_emm_budget]]) с большим запасом покрывает (char,attr)-формат для +ВСЕХ строк без исключения, поэтому усложнение не оправдано. + +**Единый формат**: кэш-запись = `len` пар `(char,attr)` — реальная длина +контента в ячейках, без паддинга до 80, капается на `MAX_CACHE_LINE_LEN`=255. +Вывод: `bios_fillcharattr(' ', base_attr, SCREEN_W)` (очистить строку) → +`win_rest(row, left_margin, 1, len, page)` (контент). Для широких nowrap-строк +горизонтальный скролл (Фаза 5) — это просто смещение НАЧАЛА среза внутри ТОГО +ЖЕ (char,attr)-буфера на `hscroll*2` байт, тот же `win_rest`, без отдельного +плain-текстового формата и без необходимости на лету "разворачивать" текст+ +атрибут в пары. + +## Фазы реализации + +### Фаза 0 — скаффолдинг `examples/mdview2/` [СДЕЛАНО 2026-06-23] +Новая директория со своим `Makefile`/`app.mk` (по аналогии с `mdview/`, не +модифицируем `mdview.c`). `mdview2.c` — копия `mdview.c` без изменений логики +(только usage-строка/заголовок комментария). Собирается чисто, дискета собрана. + +### Фаза 1 — формат кэша и директория строк [СДЕЛАНО и ЗАКРЫТО 2026-06-23] +Реализовано в `mdview2.c`: +- `cache_rec_t` (РОВНО 8 байт: `page`, `off` (uint16_t), `len`, `flags`, + `reserved`, `pad[2]`; размер задаётся `CACHE_DIR_REC_SIZE = sizeof(cache_rec_t)`, + НЕ хардкодом — см. разобранный инцидент ниже) — **отдельная** директория + (`cache_dir_phys[]`/`cache_dir_get`/`cache_dir_put`), своя ёмкость = + `file_pages+1` страниц (как у индекса). **Важное уточнение к исходному тексту + плана ниже**: директория НЕ переиспользует слоты `idx_rec_t` in-place — + рендер-воркер (Фаза 2) при обработке строки N может заглядывать в idx-записи + СОСЕДНИХ строк (откат cont-сегментов, line_idx+1 для границы сегмента); если + бы строка N-1 была перезатёрта сразу после своего рендера, воркер строки N + прочитал бы уже не исходные off/flags, а указатель в кэш — поломав откат. + Бюджет EMM ([[sprinter_emm_budget]]) позволяет отдельный массив — он безопаснее. +- Пул контента — отдельные EMM-страницы (`cache_content_phys[]`), ленивый рост + по 1 странице в `cache_reserve()` (bump-allocator, запись никогда не + разбивается через границу страницы; длина капается на `MAX_CACHE_LINE_LEN`=255 + ячеек). +- `cache_commit()` = один `bank_write()` на строку (буфер строки собирается + локально в W1/W2 заранее, не в W3 — иначе конфликт с `fb()` при чтении + исходника во время рендера). +- `win_rest()` (ESTEX WINREST 5Ah) — вывод готового буфера на экран. +- `phase1_selftest()` — самопроверка (резервирует/коммитит 4 тестовые ячейки, + кладёт запись в директорию по индексу 0, рисует через `win_rest` дважды + подряд с координатами через параметры функции) — убрать в Фазе 2. + +**Подтверждено визуально в MAME (2026-06-23)**: оба квадрата 2×2 на месте, без +единой задержки, с координатами через переменные — Фаза 1 полностью закрыта. + +**Разобранный инцидент (НЕ платформенный квирк)**: по пути долго казалось, что +`win_rest()` после "всплеска" обычных BIOS print-вызовов рисует буфер в +неправильном месте, причём воспроизводилось только когда `row`/`col` приходили +через переменные, а не как константы — это и было ключом. Настоящая причина: +`cache_rec_t` фактически занимал 7 байт (SDCC z80 не паддит структуры), а +`CACHE_DIR_REC_SIZE` был захардкожен как 8 — `bank_read`/`bank_write` копировали +8 байт в 7-байтный буфер на стеке, затирая соседнюю переменную (параметр `col`) +вызывающей функции. Фикс — `cache_rec_t` явно до 8 байт + размер через +`sizeof()`. Полная история и общий урок — [[sprinter_winrest_format]] и +[[defer_unexplained_quirks]]. + +### Фаза 2 — рендер-воркер (бывший `render_line`) [СДЕЛАНО 2026-06-23] +`render_line_to_cache(line_idx)` — адаптация `render_line()`: та же классификация +строк/префиксов/inline-парсинг (`handle_inline_marker`, emphasis state machine, +soft-wrap join) БЕЗ ИЗМЕНЕНИЙ, но вместо `bios_writeattr`/`flush_run`/`wrchar`/ +`bios_fillcharattr` на экран — пишем `(char,attr)` пары через `cc_put`/`cc_fill` +в локальный `cellbuf` (без батчинга через `runbuf`/`flush_run` — тот паттерн был +нужен только чтобы минимизировать число BIOS-вызовов, при записи в локальный +буфер смысла нет, пишем посимвольно сразу по месту), затем ОДНИМ +`cache_reserve()`+`cache_commit()`+`cache_dir_put()` коммитим всю строку. +Кэшируется ПОЛНЫЙ контент строки до `MAX_CACHE_LINE_LEN`, без обрезки по +`SCREEN_W` и без среза по `viewport_x` (view/scroll-time понятия, Фаза 4-5). +Также найден и исправлен по ходу баг в `win_rest()`: `IX` всегда указывал на +начало страницы (`#0xC000`), полностью игнорируя `off` — работало только в +Фазе 1, где в кэше была ровно одна запись со смещением 0; как только Фаза 2 +начала пакетировать много строк на одной странице с разными `off`, все строки +стали читаться с начала страницы. Фикс: `win_rest()` принимает `off` +(uint16_t), `IX = 0xC000 + off` (новая раскладка ABI сверена через `sdcc -S` +с непустым телом — у `__naked` с пустым телом SDCC не генерирует код доступа +к параметрам, нужен пробный non-naked враппер). Подтверждено визуально в MAME. + +### Фаза 3 — фоновый пре-рендер с прогрессом [СДЕЛАНО 2026-06-23] +`emit_seg()` вызывает `render_line_to_cache()` **interleaved** с построением +индекса — но с отставанием на одну строку: рендер строки N требует уже +существующей idx-записи N+1 (источник `seg_end`), которой ещё нет в момент, +когда строка N только создана. Поэтому `emit_seg()` для новой строки рендерит +ПРЕДЫДУЩУЮ (`n_lines-2` после инкремента) — её флаги (`IF_NOWRAP`/`IF_CODE`/ +`IF_BLANK`) к этому моменту уже дописаны вызовом `set_*_cur()` на предыдущей +итерации `index_lines()`. Последнюю строку файла (у которой "следующей" не +будет) дорендеривает сам `index_lines()` после выхода из цикла, как и +`render_line()` делал для последней строки при живом рендере (`seg_end = +file_size`). Требует, чтобы `cache_dir_phys[]` был выделен ДО `index_lines()` — +это уже так (`load_file()` выделяет директорию кэша, затем вызывается +`index_lines()`). + +**Важное сужение скоупа относительно исходного текста плана ниже**: пункты +"спиннер крутится, пока `rendered_up_to < n_lines`" и "скролл ограничен +диапазоном `[0, rendered_up_to]`" **не реализованы и не нужны** в этой +архитектуре — рендеринг происходит СИНХРОННО внутри той же однопроходной +`index_lines()`, без событийного цикла во время загрузки; пользователь +физически не может начать скроллить, пока `index_lines()` не вернёт +управление, а к этому моменту весь документ уже полностью в кэше. Спиннер +из `index_lines()` (каждые 16 строк) сохранён как есть — он покрывает +индексацию+рендер вместе, отдельный прогресс-индикатор не нужен. + +### Фаза 4 — cache-only draw path при скролле +Цикл перерисовки видимой области (после пре-рендера) идёт **только** по +директории строк → `win_rest` (тип 1) или срез текста+`bios_writeattr` (тип 2). +Никаких обращений к `fb()`/исходному файлу в steady-state скролле. + +### Фаза 5 — горизонтальный скролл для широких nowrap-строк +Срез ТОГО ЖЕ (char,attr)-кэш-буфера по текущему `hscroll`-офсету (смещение +начала на `hscroll*2` байт внутри буфера строки), `win_rest(row, col, 1, +visible_len, page)` с новым `off`. Никакого отдельного плоско-текстового +формата не нужно (см. пересмотр "Единый формат строки" выше). + +### Фаза 6 — тестирование в MAME +Тот же набор паттернов, что использовался для mdview1 (заголовки/списки/цитаты/ +code-block/bold-italic/nowrap-обрезка), ПЛЮС: реальный замер EMM на большом +документе (не синтетика — проверить, что бюджет из [[sprinter_emm_budget]] +действительно держится на чём-то близком к 128 КБ); UX пре-рендера (спиннер + +ограничение скролла, отсутствие "дыр" в недорендеренной области); проверка +`win_rest` на РЕАЛЬНОМ отрендеренном контенте (не только синтетический A/B/C/D +тест из `tests/winrest`). + +## Открытые вопросы (решить по ходу, не блокируют старт Фазы 0) + +- ~~Хранение длины строки в директории~~ — решено в Фазе 1: поле `len` (uint8_t) + прямо в `cache_rec_t`. +- ~~Деление строк на тип 1/тип 2~~ — отказались (см. "Единый формат строки" + выше): единый (char,attr)-формат для всех строк, капается на + `MAX_CACHE_LINE_LEN`=255 ячеек (с тем же индикатором обрезки на этапе вывода, + что уже есть в рендере для nowrap-строк). +- Освобождать ли страницы исходного файла после того, как все его строки + отрендерены (вернуть EMM в пул) — даёт больше места про запас, но усложняет + (нужна гарантия, что назад к файлу обращаться больше не придётся — а это не + так, если позже добавится поиск по тексту). Не делать в v1. +- BIOS-вариант `WIN_COPY_WIN`/`WIN_RESTORE_WIN` (0B2h/0B3h, RST 8) не проверен + (см. [[sprinter_winrest_format]]) — ESTEX-варианта достаточно для v1, проверять + BIOS-вариант только если понадобится экономия на RST-диспетчеризации. diff --git a/examples/mdview2/mdview2.c b/examples/mdview2/mdview2.c new file mode 100644 index 0000000..d64eb35 --- /dev/null +++ b/examples/mdview2/mdview2.c @@ -0,0 +1,2539 @@ +/* + * mdview2 — просмотрщик Markdown для Sprinter (render-cache версия). + * + * Фаза 0 (скаффолдинг): этот файл — копия mdview/mdview.c, без изменений + * логики. План переделки на render-cache — docs/mdview2-plan.md. + * + * Разметка экрана (80x32): + * строка 0 — статус-бар (имя файла / диапазон строк / всего / %) + * строки 1..30 — область документа (30 видимых строк текста) + * строка 31 — меню (F1 Help / F2 Reserved / F10 Exit) + * + * Навигация: + * стрелки, PgUp/PgDn, Home/End, F1 (справка), F10/Esc (выход). + * Горизонтальный сдвиг доступен только для nowrap-строк. + * + * Память: + * CODE → W1 (режим sprinter-cc --memory small) + * STACK/HEAP/DATA → W2 + * Буфер файла → страницы EMM (до 8 × 16 КБ = 128 КБ), + * отображаемые в W3 по требованию через fb()/map_page(). + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* ---- Геометрия экрана -------------------------------------------- */ + +#define SCREEN_W 80 +#define SCREEN_H 32 +#define VIEW_TOP_ROW 1 +#define VIEW_H 30 /* видимая область: строки 1..30 включительно */ +#define MENU_ROW 31 +#define TAB_STOP 4 + +/* ---- Параметры файла и памяти ------------------------------------ */ + +#define PAGE_BITS 14u +#define PAGE_SIZE (1u << PAGE_BITS) /* размер EMM-страницы: 16 КБ */ +#define PAGE_MASK ((uint16_t)(PAGE_SIZE - 1u)) +#define MAX_PAGES 8 /* 8 страниц × 16 КБ = 128 КБ */ +#define MAX_FILE ((uint32_t)MAX_PAGES * PAGE_SIZE) /* максимальный размер файла: 131072 байт */ +// #define MAX_LINES 2048 +#define FILE_BUF ((char *)0xC000) /* окно W3, куда мапится текущая EMM-страница */ + +/* ---- Палитра атрибутов ------------------------------------------- */ + +static const uint8_t md_pallete[16 * 4] = { + 0x00, 0x00, 0x00, 0x00, + 0x60, 0x00, 0x00, 0x00, + 0x00, 0x60, 0x00, 0x00, + 0x60, 0x60, 0x00, 0x00, + 0x00, 0x00, 0x60, 0x00, + 0x60, 0x00, 0x60, 0x00, + 0x00, 0x60, 0x60, 0x00, + 0xC8, 0xC8, 0xC8, 0x00, + 0xA0, 0xA0, 0x80, 0x00, + 0xFF, 0xAA, 0xAA, 0x00, + 0xAA, 0xFF, 0xAA, 0x00, + 0xFF, 0xFF, 0xAA, 0x00, + 0xAA, 0xAA, 0xFF, 0x00, + 0xFF, 0xAA, 0xFF, 0x00, + 0xAA, 0xFF, 0xFF, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF +}; + +#define ATTR_RESET COLOR(COLOR_LIGHTGRAY, COLOR_BLACK) +#define ATTR_TEXT COLOR(COLOR_LIGHTGRAY, COLOR_BLUE) +#define ATTR_TEXT_TITLE1 COLOR(COLOR_YELLOW, COLOR_BLUE) +#define ATTR_TEXT_TITLE2 COLOR(COLOR_LIGHTBLUE, COLOR_BLUE) +#define ATTR_TEXT_TITLE3 COLOR(COLOR_LIGHTGREEN, COLOR_BLUE) +#define ATTR_TEXT_TITLE4 COLOR(COLOR_LIGHTGREEN, COLOR_BLUE) +#define ATTR_TEXT_BOLD COLOR(COLOR_LIGHTRED, COLOR_BLUE) +#define ATTR_TEXT_ITALIC COLOR(COLOR_LIGHTGREEN, COLOR_BLUE) +#define ATTR_TEXT_UNDERSORE COLOR(COLOR_LIGHTMAGENTA, COLOR_BLUE) +#define ATTR_TEXT_CODE COLOR(COLOR_WHITE, COLOR_BLUE) +#define ATTR_TEXT_STRIKE COLOR(COLOR_DARKGRAY, COLOR_BLUE) +#define ATTR_LIST_MARKER COLOR(COLOR_LIGHTCYAN, COLOR_BLUE) +#define ATTR_QUOTE_MARKER COLOR(COLOR_CYAN, COLOR_BLUE) +#define ATTR_HR COLOR(COLOR_LIGHTGRAY, COLOR_BLUE) +#define ATTR_TRUNC COLOR(COLOR_YELLOW, COLOR_BLUE) +#define ATTR_BAR COLOR(COLOR_BLACK, COLOR_LIGHTCYAN) +#define ATTR_BAR_SPINNER COLOR(COLOR_WHITE, COLOR_LIGHTCYAN) +#define ATTR_MENU_T COLOR(COLOR_BLACK, COLOR_LIGHTCYAN) +#define ATTR_MENU_K COLOR(COLOR_YELLOW, COLOR_BLACK) + +/* ---- Атрибуты диалога справки ------------------------------------ */ + +#define ATTR_HELP_BG COLOR(COLOR_LIGHTGRAY, COLOR_BLACK) +#define ATTR_HELP_BDR COLOR(COLOR_WHITE, COLOR_BLACK) +#define ATTR_HELP_TIT COLOR(COLOR_YELLOW, COLOR_BLACK) +#define ATTR_HELP_HDR COLOR(COLOR_WHITE, COLOR_BLACK) +#define ATTR_HELP_HINT COLOR(COLOR_YELLOW, COLOR_BLACK) + +/* Геометрия диалога справки (в символьных координатах 80×32). */ +#define HELP_X 8u /* левая граница рамки */ +#define HELP_Y 4u /* верхняя граница рамки */ +#define HELP_W 64u /* ширина рамки (включая │) */ +#define HELP_H 22u /* высота рамки (включая ─) */ + +/* ---- Глобальное состояние ---------------------------------------- */ + +/* Файл до 128 КБ хранится в наборе EMM-страниц (до 8 шт. по 16 КБ). + * В окно W3 одновременно отображается только одна страница (FILE_BUF): + * функция fb() при необходимости переключает маппинг и кэширует текущую + * страницу в cur_page, чтобы не делать лишние переключения. + * + * Индекс экранных сегментов хранится отдельно в EMM (записи по 8 байт, + * одна запись на видимую строку). Каждая запись содержит: + * - смещение первого байта сегмента в исходном файле, + * - флаги (continuation/nowrap/blank/code), + * - начальный inline-стиль сегмента. + * Правая граница сегмента определяется смещением следующей записи. + * Такой подход освобождает около 11 КБ ближней RAM и снимает старый + * лимит MAX_LINES для near-массивов. + */ + +/* В SDCC/z80 критично явно инициализировать static-переменные (`= 0`). + * Неинициализированные static-объекты могут конфликтовать по размещению + * и перезаписывать друг друга. См. заметку memory/sdcc_static_storage_gotcha. */ +/* Компактная запись индекса сегмента. + * Каждый элемент соответствует одной строке рендера в viewport. */ +typedef struct idx_rec_s { + uint32_t off; /* смещение первого байта исходного текста для сегмента */ + uint8_t flags; /* битовые флаги IF_* ниже */ + uint8_t style; /* начальный стиль INIT_STYLE_* в начале сегмента */ + uint8_t pad0; /* добивка до 8 байт (2048 записей на страницу 16 КБ) */ + uint8_t pad1; +} idx_rec_t; + +#define INDEX_REC_SIZE 8u +#define INDEX_RECS_PER_PAGE 2048u /* 16384 / 8; запись всегда целиком в одной странице */ +#define MAX_INDEX_PAGES 8u /* максимум 8 * 2048 = 16384 сегментов индекса */ + +#define IF_CONT 0x01u /* сегмент является продолжением перенесённой строки */ +#define IF_NOWRAP 0x02u /* строка не переносится (кодовый блок / HR / таблица) */ +#define IF_BLANK 0x04u /* визуально пустая строка */ +#define IF_CODE 0x08u /* тело fenced code-блока (verbatim-режим) */ + +/* ================================================================== + * Фаза 1 (mdview2): директория и пул рендер-кэша + * ================================================================== + * Каждая логическая строка экрана рендерится РОВНО ОДИН РАЗ (Фаза 2) + * в готовый буфер и больше никогда не парсится повторно при скролле. + * Содержимое живёт в ОТДЕЛЬНОМ пуле EMM-страниц (cache_content_*), + * не путать с idx_rec_t/index_phys[] выше — те остаются неизменными, + * ими пользуется рендер-воркер ПРИ построении кэша (Фаза 2), и их + * нельзя перезатирать, т.к. воркер для одной строки может заглядывать + * в idx-записи СОСЕДНИХ строк (откат cont-сегментов). + * + * ЕДИНЫЙ формат для ВСЕХ строк (решено 2026-06-23 — изначальный план + * различал тип 1/тип 2 по экономии памяти, но строки таблиц — IF_NOWRAP, + * но НЕ IF_CODE — всё равно проходят inline-парсер и могут содержать + * несколько атрибутов, так что "один стиль" для них неверно; бюджет EMM + * (см. sprinter_emm_budget) с большим запасом покрывает (char,attr) + * для всех строк без исключения): контент — `len` пар (char,attr), + * `len` — реальная длина в ЯЧЕЙКАХ без паддинга до SCREEN_W, капается на + * 255 (патологически длинные nowrap-строки урезаются; индикатор обрезки + * добавляется на этапе вывода из кэша, не при рендере в кэш). + */ +/* РОВНО 8 байт — критично: cache_dir_get/put адресуют запись сдвигом + * idx<<3 (умножение на 8 через сдвиг, а не на sizeof()), так что + * структура ОБЯЗАНА быть именно 8 байт, а не "8 минус случайный паддинг + * компилятора". (СSDCC z80 НЕ паддит структуры сама по себе, поэтому + * 1+2+1+1+1 = 6 байт без явных pad-полей превратились бы в 6, а не 8 — + * фактический баг был в том, что без 2 явных pad оно было 7, а + * CACHE_DIR_REC_SIZE был захардкожен как 8: bank_read/bank_write писали + * 8 байт в 7-байтный буфер на стеке, затирая соседнюю переменную + * вызывающей функции — это была настоящая причина "квирка WINREST", + * не компилятор/эмулятор/DSS, см. memory/sprinter_winrest_format.md.) */ +typedef struct cache_rec_s { + uint8_t page; /* EMM-страница рендер-кэша (физический номер) */ + uint16_t off; /* смещение в странице, 0..16383 (байты, не ячейки) */ + uint8_t len; /* длина контента в ЯЧЕЙКАХ (char,attr пар), 0..255; 0 = пустая/HR строка без сохранённого контента */ + uint8_t flags; /* копия IF_NOWRAP/IF_CODE/IF_BLANK на момент рендера */ + uint8_t reserved; /* не используется (раньше — attr для типа 2, см. план) */ + uint8_t pad[2]; /* явный резерв, добивка до 8 байт — см. комментарий выше */ +} cache_rec_t; + +#define CACHE_DIR_REC_SIZE ((uint16_t)sizeof(cache_rec_t)) +#define CACHE_DIR_RECS_PER_PAGE 2048u +#define MAX_CACHE_DIR_PAGES 8u +#define MAX_CACHE_LINE_LEN 255u /* кап длины контента одной строки, в ячейках (char,attr пара = 2 байта) */ +#define MAX_CACHE_CONTENT_PAGES 64u /* запас: до 1 МБ кэша контента */ + +static uint8_t cache_dir_blk = 0; +static uint8_t cache_dir_pages = 0; +static uint8_t cache_dir_phys[MAX_CACHE_DIR_PAGES] = {0}; + +static uint8_t cache_content_blk[MAX_CACHE_CONTENT_PAGES] = {0}; /* id блока на каждую выделенную страницу */ +static uint8_t cache_content_phys[MAX_CACHE_CONTENT_PAGES] = {0}; +static uint8_t cache_content_n_pages = 0; /* сколько страниц уже выделено */ +static uint8_t cache_cur_page_idx = 0; /* индекс текущей страницы записи в cache_content_phys */ +static uint16_t cache_cur_off = 0; /* текущее смещение записи в этой странице */ + +/* Доступ к директории рендер-кэша — тот же приём, что у idx_get/idx_put. */ +static void cache_dir_get(uint16_t idx, cache_rec_t *r) +{ + uint8_t page = (uint8_t)(idx >> 11); + uint16_t off = (uint16_t)((idx & 2047u) << 3); + bank_read(cache_dir_phys[page], off, r, CACHE_DIR_REC_SIZE); +} + +static void cache_dir_put(uint16_t idx, const cache_rec_t *r) +{ + uint8_t page = (uint8_t)(idx >> 11); + uint16_t off = (uint16_t)((idx & 2047u) << 3); + bank_write(cache_dir_phys[page], off, r, CACHE_DIR_REC_SIZE); +} + +/* Резервирует nbytes подряд в пуле контента рендер-кэша. + * Запись никогда не разбивается через границу страницы: если остаток + * текущей страницы недостаточен — переходим на следующую целиком, + * жертвуя остатком (проще и безопаснее, чем page-crossing в fb()). + * При нехватке текущего пула — лениво доращиваем его на 1 страницу. + * Возврат: 1 — успех (out_page и out_off заполнены), 0 — EMM кончилась. */ +static uint8_t cache_reserve(uint16_t nbytes, uint8_t *out_page, uint16_t *out_off) +{ + if (nbytes == 0 || nbytes > PAGE_SIZE) return 0; + + if (cache_content_n_pages == 0 || + (uint16_t)(cache_cur_off + nbytes) > PAGE_SIZE) { + /* Нужна новая страница: либо пул ещё пуст, либо текущая страница + * не вмещает запись целиком. */ + uint8_t next_idx = (cache_content_n_pages == 0) ? 0 : (uint8_t)(cache_cur_page_idx + 1); + if (next_idx >= cache_content_n_pages) { + if (cache_content_n_pages >= MAX_CACHE_CONTENT_PAGES) return 0; + uint8_t blk = mem_alloc_pages(1); + if (blk == 0) return 0; + cache_content_blk[cache_content_n_pages] = blk; + cache_content_phys[cache_content_n_pages] = mem_get_page(blk, 0); + cache_content_n_pages++; + } + cache_cur_page_idx = next_idx; + cache_cur_off = 0; + } + + *out_page = cache_content_phys[cache_cur_page_idx]; + *out_off = cache_cur_off; + cache_cur_off = (uint16_t)(cache_cur_off + nbytes); + return 1; +} + +/* Копирует уже готовый буфер (рендер одной строки, собранный в W1/W2 — + * НЕ в W3, чтобы не конфликтовать с fb()/чтением исходника во время + * рендера) в зарезервированное место в пуле контента. Один bank_write + * на строку, а не по байту. len — РОВНО как у cache_reserve()/bank_write + * — uint16_t, не uint8_t: строка до MAX_CACHE_LINE_LEN=255 ячеек даёт + * до 510 байт (char,attr), это уже не лезет в uint8_t. */ +static void cache_commit(uint8_t page, uint16_t off, const void *buf, uint16_t len) +{ + bank_write(page, off, buf, len); +} + +/* Фаза 3: рендер-воркер вызывается из emit_seg() с отставанием на одну + * строку, поэтому нужна форвард-декларация — определение далеко ниже, + * рядом с остальной inline-парсинг логикой. */ +static void render_line_to_cache(uint16_t line_idx); + +static uint16_t n_lines = 0; +static uint16_t max_lines = 0; /* ёмкость индекса: index_pages * 2048 */ +static uint16_t top_line = 0; +static uint32_t file_size = 0; +static uint8_t file_blk = 0; +static uint8_t file_pages = 0; +static uint8_t file_phys[MAX_PAGES] = {0}; +static uint8_t index_blk = 0; +static uint8_t index_pages = 0; +static uint8_t index_phys[MAX_INDEX_PAGES] = {0}; +static uint8_t index_truncated = 0; /* выставляется при исчерпании ёмкости индекса */ +static uint32_t cur_seg_off = 0; /* смещение последнего emit_seg (кэш в near-памяти) */ +static idx_rec_t cur_rec; /* near-копия последней записанной idx-записи */ +static uint8_t cur_page = 0xFF; /* 0xFF = в W3 ещё не отображена ни одна страница */ +static uint8_t viewport_x = 0; /* горизонтальный сдвиг для nowrap-строк */ +static char filename[64] = {0}; + +#define HPAN_STEP 8u + +/* Спиннер в статус-баре (col=8,row=0). + * Включается во время загрузки/индексации, чтобы показать, + * что программа занята и не зависла. */ +#define SPINNER_COL 8 +static const char spinner_chars[4] = { '|', '/', '-', '\\' }; +static uint8_t spinner_phase = 0; +static uint8_t spinner_active = 0; + +/* ================================================================== + * Вспомогательные функции + * ================================================================== */ + +/* Печатает строку с указанным атрибутом, начиная с позиции (x,y), + * и останавливается на конце строки или правой границе экрана. + * `s` — литерал/статический буфер (CODE/DATA, окна 1/2), поэтому его + * можно передать в BIOS LP_PRINT_LN напрямую, без промежуточной копии. */ +static void put_str_attr(uint8_t x, uint8_t y, const char *s, uint8_t attr) +{ + uint8_t maxlen = (x < SCREEN_W) ? (uint8_t)(SCREEN_W - x) : 0; + uint8_t len = (uint8_t)strlen(s); + if (len > maxlen) len = maxlen; + if (len == 0) return; + bios_set_place(y, x); + bios_writeattr(s, len, attr); +} + + +/* Полностью очищает указанную строку экрана символами пробела с attr. */ +static void fill_row(uint8_t y, uint8_t attr) +{ + bios_set_place(y, 0); + bios_fillcharattr(' ', attr, SCREEN_W); +} + +/* Продвигает спиннер на один кадр. + * Если спиннер выключен — функция ничего не делает. */ +static void spinner_tick(void) +{ + if (!spinner_active) return; + wrchar(SPINNER_COL, 0, spinner_chars[spinner_phase & 3], ATTR_BAR_SPINNER); + spinner_phase++; +} + +/* Включает/выключает спиннер. + * При выключении очищает его позицию пробелом в статус-баре. */ +static void spinner_show(uint8_t on) +{ + spinner_active = on; + if (!on) wrchar(SPINNER_COL, 0, ' ', ATTR_BAR); +} + +/* Отображает указанную EMM-страницу файла в окно W3. + * Если эта страница уже активна, переключение не выполняется. */ +static inline void map_page(uint8_t page) +{ + if (page != cur_page) { + /* file_phys[] позволяет не вызывать mem_get_page() на каждом свопе. */ + sprinter_page_w3(file_phys[page]); + cur_page = page; + } +} +/* Читает один байт логического файла по смещению p. + * При переходе между страницами автоматически переключает W3. + * Проверка границ выполняется вызывающей стороной. + */ + +static char fb(uint32_t p) +{ + uint8_t *pb = (uint8_t *)&p; + uint8_t page = (uint8_t)((pb[1] >> 6) | (pb[2] << 2)); + uint16_t off = (uint16_t)(((uint16_t)(pb[1] & 0x3F) << 8) | pb[0]); + map_page(page); + return FILE_BUF[off]; +/* + // p = HLDE + (void)p; + + __asm +; uint16_t off = (uint16_t)(((uint16_t)(pb[1] | 0xC0) << 8) | pb[0]); + ld a, d + or a, #0xC0 + ld b, a + ld c, e + +; uint8_t page = (uint8_t)((pb[1] >> 6) | (pb[2] << 2)); + ld a, l ;; pb[2] << 2 + add a, a + add a, a + ld e, a ;; e = pb[2] << 2 + + ld a, d ;; pb[1] >> 6 + rlca + rlca + and a, #0x03 + + or a, e ;; a = (pb[2] << 2) | (pb[1] >> 6) + ld e, a +; map_page(page); +; if (page != cur_page) { + ld a, (_cur_page) + cp e + jr Z, __fbl0 +; cur_page = page; + ld a, e + ld (_cur_page), a +; sprinter_page_w3(file_phys[page]); + ld hl, #_file_phys + ld d, #0x00 + add hl, de + ld a, (hl) + out (__io_page_w3), a +; } +__fbl0: +; return FILE_BUF[off]; + ld a, (bc) + ret + __endasm; +*/ +} + +/* ESTEX WINREST (5Ah, RST 0x10) — копирует готовый буфер (char,attr) + * из смещения `off` ВНУТРИ EMM-страницы `page` прямо в текстовое окно + * экрана, минуя любые BIOS print-вызовы. Формат буфера подтверждён + * эмпирически в MAME (см. memory/sprinter_winrest_format.md и + * tests/winrest/winrest.c): пары (char,attr) по ячейкам, по строкам, + * шаг строки = width*2, без паддинга. row/col — левый верхний угол + * вывода на экране, h/w — высота/ширина в символах. + * + * Регистровая раскладка SDCC __sdcccall(1) для (row,col,h,w,page,off) + * проверена через `sdcc -S` (пробный non-naked враппер): A=row, L=col, + * стек [SP+2]=h [SP+3]=w [SP+4]=page [SP+5..6]=off (little-endian). + * + * История: до 2026-06-23 `off` не было — IX всегда указывал на самое + * начало страницы (#0xC000), это работало только пока в кэше было + * РОВНО ОДНА запись на странице со смещением 0 (Фаза 1 self-test). + * Когда Фаза 2 начала пакетировать много строк на одной странице с + * РАЗНЫМИ off, всё стало читать с начала страницы (т.е. контент первой + * записи) независимо от реальной строки — добавлен off, IX = 0xC000+off. */ +static void win_rest(uint8_t row, uint8_t col, uint8_t h, uint8_t w, uint8_t page, uint16_t off) __naked +{ + (void)row; (void)col; (void)h; (void)w; (void)page; (void)off; + __asm + ld iy, #2 + add iy, sp + ld d, a ; D = row + ld e, l ; E = col + push ix ; сохранить IX вызывающей функции (callee-saved) + ld l, 3 (iy) ; off (младший байт), SP+5 + ld h, 4 (iy) ; off (старший байт), SP+6 + ld bc, #0xC000 + add hl, bc + push hl + pop ix ; IX = 0xC000 + off + ld h, 0 (iy) ; H = height + ld a, 1 (iy) ; width -> L + ld l, a + ld a, 2 (iy) ; page -> B + ld b, a + ld c, #0x5A ; ESTEX WINREST + di + rst #0x10 + ei + pop ix ; восстановить IX вызывающей функции + pop hl ; адрес возврата + inc sp + inc sp + inc sp + inc sp + inc sp ; снять h,w,page,off (1+1+1+2 байт) со стека + jp (hl) + __endasm; +} + +/* Для проверки границ выделения считаем пробел/таб/конец строки/0 пробельными. */ +static uint8_t ws_or_eol(char c) +{ + return (uint8_t)(c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == 0); +} + +static uint8_t ws_or_eol_or_delim(char c) +{ + return (uint8_t)(c == ' ' || c == ',' || c == '.' || c == '!' || c == '?' || c == ':' || c == ';' || c == '/' + || c == '\t' || c == '\n' || c == '\r' || c == 0); +} + +/* Маркер inline-выделения (*, **, _) распознаётся только если + * пробельная граница есть ровно с одной стороны (XOR-правило). + * Это защищает выражения вроде "2 * 3", "2 ** 3" и внутрисловные + * случаи (например COLOR_YELLOW) от ложного форматирования. */ +static uint8_t is_emph_flanked(char prev_ch, char next_ch) +{ + return (uint8_t)(ws_or_eol(prev_ch) != ws_or_eol_or_delim(next_ch)); +} + +/* Тип сегмента-продолжения, передаваемый в emit_seg(). + * Явно в индексе не хранится: рендер при необходимости восстанавливает + * тип через classify_line(), но параметр оставлен для читаемости вызовов. */ +#define CK_PLAIN 0 +#define CK_QUOTE 1 +#define CK_LIST 2 +#define CK_OTHER 3 + +/* Доступ к индексу в EMM. + * Размер записи 8 байт, поэтому запись не пересекает границу страницы + * и bank_read/bank_write работают с цельным блоком. */ +static void idx_get(uint16_t idx, idx_rec_t *r) +{ + uint8_t page = (uint8_t)(idx >> 11); /* номер страницы: idx / 2048 */ + uint16_t off = (uint16_t)((idx & 2047u) << 3); /* смещение записи: (idx % 2048) * 8 */ + bank_read(index_phys[page], off, r, INDEX_REC_SIZE); +} + +static void idx_put(uint16_t idx, const idx_rec_t *r) +{ + uint8_t page = (uint8_t)(idx >> 11); + uint16_t off = (uint16_t)((idx & 2047u) << 3); + bank_write(index_phys[page], off, r, INDEX_REC_SIZE); +} + +/* Чтение полей индекса для рендера и навигации (построчный доступ). */ +static uint8_t seg_flags(uint16_t idx) +{ + idx_rec_t r; + if (idx >= n_lines) return 0; + idx_get(idx, &r); + return r.flags; +} + +static uint32_t seg_off(uint16_t idx) +{ + idx_rec_t r; + if (idx >= n_lines) return file_size; + idx_get(idx, &r); + return r.off; +} + +static uint8_t is_cont(uint16_t idx) { return (uint8_t)((seg_flags(idx) & IF_CONT) != 0); } +static uint8_t is_nowrap(uint16_t idx) { return (uint8_t)((seg_flags(idx) & IF_NOWRAP) != 0); } +static uint8_t is_blank(uint16_t idx) { return (uint8_t)((seg_flags(idx) & IF_BLANK) != 0); } +static uint8_t is_code_body(uint16_t idx){ return (uint8_t)((seg_flags(idx) & IF_CODE) != 0); } + +/* Установка флагов всегда идёт в последний emit_seg (n_lines-1). + * Используется near-копия cur_rec, чтобы не делать обратное чтение из банка. */ +static void set_nowrap_cur(void) { cur_rec.flags |= IF_NOWRAP; idx_put((uint16_t)(n_lines - 1), &cur_rec); } +static void set_blank_cur(void) { cur_rec.flags |= IF_BLANK; idx_put((uint16_t)(n_lines - 1), &cur_rec); } +static void set_code_cur(void) { cur_rec.flags |= IF_CODE; idx_put((uint16_t)(n_lines - 1), &cur_rec); } + +/* Загружает 16 текстовых атрибутов в палитры ink/paper/blink. */ +static void set_pallete(void) { + uint8_t buff[64]; + for(int i = 0; i < 16; i++) { + for(int j = 0; j < 16; j++) { + buff[j * 4] = md_pallete[i * 4]; + buff[j * 4 + 1] = md_pallete[i * 4 + 1]; + buff[j * 4 + 2] = md_pallete[i * 4 + 2]; + buff[j * 4 + 3] = md_pallete[i * 4 + 3]; + } + + text_pal_load(TEXT_PAL_INK, i * 16, 16, md_pallete); + text_pal_load(TEXT_PAL_BLINK_INK, i * 16, 16, md_pallete); + text_pal_load(TEXT_PAL_PAPER, i * 16, 16, buff); + text_pal_load(TEXT_PAL_BLINK_PAPER, i * 16, 8, buff + 32); + text_pal_load(TEXT_PAL_BLINK_PAPER, i * 16 + 8, 8, buff + 32); + } +} + +/* ================================================================== + * Загрузка/выгрузка файла + * ================================================================== */ + +/* Загружает файл в EMM-страницы и подготавливает EMM-индекс. + * Возврат: 0 — успех, отрицательное значение — код ошибки. */ +static int load_file(const char *path) +{ + int fd = open(path, O_RDONLY); + if (fd < 0) return -1; + + long sz = lseek(fd, 0, SEEK_END); + if (sz < 0 || (uint32_t)sz > MAX_FILE) { + close(fd); + return -2; + } + file_size = (uint32_t)sz; + lseek(fd, 0, SEEK_SET); + + /* Выделяем ровно столько EMM-страниц, сколько нужно под файл. */ + file_pages = (uint8_t)((file_size + PAGE_SIZE - 1u) / PAGE_SIZE); + if (file_pages == 0) file_pages = 1; + file_blk = mem_alloc_pages(file_pages); + if (file_blk == 0) { + close(fd); + return -3; + } + + /* Читаем файл кусками по 16 КБ, последовательно маппя страницы в W3. + * На последней итерации читаем остаток меньше PAGE_SIZE. */ + uint32_t remaining = file_size; + for (uint8_t page = 0; page < file_pages; page++) { + spinner_tick(); + uint8_t phys = mem_get_page(file_blk, page); + file_phys[page] = phys; + sprinter_page_w3(phys); + cur_page = page; + uint16_t chunk = (remaining > PAGE_SIZE) + ? (uint16_t)PAGE_SIZE + : (uint16_t)remaining; + int n = read(fd, FILE_BUF, chunk); + if (n != (int)chunk) { + close(fd); + return -4; + } + remaining -= chunk; + } + close(fd); + + /* Выделяем блок индекса в EMM (8 байт на сегмент, 2048 записей/страница). + * Размер берем с запасом относительно файла; если памяти мало — + * уменьшаем количество страниц индекса до доступного значения. */ + { + uint8_t want = (uint8_t)(file_pages + 1u); + if (want > MAX_INDEX_PAGES) want = MAX_INDEX_PAGES; + index_blk = 0; + while (want >= 1u) { + index_blk = mem_alloc_pages(want); + if (index_blk) break; + want--; + } + if (index_blk == 0) return -5; + index_pages = want; + for (uint8_t i = 0; i < index_pages; i++) + index_phys[i] = mem_get_page(index_blk, i); + max_lines = (uint16_t)index_pages * INDEX_RECS_PER_PAGE; + } + + /* Директория рендер-кэша (Фаза 1) — та же ёмкость, что у индекса: + * по одной cache_rec_t на каждую логическую строку. */ + { + uint8_t want = (uint8_t)(file_pages + 1u); + if (want > MAX_CACHE_DIR_PAGES) want = MAX_CACHE_DIR_PAGES; + cache_dir_blk = 0; + while (want >= 1u) { + cache_dir_blk = mem_alloc_pages(want); + if (cache_dir_blk) break; + want--; + } + if (cache_dir_blk == 0) return -6; + cache_dir_pages = want; + for (uint8_t i = 0; i < cache_dir_pages; i++) + cache_dir_phys[i] = mem_get_page(cache_dir_blk, i); + } + return 0; +} + +/* Освобождает выделенные EMM-блоки файла и индекса. */ +static void unload_file(void) +{ + if (file_blk) { + mem_free_block(file_blk); + file_blk = 0; + } + if (index_blk) { + mem_free_block(index_blk); + index_blk = 0; + } +} + +/* ================================================================== + * Индексация строк + * ================================================================== */ + +/* Возвращает 1, если сегмент idx — это старт логической строки, + * и эта строка начинается с ``` (граница блока кода). + * Сегменты-продолжения никогда не считаются такими границами. */ +static uint8_t is_fence_delim(uint16_t idx) +{ + if (idx >= n_lines) return 0; + if (is_cont(idx)) return 0; + uint32_t off = seg_off(idx); + if (off + 2 >= file_size) return 0; + return (uint8_t)(fb(off) == '`' && + fb(off + 1) == '`' && + fb(off + 2) == '`'); +} + +#define INIT_STYLE_PLAIN 0x0 +#define INIT_STYLE_BOLD 0x1 +#define INIT_STYLE_ITALIC 0x2 +#define INIT_STYLE_UNDER 0x3 +#define INIT_STYLE_CODE 0x4 +#define INIT_STYLE_STRIKE 0x5 + +/* Таблица сопоставления init-style -> атрибут. + * Порядок должен совпадать с INIT_STYLE_* / EM_* ниже. */ +static const uint8_t styles_map[] = { + ATTR_TEXT, ATTR_TEXT_BOLD, ATTR_TEXT_ITALIC, ATTR_TEXT_UNDERSORE, ATTR_TEXT_CODE, ATTR_TEXT_STRIKE +}; + +/* Возвращает стартовый атрибут сегмента из индексной записи. */ +static uint8_t get_init_style(uint16_t idx) +{ + idx_rec_t r; + if (idx >= n_lines) return styles_map[INIT_STYLE_PLAIN]; + idx_get(idx, &r); + return styles_map[r.style & 7u]; +} + +/* Возвращает «сырой» INIT_STYLE_* из индексной записи. */ +static uint8_t get_init_style_raw(uint16_t idx) +{ + idx_rec_t r; + if (idx >= n_lines) return INIT_STYLE_PLAIN; + idx_get(idx, &r); + return (uint8_t)(r.style & 7u); +} + + +/* Тип строки, который возвращает classify_line(). */ +#define LK_PLAIN 0 +#define LK_H1 1 +#define LK_H2 2 +#define LK_H3 3 +#define LK_H4 4 /* также используется для H5/H6 */ +#define LK_HR 5 +#define LK_ULIST 6 /* маркер ненумерованного списка */ +#define LK_OLIST 7 /* маркер нумерованного списка */ +#define LK_QUOTE 8 /* маркер цитаты */ + +/* Классифицирует логическую строку. + * - Заголовки: 1..4 символа '#' и затем пробел/таб (H5/H6 сводятся к H4). + * - Горизонтальная линия: только '-', '*' или '_' (одного вида, >=3), + * возможно с пробелами/табами между символами. + * - Ненумерованный список: "- ", "* " или "+ " перед содержимым. + * - Нумерованный список: цифры + '.'/ ')' + пробел. + * - Цитата: '>' с опциональным пробелом. + * - Иначе обычный текст. + * Для заголовков/списков/цитат out_content указывает на первый байт + * содержимого после маркера и завершающего пробела. */ +static uint8_t classify_line(uint32_t p_start, uint32_t *out_content) +{ + if (p_start >= file_size) return LK_PLAIN; + + char c0 = fb(p_start); + + /* Заголовок? */ + if (c0 == '#') { + uint8_t lvl = 0; + uint32_t p = p_start; + while (p < file_size && fb(p) == '#' && lvl < 6) { + lvl++; p++; + } + if (p < file_size) { + char ch = fb(p); + if (ch == ' ' || ch == '\t') { + p++; + *out_content = p; + if (lvl > 4) lvl = 4; + return lvl; /* LK_H1 .. LK_H4 */ + } + } + /* Символ '#' без пробела после него не считается заголовком. */ + } + + /* Горизонтальный разделитель. + * Проверяется до списков, чтобы `- - -` не стало маркером списка. */ + if (c0 == '-' || c0 == '*' || c0 == '_') { + char marker = 0; + uint8_t count = 0; + uint8_t ok = 1; + uint32_t p = p_start; + while (p < file_size) { + char ch = fb(p++); + if (ch == '\n' || ch == '\r') break; + if (ch == ' ' || ch == '\t') continue; + if (ch != '-' && ch != '*' && ch != '_') { ok = 0; break; } + if (marker == 0) marker = ch; + else if (ch != marker) { ok = 0; break; } + count++; + } + if (ok && count >= 3) return LK_HR; + } + + /* Для списков/цитат дополнительно допускаем ведущие пробелы + * (базовая поддержка вложенности). */ + uint32_t lp = p_start; + while (lp < file_size && fb(lp) == ' ') lp++; + char cl = (lp < file_size) ? fb(lp) : 0; + + /* Ненумерованный список? */ + if ((cl == '-' || cl == '*' || cl == '+') && + (lp + 1) < file_size && + fb(lp + 1) == ' ') { + *out_content = lp + 2; + return LK_ULIST; + } + + /* Нумерованный список? */ + if (cl >= '0' && cl <= '9') { + uint32_t p = lp; + while (p < file_size) { + char ch = fb(p); + if (ch < '0' || ch > '9') break; + p++; + } + if (p < file_size) { + char ch = fb(p); + if ((ch == '.' || ch == ')') && + (p + 1) < file_size && fb(p + 1) == ' ') { + *out_content = p + 2; + return LK_OLIST; + } + } + } + + /* Цитата? */ + if (cl == '>') { + uint32_t p = lp + 1; + if (p < file_size && fb(p) == ' ') p++; + *out_content = p; + return LK_QUOTE; + } + + return LK_PLAIN; +} + +/* Возвращает видимую ширину префикса (отступ + маркер) в колонках. */ +static uint8_t marker_visible_col(uint8_t kind, uint32_t p_start, uint32_t content_off) +{ + switch (kind) { + case LK_ULIST: + case LK_QUOTE: + case LK_OLIST: return (uint8_t)(content_off - p_start); + default: return 0; /* plain/headers спец-префикса не имеют */ + } +} + +/* Добавляет запись сегмента в EMM-индекс. + * near-копии cur_rec/cur_seg_off используются для быстрых последующих + * операций без повторного чтения последней записи из банка. + * + * Фаза 3: рендер-воркер вызывается отсюда же, но с ОТСТАВАНИЕМ НА ОДНУ + * строку — render_line_to_cache(N) сам ищет конец сегмента через + * idx_get(N+1), а пока emit_seg() для строки N не вызван, этой записи + * ещё не существует. Поэтому здесь, сразу после того как строка N + * (n_lines-1, ДО инкремента) получила свой off, мы рендерим строку + * N-1 (n_lines-2) — её собственные флаги (IF_NOWRAP/IF_CODE/IF_BLANK) + * уже были дописаны вызовом set_*_cur() на ПРЕДЫДУЩЕЙ итерации + * index_lines(), раньше этого вызова emit_seg(). Последнюю строку + * файла (для которой "следующей" не будет) дорендеривает сам + * index_lines() после выхода из своего основного цикла. */ +static void emit_seg(uint32_t off, uint8_t style, uint8_t ckind, uint8_t cont) +{ + (void)ckind; /* тип сегмента восстанавливается на этапе рендера */ + if (n_lines >= max_lines) { index_truncated = 1; return; } + cur_rec.off = off; + cur_rec.flags = (uint8_t)(cont ? IF_CONT : 0); + cur_rec.style = (uint8_t)(style & 7u); + cur_rec.pad0 = 0; + cur_rec.pad1 = 0; + idx_put(n_lines, &cur_rec); + cur_seg_off = off; + n_lines++; + if (n_lines >= 2) render_line_to_cache((uint16_t)(n_lines - 2)); +} + +/* Проверяет, что физическая строка пуста (только пробелы/таб + перевод строки). */ +static uint8_t is_line_blank(uint32_t p) +{ + while (p < file_size) { + char c = fb(p); + if (c == '\n') return 1; + if (c != ' ' && c != '\t') return 0; + p++; + } + return 1; +} + +/* Быстрая проверка начала блока кода по маркеру ``` . */ +static uint8_t is_fence_raw(uint32_t p) +{ + if (p + 2 >= file_size) return 0; + return (fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`'); +} + +/* Быстрая проверка горизонтального разделителя (`---`, `***`, `___`). */ +static uint8_t is_hr_raw(uint32_t p) +{ + char c0 = fb(p); + if (c0 != '-' && c0 != '*' && c0 != '_') return 0; + char marker = 0; + uint8_t count = 0, ok = 1; + uint32_t hr_p = p; + while (hr_p < file_size) { + char ch = fb(hr_p++); + if (ch == '\n' || ch == '\r') break; + if (ch == ' ' || ch == '\t') continue; + if (ch != '-' && ch != '*' && ch != '_') { ok = 0; break; } + if (marker == 0) marker = ch; + else if (ch != marker) { ok = 0; break; } + count++; + } + return ok && count >= 3; +} + +/* Строка таблицы: первый непустой символ — '|'. + * Ведущие пробелы разрешены; такие строки не склеиваются и не переносятся. */ +static uint8_t is_table_raw(uint32_t p) +{ + while (p < file_size) { + char c = fb(p); + if (c == ' ' || c == '\t') { p++; continue; } + return (uint8_t)(c == '|'); + } + return 0; +} + +/* Общий сканер inline-форматирования и переносов. + * Идёт от q до q_end, учитывает стартовую колонку col и при необходимости + * эмитит continuation-сегменты. Возвращает итоговый line_style. */ +static uint8_t inline_scan(uint32_t q, uint32_t q_end, uint8_t col, + uint8_t ckind, uint8_t line_style) +{ + uint32_t last_space = 0xFFFFFFFFu; + uint8_t last_space_style = line_style; /* снимок line_style на момент last_space */ + char prev_ch = ' '; + uint8_t seg_col = col; + + while (q < q_end && n_lines < max_lines) { + char ch = fb(q); + + if (ch == '\n') { + /* Жёсткий перенос (уже проверен снаружи) или конец потока. */ + q++; + emit_seg(q, line_style, ckind, (ckind == CK_LIST || ckind == CK_QUOTE) ? 1 : 0); + last_space = 0xFFFFFFFFu; prev_ch = ' '; seg_col = col; + continue; + } + + if (ch == '`') { + if (line_style == INIT_STYLE_CODE) line_style = INIT_STYLE_PLAIN; + else line_style = INIT_STYLE_CODE; + q++; continue; + } + if (ch == '*' && (q + 1) < q_end && fb(q + 1) == '*') { + char next_ch = ((q + 2) < q_end) ? fb(q + 2) : '\n'; + if (is_emph_flanked(prev_ch, next_ch)) { + if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) { + line_style = INIT_STYLE_BOLD; q += 2; continue; + } else if (line_style == INIT_STYLE_BOLD && ws_or_eol_or_delim(next_ch)) { + line_style = INIT_STYLE_PLAIN; q += 2; continue; + } + } + seg_col += 2; q += 2; prev_ch = '*'; continue; + } + if (ch == '~' && (q + 1) < q_end && fb(q + 1) == '~') { + char next_ch = ((q + 2) < q_end) ? fb(q + 2) : '\n'; + if (is_emph_flanked(prev_ch, next_ch)) { + if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) { + line_style = INIT_STYLE_STRIKE; q += 2; continue; + } else if (line_style == INIT_STYLE_STRIKE && ws_or_eol_or_delim(next_ch)) { + line_style = INIT_STYLE_PLAIN; q += 2; continue; + } + } + seg_col += 2; q += 2; prev_ch = '~'; continue; + } + if (ch == '*' || ch == '_') { + char next_ch = ((q + 1) < q_end) ? fb(q + 1) : '\n'; + if (is_emph_flanked(prev_ch, next_ch)) { + uint8_t new_style = (ch == '*') ? INIT_STYLE_ITALIC : INIT_STYLE_UNDER; + if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) { + line_style = new_style; q++; continue; + } else if (line_style == new_style && ws_or_eol_or_delim(next_ch)) { + line_style = INIT_STYLE_PLAIN; q++; continue; + } + } + seg_col++; q++; prev_ch = ch; continue; + } + if (ch == '\t') { + uint8_t tgt = (uint8_t)((seg_col & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP); + if (tgt > SCREEN_W) tgt = SCREEN_W; + seg_col = tgt; q++; prev_ch = ' '; continue; + } + if (ch == ' ') { + last_space = q + 1; + last_space_style = line_style; + seg_col++; q++; prev_ch = ' '; + } else { + seg_col++; q++; prev_ch = ch; + } + if (seg_col >= SCREEN_W) { + uint8_t use_ls = (uint8_t)(last_space != 0xFFFFFFFFu && last_space > cur_seg_off); + uint32_t wrap_at = use_ls ? last_space : q; + if (wrap_at >= q_end || wrap_at == cur_seg_off) break; + uint8_t wrap_style = use_ls ? last_space_style : line_style; + emit_seg(wrap_at, wrap_style, ckind, 1); + seg_col = col; last_space = 0xFFFFFFFFu; prev_ch = ' '; q = wrap_at; + /* Откатываем line_style к снимку на момент wrap_at: диапазон + * [wrap_at, q_old) будет пересканирован заново, и переключающие + * символы (обратная кавычка, звёздочка, подчёркивание, тильда) + * внутри него иначе применились бы дважды — именно так возникал + * баг: code-маркер на границе переноса ломает цвет следующего слова. */ + line_style = wrap_style; + } + } + return line_style; +} + +#define JOIN_MODE_LIST 1u +#define JOIN_MODE_QUOTE 2u +#define JOIN_MODE_PLAIN 3u + +/* Общий потоковый сканер для index_lines(). + * Убирает дублирование между ветками list/quote/plain: + * - mode задаёт правила обработки перевода строки, + * - остальная inline-логика (emphasis/таб/перенос) едина. + * Возвращает позицию q, на которой сканер остановился, и обновляет стиль. */ +static uint32_t scan_join_stream(uint32_t q, uint32_t para_start, uint8_t col, + uint8_t ckind, uint8_t *io_line_style, uint8_t mode) +{ + uint32_t last_space = 0xFFFFFFFFu; + uint8_t last_space_style = *io_line_style; /* снимок line_style на момент last_space */ + char prev_ch = ' '; + uint8_t seg_col = col; + uint8_t line_style = *io_line_style; + + while (q < file_size && n_lines < max_lines) { + char ch = fb(q); + uint8_t soft_break = 0; + + if (ch == '\n') { + uint32_t next = q + 1; + if (next >= file_size) break; + + if (mode == JOIN_MODE_LIST) { + if (is_line_blank(next)) break; + if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break; + + { + uint32_t next_content = next; + uint8_t next_kind = classify_line(next, &next_content); + if (next_kind == LK_ULIST || next_kind == LK_OLIST || + next_kind == LK_QUOTE || + (next_kind >= LK_H1 && next_kind <= LK_H4) || + next_kind == LK_HR) { + break; + } + } + while (next < file_size) { + char ws = fb(next); + if (ws == ' ' || ws == '\t') next++; + else break; + } + q = next; + soft_break = 1; + ch = ' '; + } else if (mode == JOIN_MODE_QUOTE) { + if (is_line_blank(next)) break; + if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break; + + { + uint32_t next_content = next; + uint8_t next_kind = classify_line(next, &next_content); + if (next_kind != LK_QUOTE) break; + + { + uint32_t next_line_end = next_content; + while (next_line_end < file_size && fb(next_line_end) != '\n') next_line_end++; + + { + uint8_t empty_quote = 1; + for (uint32_t z = next_content; z < next_line_end; z++) { + char c = fb(z); + if (c != ' ' && c != '\t') { empty_quote = 0; break; } + } + if (empty_quote) break; + } + + { + uint32_t t = next_content; + while (t < next_line_end && (fb(t) == ' ' || fb(t) == '\t')) t++; + if (t < next_line_end && fb(t) == '>') break; + } + + while (next_content < next_line_end) { + char ws = fb(next_content); + if (ws == ' ' || ws == '\t') next_content++; + else break; + } + if (next_content < next_line_end && fb(next_content) == '>') { + next_content++; + if (next_content < next_line_end && fb(next_content) == ' ') next_content++; + } + q = next_content; + soft_break = 1; + ch = ' '; + } + } + } else { /* JOIN_MODE_PLAIN */ + if (fb(next) == '\n') break; + if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break; + { + uint32_t dummy; + if (classify_line(next, &dummy) != LK_PLAIN) break; + } + if (is_line_blank(next)) break; + + { + uint8_t is_hard = 0; + if (q >= para_start + 1) { + char b1 = fb(q - 1); + if (b1 == '\\') { + is_hard = 1; + } else if (q >= para_start + 2) { + char b2 = fb(q - 2); + if ((b1 == ' ' || b1 == '\t') && (b2 == ' ' || b2 == '\t')) + is_hard = 1; + } + } + if (is_hard) { + q++; + emit_seg(q, line_style, ckind, 0); + last_space = 0xFFFFFFFFu; + prev_ch = ' '; + seg_col = col; + continue; + } + } + + q++; + soft_break = 1; + ch = ' '; + } + } + + if (ch == '`') { + if (line_style == INIT_STYLE_CODE) line_style = INIT_STYLE_PLAIN; + else line_style = INIT_STYLE_CODE; + if (!soft_break) q++; + continue; + } + if (ch == '*' && (q + 1) < file_size && fb(q + 1) == '*') { + char next_ch = ((q + 2) < file_size) ? fb(q + 2) : '\n'; + if (is_emph_flanked(prev_ch, next_ch)) { + if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) { + line_style = INIT_STYLE_BOLD; + if (!soft_break) q += 2; else q++; + continue; + } else if (line_style == INIT_STYLE_BOLD && ws_or_eol_or_delim(next_ch)) { + line_style = INIT_STYLE_PLAIN; + if (!soft_break) q += 2; else q++; + continue; + } + } + seg_col += 2; + if (!soft_break) q += 2; else q++; + prev_ch = '*'; + continue; + } + if (ch == '~' && (q + 1) < file_size && fb(q + 1) == '~') { + char next_ch = ((q + 2) < file_size) ? fb(q + 2) : '\n'; + if (is_emph_flanked(prev_ch, next_ch)) { + if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) { + line_style = INIT_STYLE_STRIKE; + if (!soft_break) q += 2; else q++; + continue; + } else if (line_style == INIT_STYLE_STRIKE && ws_or_eol_or_delim(next_ch)) { + line_style = INIT_STYLE_PLAIN; + if (!soft_break) q += 2; else q++; + continue; + } + } + seg_col += 2; + if (!soft_break) q += 2; else q++; + prev_ch = '~'; + continue; + } + if (ch == '*' || ch == '_') { + char next_ch = ((q + 1) < file_size) ? fb(q + 1) : '\n'; + if (is_emph_flanked(prev_ch, next_ch)) { + uint8_t new_style = (ch == '*') ? INIT_STYLE_ITALIC : INIT_STYLE_UNDER; + if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) { + line_style = new_style; + if (!soft_break) q++; else q++; + continue; + } else if (line_style == new_style && ws_or_eol_or_delim(next_ch)) { + line_style = INIT_STYLE_PLAIN; + if (!soft_break) q++; else q++; + continue; + } + } + seg_col++; + if (!soft_break) q++; else q++; + prev_ch = ch; + continue; + } + if (ch == '\t') { + uint8_t tgt = (uint8_t)((seg_col & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP); + if (tgt > SCREEN_W) tgt = SCREEN_W; + seg_col = tgt; + if (!soft_break) q++; else q++; + prev_ch = ' '; + continue; + } + if (ch == ' ') { + if (soft_break) { + last_space = q; + last_space_style = line_style; + seg_col++; + } else { + last_space = q + 1; + last_space_style = line_style; + seg_col++; + q++; + } + prev_ch = ' '; + } else { + seg_col++; + if (!soft_break) q++; else q++; + prev_ch = ch; + } + if (seg_col >= SCREEN_W) { + uint8_t use_ls = (uint8_t)(last_space != 0xFFFFFFFFu && last_space > cur_seg_off); + uint32_t wrap_at = use_ls ? last_space : q; + if (wrap_at >= file_size || wrap_at == cur_seg_off) break; + uint8_t wrap_style = use_ls ? last_space_style : line_style; + emit_seg(wrap_at, wrap_style, ckind, 1); + seg_col = col; + last_space = 0xFFFFFFFFu; + prev_ch = ' '; + q = wrap_at; + /* Откатываем line_style к снимку на момент wrap_at: диапазон + * [wrap_at, q_old) будет пересканирован заново, и переключающие + * символы (обратная кавычка, звёздочка, подчёркивание, тильда) + * внутри него иначе применились бы дважды — именно так возникал + * баг: code-маркер на границе переноса ломает цвет следующего слова (mdview.c, отчёт пользователя + * 2026-06-23). */ + line_style = wrap_style; + } + } + + *io_line_style = line_style; + return q; +} + +/* Строит индекс сегментов рендера по загруженному файлу. */ +static void index_lines(void) +{ + uint8_t in_block = 0; + n_lines = 0; + index_truncated = 0; + cur_seg_off = 0xFFFFFFFFu; /* пока не эмитили ни одного сегмента */ + if (file_size == 0) return; + + uint32_t p = 0; + while (p < file_size && n_lines < max_lines) { + + if ((n_lines & 15) == 0) + spinner_tick(); + + /* Пустая строка — разделитель параграфов. + * В индекс добавляем не более одной пустой экранной строки подряд. */ + if (is_line_blank(p)) { + while (p < file_size && fb(p) != '\n') p++; + if (p < file_size) p++; + /* Серия пустых строк схлопывается в одну визуальную строку. */ + if (n_lines == 0 || !(cur_rec.flags & IF_BLANK)) { + emit_seg(p, INIT_STYLE_PLAIN, CK_PLAIN, 0); + set_blank_cur(); + } + continue; + } + + /* ---- fenced code-блок (граница или тело) ---- */ + if (is_fence_raw(p)) { + in_block = !in_block; + emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); + set_nowrap_cur(); + while (p < file_size && fb(p) != '\n') p++; + if (p < file_size) p++; + continue; + } + if (in_block) { + emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); + set_nowrap_cur(); + set_code_cur(); + while (p < file_size && fb(p) != '\n') p++; + if (p < file_size) p++; + continue; + } + + /* ---- горизонтальный разделитель ---- */ + if (is_hr_raw(p)) { + emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); + set_nowrap_cur(); + while (p < file_size && fb(p) != '\n') p++; + if (p < file_size) p++; + continue; + } + + /* ---- строка таблицы (| ... |): один nowrap-сегмент на исходную строку ---- */ + if (is_table_raw(p)) { + emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); + set_nowrap_cur(); + while (p < file_size && fb(p) != '\n') p++; + if (p < file_size) p++; + continue; + } + + /* ---- классификация текущей строки ---- */ + uint32_t content_off = p; + uint8_t kind = classify_line(p, &content_off); + + /* ---- Заголовок: отдельная строка, не склеивается с параграфом ---- */ + if (kind >= LK_H1 && kind <= LK_H4) { + emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); + uint32_t line_end = content_off; + while (line_end < file_size && fb(line_end) != '\n') line_end++; + (void)inline_scan(content_off, line_end, 0, CK_OTHER, INIT_STYLE_PLAIN); + while (p < file_size && fb(p) != '\n') p++; + if (p < file_size) p++; + continue; + } + + /* ---- Пункт списка: может занимать несколько физических строк ---- */ + if (kind == LK_ULIST || kind == LK_OLIST) { + uint8_t col = marker_visible_col(kind, p, content_off); + uint8_t line_style = INIT_STYLE_PLAIN; + uint32_t q = content_off; + + emit_seg(p, line_style, CK_LIST, 0); + q = scan_join_stream(q, p, col, CK_LIST, &line_style, JOIN_MODE_LIST); + + /* Переходим к началу следующей физической строки после пункта. */ + if (q < file_size) { + while (q < file_size && fb(q) != '\n') q++; + if (q < file_size) q++; + } + + /* Одна пустая строка между соседними маркерами списка подавляется. + * Две и более пустых строки оставляют видимый разделитель. */ + if (q < file_size && is_line_blank(q)) { + uint32_t t = q; + uint8_t blanks = 0; + while (t < file_size && is_line_blank(t)) { + while (t < file_size && fb(t) != '\n') t++; + if (t < file_size) t++; + blanks++; + // if (blanks >= 2) break; + } + if (blanks == 1 && t < file_size) { + uint32_t d = t; + uint8_t nk = classify_line(t, &d); + if (nk == LK_ULIST || nk == LK_OLIST) { + p = t; /* подавляем единственную пустую строку */ + continue; + } + } + } + + p = q; + continue; + } + + /* ---- Параграф цитаты: может покрывать несколько физических строк ---- */ + if (kind == LK_QUOTE) { + uint8_t col = marker_visible_col(kind, p, content_off); + uint8_t line_style = INIT_STYLE_PLAIN; + uint32_t q = content_off; + + emit_seg(p, line_style, CK_QUOTE, 0); + q = scan_join_stream(q, p, col, CK_QUOTE, &line_style, JOIN_MODE_QUOTE); + + while (q < file_size && fb(q) != '\n') q++; + if (q < file_size) q++; + p = q; + continue; + } + + /* ---- Обычный текстовый параграф: склейка через soft-break ---- */ + { + uint8_t line_style = INIT_STYLE_PLAIN; + emit_seg(p, line_style, CK_PLAIN, 0); + + uint32_t q = p; + q = scan_join_stream(q, p, 0, CK_PLAIN, &line_style, JOIN_MODE_PLAIN); + + if (q >= file_size) { + p = file_size; + } else { + /* q стоит на завершающем newline параграфа. + * Переходим на следующую строку, чтобы внешняя петля её + * корректно переклассифицировала. */ + while (q < file_size && fb(q) != '\n') q++; + if (q < file_size) q++; + p = q; + } + } + } + /* emit_seg() рендерит строки с отставанием на одну (см. её + * комментарий) — последняя эмитированная строка файла так и + * остаётся неотрендеренной, дорендериваем её здесь. seg_end для + * неё в render_line_to_cache() берётся как file_size (нет + * "следующей" idx-записи), как и при живом рендере в render_line(). */ + if (n_lines > 0) render_line_to_cache((uint16_t)(n_lines - 1)); +} +#define EM_NONE 0 +#define EM_BOLD 1 +#define EM_ITALIC 2 +#define EM_UNDER 3 +#define EM_CODE 4 +#define EM_STRIKE 5 + +/* Преобразует внутреннее состояние emphasis в экранный атрибут. */ +static uint8_t emph_to_attr(uint8_t emph, uint8_t base_attr) +{ + switch (emph) { + case EM_BOLD: return ATTR_TEXT_BOLD; + case EM_ITALIC: return ATTR_TEXT_ITALIC; + case EM_UNDER: return ATTR_TEXT_UNDERSORE; + case EM_CODE: return ATTR_TEXT_CODE; + case EM_STRIKE: return ATTR_TEXT_STRIKE; + default: return base_attr; + } +} + +/* Единая обработка inline-маркеров в render_line(). + * Возврат: + * 0 = маркер не поглощён (символ выводится как есть), + * 1 = маркер поглощён как управляющий (ничего не рисуем), + * 2 = литеральный двойной маркер (`**` или `~~`), нужно вывести две копии out_ch. */ +static uint8_t handle_inline_marker(char ch, uint32_t *io_p, uint32_t seg_end, + char prev_ch, uint8_t *io_emph, char *out_ch) +{ + uint32_t p = *io_p; + + *out_ch = 0; + + if (ch == '`') { + if (*io_emph == EM_NONE) { + *io_emph = EM_CODE; + *io_p = p + 1; + return 1; + } + if (*io_emph == EM_CODE) { + *io_emph = EM_NONE; + *io_p = p + 1; + return 1; + } + return 0; + } + + if ((ch == '*' || ch == '~') && (p + 1) < seg_end && fb(p + 1) == ch) { + uint8_t pair_emph = (ch == '*') ? EM_BOLD : EM_STRIKE; + char next_ch = ((p + 2) < seg_end) ? fb(p + 2) : '\n'; + + if (is_emph_flanked(prev_ch, next_ch)) { + if (*io_emph == EM_NONE && ws_or_eol(prev_ch)) { + *io_emph = pair_emph; + *io_p = p + 2; + return 1; + } else if (*io_emph == pair_emph && ws_or_eol_or_delim(next_ch)) { + *io_emph = EM_NONE; + *io_p = p + 2; + return 1; + } + } + + /* Невалидный по границам двойной маркер отображаем как литерал. */ + *io_p = p + 2; + *out_ch = ch; + return 2; + } + + if (ch == '*' || ch == '_') { + char next_ch = ((p + 1) < seg_end) ? fb(p + 1) : '\n'; + if (is_emph_flanked(prev_ch, next_ch)) { + uint8_t active = (ch == '*') ? EM_ITALIC : EM_UNDER; + if (*io_emph == EM_NONE && ws_or_eol(prev_ch)) { + *io_emph = active; + *io_p = p + 1; + return 1; + } else if (*io_emph != EM_NONE && ws_or_eol_or_delim(next_ch)) { + *io_emph = EM_NONE; + *io_p = p + 1; + return 1; + } + } + } + + return 0; +} + +/* Сбрасывает накопленный "прогон" из *col символов одного атрибута + * одним bios_writeattr() вместо посимвольных wrchar(). *need_place + * лениво ставит BIOS-курсор перед первым выводом в текущей строке — + * далее place продвигается сам (verified), повторный bios_set_place + * не нужен пока *col растёт монотонно. */ +static void flush_run(uint8_t row, uint8_t *col, const char *buf, + uint8_t len, uint8_t attr, uint8_t *need_place) +{ + if (len == 0) return; + /* *col уже указывает на конец прогона (вызывающий цикл инкрементит + * col на каждый символ ещё во время накопления в буфер — это нужно + * для верных проверок col < SCREEN_W до сброса). Поэтому начало + * прогона на экране = *col - len, а не *col; и *col второй раз + * наращивать НЕ нужно — он уже корректен. */ + if (*need_place) { + bios_set_place(row, (uint8_t)(*col - len)); + *need_place = 0; + } + bios_writeattr(buf, len, attr); +} + +static void render_line(uint16_t line_idx, uint8_t row) +{ + /* Один idx_get() на line_idx вместо ~13 отдельных is_blank/is_cont/ + * is_nowrap/is_code_body/seg_off/get_init_style* вызовов. Каждый из + * них шёл через seg_flags()/seg_off() -> idx_get() -> bank_read(), + * а bank_read() ДВАЖДЫ переключает банк окна 3 (на страницу индекса + * и обратно) плюс memcpy — это оказалось главным узким местом + * рендера (≈700+ переключений банка на кадр), а не сам вывод на + * экран. Все нужные поля (off/flags/style) лежат в одной 8-байтовой + * записи idx_rec_t — читаем её один раз и используем дальше. */ + uint8_t have_rec = (line_idx < n_lines); + idx_rec_t rec; + if (have_rec) idx_get(line_idx, &rec); + + uint8_t col = 0; + uint8_t base_attr = ATTR_TEXT; + uint8_t is_code = (uint8_t)(have_rec && (rec.flags & IF_CODE)); + uint8_t cur_attr = is_code ? ATTR_TEXT_CODE + : styles_map[(have_rec ? rec.style : INIT_STYLE_PLAIN) & 7u]; + uint8_t emph = is_code ? EM_CODE + : (uint8_t)((have_rec ? rec.style : INIT_STYLE_PLAIN) & 7u); + uint8_t parse_inline = 1; /* 1: включён парсер выделений, 0: выводим код как есть */ + + if (have_rec && (rec.flags & IF_BLANK)) { + bios_set_place(row, col); + bios_fillcharattr(' ', ATTR_TEXT, (uint8_t)(SCREEN_W - col)); + return; + } + + if (have_rec) { + uint32_t p = rec.off; + uint32_t content_off = p; + uint8_t cont = (uint8_t)((rec.flags & IF_CONT) != 0); + /* Правая граница сегмента: начало следующего сегмента или конец файла. */ + uint32_t seg_end; + if ((uint16_t)(line_idx + 1) < n_lines) { + idx_rec_t rec_next; + idx_get((uint16_t)(line_idx + 1), &rec_next); + seg_end = rec_next.off; + } else { + seg_end = file_size; + } + if (seg_end > file_size) seg_end = file_size; + uint8_t kind; + uint8_t quote_stream = 0; /* 1: текущий сегмент относится к потоку цитаты */ + + /* Для сегмента-продолжения повторяем префикс/отступ + * от первого не-CONT сегмента логической строки. */ + if (cont) { + uint16_t first = line_idx; + idx_rec_t rec_first = rec; /* rec.flags здесь уже содержит IF_CONT */ + /* Для любого сегмента-продолжения откатываемся к первому + * НЕ-cont сегменту этой логической строки — один idx_get() + * на шаг вместо двух (is_cont + повторный seg_off). */ + while (first > 0 && (rec_first.flags & IF_CONT)) { + first--; + idx_get(first, &rec_first); + } + uint32_t p_start = rec_first.off; + uint32_t content_off = p_start; + uint8_t first_kind = classify_line(p_start, &content_off); + uint8_t indent = marker_visible_col(first_kind, p_start, content_off); + if (first_kind == LK_QUOTE) { + /* Для продолжения цитаты нужно помнить тип потока, + * чтобы далее на newline корректно пропускать сырой `>`. */ + quote_stream = 1; + uint8_t q = 0; + while (q + 2 < content_off && q < SCREEN_W && fb(p_start + q) == ' ') q++; + if (q > (uint8_t)(SCREEN_W - col)) q = (uint8_t)(SCREEN_W - col); + if (q > 0) { + bios_set_place(row, col); + bios_fillcharattr(' ', ATTR_TEXT, q); + col = (uint8_t)(col + q); + } + if (col < SCREEN_W) wrchar(col++, row, 0xB3, ATTR_QUOTE_MARKER); + if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT); + } else if (first_kind == LK_ULIST || first_kind == LK_OLIST) { + uint8_t q = indent; + if (q > (uint8_t)(SCREEN_W - col)) q = (uint8_t)(SCREEN_W - col); + if (q > 0) { + bios_set_place(row, col); + bios_fillcharattr(' ', ATTR_TEXT, q); + col = (uint8_t)(col + q); + } + } + /* p/content_off уже равны rec.off — внутри cont-блока они не + * менялись (там свои локальные тени с тем же именем), + * пересчитывать через seg_off() заново не нужно. */ + } + + /* Кодовый блок: + * - строка-разделитель показывается как пустая; + * - тело рисуется «как есть» с ATTR_TEXT_CODE без inline-парсинга. + * is_fence_delim(line_idx) разворачено на месте: cont и p уже + * известны, повторный idx_get()/is_cont() не нужен. */ + if (!cont && p + 2 < file_size && + fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`') { + bios_set_place(row, col); + bios_fillcharattr(' ', ATTR_TEXT, (uint8_t)(SCREEN_W - col)); + return; + } + if (is_code) { + base_attr = ATTR_TEXT_CODE; + cur_attr = ATTR_TEXT_CODE; + parse_inline = 0; + kind = LK_PLAIN; + quote_stream = 0; + } else if (cont) { + /* Строка-продолжение: префикс уже нарисован выше. + * Содержимое рисуем как plain, без повторной классификации, + * иначе начало слова могло бы ложно стать маркером. */ + kind = LK_PLAIN; + } else { + kind = classify_line(p, &content_off); + if (kind == LK_QUOTE) quote_stream = 1; + } + + if (kind == LK_HR) { + bios_set_place(row, col); + bios_fillcharattr(0xC4, ATTR_HR, (uint8_t)(SCREEN_W - col)); + return; + } + if (kind >= LK_H1 && kind <= LK_H4) { + switch (kind) { + case LK_H1: base_attr = ATTR_TEXT_TITLE1; break; + case LK_H2: base_attr = ATTR_TEXT_TITLE2; break; + case LK_H3: base_attr = ATTR_TEXT_TITLE3; break; + default: base_attr = ATTR_TEXT_TITLE4; break; + } + cur_attr = base_attr; + p = content_off; + } else if (kind == LK_ULIST) { + /* Базовая поддержка вложенности: ведущие пробелы сохраняем, + * затем рисуем маркер списка и пробел перед содержимым. */ + uint32_t q = p; + uint32_t qmax = p + SCREEN_W; + while (q + 2 < content_off && q < qmax && fb(q) == ' ') q++; + uint8_t nsp = (uint8_t)(q - p); + if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col); + if (nsp > 0) { + bios_set_place(row, col); + bios_fillcharattr(' ', ATTR_TEXT, nsp); + col = (uint8_t)(col + nsp); + } + if (col < SCREEN_W) wrchar(col++, row, 0x07, ATTR_LIST_MARKER); /* символ маркера списка */ + if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT); + p = content_off; + } else if (kind == LK_OLIST) { + /* Отступ -> пробелы; цифры и '.' / ')' рисуем как маркер; + * завершающий пробел — обычным атрибутом текста. */ + uint32_t q = p; + uint32_t qmax = p + SCREEN_W; + while (q < content_off && q < qmax && fb(q) == ' ') q++; + uint8_t nsp = (uint8_t)(q - p); + if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col); + if (nsp > 0) { + bios_set_place(row, col); + bios_fillcharattr(' ', ATTR_TEXT, nsp); + col = (uint8_t)(col + nsp); + } + /* Маркер (цифры/'.'/')') читается из fb() — это окно 3, + * BIOS принимает указатели только из #4000..#BFFF, поэтому + * сначала копируем во временный буфер на стеке (окно 2), + * чанками по 8 байт (маркер почти всегда короче, но длинный + * корректно не обрезается, просто шлётся несколькими + * bios_writeattr вместо одного). */ + { + char mkbuf[8]; + uint8_t first_chunk = 1; + while (q < content_off - 1 && col < SCREEN_W) { + uint8_t n = 0; + while (q < content_off - 1 && + (uint8_t)(col + n) < SCREEN_W && + n < sizeof(mkbuf)) { + mkbuf[n] = fb(q); + q++; + n++; + } + if (n == 0) break; + if (first_chunk) { bios_set_place(row, col); first_chunk = 0; } + bios_writeattr(mkbuf, n, ATTR_LIST_MARKER); + col = (uint8_t)(col + n); + } + } + if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT); + p = content_off; + } else if (kind == LK_QUOTE) { + /* Отступ -> пробелы; затем quote-префикс '│' (0xB3), + * пробел и далее содержимое. */ + uint32_t q = p; + uint32_t qmax = p + SCREEN_W; + while (q + 1 < content_off && q < qmax && fb(q) == ' ') q++; + uint8_t nsp = (uint8_t)(q - p); + if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col); + if (nsp > 0) { + bios_set_place(row, col); + bios_fillcharattr(' ', ATTR_TEXT, nsp); + col = (uint8_t)(col + nsp); + } + if (col < SCREEN_W) wrchar(col++, row, 0xB3, ATTR_QUOTE_MARKER); + if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT); + p = content_off; + } + + /* Эффективный горизонтальный сдвиг: применяется только к nowrap-строкам. */ + uint8_t effective_vx = (rec.flags & IF_NOWRAP) ? viewport_x : 0; + /* cc — видимая колонка внутри области контента + * (после фиксированного префикса строки). */ + uint8_t cc = 0; + char prev_ch = ' '; /* используется в проверке границ emphasis */ + + /* Буферизация "ровного" прогона одного атрибута: символ и атрибут + * внутри этого while переменные (emphasis/tab/soft-wrap), поэтому + * весь цикл одним BIOS-вызовом не свернуть — но между сменами + * атрибута почти всегда идёт протяжённый одноцветный текст, и + * именно его стоит копить в runbuf и сбрасывать одним + * bios_writeattr() вместо посимвольных wrchar(). + * runbuf вмещает целую строку экрана — переполнение невозможно, + * т.к. весь цикл и так ограничен col < SCREEN_W. */ + char runbuf[SCREEN_W]; + uint8_t run_len = 0; + uint8_t run_attr = cur_attr; + uint8_t need_place = 1; + + while (p < seg_end && col < SCREEN_W) { + uint8_t soft_join = 0; /* 1: вставлен виртуальный пробел мягкой склейки, p уже сдвинут */ + char ch = fb(p); + /* '\' + * перед концом сегмента — это маркер wide-break: + * поглощаем его без вывода (кроме verbatim code-body). */ + if (parse_inline && ch == '\\' && (p + 1) < seg_end) { + char nb = fb(p + 1); + if (nb == '\n' || nb == '\r') { p++; continue; } + } + /* Мягкий перенос (перевод строки внутри одного сегмента). + * Рендер должен повторить логику индексатора: + * - quote: пропустить сырой `> ` следующей строки; + * - list/plain: схлопнуть перенос + ведущий отступ в 1 пробел. */ + if (parse_inline && (ch == '\n' || ch == '\r')) { + uint32_t next = p + 1; + if (quote_stream && next < seg_end) { + uint32_t next_content = next; + if (classify_line(next, &next_content) == LK_QUOTE) { + /* Мягкая склейка quote-строк: `> ` не рендерим. */ + p = next_content; + ch = ' '; + soft_join = 1; + } else { + /* Защита: если внутри quote-сегмента встретился + * не-цитатный перенос, работаем как обычная мягкая склейка. */ + while (next < seg_end) { + char ws = fb(next); + if (ws == ' ' || ws == '\t') next++; + else break; + } + if (next > (p + 1)) { + p = next; + soft_join = 1; + } + ch = ' '; + } + } else { + while (next < seg_end) { + char ws = fb(next); + if (ws == ' ' || ws == '\t') next++; + else break; + } + if (next > (p + 1)) { + /* Схлопываем перенос + отступ (ленивое продолжение). */ + p = next; + soft_join = 1; + } + ch = ' '; + } + } else if (ch == '\n' || ch == '\r') { + ch = ' '; + } + + /* inline-маркеры (**, ~~, *, _, `): + * helper синхронизирует состояние emphasis и возвращает режим + * обработки текущего токена (поглотить/вывести литерал/обычный символ). */ + if (parse_inline) { + char lit_ch = 0; + uint8_t mk = handle_inline_marker(ch, &p, seg_end, prev_ch, &emph, &lit_ch); + if (mk == 1) { + /* Атрибут меняется -> сбросить накопленный прогон + * со СТАРЫМ атрибутом, прежде чем переключиться. */ + flush_run(row, &col, runbuf, run_len, run_attr, &need_place); + run_len = 0; + cur_attr = emph_to_attr(emph, base_attr); + run_attr = cur_attr; + continue; + } + if (mk == 2) { + if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) { + runbuf[run_len++] = lit_ch; + col++; + } + cc++; + if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) { + runbuf[run_len++] = lit_ch; + col++; + } + cc++; + prev_ch = lit_ch; + continue; + } + } + + if (!soft_join) p++; + if (ch == '\t') { + /* Таб расширяется в координатах контента (cc). + * Сначала сбросить накопленный текстовый прогон — таб + * выводится отдельным bios_fillcharattr(), не через runbuf. */ + flush_run(row, &col, runbuf, run_len, run_attr, &need_place); + run_len = 0; + + uint8_t cc_start = cc; + uint8_t tgt = (uint8_t)((cc & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP); + uint8_t n_total = (uint8_t)(tgt - cc_start); + uint8_t skip = (effective_vx > cc_start) ? (uint8_t)(effective_vx - cc_start) : 0; + if (skip > n_total) skip = n_total; + uint8_t want = (uint8_t)(n_total - skip); + if (want > (uint8_t)(SCREEN_W - col)) want = (uint8_t)(SCREEN_W - col); + if (want > 0) { + bios_set_place(row, col); + bios_fillcharattr(' ', cur_attr, want); + col = (uint8_t)(col + want); + need_place = 0; + } + cc = (uint8_t)(cc_start + skip + want); + prev_ch = ' '; + } else { + if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) { + runbuf[run_len++] = ch; + col++; + } + cc++; + prev_ch = ch; + } + } + flush_run(row, &col, runbuf, run_len, run_attr, &need_place); + + /* Индикатор обрезки справа для nowrap-строки: + * если после текущей позиции есть ещё видимый контент, + * поверх последней колонки рисуем '>'. */ + if ((rec.flags & IF_NOWRAP) && col >= SCREEN_W) { + uint32_t pp = p; + uint32_t left_bound = rec.off; + while (pp < seg_end) { + char c = fb(pp); + if (c == '\n' || c == '\r') break; + if (c == '`') { pp++; continue; } + if (c == '*' && (pp + 1) < seg_end && fb(pp + 1) == '*') { + char pc = (pp > left_bound) ? fb(pp - 1) : ' '; + char nc = (pp + 2 < seg_end) ? fb(pp + 2) : '\n'; + if (is_emph_flanked(pc, nc)) { pp += 2; continue; } + /* Литерал `**` тоже считается видимым контентом справа. */ + } else if (c == '*' || c == '_') { + char pc = (pp > left_bound) ? fb(pp - 1) : ' '; + char nc = (pp + 1 < seg_end) ? fb(pp + 1) : '\n'; + if (is_emph_flanked(pc, nc)) { pp++; continue; } + } + wrchar(SCREEN_W - 1, row, '>', ATTR_TRUNC); + break; + } + } + + /* Индикатор скрытого контента слева при горизонтальном сдвиге. */ + if ((rec.flags & IF_NOWRAP) && viewport_x > 0) { + wrchar(0, row, '<', ATTR_TRUNC); + } + } + /* Дозаполняем остаток строки пробелами, чтобы стереть хвост + * от предыдущего кадра рендера. */ + { + uint8_t pad_attr = (parse_inline ? ATTR_TEXT : ATTR_TEXT_CODE); + if (col < SCREEN_W) { + bios_set_place(row, col); + bios_fillcharattr(' ', pad_attr, (uint8_t)(SCREEN_W - col)); + } + } +} + +/* Пишет одну (char,attr) ячейку в локальный буфер кэш-строки. Молча + * не пишет (и не даёт col перевалить за MAX_CACHE_LINE_LEN), если буфер + * уже заполнен — длинные nowrap-строки просто обрезаются по канону, + * как и раньше обрезались по SCREEN_W при живом рендере. */ +static void cc_put(uint8_t *cellbuf, uint8_t *col, char ch, uint8_t attr) +{ + if (*col < MAX_CACHE_LINE_LEN) { + uint16_t i = (uint16_t)(*col) * 2u; + cellbuf[i] = (uint8_t)ch; + cellbuf[i + 1] = attr; + (*col)++; + } +} + +static void cc_fill(uint8_t *cellbuf, uint8_t *col, char ch, uint8_t attr, uint8_t n) +{ + while (n-- > 0) cc_put(cellbuf, col, ch, attr); +} + +/* Фаза 2 — рендер-воркер. Адаптация render_line(): та же классификация + * строк/префиксов/inline-парсинг (handle_inline_marker, emphasis state + * machine, soft-wrap join) БЕЗ ИЗМЕНЕНИЙ, но результат идёт не на экран + * (bios_set_place/wrchar/bios_fillcharattr/flush_run), а в локальный + * буфер cellbuf через cc_put/cc_fill, и в конце КОММИТИТСЯ одним + * cache_commit()+cache_dir_put(). Буферизация через runbuf/flush_run + * (нужная в render_line() только чтобы минимизировать число BIOS- + * вызовов) здесь не нужна — пишем посимвольно прямо по месту. + * + * Кэшируется ПОЛНЫЙ контент строки до MAX_CACHE_LINE_LEN ячеек, БЕЗ + * обрезки по SCREEN_W и без среза по viewport_x — это view/scroll-time + * понятия (Фаза 4-5: h-скролл — это смещение начала среза в ТОМ ЖЕ + * кэш-буфере, не повторный рендер). По той же причине здесь нет + * truncation-индикаторов '>'/'<' и финального заполнения остатка + * строки пробелами до SCREEN_W — оба добавляются на этапе вывода. */ +static void render_line_to_cache(uint16_t line_idx) +{ + cache_rec_t out_rec; + out_rec.flags = 0; + out_rec.reserved = 0; + out_rec.pad[0] = 0; + out_rec.pad[1] = 0; + + uint8_t have_rec = (line_idx < n_lines); + idx_rec_t rec; + if (have_rec) idx_get(line_idx, &rec); + + if (have_rec) out_rec.flags = rec.flags; + + if (!have_rec || (rec.flags & IF_BLANK)) { + out_rec.page = 0; + out_rec.off = 0; + out_rec.len = 0; + cache_dir_put(line_idx, &out_rec); + return; + } + + uint8_t col = 0; /* позиция в cellbuf, она же длина уже записанного контента */ + uint8_t base_attr = ATTR_TEXT; + uint8_t is_code = (uint8_t)(rec.flags & IF_CODE); + uint8_t cur_attr = is_code ? ATTR_TEXT_CODE + : styles_map[rec.style & 7u]; + uint8_t emph = is_code ? EM_CODE : (uint8_t)(rec.style & 7u); + uint8_t parse_inline = 1; + + uint8_t cellbuf[MAX_CACHE_LINE_LEN * 2]; + + uint32_t p = rec.off; + uint32_t content_off = p; + uint8_t cont = (uint8_t)((rec.flags & IF_CONT) != 0); + uint32_t seg_end; + if ((uint16_t)(line_idx + 1) < n_lines) { + idx_rec_t rec_next; + idx_get((uint16_t)(line_idx + 1), &rec_next); + seg_end = rec_next.off; + } else { + seg_end = file_size; + } + if (seg_end > file_size) seg_end = file_size; + uint8_t kind; + uint8_t quote_stream = 0; + + if (cont) { + uint16_t first = line_idx; + idx_rec_t rec_first = rec; + while (first > 0 && (rec_first.flags & IF_CONT)) { + first--; + idx_get(first, &rec_first); + } + uint32_t p_start = rec_first.off; + uint32_t content_off2 = p_start; + uint8_t first_kind = classify_line(p_start, &content_off2); + uint8_t indent = marker_visible_col(first_kind, p_start, content_off2); + if (first_kind == LK_QUOTE) { + quote_stream = 1; + uint8_t q = 0; + while (q + 2 < content_off2 && q < SCREEN_W && fb(p_start + q) == ' ') q++; + cc_fill(cellbuf, &col, ' ', ATTR_TEXT, q); + cc_put(cellbuf, &col, 0xB3, ATTR_QUOTE_MARKER); + cc_put(cellbuf, &col, ' ', ATTR_TEXT); + } else if (first_kind == LK_ULIST || first_kind == LK_OLIST) { + cc_fill(cellbuf, &col, ' ', ATTR_TEXT, indent); + } + } + + /* Разделитель fenced-блока — пустая строка в кэше (как IF_BLANK). */ + if (!cont && p + 2 < file_size && + fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`') { + out_rec.page = 0; + out_rec.off = 0; + out_rec.len = 0; + cache_dir_put(line_idx, &out_rec); + return; + } + if (is_code) { + base_attr = ATTR_TEXT_CODE; + cur_attr = ATTR_TEXT_CODE; + parse_inline = 0; + kind = LK_PLAIN; + quote_stream = 0; + } else if (cont) { + kind = LK_PLAIN; + } else { + kind = classify_line(p, &content_off); + if (kind == LK_QUOTE) quote_stream = 1; + } + + if (kind == LK_HR) { + cc_fill(cellbuf, &col, 0xC4, ATTR_HR, SCREEN_W); + out_rec.len = col; + if (cache_reserve((uint16_t)(col * 2u), &out_rec.page, &out_rec.off)) { + cache_commit(out_rec.page, out_rec.off, cellbuf, (uint16_t)(col * 2u)); + } else { + out_rec.page = 0; out_rec.off = 0; out_rec.len = 0; + } + cache_dir_put(line_idx, &out_rec); + return; + } + if (kind >= LK_H1 && kind <= LK_H4) { + switch (kind) { + case LK_H1: base_attr = ATTR_TEXT_TITLE1; break; + case LK_H2: base_attr = ATTR_TEXT_TITLE2; break; + case LK_H3: base_attr = ATTR_TEXT_TITLE3; break; + default: base_attr = ATTR_TEXT_TITLE4; break; + } + cur_attr = base_attr; + p = content_off; + } else if (kind == LK_ULIST) { + uint32_t q = p; + uint32_t qmax = p + SCREEN_W; + while (q + 2 < content_off && q < qmax && fb(q) == ' ') q++; + cc_fill(cellbuf, &col, ' ', ATTR_TEXT, (uint8_t)(q - p)); + cc_put(cellbuf, &col, 0x07, ATTR_LIST_MARKER); + cc_put(cellbuf, &col, ' ', ATTR_TEXT); + p = content_off; + } else if (kind == LK_OLIST) { + uint32_t q = p; + uint32_t qmax = p + SCREEN_W; + while (q < content_off && q < qmax && fb(q) == ' ') q++; + cc_fill(cellbuf, &col, ' ', ATTR_TEXT, (uint8_t)(q - p)); + /* Маркер (цифры/'.'/')') читаем прямо из fb() — здесь, в отличие + * от render_line(), нет BIOS-вызова с требованием адреса в + * #4000-#BFFF, так что промежуточный mkbuf-чанкинг не нужен. */ + while (q < content_off - 1) { + cc_put(cellbuf, &col, fb(q), ATTR_LIST_MARKER); + q++; + } + cc_put(cellbuf, &col, ' ', ATTR_TEXT); + p = content_off; + } else if (kind == LK_QUOTE) { + uint32_t q = p; + uint32_t qmax = p + SCREEN_W; + while (q + 1 < content_off && q < qmax && fb(q) == ' ') q++; + cc_fill(cellbuf, &col, ' ', ATTR_TEXT, (uint8_t)(q - p)); + cc_put(cellbuf, &col, 0xB3, ATTR_QUOTE_MARKER); + cc_put(cellbuf, &col, ' ', ATTR_TEXT); + p = content_off; + } + + uint8_t cc = 0; /* позиция внутри контента (после префикса), только для табов */ + char prev_ch = ' '; + + while (p < seg_end && col < MAX_CACHE_LINE_LEN) { + uint8_t soft_join = 0; + char ch = fb(p); + + if (parse_inline && ch == '\\' && (p + 1) < seg_end) { + char nb = fb(p + 1); + if (nb == '\n' || nb == '\r') { p++; continue; } + } + if (parse_inline && (ch == '\n' || ch == '\r')) { + uint32_t next = p + 1; + if (quote_stream && next < seg_end) { + uint32_t next_content = next; + if (classify_line(next, &next_content) == LK_QUOTE) { + p = next_content; + ch = ' '; + soft_join = 1; + } else { + while (next < seg_end) { + char ws = fb(next); + if (ws == ' ' || ws == '\t') next++; + else break; + } + if (next > (p + 1)) { + p = next; + soft_join = 1; + } + ch = ' '; + } + } else { + while (next < seg_end) { + char ws = fb(next); + if (ws == ' ' || ws == '\t') next++; + else break; + } + if (next > (p + 1)) { + p = next; + soft_join = 1; + } + ch = ' '; + } + } else if (ch == '\n' || ch == '\r') { + ch = ' '; + } + + if (parse_inline) { + char lit_ch = 0; + uint8_t mk = handle_inline_marker(ch, &p, seg_end, prev_ch, &emph, &lit_ch); + if (mk == 1) { + cur_attr = emph_to_attr(emph, base_attr); + continue; + } + if (mk == 2) { + cc_put(cellbuf, &col, lit_ch, cur_attr); + cc++; + cc_put(cellbuf, &col, lit_ch, cur_attr); + cc++; + prev_ch = lit_ch; + continue; + } + } + + if (!soft_join) p++; + if (ch == '\t') { + uint8_t cc_start = cc; + uint8_t tgt = (uint8_t)((cc & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP); + uint8_t want = (uint8_t)(tgt - cc_start); + cc_fill(cellbuf, &col, ' ', cur_attr, want); + cc = tgt; + prev_ch = ' '; + } else { + cc_put(cellbuf, &col, ch, cur_attr); + cc++; + prev_ch = ch; + } + } + + out_rec.len = col; + if (col > 0 && cache_reserve((uint16_t)(col * 2u), &out_rec.page, &out_rec.off)) { + cache_commit(out_rec.page, out_rec.off, cellbuf, (uint16_t)(col * 2u)); + } else { + out_rec.page = 0; out_rec.off = 0; out_rec.len = 0; + } + cache_dir_put(line_idx, &out_rec); +} + +/* Рендерит все 30 строк окна, начиная от top_line. ЖИВОЙ путь (парсинг + * через fb()) — после Фазы 4 используется только для горизонтального + * скролла (scroll_h(), Фаза 5 ещё не мигрировала его на кэш), для + * вертикального скролла и обычной перерисовки см. + * draw_viewport_from_cache() ниже. */ +static void render_viewport(void) +{ + for (uint8_t i = 0; i < VIEW_H; i++) { + render_line((uint16_t)(top_line + i), (uint8_t)(VIEW_TOP_ROW + i)); + } +} + +/* Фаза 4 — cache-only draw path: рисует одну строку ИЗ КЭША, без + * единого обращения к fb()/исходному файлу. rec.len==0 — пустая + * строка, остаётся только фон. rec.len>SCREEN_W — nowrap-строка шире + * экрана: показываем первые SCREEN_W ячеек (viewport_x=0, как и + * остальные строки этим путём — горизонтальный скролл пока только + * через scroll_h()/render_viewport(), см. Фазу 5) и индикатор обрезки + * '>' в последней колонке. */ +static void draw_line_from_cache(uint16_t line_idx, uint8_t row) +{ + cache_rec_t rec; + cache_dir_get(line_idx, &rec); + + bios_set_place(row, 0); + bios_fillcharattr(' ', ATTR_TEXT, SCREEN_W); + + if (rec.len == 0) return; + + uint8_t w = (rec.len > SCREEN_W) ? SCREEN_W : rec.len; + win_rest(row, 0, 1, w, rec.page, rec.off); + if (rec.len > SCREEN_W) { + wrchar(SCREEN_W - 1, row, '>', ATTR_TRUNC); + } +} + +/* Перерисовывает все VIEW_H строк окна из кэша — основной путь + * перерисовки видимой области после загрузки файла (Фаза 3-4). */ +static void draw_viewport_from_cache(void) +{ + for (uint8_t i = 0; i < VIEW_H; i++) { + draw_line_from_cache((uint16_t)(top_line + i), (uint8_t)(VIEW_TOP_ROW + i)); + } +} + +/* Возвращает процент прокрутки (0..100) относительно доступного диапазона. */ +static uint8_t calc_pct(void) +{ + uint8_t pct; + if (n_lines <= VIEW_H) + pct = 100; + else + pct = (uint8_t)((uint32_t)top_line * 100UL / (uint32_t)(n_lines - VIEW_H)); + return pct; +} + +/* Рисует динамическую часть статус-бара без printf. + * Полностью перезаписывает колонки 45..79, чтобы не оставлять «хвосты». */ +static void render_status_numbers(void) +{ + uint16_t last = top_line + VIEW_H; + if (last > n_lines) last = n_lines; + uint8_t pct = calc_pct(); + + textattr(ATTR_BAR); + bios_set_place(0, 45); + bios_fillcharattr(' ', ATTR_BAR, (uint8_t)(SCREEN_W - 45)); + + wrchar(45, 0, 0xB3, ATTR_BAR); /* вертикальный разделитель */ + gotoxy(47, 0); + cputs("L "); + dec16(top_line + 1); + cputs("-"); + dec16(last); + cputs(" / "); + dec16(n_lines); + + + wrchar(70, 0, 0xB3, ATTR_BAR); /* вертикальный разделитель */ + gotoxy( 73, 0); + dec8(pct); + cputs("%"); +} + +/* Полностью перерисовывает статус-бар (заголовок + числа). */ +static void render_full_status(void) +{ + + fill_row(0, ATTR_BAR); + /* Размещение в шапке: "MDVIEW", затем слот спиннера и имя файла. */ + put_str_attr(1, 0, "MDVIEW", ATTR_BAR); + put_str_attr(10, 0, filename, ATTR_BAR); + render_status_numbers(); +} + +/* Обновляет только изменяемую числовую часть статус-бара. */ +static void render_updated_status(void) +{ + render_status_numbers(); +} + +/* Отрисовывает нижнюю строку меню. */ +static void render_menu(void) +{ + fill_row(MENU_ROW, ATTR_MENU_T); + put_str_attr(1, MENU_ROW, "F1", ATTR_MENU_K); + put_str_attr(4, MENU_ROW, "Help", ATTR_MENU_T); + /* F2 зарезервирован: режимов wrap/unwrap больше нет. */ + put_str_attr(SCREEN_W - 10, MENU_ROW, "F10", ATTR_MENU_K); + put_str_attr(SCREEN_W - 5, MENU_ROW, "Exit", ATTR_MENU_T); +} + +/* ================================================================== + * Прокрутка + * ================================================================== */ + +/* Ограничивает top_line допустимым диапазоном с учётом высоты окна. */ +static void clamp_top(void) +{ + if (n_lines <= VIEW_H) { + top_line = 0; + } else if (top_line > n_lines - VIEW_H) { + top_line = (uint16_t)(n_lines - VIEW_H); + } +} + +/* Прокрутка вверх на n строк с частичной перерисовкой при n == 1. */ +static void scroll_up(uint16_t n) +{ + uint16_t new_top_line = (top_line >= n) ? (uint16_t)(top_line - n) : 0; + if( new_top_line != top_line) { + top_line = new_top_line; + if (n == 1) { + scroll(0, VIEW_TOP_ROW, SCREEN_W, VIEW_H, 2, 0); + draw_line_from_cache((uint16_t)(top_line), (uint8_t)(VIEW_TOP_ROW)); + } else { + draw_viewport_from_cache(); + } + } +} + +/* Прокрутка вниз на n строк с частичной перерисовкой при n == 1. */ +static void scroll_down(uint16_t n) +{ + uint16_t new_top_line = (top_line + n < n_lines - VIEW_H) ? + (uint16_t)(top_line + n) : n_lines - VIEW_H; + if( new_top_line != top_line) { + top_line = new_top_line; + if (n == 1) { + scroll(0, VIEW_TOP_ROW, SCREEN_W, VIEW_H, 1, 0); + draw_line_from_cache((uint16_t)(top_line + VIEW_H - 1), (uint8_t)(VIEW_TOP_ROW + VIEW_H - 1)); + } else { + clamp_top(); + draw_viewport_from_cache(); + } + } +} + +/* Горизонтальный сдвиг (только если в окне есть nowrap-строки). + * Максимум сдвига ограничен самой широкой nowrap-строкой на экране. */ +static void scroll_h(int8_t delta) +{ + uint16_t maxw = 0; + for (uint8_t i = 0; i < VIEW_H; i++) { + uint16_t li = (uint16_t)(top_line + i); + if (li >= n_lines) break; + if (!is_nowrap(li)) continue; + uint32_t a = seg_off(li); + uint32_t b = seg_off((uint16_t)(li + 1)); + if (b > file_size) b = file_size; + uint16_t w = (b > a) ? (uint16_t)(b - a) : 0; /* приблизительная видимая ширина */ + if (w > maxw) maxw = w; + } + if (maxw == 0) return; /* в окне нет nowrap-контента */ + + /* Максимальный сдвиг = ширина за пределами экрана, в границах uint8. */ + uint16_t over = (maxw > SCREEN_W) ? (uint16_t)(maxw - SCREEN_W) : 0; + if (over > 248u) over = 248u; + uint8_t max_vx = (uint8_t)over; + + int16_t nx = (int16_t)viewport_x + delta; + if (nx < 0) nx = 0; + if (nx > (int16_t)max_vx) nx = max_vx; + if ((uint8_t)nx != viewport_x) { + viewport_x = (uint8_t)nx; + render_viewport(); + } +} + + +/* ================================================================== + * Вывод ошибки + * ================================================================== */ + +/* Печатает сообщение об ошибке и ждёт нажатия клавиши. */ +static void die(const char *msg) +{ + clrscr_attr(ATTR_TEXT); + puts(msg); + puts("Press any key to exit..."); + (void)getkey(); +} + +/* ================================================================== + * Диалог справки (F1) + * ================================================================== */ + +/* Заполняет одну внутреннюю строку диалога (r=0 — первая строка за рамкой). + * Строка s в кодировке CP866; остаток до края дополняется пробелами. */ +static void help_line(uint8_t r, const char *s, uint8_t attr) +{ + uint8_t x = HELP_X + 1u; + uint8_t y = HELP_Y + 1u + r; + uint8_t len = (uint8_t)strlen(s); + if (len > HELP_W - 2u) len = HELP_W - 2u; + + bios_set_place(y, x); + if (len > 0) bios_writeattr(s, len, attr); + /* place уже продвинут bios_writeattr на len колонок (verified). */ + if (len < HELP_W - 2u) { + bios_fillcharattr(' ', ATTR_HELP_BG, (uint8_t)(HELP_W - 2u - len)); + } +} + +static void show_help(void) +{ + /* " Помощь " в CP866 (8 байт) */ + static const char title[] = " \x8F\xAE\xAC\xAE\xE9\xEC "; + uint8_t tlen = 8u; + uint8_t lft = (uint8_t)((HELP_W - 2u - tlen) / 2u); /* = 27 */ + uint8_t rgt = (uint8_t)(HELP_W - 2u - tlen - lft); + /* Верхняя граница рамки с заголовком по центру. + * place продвигается каждым BIOS-вызовом (verified), поэтому + * достаточно одной установки места на всю строку рамки. */ + wrchar(HELP_X, HELP_Y, 0xDA, ATTR_HELP_BDR); /* ┌ */ + bios_set_place(HELP_Y, (uint8_t)(HELP_X + 1u)); + if (lft > 0) bios_fillcharattr(0xC4, ATTR_HELP_BDR, lft); + if (tlen > 0) bios_writeattr(title, tlen, ATTR_HELP_TIT); + if (rgt > 0) bios_fillcharattr(0xC4, ATTR_HELP_BDR, rgt); + wrchar(HELP_X + HELP_W - 1u, HELP_Y, 0xBF, ATTR_HELP_BDR); /* ┐ */ + + /* Боковые границы (левый и правый │ для каждой строки тела) */ + for (uint8_t r = 1u; r < HELP_H - 1u; r++) { + wrchar(HELP_X, HELP_Y + r, 0xB3, ATTR_HELP_BDR); /* │ */ + wrchar(HELP_X + HELP_W - 1u, HELP_Y + r, 0xB3, ATTR_HELP_BDR); /* │ */ + } + + /* Нижняя граница рамки */ + wrchar(HELP_X, HELP_Y + HELP_H - 1u, 0xC0, ATTR_HELP_BDR); /* └ */ + bios_set_place((uint8_t)(HELP_Y + HELP_H - 1u), (uint8_t)(HELP_X + 1u)); + bios_fillcharattr(0xC4, ATTR_HELP_BDR, (uint8_t)(HELP_W - 2u)); + wrchar(HELP_X + HELP_W - 1u, HELP_Y + HELP_H - 1u, 0xD9, ATTR_HELP_BDR); /* ┘ */ + + /* Содержимое (20 внутренних строк) */ + uint8_t r = 0; + help_line(r++, "", ATTR_HELP_BG); + help_line(r++, " MDView v0.2 -- Markdown Viewer for Sprinter", ATTR_HELP_HDR); + help_line(r++, " (c) 2026 Petrov A.G.", ATTR_HELP_BG); + help_line(r++, "", ATTR_HELP_BG); + help_line(r++, " Navigation:", ATTR_HELP_HDR); + help_line(r++, " \x18 \x19 Scroll one line up / down", ATTR_HELP_BG); + help_line(r++, " PgUp PgDn Scroll one page up / down", ATTR_HELP_BG); + help_line(r++, " Home End Jump to beginning / end of document", ATTR_HELP_BG); + help_line(r++, " \x1B \x1A Horizontal pan (code blocks/tables)", ATTR_HELP_BG); + help_line(r++, " Esc F10 Exit", ATTR_HELP_BG); + help_line(r++, "", ATTR_HELP_BG); + help_line(r++, " Markdown elements:", ATTR_HELP_HDR); + help_line(r++, " # ## ### Headings H1-H6", ATTR_HELP_BG); + help_line(r++, " **bold** *italic* `code` ~~strike~~", ATTR_HELP_BG); + help_line(r++, " > quote ``` ... ``` Fenced code block", ATTR_HELP_BG); + help_line(r++, " - * + item 1. 2. Ordered list", ATTR_HELP_BG); + help_line(r++, " |----|----| Tables", ATTR_HELP_BG); + help_line(r++, "", ATTR_HELP_BG); + help_line(r++, " File size: up to 128 KB (EMM). Lines: up to 16384.", ATTR_HELP_BG); + help_line(r++, "", ATTR_HELP_BG); + + (void)getkey(); + + render_full_status(); + draw_viewport_from_cache(); + render_menu(); +} + +/* ================================================================== + * Точка входа + * ================================================================== */ + +int main(int argc, char **argv) +{ + const char *path; + if (argc >= 2) { + path = argv[1]; + } else { + path = "README.MD"; + /* Дефолтный файл отсутствует — выводим подсказку без смены режима экрана. */ + { + int chk = open(path, O_RDONLY); + if (chk < 0) { + cputs("MDView v0.2 -- Markdown Viewer for Sprinter\r\n"); + cputs("(c) 2026 Petrov A.G.\r\n\r\n"); + cputs("Usage: mdview2 \r\n"); + cputs(" Displays a Markdown document (CP866, up to 128 KB).\r\n"); + return 1; + } + close(chk); + } + } + + /* Копируем путь в static filename[] для отображения в статус-баре. */ + { + uint8_t i = 0; + while (i < (uint8_t)(sizeof(filename) - 1) && path[i]) { + filename[i] = path[i]; + i++; + } + filename[i] = 0; + } + + /* Сначала поднимаем UI, чтобы пользователь видел шапку/меню/спиннер + * уже во время загрузки файла и построения индекса. */ + settextmode(TEXT_MODE_80x32); + clrscr_attr(ATTR_TEXT); + set_pallete(); + render_menu(); + render_full_status(); + spinner_show(1); + + int rc = load_file(path); + if (rc < 0) { + spinner_show(0); + clrscr_attr(ATTR_RESET); + textattr(ATTR_RESET); + cputs("MDView: cannot load file '"); + cputs(path); + cputs("'\n\rError: "); + switch (rc) { + case -1: cputs("open failed"); break; + case -2: cputs("size > 128K or seek failed"); break; + case -3: cputs("mem_alloc_pages failed"); break; + case -4: cputs("short read"); break; + case -5: cputs("index alloc failed"); break; + case -6: cputs("render-cache dir alloc failed"); break; + } + cputs("\n\r\n\r"); + return 1; + } + index_lines(); + spinner_show(0); + if (n_lines == 0) { + die("mdview: empty file"); + unload_file(); + return 1; + } + + render_full_status(); + draw_viewport_from_cache(); /* Фаза 4: рендер уже сделан во время index_lines(), рисуем только из кэша */ + + for (;;) { + uint16_t k = getkey(); + uint8_t ascii = (uint8_t)(k & 0xFF); + uint8_t scan = (uint8_t)((k >> 8) & 0x7F); + + if (ascii) { + if (ascii == 0x1B) break; /* клавиша Esc */ + continue; + } + switch (scan) { + case KEY_F1: show_help(); break; + case KEY_F10: goto exit_loop; + case KEY_UP: scroll_up(1); break; + case KEY_DOWN: scroll_down(1); break; + case KEY_LEFT: scroll_h(-(int8_t)HPAN_STEP); break; + case KEY_RIGHT: scroll_h(+(int8_t)HPAN_STEP); break; + case KEY_PGUP: scroll_up(VIEW_H); break; + case KEY_PGDN: scroll_down(VIEW_H); break; + case KEY_HOME: top_line = 0; + viewport_x = 0; + draw_viewport_from_cache(); break; + case KEY_END: top_line = (n_lines > VIEW_H) + ? (uint16_t)(n_lines - VIEW_H) : 0; + viewport_x = 0; + draw_viewport_from_cache(); break; + default: continue; + } + render_updated_status(); + } +exit_loop: + unload_file(); + pal_reset(PAL_CGA); + clrscr_attr(ATTR_TEXT); + return 0; +} diff --git a/tests/winrest/Makefile b/tests/winrest/Makefile new file mode 100644 index 0000000..a2fa4e9 --- /dev/null +++ b/tests/winrest/Makefile @@ -0,0 +1,5 @@ +# Build winrest.exe — probes ESTEX WINCOPY/WINREST buffer format, TINY mode. + +PROJ_ROOT := $(abspath $(CURDIR)/../..) +EXAMPLE := winrest +include $(PROJ_ROOT)/app.mk diff --git a/tests/winrest/winrest.c b/tests/winrest/winrest.c new file mode 100644 index 0000000..ffcdcaf --- /dev/null +++ b/tests/winrest/winrest.c @@ -0,0 +1,151 @@ +/* + * Probe for ESTEX WINCOPY (59h) / WINREST (5Ah) — "save/restore screen + * window" calls (docs/part2/DSS 1.60 rst 10.txt:1379-1417): + * + * D=row E=col H=height L=width B=buffer page IX=buffer address + * + * Goal: reverse-engineer the on-disk cell format (char/attr order, + * per-row stride/padding) by writing four DISTINCT (char,attr) cells, + * capturing them with WINCOPY, and hex-dumping the captured page. + * A second WINREST to a different screen location confirms the + * round-trip visually. + * + * Register layout verified via `sdcc -S` for win_copy(row,col,h,w,page): + * A=row, L=col, stack [SP+2]=h [SP+3]=w [SP+4]=page (sdcccall(1)). + * + * CONFIRMED in MAME (2026-06-23): buffer format is (char,attr) pairs, + * row-major, stride = width*2 bytes, no padding. See memory note + * sprinter_winrest_format. + * + * Also reports mem_info() (total/free 16 KB EMM pages) at program + * start, to size a future mdview2 pre-render cache budget. + */ +#include +#include +#include +#include +#include + +#define BUF_ADDR 0xC000u + +static void win_copy(uint8_t row, uint8_t col, uint8_t h, uint8_t w, uint8_t page) __naked +{ + (void)row; (void)col; (void)h; (void)w; (void)page; + __asm + ld iy, #2 + add iy, sp + ld d, a ; D = row + ld e, l ; E = col + ld h, 0 (iy) ; H = height + ld a, 1 (iy) ; width -> L + ld l, a + ld a, 2 (iy) ; page -> B + ld b, a + push ix + ld ix, #0xC000 + ld c, #0x59 ; ESTEX WINCOPY + di + rst #0x10 + ei + pop ix + pop hl ; return address + inc sp + inc sp + inc sp ; discard h,w,page + jp (hl) + __endasm; +} + +static void win_rest(uint8_t row, uint8_t col, uint8_t h, uint8_t w, uint8_t page) __naked +{ + (void)row; (void)col; (void)h; (void)w; (void)page; + __asm + ld iy, #2 + add iy, sp + ld d, a ; D = row + ld e, l ; E = col + ld h, 0 (iy) ; H = height + ld a, 1 (iy) ; width -> L + ld l, a + ld a, 2 (iy) ; page -> B + ld b, a + push ix + ld ix, #0xC000 + ld c, #0x5A ; ESTEX WINREST + di + rst #0x10 + ei + pop ix + pop hl + inc sp + inc sp + inc sp + jp (hl) + __endasm; +} + +int main(void) +{ + clrscr(); + + /* --- mem_info() baseline, before any allocation by this program. */ + uint16_t total_pages = 0, free_pages = 0; + mem_info(&total_pages, &free_pages); + + textattr(0x0F); + gotoxy(0, 0); + printf("EMM pages: total=%u free=%u (each = 16 KB)\n", total_pages, free_pages); + printf(" => total %lu KB, free %lu KB\n", + (uint32_t)total_pages * 16UL, (uint32_t)free_pages * 16UL); + printf("Press a key to continue to WINCOPY/WINREST probe...\n"); + (void)getchar(); + + /* Four distinguishable cells: char and attr both easy to recognise + * in a hex dump (A/B/C/D = 0x41..0x44, attrs 0x11/0x22/0x33/0x44). */ + gotoxy(5, 10); textattr(0x11); putch('A'); + gotoxy(6, 10); textattr(0x22); putch('B'); + gotoxy(5, 11); textattr(0x33); putch('C'); + gotoxy(6, 11); textattr(0x44); putch('D'); + + textattr(0x0F); + gotoxy(0, 13); + printf("Captured window drawn above (row 10-11, col 5-6).\n"); + printf("Press a key to WINCOPY it...\n"); + (void)getchar(); + + uint8_t blk = mem_alloc_pages(1); + uint8_t page = mem_get_page(blk, 0); + + win_copy(10, 5, 2, 2, page); + + /* Map the same physical page into our own W3 to inspect the bytes + * WINCOPY just wrote there. */ + sprinter_page_w3(page); + { + volatile uint8_t *buf = (volatile uint8_t *)BUF_ADDR; + gotoxy(0, 15); + printf("Buffer dump (first 16 bytes of page %u):\n", page); + for (uint8_t i = 0; i < 16; i++) { + printf("%02X ", buf[i]); + } + printf("\n"); + for (uint8_t i = 0; i < 16; i++) { + char c = (char)buf[i]; + putchar((c >= 0x20 && c < 0x7F) ? c : '.'); + } + printf("\n"); + } + + printf("Press a key to WINREST it to row 20...\n"); + (void)getchar(); + + win_rest(20, 5, 2, 2, page); + + gotoxy(0, 23); + printf("Restored at row 20-21, col 5-6 -- compare colours/chars\n"); + printf("with the original at row 10-11.\n"); + printf("Press a key to exit...\n"); + (void)getchar(); + + return 0; +}