016fedd94c
Replace per-character wrchar() loops in render_line/fill_row/help screens with batched BIOS calls (bios_writeattr/bios_fillcharattr), and cache the per-line index record (idx_get) instead of refetching it ~13 times per rendered line — each refetch cost two W3 bank switches via bank_read(). Also fixes a pre-existing indexer bug: when word-wrap pushes a token that starts with an emphasis/code marker (e.g. `_text_`) onto a new line, the marker got re-scanned a second time during the wrap continuation, flipping line_style back off and corrupting the rendered attribute of the next word. Fixed by snapshotting line_style at the last seen space and rolling back to that snapshot (both the saved continuation style and the live scan state) when a wrap is taken. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
104 lines
21 KiB
Markdown
104 lines
21 KiB
Markdown
# mdview — модель документа и рендеринг (единый смешанный режим)
|
||
Дизайн-документ переработки `mdview.c` под новое ТЗ форматирования (`examples/mdview/todo2`). Описывает решение по хранению данных, разбор документа при загрузке, упрощение рендера и снятие лимита на число строк.
|
||
## 1. Контекст и главный вопрос
|
||
Мы отказываемся от двух режимов показа (Wrap/UnWrap-переключатель) и переходим к единому смешанному режиму: тип переноса задаётся типом блока (обычный текст/заголовки/списки/цитаты — Wrap; код/таблицы — UnWrap).
|
||
Главный вопрос: нужно ли хранить оригинальный байтовый контент файла, или при загрузке сразу преобразовать его в «готовый к показу» текст (склеить строки параграфов, убрать маркеры, развернуть отступы и т.п.)?
|
||
Ответ: **оригинал храним; отдельный «готовый» текстовый/ячеечный буфер не строим.** «Подготовка при загрузке» реализуется как построение компактного *индекса метаданных*, а не как преобразование *содержимого*.
|
||
## 2. Решение по архитектуре
|
||
### 2.1 Текущее состояние (база)
|
||
* Файл целиком лежит в EMM-страницах (до 8×16 КБ). В окно W3 (`0xC000`) в каждый момент замаплена ровно одна страница; доступ к байту — через `fb()`/`map_page()` (`mdview.c:198-213`).
|
||
* Индекс — это параллельные массивы по экранным сегментам: `line_offset[]` (смещение в файле) + битфлаги `cont/nowrap/blank/in_code`, 2-битный `line_kind[]`, `init_style[]` (`mdview.c:122-135`).
|
||
* `index_lines()` за один проход уже делает склейку параграфов обычного текста, перенос по словам на 80 колонок и перенос emphasis через soft/hard break (`mdview.c:692-911`).
|
||
* `render_line()` при каждой отрисовке заново читает байты из оригинала и заново парсит inline-разметку (`mdview.c:918-1156`).
|
||
Вывод: «готовим при загрузке» мы уже делаем — но готовим **индекс**, а не текст.
|
||
### 2.2 Почему не материализуем содержимое
|
||
* **Одно свободное окно W3.** Режим памяти `small`: код в W1, данные/стек/куча в W2, для банкуемых данных свободен только W3. Преобразование «оригинал → готовый буфер» требует одновременно держать замапленными исходную страницу (чтение) и страницу-приёмник (запись). При единственном окне это поток swap-ов на каждую границу. In-place преобразование тоже невозможно: склейка меняет длины, смещения «съезжают», параграф пересекает границу 16 КБ.
|
||
* **Удвоение памяти и срыв гарантии 128 КБ.** Оригинал может занимать все 8 страниц; готовой копии нужны свои страницы — гарантировать, что влезут обе, нельзя.
|
||
* **Готовая форма не обязательно меньше.** Снятие маркеров экономит байты, но добавляются отступы-продолжения у переносов списков/цитат. В лучшем случае ≈ размер оригинала, в худшем — больше. Ячеечная модель (символ+атрибут) — это ×2 (до 256 КБ), невозможно.
|
||
* **Покадровая стоимость и так мала.** За кадр рисуются только 30 видимых строк (~30×80 чтений). Единственный дорогой проход — `index_lines()` — неизбежен в любой архитектуре (нужен полный скан для `n_lines` и процента прокрутки).
|
||
### 2.3 Что реально оптимизировать
|
||
Не текст, а повторную работу `render_line()`:
|
||
* вызов `classify_line()` на каждый кадр (`mdview.c:984`);
|
||
* обратный проход к первому не-cont сегменту ради отступа продолжения (`mdview.c:944-970`).
|
||
Это снимается переносом результата классификации (`kind`, ширина отступа/контент-колонка) в сам индекс на этапе `index_lines()`.
|
||
## 3. Модель данных индекса
|
||
### 3.1 Запись сегмента
|
||
Единая запись на экранный сегмент полностью заменяет нынешние параллельные массивы (`line_offset`, `cont_flag`, `in_code`, `nowrap_flag`, `blank_flag`, `line_kind`, `init_style`); `index_lines()` переписывается с нуля под эту модель. Цель размера записи — 5–6 байт:
|
||
* `offset` — 3 байта (24-битное смещение в файле, покрывает 128 КБ).
|
||
* `flags` — 1 байт: биты `cont`, `nowrap`, `blank`, `in_code` + 2-битный `ckind` (тип продолжения: PLAIN/QUOTE/LIST/OTHER).
|
||
* `style` — 1 байт: стартовый стиль сегмента (`init_style`, теперь включая STRIKE) + при необходимости глубина вложенности.
|
||
* `indent` — 1 байт: предвычисленная контент-колонка/ширина префикса, чтобы рендер не вызывал `classify_line()` и не делал обратный проход.
|
||
### 3.2 Размещение и снятие лимита `MAX_LINES`
|
||
Проблема: `line_offset` сейчас `uint32_t[2048]` = 8 КБ, `init_style` = 2 КБ; суммарно статический индекс ~11.6 КБ near-памяти (W2). Рост лимита в near невозможен — W2 переполнится.
|
||
Решение:
|
||
* **Индекс храним в отдельном EMM-блоке** (свои страницы, помимо файловых), доступ — через тот же W3.
|
||
* **Near-кэш viewport**: перед отрисовкой кадра разово вычитываем записи для `VIEW_H+1` видимых сегментов в маленький near-массив (≈ `(VIEW_H+1)×6` ≈ 186 байт). Рендер работает по near-кэшу + читает только контент-страницы. Это устраняет per-byte thrashing между страницей индекса и страницей контента: переключений на кадр — единицы, а не тысячи.
|
||
* **Динамический размер**: число страниц под индекс выделяем пропорционально размеру файла (число сегментов ∈ размеру). `MAX_LINES` становится функцией от выделенных страниц индекса. Это прямо ложится на заметку v2 («чем больше банков под файл, тем больше буферы»).
|
||
* **Без регресса скорости при индексе в EMM**: чтобы вынос индекса в банки не замедлил сборку (W3 делится между чтением контента и записью индекса), записи копим в near-буфере батчами и сбрасываем в EMM-блок через `bank_write()` (он сам сохраняет/восстанавливает маппинг W3). Переключений окна на всю сборку — единицы, а не на каждый сегмент.
|
||
* Если индекс-страницы выделить не удалось — деградируем до текущего near-лимита и показываем явную диагностику обрезки (а не молчаливый обрыв в `emit_seg()` — `mdview.c:574`).
|
||
## 4. `index_lines()` — разбор по новому ТЗ
|
||
Единый проход по файлу строит сегменты. Деление на параграфы — по пустым строкам; несколько пустых строк подряд схлопываются в одну.
|
||
### 4.0 Скорость подготовки — главный приоритет
|
||
Требование: подготовка максимально быстрая (сейчас ~25 КБ готовятся 6–10 с). Две структурные причины медленности и их устранение:
|
||
* **Per-byte `fb(uint32_t)`.** Каждый доступ к байту пересчитывает страницу 32-битными `p >> 14` и `p & 0x3FFF` (`mdview.c:209-213`). На Z80 32-битная арифметика — это программные подпрограммы на каждый символ. Замена: **потоковый разбор** — мапим страницу один раз, идём по окну `char *`/16-битным индексом, страницу переключаем только на границе 16 КБ. 32-битным остаётся лишь сохраняемый в индекс `offset`.
|
||
* **Многократные пере-сканы.** На каждом `\n` внутри параграфа вызываются `is_fence_raw()`, `is_hr_raw()`, `classify_line()`, `is_line_blank()` (`mdview.c:800-809`) — каждая заново сканирует следующую строку, а `classify_line()` ещё и повторяет цикл детекции HR. Для параграфа из N строк — O(N×длина) лишней работы. Замена: **один проход** — каждую строку классифицируем ровно один раз в момент её начала, lookahead — минимальный (несколько первых байт).
|
||
* **32-битные сравнения.** Курсор скана — (страница:8 бит, смещение:16 бит); сравнение с концом — сначала по странице, потом 16-битно.
|
||
Ожидаемый эффект: подготовка — по сути один линейный проход с 16-битными операциями, кратное ускорение относительно текущего multi-pass + 32-bit. (`render_line()` может остаться на `fb()` — там только ~30×80 байт за кадр.)
|
||
### 4.1 Обычный текст (Wrap)
|
||
* **Soft break** (одиночный `\n`): склейка, следующая строка продолжается через пробел.
|
||
* **Wide break**: 2+ пробелов перед `\n` **или** символ `\` перед `\n` → принудительный перенос внутри параграфа, стиль сохраняется. (Текущий код ловит только 2 пробела — `mdview.c:813-818`; добавить ветку для `\`.)
|
||
* **Hard break** (пустая строка): новый параграф, отделяется ОДНОЙ пустой строкой независимо от числа пустых строк в оригинале.
|
||
* **Модификаторы** bold/italic/strike/code действуют через soft/wide break внутри параграфа и сбрасываются на границе параграфа. (Добавить STRIKE `~~…~~` — сейчас его нет в `INIT_STYLE_*`/`ATTR_*`.)
|
||
### 4.2 Заголовки (Wrap)
|
||
Один оригинальный абзац-строка; стартовый стиль по уровню. Во входе распознаём H1–H6, но H4/H5/H6 далее обрабатываются одинаково как H4 (сливаются в один стиль; `classify_line()` уже сворачивает `lvl>4` → `LK_H4`, `mdview.c:488`). Внутри допустимы bold/italic/code/strike. После заголовка всегда пустая строка.
|
||
### 4.3 Горизонтальный разделитель HR
|
||
Всегда одна строка, после неё всегда пустая строка (новый абзац).
|
||
### 4.4 Списки (Wrap) — НОВОЕ: многострочная склейка
|
||
* Пункт может занимать несколько оригинальных строк; soft break внутри пункта склеивается через пробел (как обычный текст). Сейчас списки эмитятся построчно (`mdview.c:762-772`) — переписать на paragraph-модель.
|
||
* Новая строка с префиксом пункта → новый пункт.
|
||
* Пустая строка завершает пункт. Следующая непустая НЕ-пункт строка не является продолжением.
|
||
* **Группировка**: если после ОДНОЙ пустой строки идёт снова пункт — это тот же список, пустая строка в показе подавляется (пункты идут вплотную). Только ДВЕ+ пустые строки между пунктами разрывают на разные списки (в показе — одна пустая строка между ними).
|
||
* Перенос продолжения пункта печатается с отступом до контент-колонки (для уровня 1 — 2 пробела).
|
||
* Незакрытые модификаторы НЕ переносятся на следующий пункт (каждый пункт — свой параграф).
|
||
* Вложенные списки поддерживаются (отступ растёт с ведущими пробелами).
|
||
* Отдельные стили: префикс маркера и текст списка.
|
||
Пример соответствует разделу «Списки» в `todo2` (строки 1–6).
|
||
### 4.5 Цитаты (Wrap)
|
||
* Отдельный параграф; перед текстом — префикс цитаты, перенесённые строки тоже предваряются префиксом.
|
||
* Многострочная склейка как у текста; пустая строка-цитата (`>`) показывается как пустая строка с префиксом.
|
||
* Вложенность (`> >` → двойной префикс). Отдельные стили: префикс и текст цитаты.
|
||
### 4.6 Блок кода ``` ``` ``` (UnWrap)
|
||
Весь блок одним стилем кода, без inline-модификаторов. После блока обязательна пустая строка. Строки не переносятся (truncate + горизонтальный скролл).
|
||
### 4.7 Таблицы (UnWrap)
|
||
Пока as-is, без переноса. Выравнивание столбцов — v2.
|
||
## 5. `render_line()` — упрощение
|
||
* Убрать ветку truncate-режима и `wrap_mode` (уже частично снято; `toggle_wrap()` — мёртвая заглушка `mdview.c:1286-1291`, удалить вместе с упоминаниями F2).
|
||
* Не вызывать `classify_line()` и не делать обратный проход: использовать `kind`/`indent`/`style` из индекса.
|
||
* Для cont-сегментов списков/цитат — печать отступа/префикса по `kind`+`indent` из записи сегмента.
|
||
* Inline-парсинг emphasis выполняется только в пределах видимого сегмента (дёшево); для кода/таблиц — отключён.
|
||
## 6. Горизонтальный скроллинг (UnWrap)
|
||
* Скроллится только UnWrap-текст (код/таблицы). Грануляция 8 символов (`HPAN_STEP`).
|
||
* Правый край: индикатор `>` своим стилем, если есть скрытый контент справа (есть — `mdview.c:1135-1155`).
|
||
* **Добавить** левый индикатор `<` в первой колонке, когда `viewport_x > 0`.
|
||
* **Границы скролла по факту**: текущий кламп жёстко до 240 (`mdview.c:1275`). Заменить на вычисление максимального переполнения среди UnWrap-строк в текущем viewport, чтобы вправо нельзя было уйти за самую длинную строку, а влево — до колонки 0.
|
||
## 7. Чеклист расхождений с текущим кодом
|
||
* [индекс] Перейти на запись-на-сегмент в EMM-банке + near-кэш viewport; снять `MAX_LINES=2048`.
|
||
* [скорость] Потоковый разбор: один проход, 16-битный курсор в окне (без per-byte `fb()` с 32-битной арифметикой), классификация строки один раз, минимальный lookahead; батч-флеш индекса.
|
||
* [текст] Wide break по символу `\`.
|
||
* [текст] Модификатор strikethrough `~~…~~` (+ стиль).
|
||
* [списки] Многострочная склейка пунктов и правило группировки по одной/двум пустым строкам.
|
||
* [цитаты] Многострочная склейка и повтор префикса (в т.ч. вложенные) на переносах.
|
||
* [заголовки] Читать H1–H6; H4/H5/H6 трактовать как H4 (частично уже есть — `mdview.c:488`).
|
||
* [скролл] Индикатор `<` и корректные границы по фактическому переполнению.
|
||
* [рендер] Снять per-кадровый `classify_line()` и обратный проход (данные — из индекса).
|
||
* [очистка] Удалить `toggle_wrap()` и упоминания F2 (`mdview.c:1286-1291`, `1311`).
|
||
## 8. Память: бюджет
|
||
* near (W2): текущий статический индекс ~11.6 КБ — у предела окна. После переноса `line_offset`/`init_style` в EMM в near остаётся near-кэш viewport (~0.2 КБ) + мелкие флаги → запас под стек/кучу растёт.
|
||
* EMM: файл до 8 страниц + индекс ~1–2 страницы (при 5–6 байт/сегмент и нескольких тысячах сегментов). Перед выделением проверять `mem_info()` на доступность страниц.
|
||
## 9. Заметки для v2
|
||
* **Прогрессивный показ**: отрисовать первую страницу (первые ~30 сегментов) ДО завершения полной подготовки; остальное доиндексировать дальше или по мере прокрутки. Процент и `End` показывать как «вычисляется», пока не готов полный `n_lines`. (Синергия с потоковым разбором §4.0: первый экран готов после разбора лишь нескольких КБ.)
|
||
* Разделители переноса Wrap: точка/запятая/`!`/`?`/дефис; правило «новая строка не начинается с разделителя», серия разделителей остаётся на первой строке.
|
||
* Таблицы: вычисление ширины столбцов и выравнивание.
|
||
* Подсветка синтаксиса внутри блоков кода.
|
||
* URL/Images и прочие типы строк.
|