Files
Sprinter-SDCC/examples/mdview/docs/mdview-модель-документа-и-рендеринг.md
T
snark13 016fedd94c mdview: speed up rendering via bios/text.h, fix wrap-boundary style bug
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>
2026-06-23 17:06:35 +03:00

21 KiB
Raw Blame History

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>4LK_H4, mdview.c:488). Внутри допустимы bold/italic/code/strike. После заголовка всегда пустая строка.

4.3 Горизонтальный разделитель HR

Всегда одна строка, после неё всегда пустая строка (новый абзац).

4.4 Списки (Wrap) — НОВОЕ: многострочная склейка

  • Пункт может занимать несколько оригинальных строк; soft break внутри пункта склеивается через пробел (как обычный текст). Сейчас списки эмитятся построчно (mdview.c:762-772) — переписать на paragraph-модель.
  • Новая строка с префиксом пункта → новый пункт.
  • Пустая строка завершает пункт. Следующая непустая НЕ-пункт строка не является продолжением.
  • Группировка: если после ОДНОЙ пустой строки идёт снова пункт — это тот же список, пустая строка в показе подавляется (пункты идут вплотную). Только ДВЕ+ пустые строки между пунктами разрывают на разные списки (в показе — одна пустая строка между ними).
  • Перенос продолжения пункта печатается с отступом до контент-колонки (для уровня 1 — 2 пробела).
  • Незакрытые модификаторы НЕ переносятся на следующий пункт (каждый пункт — свой параграф).
  • Вложенные списки поддерживаются (отступ растёт с ведущими пробелами).
  • Отдельные стили: префикс маркера и текст списка. Пример соответствует разделу «Списки» в todo2 (строки 16).

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 и прочие типы строк.