Files
Sprinter-SDCC/examples/mdview2/mdview2.c
T
snark13 68201dca44 mdview2: 4 улучшения рендеринга markdown
1. Экранирование пунктуации: \* \_ \` \[ … → литерал, маркером НЕ считается
   (CommonMark ASCII-punctuation). Напр. "**...FILE\***" → болд "...FILE*".
   Не действует внутри инлайн-кода.
2. [x] / [ ] — ровно один символ в квадратных скобках → болд (нестандартно,
   для читабельного отображения task-list checkbox-ов). Не внутри кода.
3. Соседние пункты списка: если следующий имеет МЕНЬШИЙ отступ (dedent),
   пустую строку между ними больше не подавляем — пункты визуально разделены.
4. Абзац с ведущим отступом: все его перенесённые строки получают такой же
   отступ (continuation-префикс из col пробелов, как у списка/цитаты).

Реализация — в обоих inline-сканерах (inline_scan/scan_join_stream) + ветках
index_lines; новый helper is_escapable() и leading_spaces().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:38:54 +03:00

2089 lines
99 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 <stdint.h>
#include <stdio.h>
#include <string.h>
#include <conio.h>
#include <bios/text.h>
#include <fcntl.h>
#include <unistd.h>
#include <sprinter.h>
#include <sprinter_mem.h>
#include <palette.h>
#include <time.h> /* DEBUG: время обработки в статус-баре прогресса */
/* ---- Геометрия экрана -------------------------------------------- */
#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-режим) */
#define IF_HSCROLL 0x10u /* блок горизонтально скроллируется ЦЕЛИКОМ (код/таблица),
* включая строки короче 80 — двигаются как единый блок.
* НЕ ставится на HR и границы fence (им нечего прятать). */
/* ==================================================================
* Фаза 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);
}
/* emph_to_attr определена ниже (рядом с inline-логикой), нужна в inline_scan. */
static uint8_t emph_to_attr(uint8_t emph, uint8_t base_attr);
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};
/* Прогрессивная загрузка: рисуем первый экран и пускаем скролл, пока
* index_lines() ещё досканирует файл (кооперативно, без прерываний). */
static uint8_t g_loading = 0; /* 1 во время index_lines() */
static uint8_t g_first_drawn = 0; /* первый экран уже нарисован */
static uint8_t g_abort = 0; /* F10/Esc во время загрузки → прервать индексацию */
static uint16_t g_last_prog = 0; /* n_lines на последнем обновлении статуса */
static datetime_t g_load_t0; /* DEBUG: старт обработки (для времени в статусе) */
static void progress_tick(void); /* определена ниже (нужна в index_lines) */
#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.
* Проверка границ выполняется вызывающей стороной.
*
* Эквивалент C-кода (оставлен для справки):
* uint8_t *pb = (uint8_t *)&p;
* uint8_t page = (pb[1] >> 6) | (pb[2] << 2);
* uint16_t off = ((pb[1] & 0x3F) << 8) | pb[0];
* map_page(page); // if (page!=cur_page){cur_page=page; OUT W3,file_phys[page];}
* return FILE_BUF[off]; // *(0xC000 + off)
*
* Ручной asm — это горячая примитивная операция (вызывается сотни тысяч раз
* при индексации 50КБ-файла); C-версия тратила вызов функции + 32-битную
* арифметику на КАЖДЫЙ байт. Раскладка __sdcccall(1): аргумент uint32_t в
* HLDE (E=pb[0], D=pb[1], L=pb[2], H=pb[3] — проверено sdcc -S), возврат
* char в A. Координация маппинга с bank_read/bank_write держится на том,
* что _io_page_w3 — это ПОРТ (__sfr 0xE2): здесь пишем его через OUT,
* bank_read читает через IN — общий аппаратный регистр, без теневой RAM.
* NB: __naked обязателен (внутри собственный ret); IX/IY не трогаем
* (callee-saved), BC/DE/HL/AF — caller-saved, можно клобберить. */
static char fb(uint32_t p) __naked
{
(void)p; /* p = HLDE; доступ только из asm */
__asm
; off -> BC = 0xC000 + ((pb[1]&0x3F)<<8 | pb[0]) ; D=pb[1], E=pb[0]
ld a, d
or a, #0xC0 ; (D|0xC0) == (D&0x3F)|0xC0 старший байт окна W3
ld b, a
ld c, e ; C = pb[0]
; page = (pb[1]>>6) | (pb[2]<<2) ; L=pb[2]
ld a, l
add a, a
add a, a ; A = pb[2]<<2
ld e, a ; E свободен (pb[0] уже сохранён в C)
ld a, d
rlca
rlca
and a, #0x03 ; A = pb[1]>>6
or a, e ; A = page
ld e, a ; E = page
; map_page(page)
ld a, (_cur_page)
cp e
jr Z, fb_same
ld a, e
ld (_cur_page), a
ld hl, #_file_phys
ld d, #0x00
add hl, de ; hl = &file_phys[page]
ld a, (hl)
out (#0xE2), a ; OUT PORT_PAGE_W3 (== _io_page_w3 sfr)
fb_same:
ld a, (bc) ; A = *(0xC000 + off)
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));
}
/* Символ можно экранировать обратным слешем (\X): любая печатная ASCII-
* пунктуация (как в CommonMark). \* \_ \` \[ и т.п. → литерал X. */
static uint8_t is_escapable(char c)
{
return (uint8_t)(c >= '!' && c <= '~' &&
!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')));
}
/* Тип сегмента-продолжения, передаваемый в 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 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;
}
/* Установка флагов всегда идёт в последний 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); }
static void set_hscroll_cur(void){ cur_rec.flags |= IF_HSCROLL; 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;
}
}
/* ==================================================================
* Индексация строк
* ================================================================== */
#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
};
/* Тип строки, который возвращает 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 спец-префикса не имеют */
}
}
/* ================== Merge: сборка ячеек на этапе индекс-скана ==============
* Форвард-сканер (scan_join_stream/inline_scan + прямые emit'ы в index_lines)
* собирает готовые (char,attr)-ячейки в g_cells ПО ХОДУ единственного прохода
* по файлу — без отдельного второго скана. На каждом emit_seg ячейки
* ПРЕДЫДУЩего сегмента флашатся в кэш (lag-1: к этому моменту сегмент уже
* полностью просканирован); последний сегмент флашится после цикла index_lines.
* g_ncells_at_space — снимок длины на последнем пробеле для усечения буфера
* при переносе строки (отбрасываем уехавшее за границу слово). */
static uint8_t g_cells[MAX_CACHE_LINE_LEN * 2];
static uint8_t g_ncells = 0; /* ячеек в текущем сегменте */
static uint8_t g_ncells_at_space = 0; /* снимок g_ncells на последнем пробеле (для усечения при переносе) */
/* Пишет одну (char,attr)-ячейку в g_cells, молча игнорируя переполнение
* MAX_CACHE_LINE_LEN. Оставлена ФУНКЦИЕЙ намеренно: инлайн в сильно
* регистро-нагруженный скан-цикл на Z80/SDCC увеличивает спиллы и замедляет
* (проверено замером — макро-инлайн дал регрессию ~11→13с). */
static void gc_put(char ch, uint8_t attr)
{
if (g_ncells < MAX_CACHE_LINE_LEN) {
uint16_t i = (uint16_t)g_ncells * 2u;
g_cells[i] = (uint8_t)ch;
g_cells[i + 1] = attr;
g_ncells++;
}
}
static void gc_fill(char ch, uint8_t attr, uint8_t n) { while (n-- > 0) gc_put(ch, attr); }
/* Пишет g_cells как контент кэша для line_idx: flags берём из idx-записи,
* пустой буфер/blank → len 0; иначе резервируем место в пуле и коммитим. */
static void seg_commit_cells(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;
idx_rec_t r;
uint8_t have = (uint8_t)(line_idx < n_lines);
if (have) { idx_get(line_idx, &r); out_rec.flags = r.flags; }
if (!have || (r.flags & IF_BLANK) || g_ncells == 0) {
out_rec.page = 0; out_rec.off = 0; out_rec.len = 0;
cache_dir_put(line_idx, &out_rec);
return;
}
out_rec.len = g_ncells;
if (cache_reserve((uint16_t)(g_ncells * 2u), &out_rec.page, &out_rec.off)) {
cache_commit(out_rec.page, out_rec.off, g_cells, (uint16_t)(g_ncells * 2u));
} else {
out_rec.page = 0; out_rec.off = 0; out_rec.len = 0;
}
cache_dir_put(line_idx, &out_rec);
}
static void emit_seg(uint32_t off, uint8_t style, uint8_t ckind, uint8_t cont)
{
(void)ckind; /* тип сегмента восстанавливается флагами idx-записи */
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++;
/* Флаш ячеек ПРЕДЫДУЩего сегмента (lag-1): он полностью собран форвард-сканером. */
if (n_lines >= 2) seg_commit_cells((uint16_t)(n_lines - 2));
g_ncells = 0; /* буфер ячеек под новый сегмент */
g_ncells_at_space = 0;
}
/* Проверяет, что физическая строка пуста (только пробелы/таб + перевод строки). */
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;
}
/* Число ведущих пробелов строки (отступ). Используется для отделения
* dedent-пунктов списка (#3) и для отступа обычного абзаца (#4). */
static uint8_t leading_spaces(uint32_t p)
{
uint8_t n = 0;
while (p < file_size && fb(p) == ' ' && n < 250) { n++; p++; }
return n;
}
/* Быстрая проверка границы fenced-блока по маркеру ``` .
* Ведущие пробелы/табы пропускаются — fence может быть с отступом
* (например, ``` ```c ``` внутри элемента списка с отступом 2+ пробела).
* Язык после бэктиков (```c, ```sh) игнорируется: достаточно трёх ` подряд. */
static uint8_t is_fence_raw(uint32_t p)
{
while (p < file_size && (fb(p) == ' ' || fb(p) == '\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, uint8_t base_attr,
uint8_t nowrap)
{
uint32_t last_space = 0xFFFFFFFFu;
uint8_t last_space_style = line_style; /* снимок line_style на момент last_space */
char prev_ch = ' ';
uint8_t seg_col = col;
/* base — атрибут «обычного» текста сегмента. Для заголовка первый сегмент
* имеет title-attr, а continuation-сегменты render рисует как PLAIN
* (ATTR_TEXT, kind=LK_PLAIN при cont) — переключаем base после 1-го переноса. */
uint8_t base = base_attr;
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);
base = ATTR_TEXT;
last_space = 0xFFFFFFFFu; prev_ch = ' '; seg_col = col;
continue;
}
/* Экранированная пунктуация: \* \_ \` … → литерал (не внутри кода). */
if (line_style != INIT_STYLE_CODE && ch == '\\' && (q + 1) < q_end && is_escapable(fb(q + 1))) {
char nb = fb(q + 1);
gc_put(nb, emph_to_attr(line_style, base));
seg_col++; q += 2; prev_ch = nb; continue;
}
/* [x] — ровно один символ в скобках → болд (task-list checkbox; не внутри кода). */
if (line_style != INIT_STYLE_CODE && ch == '[' && (q + 2) < q_end && fb(q + 2) == ']') {
char mid = fb(q + 1);
gc_put('[', ATTR_TEXT_BOLD);
gc_put(mid, ATTR_TEXT_BOLD);
gc_put(']', ATTR_TEXT_BOLD);
seg_col += 3; q += 3; prev_ch = ']'; continue;
}
if (ch == '`') {
if (line_style == INIT_STYLE_PLAIN) { line_style = INIT_STYLE_CODE; q++; continue; }
if (line_style == INIT_STYLE_CODE) { line_style = INIT_STYLE_PLAIN; q++; continue; }
gc_put('`', emph_to_attr(line_style, base));
seg_col++; q++; 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_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;
}
}
{ gc_put('*', emph_to_attr(line_style, base)); gc_put('*', emph_to_attr(line_style, base)); }
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;
}
}
{ gc_put('~', emph_to_attr(line_style, base)); gc_put('~', emph_to_attr(line_style, base)); }
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 != INIT_STYLE_PLAIN && ws_or_eol_or_delim(next_ch)) {
/* как render: закрывает любой активный эмфазис */
line_style = INIT_STYLE_PLAIN; q++; continue;
}
}
gc_put(ch, emph_to_attr(line_style, base));
seg_col++; q++; prev_ch = ch; continue;
}
if (ch == '\t') {
uint8_t old_sc = seg_col;
uint8_t tgt_full = (uint8_t)((seg_col & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
uint8_t tgt = tgt_full;
if (tgt > SCREEN_W) tgt = SCREEN_W;
gc_fill(' ', emph_to_attr(line_style, base), (uint8_t)(tgt_full - old_sc));
seg_col = tgt; q++; prev_ch = ' '; continue;
}
if (ch == ' ') {
gc_put(' ', emph_to_attr(line_style, base));
last_space = q + 1;
last_space_style = line_style;
g_ncells_at_space = g_ncells;
seg_col++; q++; prev_ch = ' ';
} else {
gc_put(ch, emph_to_attr(line_style, base));
seg_col++; q++; prev_ch = ch;
}
if (!nowrap && 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;
if (use_ls) g_ncells = g_ncells_at_space;
emit_seg(wrap_at, wrap_style, ckind, 1);
base = ATTR_TEXT; /* continuation заголовка → PLAIN */
seg_col = col; last_space = 0xFFFFFFFFu; prev_ch = ' '; q = wrap_at;
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++;
/* как render: пропускаем ведущие пробелы/табы строки-продолжения,
* склейка даёт РОВНО один пробел (иначе merge эмитит отступ как лишние ячейки) */
while (q < file_size && (fb(q) == ' ' || fb(q) == '\t')) q++;
soft_break = 1;
ch = ' ';
}
}
/* Экранированная пунктуация: \* \_ \` … → литерал (не внутри кода). */
if (line_style != INIT_STYLE_CODE && ch == '\\' && (q + 1) < file_size && is_escapable(fb(q + 1))) {
char nb = fb(q + 1);
gc_put(nb, styles_map[line_style]);
seg_col++; q += 2; prev_ch = nb; continue;
}
/* [x] — ровно один символ в скобках → болд (task-list checkbox; не внутри кода). */
if (line_style != INIT_STYLE_CODE && ch == '[' && (q + 2) < file_size && fb(q + 2) == ']') {
char mid = fb(q + 1);
gc_put('[', ATTR_TEXT_BOLD);
gc_put(mid, ATTR_TEXT_BOLD);
gc_put(']', ATTR_TEXT_BOLD);
seg_col += 3; q += 3; prev_ch = ']'; continue;
}
if (ch == '`') {
/* Как handle_inline_marker в render: backtick переключает code-режим
* ТОЛЬКО из PLAIN или CODE; если активен эмфазис (bold/italic/...),
* backtick — обычный литеральный символ (нет вложенного кода). */
if (line_style == INIT_STYLE_PLAIN) {
line_style = INIT_STYLE_CODE;
if (!soft_break) q++;
continue;
}
if (line_style == INIT_STYLE_CODE) {
line_style = INIT_STYLE_PLAIN;
if (!soft_break) q++;
continue;
}
gc_put('`', styles_map[line_style]);
seg_col++;
if (!soft_break) q++; 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_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;
}
}
{ gc_put('*', styles_map[line_style]); gc_put('*', styles_map[line_style]); }
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;
}
}
{ gc_put('~', styles_map[line_style]); gc_put('~', styles_map[line_style]); }
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 != INIT_STYLE_PLAIN && ws_or_eol_or_delim(next_ch)) {
/* как render: одиночный маркер закрывает ЛЮБОЙ активный эмфазис */
line_style = INIT_STYLE_PLAIN;
if (!soft_break) q++; else q++;
continue;
}
}
gc_put(ch, styles_map[line_style]);
seg_col++;
if (!soft_break) q++; else q++;
prev_ch = ch;
continue;
}
if (ch == '\t') {
uint8_t old_sc = seg_col;
uint8_t tgt_full = (uint8_t)((seg_col & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
uint8_t tgt = tgt_full;
if (tgt > SCREEN_W) tgt = SCREEN_W;
/* render заполняет до НЕкапнутого tgt_full (по cc); для plain cc==seg_col */
gc_fill(' ', styles_map[line_style], (uint8_t)(tgt_full - old_sc));
seg_col = tgt;
if (!soft_break) q++; else q++;
prev_ch = ' ';
continue;
}
if (ch == ' ') {
gc_put(' ', styles_map[line_style]);
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++;
}
g_ncells_at_space = g_ncells; /* снимок для усечения */
prev_ch = ' ';
} else {
gc_put(ch, styles_map[line_style]);
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;
if (use_ls) g_ncells = g_ncells_at_space; /* усечь до пробела */
emit_seg(wrap_at, wrap_style, ckind, 1);
/* Префикс continuation-сегмента: список и отступленный абзац (#4) —
* col пробелов выравнивания; цитата — отступ + маркер 0xB3 + пробел.
* Для обычного абзаца без отступа col==0 → префикса нет. */
if (ckind == CK_LIST || ckind == CK_PLAIN) {
gc_fill(' ', ATTR_TEXT, col);
} else if (ckind == CK_QUOTE) {
uint32_t content_off2 = para_start + col;
uint8_t qsp = 0;
while (qsp + 2 < content_off2 && qsp < SCREEN_W && fb(para_start + qsp) == ' ') qsp++;
gc_fill(' ', ATTR_TEXT, qsp);
gc_put(0xB3, ATTR_QUOTE_MARKER);
gc_put(' ', ATTR_TEXT);
}
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 && !g_abort) {
if (g_loading)
progress_tick(); /* первый экран + статус + скролл по готовым данным */
else 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();
set_hscroll_cur(); /* код скроллится целым блоком */
{
uint8_t ccx = 0;
uint32_t pp = p;
while (pp < file_size && fb(pp) != '\n') {
char ch = fb(pp);
if (ch == '\t') {
uint8_t tgt = (uint8_t)((ccx & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
gc_fill(' ', ATTR_TEXT_CODE, (uint8_t)(tgt - ccx));
ccx = tgt;
} else if (ch == '\r') {
gc_put(' ', ATTR_TEXT_CODE); ccx++; /* как render: \r → пробел */
} else {
gc_put(ch, ATTR_TEXT_CODE); ccx++;
}
pp++;
}
}
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();
gc_fill(0xC4, ATTR_HR, SCREEN_W);
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();
set_hscroll_cur(); /* таблица скроллится целым блоком */
{
uint32_t line_end = p;
while (line_end < file_size && fb(line_end) != '\n') line_end++;
(void)inline_scan(p, line_end, 0, CK_OTHER, INIT_STYLE_PLAIN, ATTR_TEXT, 1);
}
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) {
uint8_t hbase; /* title-attr, как в render */
switch (kind) {
case LK_H1: hbase = ATTR_TEXT_TITLE1; break;
case LK_H2: hbase = ATTR_TEXT_TITLE2; break;
case LK_H3: hbase = ATTR_TEXT_TITLE3; break;
default: hbase = ATTR_TEXT_TITLE4; break;
}
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, hbase, 0);
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);
/* Префикс ПЕРВОГО сегмента: отступ + маркер + пробел (как render). */
{
uint32_t qq = p, qmax = p + SCREEN_W;
if (kind == LK_ULIST) {
while (qq + 2 < content_off && qq < qmax && fb(qq) == ' ') qq++;
gc_fill(' ', ATTR_TEXT, (uint8_t)(qq - p));
gc_put(0x07, ATTR_LIST_MARKER);
gc_put(' ', ATTR_TEXT);
} else {
while (qq < content_off && qq < qmax && fb(qq) == ' ') qq++;
gc_fill(' ', ATTR_TEXT, (uint8_t)(qq - p));
while (qq < content_off - 1) { gc_put(fb(qq), ATTR_LIST_MARKER); qq++; }
gc_put(' ', ATTR_TEXT);
}
}
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) {
/* #3: подавляем blank только если следующий пункт того же
* уровня или глубже; при МЕНЬШЕМ отступе (dedent) пустую
* строку оставляем — пункты должны визуально разделиться. */
if (leading_spaces(t) >= leading_spaces(p)) {
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);
/* Префикс ПЕРВОГО сегмента: отступ + маркер 0xB3 + пробел (как render LK_QUOTE). */
{
uint32_t qq = p, qmax = p + SCREEN_W;
while (qq + 1 < content_off && qq < qmax && fb(qq) == ' ') qq++;
gc_fill(' ', ATTR_TEXT, (uint8_t)(qq - p));
gc_put(0xB3, ATTR_QUOTE_MARKER);
gc_put(' ', ATTR_TEXT);
}
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;
/* #4: если абзац начинается с отступа — все его перенесённые строки
* получают такой же отступ (как continuation-префикс списка, но без
* маркера). Контент сканируем после отступа. Патологически большой
* отступ игнорируем. */
uint8_t indent = leading_spaces(p);
if (indent >= SCREEN_W / 2) indent = 0;
emit_seg(p, line_style, CK_PLAIN, 0);
if (indent) gc_fill(' ', ATTR_TEXT, indent); /* префикс первого сегмента */
uint32_t q = p + indent;
q = scan_join_stream(q, p, indent, 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() флашит ячейки с отставанием на одну строку (см. её
* комментарий) — последняя эмитированная строка файла так и остаётся
* незафлашенной, её ячейки ещё в g_cells; коммитим здесь. */
if (n_lines > 0) seg_commit_cells((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;
}
}
/* Фаза 4-5 — cache-only draw path: рисует одну строку ИЗ КЭША, без
* единого обращения к fb()/исходному файлу. Горизонтальный скролл для
* nowrap-строк (Фаза 5) — это просто смещение начала среза на vx*2
* байт ВНУТРИ ТОГО ЖЕ кэш-буфера (cache_reserve гарантирует, что вся
* строка лежит в одной странице, так что off+vx*2 не пересекает
* границу страницы); не nowrap-строки игнорируют viewport_x, как и
* раньше в живом render_line() (там это называлось effective_vx). */
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);
/* Горизонтальный сдвиг применяется ко ВСЕМ строкам hscroll-блока (код,
* таблицы) — по ТИПУ, а не по длине: блок двигается целиком, включая
* строки короче 80 (короткая строка, ушедшая за левый край, просто
* показывает маркер '<' на пустом месте). HR и границы fence не имеют
* IF_HSCROLL и не сдвигаются. */
uint8_t vx = (rec.flags & IF_HSCROLL) ? viewport_x : 0;
if (rec.len > vx) {
uint16_t remaining = (uint16_t)(rec.len - vx);
uint8_t w = (remaining > SCREEN_W) ? SCREEN_W : (uint8_t)remaining;
uint16_t off = (uint16_t)(rec.off + (uint16_t)vx * 2u);
win_rest(row, 0, 1, w, rec.page, off);
if (remaining > SCREEN_W) {
wrchar(SCREEN_W - 1, row, '>', ATTR_TRUNC);
}
}
if (vx > 0) {
wrchar(0, 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));
}
}
/* Число строк, готовых к показу. Во время загрузки последняя эмитированная
* строка ещё в g_cells (не зафлашена), поэтому доступны [0..n_lines-2]. */
static uint16_t drawable_lines(void)
{
if (g_loading) return (n_lines >= 1) ? (uint16_t)(n_lines - 1) : 0;
return n_lines;
}
/* Возвращает процент прокрутки (0..100) относительно доступного диапазона. */
static uint8_t calc_pct(void)
{
uint16_t total = drawable_lines();
uint8_t pct;
if (total <= VIEW_H)
pct = 100;
else
pct = (uint8_t)((uint32_t)top_line * 100UL / (uint32_t)(total - VIEW_H));
return pct;
}
/* Рисует динамическую часть статус-бара без printf.
* Полностью перезаписывает колонки 45..79, чтобы не оставлять «хвосты». */
static void render_status_numbers(void)
{
uint16_t total = drawable_lines();
uint16_t last = top_line + VIEW_H;
if (last > total) last = total;
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(total);
if (g_loading) cputs("..."); /* ещё грузится */
wrchar(70, 0, 0xB3, ATTR_BAR); /* вертикальный разделитель */
gotoxy( 73, 0);
dec8(pct);
cputs("%");
if (g_loading) { /* DEBUG: время обработки (зона имени файла) */
datetime_t now;
getdatetime(&now);
int16_t el = (int16_t)now.minute*60 + now.second
- ((int16_t)g_load_t0.minute*60 + g_load_t0.second);
if (el < 0) el += 3600;
gotoxy(31, 0);
cputs("t=");
dec16((uint16_t)el);
cputs("s "); /* трейлинг-пробелы затирают прошлую ширину */
}
}
/* Полностью перерисовывает статус-бар (заголовок + числа). */
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-строкой на экране.
* Фаза 5: ширина берётся прямо из директории кэша (rec.len — уже
* посчитанная реальная длина в ячейках после рендера), без единого
* обращения к fb()/seg_off()/исходному файлу. */
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;
cache_rec_t rec;
cache_dir_get(li, &rec);
if (!(rec.flags & IF_HSCROLL)) continue; /* только код/таблицы */
if (rec.len > maxw) maxw = rec.len; /* самая широкая строка блока */
}
if (maxw == 0) return; /* нет строк шире экрана */
/* Максимальный сдвиг = ширина за пределами экрана, в границах 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;
/* ПОРЯДОК ВАЖЕН: записываем viewport_x ДО сравнения, а сравниваем уже
* сохранённую копию old_vx с new_vx. Иначе SDCC генерирует для
* `if (new_vx != viewport_x) viewport_x = new_vx;` такой код:
* ld a, new_vx ; sub a,(viewport_x) ; jr Z,.. ; ld (viewport_x),a
* — т.е. для записи переиспользует регистр A, испорченный вычитанием в
* сравнении, и кладёт в viewport_x не new_vx, а (new_vx - old_vx).
* (asm-дамп 2026-06-24 подтвердил; даёт 10-8=2, 10-2=8 — ровно
* наблюдавшийся "прыжок" скролла.) Записав viewport_x первой, мы убираем
* портящий sub с пути записи; сравнение ниже только решает, перерисовывать
* ли экран. */
uint8_t new_vx = (uint8_t)nx;
uint8_t old_vx = viewport_x;
viewport_x = new_vx;
if (new_vx != old_vx)
draw_viewport_from_cache();
}
/* ==================================================================
* Вывод ошибки
* ================================================================== */
/* Печатает сообщение об ошибке и ждёт нажатия клавиши. */
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();
}
/* Обработка клавиши во время прогрессивной загрузки: скролл строго в
* пределах готовых строк (drawable_lines); End = последняя готовая страница;
* F1 = справка (show_help сам блокирует и перерисовывает). */
static void load_key(uint16_t k)
{
uint8_t ascii = (uint8_t)(k & 0xFF);
uint8_t scan = (uint8_t)((k >> 8) & 0x7F);
uint16_t dr = drawable_lines();
uint16_t maxtop = (dr > VIEW_H) ? (uint16_t)(dr - VIEW_H) : 0;
uint16_t nt = top_line;
if (ascii == 0x1B || scan == KEY_F10) { g_abort = 1; return; } /* Esc/F10 → выход */
if (ascii) return; /* прочие ascii игнорируем */
switch (scan) {
case KEY_UP: nt = (top_line >= 1) ? (uint16_t)(top_line - 1) : 0; break;
case KEY_DOWN: nt = top_line + 1; break;
case KEY_PGUP: nt = (top_line >= VIEW_H) ? (uint16_t)(top_line - VIEW_H) : 0; break;
case KEY_PGDN: nt = top_line + VIEW_H; break;
case KEY_HOME: top_line = 0; viewport_x = 0; draw_viewport_from_cache(); render_status_numbers(); return;
case KEY_END: top_line = maxtop; viewport_x = 0; draw_viewport_from_cache(); render_status_numbers(); return;
case KEY_LEFT: scroll_h(-(int8_t)HPAN_STEP); render_status_numbers(); return;
case KEY_RIGHT: scroll_h(+(int8_t)HPAN_STEP); render_status_numbers(); return;
case KEY_F1: show_help(); return;
default: return;
}
if (nt > maxtop) nt = maxtop;
if (nt != top_line) {
top_line = nt;
draw_viewport_from_cache();
render_status_numbers();
}
}
/* Кооперативный «тик» прогрессивной загрузки — вызывается из index_lines(). */
static void progress_tick(void)
{
uint8_t touched = 0;
if (!g_first_drawn && drawable_lines() >= VIEW_H) {
draw_viewport_from_cache();
render_status_numbers();
g_first_drawn = 1;
touched = 1;
}
if (n_lines >= (uint16_t)(g_last_prog + 20)) {
g_last_prog = n_lines;
spinner_tick();
if (g_first_drawn) render_status_numbers();
touched = 1;
}
if (g_first_drawn && kbhit()) {
load_key(getkey());
touched = 1;
}
/* Отрисовка/WINREST/BIOS могли сбить маппинг W3, на который опирается fb()
* при индексации → форсируем ре-маппинг страницы файла на следующем fb(). */
if (touched) cur_page = 0xFF;
}
/* ==================================================================
* Точка входа
* ================================================================== */
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 <file.md>\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() сама рисует первый экран и пускает
* скролл по готовым данным через progress_tick(). */
g_loading = 1;
g_first_drawn = 0;
g_last_prog = 0;
getdatetime(&g_load_t0); /* DEBUG: старт обработки */
index_lines();
g_loading = 0;
spinner_show(0);
if (g_abort) goto exit_loop; /* F10/Esc во время загрузки — корректный выход */
if (n_lines == 0) {
die("mdview: empty file");
unload_file();
return 1;
}
/* Финальная отрисовка: все строки готовы, top_line мог уехать при скролле. */
clamp_top();
render_full_status();
draw_viewport_from_cache();
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;
}