Files
Sprinter-SDCC/examples/mdview2/mdview2.c
T
snark13 eab5a2d6ac mdview2: add render-cache markdown viewer (Phases 0-4)
New examples/mdview2 — render-cache version of mdview: each logical
line is rendered into an EMM (char,attr) buffer once during file load
(interleaved with index_lines()), then scrolling draws straight from
the cache via ESTEX WINREST, with no re-parsing or fb() access in the
steady state. Horizontal scroll still uses the old live-render path
(Phase 5, not yet migrated).

Format and budget were derisked empirically first (tests/winrest):
confirmed ESTEX WINCOPY/WINREST buffer layout and measured EMM free
space, both folded into the implementation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:41:32 +03:00

2540 lines
115 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>
/* ---- Геометрия экрана -------------------------------------------- */
#define SCREEN_W 80
#define SCREEN_H 32
#define VIEW_TOP_ROW 1
#define VIEW_H 30 /* видимая область: строки 1..30 включительно */
#define MENU_ROW 31
#define TAB_STOP 4
/* ---- Параметры файла и памяти ------------------------------------ */
#define PAGE_BITS 14u
#define PAGE_SIZE (1u << PAGE_BITS) /* размер EMM-страницы: 16 КБ */
#define PAGE_MASK ((uint16_t)(PAGE_SIZE - 1u))
#define MAX_PAGES 8 /* 8 страниц × 16 КБ = 128 КБ */
#define MAX_FILE ((uint32_t)MAX_PAGES * PAGE_SIZE) /* максимальный размер файла: 131072 байт */
// #define MAX_LINES 2048
#define FILE_BUF ((char *)0xC000) /* окно W3, куда мапится текущая EMM-страница */
/* ---- Палитра атрибутов ------------------------------------------- */
static const uint8_t md_pallete[16 * 4] = {
0x00, 0x00, 0x00, 0x00,
0x60, 0x00, 0x00, 0x00,
0x00, 0x60, 0x00, 0x00,
0x60, 0x60, 0x00, 0x00,
0x00, 0x00, 0x60, 0x00,
0x60, 0x00, 0x60, 0x00,
0x00, 0x60, 0x60, 0x00,
0xC8, 0xC8, 0xC8, 0x00,
0xA0, 0xA0, 0x80, 0x00,
0xFF, 0xAA, 0xAA, 0x00,
0xAA, 0xFF, 0xAA, 0x00,
0xFF, 0xFF, 0xAA, 0x00,
0xAA, 0xAA, 0xFF, 0x00,
0xFF, 0xAA, 0xFF, 0x00,
0xAA, 0xFF, 0xFF, 0x00,
0xFF, 0xFF, 0xFF, 0xFF
};
#define ATTR_RESET COLOR(COLOR_LIGHTGRAY, COLOR_BLACK)
#define ATTR_TEXT COLOR(COLOR_LIGHTGRAY, COLOR_BLUE)
#define ATTR_TEXT_TITLE1 COLOR(COLOR_YELLOW, COLOR_BLUE)
#define ATTR_TEXT_TITLE2 COLOR(COLOR_LIGHTBLUE, COLOR_BLUE)
#define ATTR_TEXT_TITLE3 COLOR(COLOR_LIGHTGREEN, COLOR_BLUE)
#define ATTR_TEXT_TITLE4 COLOR(COLOR_LIGHTGREEN, COLOR_BLUE)
#define ATTR_TEXT_BOLD COLOR(COLOR_LIGHTRED, COLOR_BLUE)
#define ATTR_TEXT_ITALIC COLOR(COLOR_LIGHTGREEN, COLOR_BLUE)
#define ATTR_TEXT_UNDERSORE COLOR(COLOR_LIGHTMAGENTA, COLOR_BLUE)
#define ATTR_TEXT_CODE COLOR(COLOR_WHITE, COLOR_BLUE)
#define ATTR_TEXT_STRIKE COLOR(COLOR_DARKGRAY, COLOR_BLUE)
#define ATTR_LIST_MARKER COLOR(COLOR_LIGHTCYAN, COLOR_BLUE)
#define ATTR_QUOTE_MARKER COLOR(COLOR_CYAN, COLOR_BLUE)
#define ATTR_HR COLOR(COLOR_LIGHTGRAY, COLOR_BLUE)
#define ATTR_TRUNC COLOR(COLOR_YELLOW, COLOR_BLUE)
#define ATTR_BAR COLOR(COLOR_BLACK, COLOR_LIGHTCYAN)
#define ATTR_BAR_SPINNER COLOR(COLOR_WHITE, COLOR_LIGHTCYAN)
#define ATTR_MENU_T COLOR(COLOR_BLACK, COLOR_LIGHTCYAN)
#define ATTR_MENU_K COLOR(COLOR_YELLOW, COLOR_BLACK)
/* ---- Атрибуты диалога справки ------------------------------------ */
#define ATTR_HELP_BG COLOR(COLOR_LIGHTGRAY, COLOR_BLACK)
#define ATTR_HELP_BDR COLOR(COLOR_WHITE, COLOR_BLACK)
#define ATTR_HELP_TIT COLOR(COLOR_YELLOW, COLOR_BLACK)
#define ATTR_HELP_HDR COLOR(COLOR_WHITE, COLOR_BLACK)
#define ATTR_HELP_HINT COLOR(COLOR_YELLOW, COLOR_BLACK)
/* Геометрия диалога справки (в символьных координатах 80×32). */
#define HELP_X 8u /* левая граница рамки */
#define HELP_Y 4u /* верхняя граница рамки */
#define HELP_W 64u /* ширина рамки (включая │) */
#define HELP_H 22u /* высота рамки (включая ─) */
/* ---- Глобальное состояние ---------------------------------------- */
/* Файл до 128 КБ хранится в наборе EMM-страниц (до 8 шт. по 16 КБ).
* В окно W3 одновременно отображается только одна страница (FILE_BUF):
* функция fb() при необходимости переключает маппинг и кэширует текущую
* страницу в cur_page, чтобы не делать лишние переключения.
*
* Индекс экранных сегментов хранится отдельно в EMM (записи по 8 байт,
* одна запись на видимую строку). Каждая запись содержит:
* - смещение первого байта сегмента в исходном файле,
* - флаги (continuation/nowrap/blank/code),
* - начальный inline-стиль сегмента.
* Правая граница сегмента определяется смещением следующей записи.
* Такой подход освобождает около 11 КБ ближней RAM и снимает старый
* лимит MAX_LINES для near-массивов.
*/
/* В SDCC/z80 критично явно инициализировать static-переменные (`= 0`).
* Неинициализированные static-объекты могут конфликтовать по размещению
* и перезаписывать друг друга. См. заметку memory/sdcc_static_storage_gotcha. */
/* Компактная запись индекса сегмента.
* Каждый элемент соответствует одной строке рендера в viewport. */
typedef struct idx_rec_s {
uint32_t off; /* смещение первого байта исходного текста для сегмента */
uint8_t flags; /* битовые флаги IF_* ниже */
uint8_t style; /* начальный стиль INIT_STYLE_* в начале сегмента */
uint8_t pad0; /* добивка до 8 байт (2048 записей на страницу 16 КБ) */
uint8_t pad1;
} idx_rec_t;
#define INDEX_REC_SIZE 8u
#define INDEX_RECS_PER_PAGE 2048u /* 16384 / 8; запись всегда целиком в одной странице */
#define MAX_INDEX_PAGES 8u /* максимум 8 * 2048 = 16384 сегментов индекса */
#define IF_CONT 0x01u /* сегмент является продолжением перенесённой строки */
#define IF_NOWRAP 0x02u /* строка не переносится (кодовый блок / HR / таблица) */
#define IF_BLANK 0x04u /* визуально пустая строка */
#define IF_CODE 0x08u /* тело fenced code-блока (verbatim-режим) */
/* ==================================================================
* Фаза 1 (mdview2): директория и пул рендер-кэша
* ==================================================================
* Каждая логическая строка экрана рендерится РОВНО ОДИН РАЗ (Фаза 2)
* в готовый буфер и больше никогда не парсится повторно при скролле.
* Содержимое живёт в ОТДЕЛЬНОМ пуле EMM-страниц (cache_content_*),
* не путать с idx_rec_t/index_phys[] выше — те остаются неизменными,
* ими пользуется рендер-воркер ПРИ построении кэша (Фаза 2), и их
* нельзя перезатирать, т.к. воркер для одной строки может заглядывать
* в idx-записи СОСЕДНИХ строк (откат cont-сегментов).
*
* ЕДИНЫЙ формат для ВСЕХ строк (решено 2026-06-23 — изначальный план
* различал тип 1/тип 2 по экономии памяти, но строки таблиц — IF_NOWRAP,
* но НЕ IF_CODE — всё равно проходят inline-парсер и могут содержать
* несколько атрибутов, так что "один стиль" для них неверно; бюджет EMM
* (см. sprinter_emm_budget) с большим запасом покрывает (char,attr)
* для всех строк без исключения): контент — `len` пар (char,attr),
* `len` — реальная длина в ЯЧЕЙКАХ без паддинга до SCREEN_W, капается на
* 255 (патологически длинные nowrap-строки урезаются; индикатор обрезки
* добавляется на этапе вывода из кэша, не при рендере в кэш).
*/
/* РОВНО 8 байт — критично: cache_dir_get/put адресуют запись сдвигом
* idx<<3 (умножение на 8 через сдвиг, а не на sizeof()), так что
* структура ОБЯЗАНА быть именно 8 байт, а не "8 минус случайный паддинг
* компилятора". (СSDCC z80 НЕ паддит структуры сама по себе, поэтому
* 1+2+1+1+1 = 6 байт без явных pad-полей превратились бы в 6, а не 8 —
* фактический баг был в том, что без 2 явных pad оно было 7, а
* CACHE_DIR_REC_SIZE был захардкожен как 8: bank_read/bank_write писали
* 8 байт в 7-байтный буфер на стеке, затирая соседнюю переменную
* вызывающей функции — это была настоящая причина "квирка WINREST",
* не компилятор/эмулятор/DSS, см. memory/sprinter_winrest_format.md.) */
typedef struct cache_rec_s {
uint8_t page; /* EMM-страница рендер-кэша (физический номер) */
uint16_t off; /* смещение в странице, 0..16383 (байты, не ячейки) */
uint8_t len; /* длина контента в ЯЧЕЙКАХ (char,attr пар), 0..255; 0 = пустая/HR строка без сохранённого контента */
uint8_t flags; /* копия IF_NOWRAP/IF_CODE/IF_BLANK на момент рендера */
uint8_t reserved; /* не используется (раньше — attr для типа 2, см. план) */
uint8_t pad[2]; /* явный резерв, добивка до 8 байт — см. комментарий выше */
} cache_rec_t;
#define CACHE_DIR_REC_SIZE ((uint16_t)sizeof(cache_rec_t))
#define CACHE_DIR_RECS_PER_PAGE 2048u
#define MAX_CACHE_DIR_PAGES 8u
#define MAX_CACHE_LINE_LEN 255u /* кап длины контента одной строки, в ячейках (char,attr пара = 2 байта) */
#define MAX_CACHE_CONTENT_PAGES 64u /* запас: до 1 МБ кэша контента */
static uint8_t cache_dir_blk = 0;
static uint8_t cache_dir_pages = 0;
static uint8_t cache_dir_phys[MAX_CACHE_DIR_PAGES] = {0};
static uint8_t cache_content_blk[MAX_CACHE_CONTENT_PAGES] = {0}; /* id блока на каждую выделенную страницу */
static uint8_t cache_content_phys[MAX_CACHE_CONTENT_PAGES] = {0};
static uint8_t cache_content_n_pages = 0; /* сколько страниц уже выделено */
static uint8_t cache_cur_page_idx = 0; /* индекс текущей страницы записи в cache_content_phys */
static uint16_t cache_cur_off = 0; /* текущее смещение записи в этой странице */
/* Доступ к директории рендер-кэша — тот же приём, что у idx_get/idx_put. */
static void cache_dir_get(uint16_t idx, cache_rec_t *r)
{
uint8_t page = (uint8_t)(idx >> 11);
uint16_t off = (uint16_t)((idx & 2047u) << 3);
bank_read(cache_dir_phys[page], off, r, CACHE_DIR_REC_SIZE);
}
static void cache_dir_put(uint16_t idx, const cache_rec_t *r)
{
uint8_t page = (uint8_t)(idx >> 11);
uint16_t off = (uint16_t)((idx & 2047u) << 3);
bank_write(cache_dir_phys[page], off, r, CACHE_DIR_REC_SIZE);
}
/* Резервирует nbytes подряд в пуле контента рендер-кэша.
* Запись никогда не разбивается через границу страницы: если остаток
* текущей страницы недостаточен — переходим на следующую целиком,
* жертвуя остатком (проще и безопаснее, чем page-crossing в fb()).
* При нехватке текущего пула — лениво доращиваем его на 1 страницу.
* Возврат: 1 — успех (out_page и out_off заполнены), 0 — EMM кончилась. */
static uint8_t cache_reserve(uint16_t nbytes, uint8_t *out_page, uint16_t *out_off)
{
if (nbytes == 0 || nbytes > PAGE_SIZE) return 0;
if (cache_content_n_pages == 0 ||
(uint16_t)(cache_cur_off + nbytes) > PAGE_SIZE) {
/* Нужна новая страница: либо пул ещё пуст, либо текущая страница
* не вмещает запись целиком. */
uint8_t next_idx = (cache_content_n_pages == 0) ? 0 : (uint8_t)(cache_cur_page_idx + 1);
if (next_idx >= cache_content_n_pages) {
if (cache_content_n_pages >= MAX_CACHE_CONTENT_PAGES) return 0;
uint8_t blk = mem_alloc_pages(1);
if (blk == 0) return 0;
cache_content_blk[cache_content_n_pages] = blk;
cache_content_phys[cache_content_n_pages] = mem_get_page(blk, 0);
cache_content_n_pages++;
}
cache_cur_page_idx = next_idx;
cache_cur_off = 0;
}
*out_page = cache_content_phys[cache_cur_page_idx];
*out_off = cache_cur_off;
cache_cur_off = (uint16_t)(cache_cur_off + nbytes);
return 1;
}
/* Копирует уже готовый буфер (рендер одной строки, собранный в W1/W2 —
* НЕ в W3, чтобы не конфликтовать с fb()/чтением исходника во время
* рендера) в зарезервированное место в пуле контента. Один bank_write
* на строку, а не по байту. len — РОВНО как у cache_reserve()/bank_write
* — uint16_t, не uint8_t: строка до MAX_CACHE_LINE_LEN=255 ячеек даёт
* до 510 байт (char,attr), это уже не лезет в uint8_t. */
static void cache_commit(uint8_t page, uint16_t off, const void *buf, uint16_t len)
{
bank_write(page, off, buf, len);
}
/* Фаза 3: рендер-воркер вызывается из emit_seg() с отставанием на одну
* строку, поэтому нужна форвард-декларация — определение далеко ниже,
* рядом с остальной inline-парсинг логикой. */
static void render_line_to_cache(uint16_t line_idx);
static uint16_t n_lines = 0;
static uint16_t max_lines = 0; /* ёмкость индекса: index_pages * 2048 */
static uint16_t top_line = 0;
static uint32_t file_size = 0;
static uint8_t file_blk = 0;
static uint8_t file_pages = 0;
static uint8_t file_phys[MAX_PAGES] = {0};
static uint8_t index_blk = 0;
static uint8_t index_pages = 0;
static uint8_t index_phys[MAX_INDEX_PAGES] = {0};
static uint8_t index_truncated = 0; /* выставляется при исчерпании ёмкости индекса */
static uint32_t cur_seg_off = 0; /* смещение последнего emit_seg (кэш в near-памяти) */
static idx_rec_t cur_rec; /* near-копия последней записанной idx-записи */
static uint8_t cur_page = 0xFF; /* 0xFF = в W3 ещё не отображена ни одна страница */
static uint8_t viewport_x = 0; /* горизонтальный сдвиг для nowrap-строк */
static char filename[64] = {0};
#define HPAN_STEP 8u
/* Спиннер в статус-баре (col=8,row=0).
* Включается во время загрузки/индексации, чтобы показать,
* что программа занята и не зависла. */
#define SPINNER_COL 8
static const char spinner_chars[4] = { '|', '/', '-', '\\' };
static uint8_t spinner_phase = 0;
static uint8_t spinner_active = 0;
/* ==================================================================
* Вспомогательные функции
* ================================================================== */
/* Печатает строку с указанным атрибутом, начиная с позиции (x,y),
* и останавливается на конце строки или правой границе экрана.
* `s` — литерал/статический буфер (CODE/DATA, окна 1/2), поэтому его
* можно передать в BIOS LP_PRINT_LN напрямую, без промежуточной копии. */
static void put_str_attr(uint8_t x, uint8_t y, const char *s, uint8_t attr)
{
uint8_t maxlen = (x < SCREEN_W) ? (uint8_t)(SCREEN_W - x) : 0;
uint8_t len = (uint8_t)strlen(s);
if (len > maxlen) len = maxlen;
if (len == 0) return;
bios_set_place(y, x);
bios_writeattr(s, len, attr);
}
/* Полностью очищает указанную строку экрана символами пробела с attr. */
static void fill_row(uint8_t y, uint8_t attr)
{
bios_set_place(y, 0);
bios_fillcharattr(' ', attr, SCREEN_W);
}
/* Продвигает спиннер на один кадр.
* Если спиннер выключен — функция ничего не делает. */
static void spinner_tick(void)
{
if (!spinner_active) return;
wrchar(SPINNER_COL, 0, spinner_chars[spinner_phase & 3], ATTR_BAR_SPINNER);
spinner_phase++;
}
/* Включает/выключает спиннер.
* При выключении очищает его позицию пробелом в статус-баре. */
static void spinner_show(uint8_t on)
{
spinner_active = on;
if (!on) wrchar(SPINNER_COL, 0, ' ', ATTR_BAR);
}
/* Отображает указанную EMM-страницу файла в окно W3.
* Если эта страница уже активна, переключение не выполняется. */
static inline void map_page(uint8_t page)
{
if (page != cur_page) {
/* file_phys[] позволяет не вызывать mem_get_page() на каждом свопе. */
sprinter_page_w3(file_phys[page]);
cur_page = page;
}
}
/* Читает один байт логического файла по смещению p.
* При переходе между страницами автоматически переключает W3.
* Проверка границ выполняется вызывающей стороной.
*/
static char fb(uint32_t p)
{
uint8_t *pb = (uint8_t *)&p;
uint8_t page = (uint8_t)((pb[1] >> 6) | (pb[2] << 2));
uint16_t off = (uint16_t)(((uint16_t)(pb[1] & 0x3F) << 8) | pb[0]);
map_page(page);
return FILE_BUF[off];
/*
// p = HLDE
(void)p;
__asm
; uint16_t off = (uint16_t)(((uint16_t)(pb[1] | 0xC0) << 8) | pb[0]);
ld a, d
or a, #0xC0
ld b, a
ld c, e
; uint8_t page = (uint8_t)((pb[1] >> 6) | (pb[2] << 2));
ld a, l ;; pb[2] << 2
add a, a
add a, a
ld e, a ;; e = pb[2] << 2
ld a, d ;; pb[1] >> 6
rlca
rlca
and a, #0x03
or a, e ;; a = (pb[2] << 2) | (pb[1] >> 6)
ld e, a
; map_page(page);
; if (page != cur_page) {
ld a, (_cur_page)
cp e
jr Z, __fbl0
; cur_page = page;
ld a, e
ld (_cur_page), a
; sprinter_page_w3(file_phys[page]);
ld hl, #_file_phys
ld d, #0x00
add hl, de
ld a, (hl)
out (__io_page_w3), a
; }
__fbl0:
; return FILE_BUF[off];
ld a, (bc)
ret
__endasm;
*/
}
/* ESTEX WINREST (5Ah, RST 0x10) — копирует готовый буфер (char,attr)
* из смещения `off` ВНУТРИ EMM-страницы `page` прямо в текстовое окно
* экрана, минуя любые BIOS print-вызовы. Формат буфера подтверждён
* эмпирически в MAME (см. memory/sprinter_winrest_format.md и
* tests/winrest/winrest.c): пары (char,attr) по ячейкам, по строкам,
* шаг строки = width*2, без паддинга. row/col — левый верхний угол
* вывода на экране, h/w — высота/ширина в символах.
*
* Регистровая раскладка SDCC __sdcccall(1) для (row,col,h,w,page,off)
* проверена через `sdcc -S` (пробный non-naked враппер): A=row, L=col,
* стек [SP+2]=h [SP+3]=w [SP+4]=page [SP+5..6]=off (little-endian).
*
* История: до 2026-06-23 `off` не было — IX всегда указывал на самое
* начало страницы (#0xC000), это работало только пока в кэше было
* РОВНО ОДНА запись на странице со смещением 0 (Фаза 1 self-test).
* Когда Фаза 2 начала пакетировать много строк на одной странице с
* РАЗНЫМИ off, всё стало читать с начала страницы (т.е. контент первой
* записи) независимо от реальной строки — добавлен off, IX = 0xC000+off. */
static void win_rest(uint8_t row, uint8_t col, uint8_t h, uint8_t w, uint8_t page, uint16_t off) __naked
{
(void)row; (void)col; (void)h; (void)w; (void)page; (void)off;
__asm
ld iy, #2
add iy, sp
ld d, a ; D = row
ld e, l ; E = col
push ix ; сохранить IX вызывающей функции (callee-saved)
ld l, 3 (iy) ; off (младший байт), SP+5
ld h, 4 (iy) ; off (старший байт), SP+6
ld bc, #0xC000
add hl, bc
push hl
pop ix ; IX = 0xC000 + off
ld h, 0 (iy) ; H = height
ld a, 1 (iy) ; width -> L
ld l, a
ld a, 2 (iy) ; page -> B
ld b, a
ld c, #0x5A ; ESTEX WINREST
di
rst #0x10
ei
pop ix ; восстановить IX вызывающей функции
pop hl ; адрес возврата
inc sp
inc sp
inc sp
inc sp
inc sp ; снять h,w,page,off (1+1+1+2 байт) со стека
jp (hl)
__endasm;
}
/* Для проверки границ выделения считаем пробел/таб/конец строки/0 пробельными. */
static uint8_t ws_or_eol(char c)
{
return (uint8_t)(c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == 0);
}
static uint8_t ws_or_eol_or_delim(char c)
{
return (uint8_t)(c == ' ' || c == ',' || c == '.' || c == '!' || c == '?' || c == ':' || c == ';' || c == '/'
|| c == '\t' || c == '\n' || c == '\r' || c == 0);
}
/* Маркер inline-выделения (*, **, _) распознаётся только если
* пробельная граница есть ровно с одной стороны (XOR-правило).
* Это защищает выражения вроде "2 * 3", "2 ** 3" и внутрисловные
* случаи (например COLOR_YELLOW) от ложного форматирования. */
static uint8_t is_emph_flanked(char prev_ch, char next_ch)
{
return (uint8_t)(ws_or_eol(prev_ch) != ws_or_eol_or_delim(next_ch));
}
/* Тип сегмента-продолжения, передаваемый в emit_seg().
* Явно в индексе не хранится: рендер при необходимости восстанавливает
* тип через classify_line(), но параметр оставлен для читаемости вызовов. */
#define CK_PLAIN 0
#define CK_QUOTE 1
#define CK_LIST 2
#define CK_OTHER 3
/* Доступ к индексу в EMM.
* Размер записи 8 байт, поэтому запись не пересекает границу страницы
* и bank_read/bank_write работают с цельным блоком. */
static void idx_get(uint16_t idx, idx_rec_t *r)
{
uint8_t page = (uint8_t)(idx >> 11); /* номер страницы: idx / 2048 */
uint16_t off = (uint16_t)((idx & 2047u) << 3); /* смещение записи: (idx % 2048) * 8 */
bank_read(index_phys[page], off, r, INDEX_REC_SIZE);
}
static void idx_put(uint16_t idx, const idx_rec_t *r)
{
uint8_t page = (uint8_t)(idx >> 11);
uint16_t off = (uint16_t)((idx & 2047u) << 3);
bank_write(index_phys[page], off, r, INDEX_REC_SIZE);
}
/* Чтение полей индекса для рендера и навигации (построчный доступ). */
static uint8_t seg_flags(uint16_t idx)
{
idx_rec_t r;
if (idx >= n_lines) return 0;
idx_get(idx, &r);
return r.flags;
}
static uint32_t seg_off(uint16_t idx)
{
idx_rec_t r;
if (idx >= n_lines) return file_size;
idx_get(idx, &r);
return r.off;
}
static uint8_t is_cont(uint16_t idx) { return (uint8_t)((seg_flags(idx) & IF_CONT) != 0); }
static uint8_t is_nowrap(uint16_t idx) { return (uint8_t)((seg_flags(idx) & IF_NOWRAP) != 0); }
static uint8_t is_blank(uint16_t idx) { return (uint8_t)((seg_flags(idx) & IF_BLANK) != 0); }
static uint8_t is_code_body(uint16_t idx){ return (uint8_t)((seg_flags(idx) & IF_CODE) != 0); }
/* Установка флагов всегда идёт в последний emit_seg (n_lines-1).
* Используется near-копия cur_rec, чтобы не делать обратное чтение из банка. */
static void set_nowrap_cur(void) { cur_rec.flags |= IF_NOWRAP; idx_put((uint16_t)(n_lines - 1), &cur_rec); }
static void set_blank_cur(void) { cur_rec.flags |= IF_BLANK; idx_put((uint16_t)(n_lines - 1), &cur_rec); }
static void set_code_cur(void) { cur_rec.flags |= IF_CODE; idx_put((uint16_t)(n_lines - 1), &cur_rec); }
/* Загружает 16 текстовых атрибутов в палитры ink/paper/blink. */
static void set_pallete(void) {
uint8_t buff[64];
for(int i = 0; i < 16; i++) {
for(int j = 0; j < 16; j++) {
buff[j * 4] = md_pallete[i * 4];
buff[j * 4 + 1] = md_pallete[i * 4 + 1];
buff[j * 4 + 2] = md_pallete[i * 4 + 2];
buff[j * 4 + 3] = md_pallete[i * 4 + 3];
}
text_pal_load(TEXT_PAL_INK, i * 16, 16, md_pallete);
text_pal_load(TEXT_PAL_BLINK_INK, i * 16, 16, md_pallete);
text_pal_load(TEXT_PAL_PAPER, i * 16, 16, buff);
text_pal_load(TEXT_PAL_BLINK_PAPER, i * 16, 8, buff + 32);
text_pal_load(TEXT_PAL_BLINK_PAPER, i * 16 + 8, 8, buff + 32);
}
}
/* ==================================================================
* Загрузка/выгрузка файла
* ================================================================== */
/* Загружает файл в EMM-страницы и подготавливает EMM-индекс.
* Возврат: 0 — успех, отрицательное значение — код ошибки. */
static int load_file(const char *path)
{
int fd = open(path, O_RDONLY);
if (fd < 0) return -1;
long sz = lseek(fd, 0, SEEK_END);
if (sz < 0 || (uint32_t)sz > MAX_FILE) {
close(fd);
return -2;
}
file_size = (uint32_t)sz;
lseek(fd, 0, SEEK_SET);
/* Выделяем ровно столько EMM-страниц, сколько нужно под файл. */
file_pages = (uint8_t)((file_size + PAGE_SIZE - 1u) / PAGE_SIZE);
if (file_pages == 0) file_pages = 1;
file_blk = mem_alloc_pages(file_pages);
if (file_blk == 0) {
close(fd);
return -3;
}
/* Читаем файл кусками по 16 КБ, последовательно маппя страницы в W3.
* На последней итерации читаем остаток меньше PAGE_SIZE. */
uint32_t remaining = file_size;
for (uint8_t page = 0; page < file_pages; page++) {
spinner_tick();
uint8_t phys = mem_get_page(file_blk, page);
file_phys[page] = phys;
sprinter_page_w3(phys);
cur_page = page;
uint16_t chunk = (remaining > PAGE_SIZE)
? (uint16_t)PAGE_SIZE
: (uint16_t)remaining;
int n = read(fd, FILE_BUF, chunk);
if (n != (int)chunk) {
close(fd);
return -4;
}
remaining -= chunk;
}
close(fd);
/* Выделяем блок индекса в EMM (8 байт на сегмент, 2048 записей/страница).
* Размер берем с запасом относительно файла; если памяти мало —
* уменьшаем количество страниц индекса до доступного значения. */
{
uint8_t want = (uint8_t)(file_pages + 1u);
if (want > MAX_INDEX_PAGES) want = MAX_INDEX_PAGES;
index_blk = 0;
while (want >= 1u) {
index_blk = mem_alloc_pages(want);
if (index_blk) break;
want--;
}
if (index_blk == 0) return -5;
index_pages = want;
for (uint8_t i = 0; i < index_pages; i++)
index_phys[i] = mem_get_page(index_blk, i);
max_lines = (uint16_t)index_pages * INDEX_RECS_PER_PAGE;
}
/* Директория рендер-кэша (Фаза 1) — та же ёмкость, что у индекса:
* по одной cache_rec_t на каждую логическую строку. */
{
uint8_t want = (uint8_t)(file_pages + 1u);
if (want > MAX_CACHE_DIR_PAGES) want = MAX_CACHE_DIR_PAGES;
cache_dir_blk = 0;
while (want >= 1u) {
cache_dir_blk = mem_alloc_pages(want);
if (cache_dir_blk) break;
want--;
}
if (cache_dir_blk == 0) return -6;
cache_dir_pages = want;
for (uint8_t i = 0; i < cache_dir_pages; i++)
cache_dir_phys[i] = mem_get_page(cache_dir_blk, i);
}
return 0;
}
/* Освобождает выделенные EMM-блоки файла и индекса. */
static void unload_file(void)
{
if (file_blk) {
mem_free_block(file_blk);
file_blk = 0;
}
if (index_blk) {
mem_free_block(index_blk);
index_blk = 0;
}
}
/* ==================================================================
* Индексация строк
* ================================================================== */
/* Возвращает 1, если сегмент idx — это старт логической строки,
* и эта строка начинается с ``` (граница блока кода).
* Сегменты-продолжения никогда не считаются такими границами. */
static uint8_t is_fence_delim(uint16_t idx)
{
if (idx >= n_lines) return 0;
if (is_cont(idx)) return 0;
uint32_t off = seg_off(idx);
if (off + 2 >= file_size) return 0;
return (uint8_t)(fb(off) == '`' &&
fb(off + 1) == '`' &&
fb(off + 2) == '`');
}
#define INIT_STYLE_PLAIN 0x0
#define INIT_STYLE_BOLD 0x1
#define INIT_STYLE_ITALIC 0x2
#define INIT_STYLE_UNDER 0x3
#define INIT_STYLE_CODE 0x4
#define INIT_STYLE_STRIKE 0x5
/* Таблица сопоставления init-style -> атрибут.
* Порядок должен совпадать с INIT_STYLE_* / EM_* ниже. */
static const uint8_t styles_map[] = {
ATTR_TEXT, ATTR_TEXT_BOLD, ATTR_TEXT_ITALIC, ATTR_TEXT_UNDERSORE, ATTR_TEXT_CODE, ATTR_TEXT_STRIKE
};
/* Возвращает стартовый атрибут сегмента из индексной записи. */
static uint8_t get_init_style(uint16_t idx)
{
idx_rec_t r;
if (idx >= n_lines) return styles_map[INIT_STYLE_PLAIN];
idx_get(idx, &r);
return styles_map[r.style & 7u];
}
/* Возвращает «сырой» INIT_STYLE_* из индексной записи. */
static uint8_t get_init_style_raw(uint16_t idx)
{
idx_rec_t r;
if (idx >= n_lines) return INIT_STYLE_PLAIN;
idx_get(idx, &r);
return (uint8_t)(r.style & 7u);
}
/* Тип строки, который возвращает classify_line(). */
#define LK_PLAIN 0
#define LK_H1 1
#define LK_H2 2
#define LK_H3 3
#define LK_H4 4 /* также используется для H5/H6 */
#define LK_HR 5
#define LK_ULIST 6 /* маркер ненумерованного списка */
#define LK_OLIST 7 /* маркер нумерованного списка */
#define LK_QUOTE 8 /* маркер цитаты */
/* Классифицирует логическую строку.
* - Заголовки: 1..4 символа '#' и затем пробел/таб (H5/H6 сводятся к H4).
* - Горизонтальная линия: только '-', '*' или '_' (одного вида, >=3),
* возможно с пробелами/табами между символами.
* - Ненумерованный список: "- ", "* " или "+ " перед содержимым.
* - Нумерованный список: цифры + '.'/ ')' + пробел.
* - Цитата: '>' с опциональным пробелом.
* - Иначе обычный текст.
* Для заголовков/списков/цитат out_content указывает на первый байт
* содержимого после маркера и завершающего пробела. */
static uint8_t classify_line(uint32_t p_start, uint32_t *out_content)
{
if (p_start >= file_size) return LK_PLAIN;
char c0 = fb(p_start);
/* Заголовок? */
if (c0 == '#') {
uint8_t lvl = 0;
uint32_t p = p_start;
while (p < file_size && fb(p) == '#' && lvl < 6) {
lvl++; p++;
}
if (p < file_size) {
char ch = fb(p);
if (ch == ' ' || ch == '\t') {
p++;
*out_content = p;
if (lvl > 4) lvl = 4;
return lvl; /* LK_H1 .. LK_H4 */
}
}
/* Символ '#' без пробела после него не считается заголовком. */
}
/* Горизонтальный разделитель.
* Проверяется до списков, чтобы `- - -` не стало маркером списка. */
if (c0 == '-' || c0 == '*' || c0 == '_') {
char marker = 0;
uint8_t count = 0;
uint8_t ok = 1;
uint32_t p = p_start;
while (p < file_size) {
char ch = fb(p++);
if (ch == '\n' || ch == '\r') break;
if (ch == ' ' || ch == '\t') continue;
if (ch != '-' && ch != '*' && ch != '_') { ok = 0; break; }
if (marker == 0) marker = ch;
else if (ch != marker) { ok = 0; break; }
count++;
}
if (ok && count >= 3) return LK_HR;
}
/* Для списков/цитат дополнительно допускаем ведущие пробелы
* (базовая поддержка вложенности). */
uint32_t lp = p_start;
while (lp < file_size && fb(lp) == ' ') lp++;
char cl = (lp < file_size) ? fb(lp) : 0;
/* Ненумерованный список? */
if ((cl == '-' || cl == '*' || cl == '+') &&
(lp + 1) < file_size &&
fb(lp + 1) == ' ') {
*out_content = lp + 2;
return LK_ULIST;
}
/* Нумерованный список? */
if (cl >= '0' && cl <= '9') {
uint32_t p = lp;
while (p < file_size) {
char ch = fb(p);
if (ch < '0' || ch > '9') break;
p++;
}
if (p < file_size) {
char ch = fb(p);
if ((ch == '.' || ch == ')') &&
(p + 1) < file_size && fb(p + 1) == ' ') {
*out_content = p + 2;
return LK_OLIST;
}
}
}
/* Цитата? */
if (cl == '>') {
uint32_t p = lp + 1;
if (p < file_size && fb(p) == ' ') p++;
*out_content = p;
return LK_QUOTE;
}
return LK_PLAIN;
}
/* Возвращает видимую ширину префикса (отступ + маркер) в колонках. */
static uint8_t marker_visible_col(uint8_t kind, uint32_t p_start, uint32_t content_off)
{
switch (kind) {
case LK_ULIST:
case LK_QUOTE:
case LK_OLIST: return (uint8_t)(content_off - p_start);
default: return 0; /* plain/headers спец-префикса не имеют */
}
}
/* Добавляет запись сегмента в EMM-индекс.
* near-копии cur_rec/cur_seg_off используются для быстрых последующих
* операций без повторного чтения последней записи из банка.
*
* Фаза 3: рендер-воркер вызывается отсюда же, но с ОТСТАВАНИЕМ НА ОДНУ
* строку — render_line_to_cache(N) сам ищет конец сегмента через
* idx_get(N+1), а пока emit_seg() для строки N не вызван, этой записи
* ещё не существует. Поэтому здесь, сразу после того как строка N
* (n_lines-1, ДО инкремента) получила свой off, мы рендерим строку
* N-1 (n_lines-2) — её собственные флаги (IF_NOWRAP/IF_CODE/IF_BLANK)
* уже были дописаны вызовом set_*_cur() на ПРЕДЫДУЩЕЙ итерации
* index_lines(), раньше этого вызова emit_seg(). Последнюю строку
* файла (для которой "следующей" не будет) дорендеривает сам
* index_lines() после выхода из своего основного цикла. */
static void emit_seg(uint32_t off, uint8_t style, uint8_t ckind, uint8_t cont)
{
(void)ckind; /* тип сегмента восстанавливается на этапе рендера */
if (n_lines >= max_lines) { index_truncated = 1; return; }
cur_rec.off = off;
cur_rec.flags = (uint8_t)(cont ? IF_CONT : 0);
cur_rec.style = (uint8_t)(style & 7u);
cur_rec.pad0 = 0;
cur_rec.pad1 = 0;
idx_put(n_lines, &cur_rec);
cur_seg_off = off;
n_lines++;
if (n_lines >= 2) render_line_to_cache((uint16_t)(n_lines - 2));
}
/* Проверяет, что физическая строка пуста (только пробелы/таб + перевод строки). */
static uint8_t is_line_blank(uint32_t p)
{
while (p < file_size) {
char c = fb(p);
if (c == '\n') return 1;
if (c != ' ' && c != '\t') return 0;
p++;
}
return 1;
}
/* Быстрая проверка начала блока кода по маркеру ``` . */
static uint8_t is_fence_raw(uint32_t p)
{
if (p + 2 >= file_size) return 0;
return (fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`');
}
/* Быстрая проверка горизонтального разделителя (`---`, `***`, `___`). */
static uint8_t is_hr_raw(uint32_t p)
{
char c0 = fb(p);
if (c0 != '-' && c0 != '*' && c0 != '_') return 0;
char marker = 0;
uint8_t count = 0, ok = 1;
uint32_t hr_p = p;
while (hr_p < file_size) {
char ch = fb(hr_p++);
if (ch == '\n' || ch == '\r') break;
if (ch == ' ' || ch == '\t') continue;
if (ch != '-' && ch != '*' && ch != '_') { ok = 0; break; }
if (marker == 0) marker = ch;
else if (ch != marker) { ok = 0; break; }
count++;
}
return ok && count >= 3;
}
/* Строка таблицы: первый непустой символ — '|'.
* Ведущие пробелы разрешены; такие строки не склеиваются и не переносятся. */
static uint8_t is_table_raw(uint32_t p)
{
while (p < file_size) {
char c = fb(p);
if (c == ' ' || c == '\t') { p++; continue; }
return (uint8_t)(c == '|');
}
return 0;
}
/* Общий сканер inline-форматирования и переносов.
* Идёт от q до q_end, учитывает стартовую колонку col и при необходимости
* эмитит continuation-сегменты. Возвращает итоговый line_style. */
static uint8_t inline_scan(uint32_t q, uint32_t q_end, uint8_t col,
uint8_t ckind, uint8_t line_style)
{
uint32_t last_space = 0xFFFFFFFFu;
uint8_t last_space_style = line_style; /* снимок line_style на момент last_space */
char prev_ch = ' ';
uint8_t seg_col = col;
while (q < q_end && n_lines < max_lines) {
char ch = fb(q);
if (ch == '\n') {
/* Жёсткий перенос (уже проверен снаружи) или конец потока. */
q++;
emit_seg(q, line_style, ckind, (ckind == CK_LIST || ckind == CK_QUOTE) ? 1 : 0);
last_space = 0xFFFFFFFFu; prev_ch = ' '; seg_col = col;
continue;
}
if (ch == '`') {
if (line_style == INIT_STYLE_CODE) line_style = INIT_STYLE_PLAIN;
else line_style = INIT_STYLE_CODE;
q++; continue;
}
if (ch == '*' && (q + 1) < q_end && fb(q + 1) == '*') {
char next_ch = ((q + 2) < q_end) ? fb(q + 2) : '\n';
if (is_emph_flanked(prev_ch, next_ch)) {
if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
line_style = INIT_STYLE_BOLD; q += 2; continue;
} else if (line_style == INIT_STYLE_BOLD && ws_or_eol_or_delim(next_ch)) {
line_style = INIT_STYLE_PLAIN; q += 2; continue;
}
}
seg_col += 2; q += 2; prev_ch = '*'; continue;
}
if (ch == '~' && (q + 1) < q_end && fb(q + 1) == '~') {
char next_ch = ((q + 2) < q_end) ? fb(q + 2) : '\n';
if (is_emph_flanked(prev_ch, next_ch)) {
if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
line_style = INIT_STYLE_STRIKE; q += 2; continue;
} else if (line_style == INIT_STYLE_STRIKE && ws_or_eol_or_delim(next_ch)) {
line_style = INIT_STYLE_PLAIN; q += 2; continue;
}
}
seg_col += 2; q += 2; prev_ch = '~'; continue;
}
if (ch == '*' || ch == '_') {
char next_ch = ((q + 1) < q_end) ? fb(q + 1) : '\n';
if (is_emph_flanked(prev_ch, next_ch)) {
uint8_t new_style = (ch == '*') ? INIT_STYLE_ITALIC : INIT_STYLE_UNDER;
if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
line_style = new_style; q++; continue;
} else if (line_style == new_style && ws_or_eol_or_delim(next_ch)) {
line_style = INIT_STYLE_PLAIN; q++; continue;
}
}
seg_col++; q++; prev_ch = ch; continue;
}
if (ch == '\t') {
uint8_t tgt = (uint8_t)((seg_col & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
if (tgt > SCREEN_W) tgt = SCREEN_W;
seg_col = tgt; q++; prev_ch = ' '; continue;
}
if (ch == ' ') {
last_space = q + 1;
last_space_style = line_style;
seg_col++; q++; prev_ch = ' ';
} else {
seg_col++; q++; prev_ch = ch;
}
if (seg_col >= SCREEN_W) {
uint8_t use_ls = (uint8_t)(last_space != 0xFFFFFFFFu && last_space > cur_seg_off);
uint32_t wrap_at = use_ls ? last_space : q;
if (wrap_at >= q_end || wrap_at == cur_seg_off) break;
uint8_t wrap_style = use_ls ? last_space_style : line_style;
emit_seg(wrap_at, wrap_style, ckind, 1);
seg_col = col; last_space = 0xFFFFFFFFu; prev_ch = ' '; q = wrap_at;
/* Откатываем line_style к снимку на момент wrap_at: диапазон
* [wrap_at, q_old) будет пересканирован заново, и переключающие
* символы (обратная кавычка, звёздочка, подчёркивание, тильда)
* внутри него иначе применились бы дважды — именно так возникал
* баг: code-маркер на границе переноса ломает цвет следующего слова. */
line_style = wrap_style;
}
}
return line_style;
}
#define JOIN_MODE_LIST 1u
#define JOIN_MODE_QUOTE 2u
#define JOIN_MODE_PLAIN 3u
/* Общий потоковый сканер для index_lines().
* Убирает дублирование между ветками list/quote/plain:
* - mode задаёт правила обработки перевода строки,
* - остальная inline-логика (emphasis/таб/перенос) едина.
* Возвращает позицию q, на которой сканер остановился, и обновляет стиль. */
static uint32_t scan_join_stream(uint32_t q, uint32_t para_start, uint8_t col,
uint8_t ckind, uint8_t *io_line_style, uint8_t mode)
{
uint32_t last_space = 0xFFFFFFFFu;
uint8_t last_space_style = *io_line_style; /* снимок line_style на момент last_space */
char prev_ch = ' ';
uint8_t seg_col = col;
uint8_t line_style = *io_line_style;
while (q < file_size && n_lines < max_lines) {
char ch = fb(q);
uint8_t soft_break = 0;
if (ch == '\n') {
uint32_t next = q + 1;
if (next >= file_size) break;
if (mode == JOIN_MODE_LIST) {
if (is_line_blank(next)) break;
if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break;
{
uint32_t next_content = next;
uint8_t next_kind = classify_line(next, &next_content);
if (next_kind == LK_ULIST || next_kind == LK_OLIST ||
next_kind == LK_QUOTE ||
(next_kind >= LK_H1 && next_kind <= LK_H4) ||
next_kind == LK_HR) {
break;
}
}
while (next < file_size) {
char ws = fb(next);
if (ws == ' ' || ws == '\t') next++;
else break;
}
q = next;
soft_break = 1;
ch = ' ';
} else if (mode == JOIN_MODE_QUOTE) {
if (is_line_blank(next)) break;
if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break;
{
uint32_t next_content = next;
uint8_t next_kind = classify_line(next, &next_content);
if (next_kind != LK_QUOTE) break;
{
uint32_t next_line_end = next_content;
while (next_line_end < file_size && fb(next_line_end) != '\n') next_line_end++;
{
uint8_t empty_quote = 1;
for (uint32_t z = next_content; z < next_line_end; z++) {
char c = fb(z);
if (c != ' ' && c != '\t') { empty_quote = 0; break; }
}
if (empty_quote) break;
}
{
uint32_t t = next_content;
while (t < next_line_end && (fb(t) == ' ' || fb(t) == '\t')) t++;
if (t < next_line_end && fb(t) == '>') break;
}
while (next_content < next_line_end) {
char ws = fb(next_content);
if (ws == ' ' || ws == '\t') next_content++;
else break;
}
if (next_content < next_line_end && fb(next_content) == '>') {
next_content++;
if (next_content < next_line_end && fb(next_content) == ' ') next_content++;
}
q = next_content;
soft_break = 1;
ch = ' ';
}
}
} else { /* JOIN_MODE_PLAIN */
if (fb(next) == '\n') break;
if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break;
{
uint32_t dummy;
if (classify_line(next, &dummy) != LK_PLAIN) break;
}
if (is_line_blank(next)) break;
{
uint8_t is_hard = 0;
if (q >= para_start + 1) {
char b1 = fb(q - 1);
if (b1 == '\\') {
is_hard = 1;
} else if (q >= para_start + 2) {
char b2 = fb(q - 2);
if ((b1 == ' ' || b1 == '\t') && (b2 == ' ' || b2 == '\t'))
is_hard = 1;
}
}
if (is_hard) {
q++;
emit_seg(q, line_style, ckind, 0);
last_space = 0xFFFFFFFFu;
prev_ch = ' ';
seg_col = col;
continue;
}
}
q++;
soft_break = 1;
ch = ' ';
}
}
if (ch == '`') {
if (line_style == INIT_STYLE_CODE) line_style = INIT_STYLE_PLAIN;
else line_style = INIT_STYLE_CODE;
if (!soft_break) q++;
continue;
}
if (ch == '*' && (q + 1) < file_size && fb(q + 1) == '*') {
char next_ch = ((q + 2) < file_size) ? fb(q + 2) : '\n';
if (is_emph_flanked(prev_ch, next_ch)) {
if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
line_style = INIT_STYLE_BOLD;
if (!soft_break) q += 2; else q++;
continue;
} else if (line_style == INIT_STYLE_BOLD && ws_or_eol_or_delim(next_ch)) {
line_style = INIT_STYLE_PLAIN;
if (!soft_break) q += 2; else q++;
continue;
}
}
seg_col += 2;
if (!soft_break) q += 2; else q++;
prev_ch = '*';
continue;
}
if (ch == '~' && (q + 1) < file_size && fb(q + 1) == '~') {
char next_ch = ((q + 2) < file_size) ? fb(q + 2) : '\n';
if (is_emph_flanked(prev_ch, next_ch)) {
if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
line_style = INIT_STYLE_STRIKE;
if (!soft_break) q += 2; else q++;
continue;
} else if (line_style == INIT_STYLE_STRIKE && ws_or_eol_or_delim(next_ch)) {
line_style = INIT_STYLE_PLAIN;
if (!soft_break) q += 2; else q++;
continue;
}
}
seg_col += 2;
if (!soft_break) q += 2; else q++;
prev_ch = '~';
continue;
}
if (ch == '*' || ch == '_') {
char next_ch = ((q + 1) < file_size) ? fb(q + 1) : '\n';
if (is_emph_flanked(prev_ch, next_ch)) {
uint8_t new_style = (ch == '*') ? INIT_STYLE_ITALIC : INIT_STYLE_UNDER;
if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
line_style = new_style;
if (!soft_break) q++; else q++;
continue;
} else if (line_style == new_style && ws_or_eol_or_delim(next_ch)) {
line_style = INIT_STYLE_PLAIN;
if (!soft_break) q++; else q++;
continue;
}
}
seg_col++;
if (!soft_break) q++; else q++;
prev_ch = ch;
continue;
}
if (ch == '\t') {
uint8_t tgt = (uint8_t)((seg_col & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
if (tgt > SCREEN_W) tgt = SCREEN_W;
seg_col = tgt;
if (!soft_break) q++; else q++;
prev_ch = ' ';
continue;
}
if (ch == ' ') {
if (soft_break) {
last_space = q;
last_space_style = line_style;
seg_col++;
} else {
last_space = q + 1;
last_space_style = line_style;
seg_col++;
q++;
}
prev_ch = ' ';
} else {
seg_col++;
if (!soft_break) q++; else q++;
prev_ch = ch;
}
if (seg_col >= SCREEN_W) {
uint8_t use_ls = (uint8_t)(last_space != 0xFFFFFFFFu && last_space > cur_seg_off);
uint32_t wrap_at = use_ls ? last_space : q;
if (wrap_at >= file_size || wrap_at == cur_seg_off) break;
uint8_t wrap_style = use_ls ? last_space_style : line_style;
emit_seg(wrap_at, wrap_style, ckind, 1);
seg_col = col;
last_space = 0xFFFFFFFFu;
prev_ch = ' ';
q = wrap_at;
/* Откатываем line_style к снимку на момент wrap_at: диапазон
* [wrap_at, q_old) будет пересканирован заново, и переключающие
* символы (обратная кавычка, звёздочка, подчёркивание, тильда)
* внутри него иначе применились бы дважды — именно так возникал
* баг: code-маркер на границе переноса ломает цвет следующего слова (mdview.c, отчёт пользователя
* 2026-06-23). */
line_style = wrap_style;
}
}
*io_line_style = line_style;
return q;
}
/* Строит индекс сегментов рендера по загруженному файлу. */
static void index_lines(void)
{
uint8_t in_block = 0;
n_lines = 0;
index_truncated = 0;
cur_seg_off = 0xFFFFFFFFu; /* пока не эмитили ни одного сегмента */
if (file_size == 0) return;
uint32_t p = 0;
while (p < file_size && n_lines < max_lines) {
if ((n_lines & 15) == 0)
spinner_tick();
/* Пустая строка — разделитель параграфов.
* В индекс добавляем не более одной пустой экранной строки подряд. */
if (is_line_blank(p)) {
while (p < file_size && fb(p) != '\n') p++;
if (p < file_size) p++;
/* Серия пустых строк схлопывается в одну визуальную строку. */
if (n_lines == 0 || !(cur_rec.flags & IF_BLANK)) {
emit_seg(p, INIT_STYLE_PLAIN, CK_PLAIN, 0);
set_blank_cur();
}
continue;
}
/* ---- fenced code-блок (граница или тело) ---- */
if (is_fence_raw(p)) {
in_block = !in_block;
emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
set_nowrap_cur();
while (p < file_size && fb(p) != '\n') p++;
if (p < file_size) p++;
continue;
}
if (in_block) {
emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
set_nowrap_cur();
set_code_cur();
while (p < file_size && fb(p) != '\n') p++;
if (p < file_size) p++;
continue;
}
/* ---- горизонтальный разделитель ---- */
if (is_hr_raw(p)) {
emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
set_nowrap_cur();
while (p < file_size && fb(p) != '\n') p++;
if (p < file_size) p++;
continue;
}
/* ---- строка таблицы (| ... |): один nowrap-сегмент на исходную строку ---- */
if (is_table_raw(p)) {
emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
set_nowrap_cur();
while (p < file_size && fb(p) != '\n') p++;
if (p < file_size) p++;
continue;
}
/* ---- классификация текущей строки ---- */
uint32_t content_off = p;
uint8_t kind = classify_line(p, &content_off);
/* ---- Заголовок: отдельная строка, не склеивается с параграфом ---- */
if (kind >= LK_H1 && kind <= LK_H4) {
emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
uint32_t line_end = content_off;
while (line_end < file_size && fb(line_end) != '\n') line_end++;
(void)inline_scan(content_off, line_end, 0, CK_OTHER, INIT_STYLE_PLAIN);
while (p < file_size && fb(p) != '\n') p++;
if (p < file_size) p++;
continue;
}
/* ---- Пункт списка: может занимать несколько физических строк ---- */
if (kind == LK_ULIST || kind == LK_OLIST) {
uint8_t col = marker_visible_col(kind, p, content_off);
uint8_t line_style = INIT_STYLE_PLAIN;
uint32_t q = content_off;
emit_seg(p, line_style, CK_LIST, 0);
q = scan_join_stream(q, p, col, CK_LIST, &line_style, JOIN_MODE_LIST);
/* Переходим к началу следующей физической строки после пункта. */
if (q < file_size) {
while (q < file_size && fb(q) != '\n') q++;
if (q < file_size) q++;
}
/* Одна пустая строка между соседними маркерами списка подавляется.
* Две и более пустых строки оставляют видимый разделитель. */
if (q < file_size && is_line_blank(q)) {
uint32_t t = q;
uint8_t blanks = 0;
while (t < file_size && is_line_blank(t)) {
while (t < file_size && fb(t) != '\n') t++;
if (t < file_size) t++;
blanks++;
// if (blanks >= 2) break;
}
if (blanks == 1 && t < file_size) {
uint32_t d = t;
uint8_t nk = classify_line(t, &d);
if (nk == LK_ULIST || nk == LK_OLIST) {
p = t; /* подавляем единственную пустую строку */
continue;
}
}
}
p = q;
continue;
}
/* ---- Параграф цитаты: может покрывать несколько физических строк ---- */
if (kind == LK_QUOTE) {
uint8_t col = marker_visible_col(kind, p, content_off);
uint8_t line_style = INIT_STYLE_PLAIN;
uint32_t q = content_off;
emit_seg(p, line_style, CK_QUOTE, 0);
q = scan_join_stream(q, p, col, CK_QUOTE, &line_style, JOIN_MODE_QUOTE);
while (q < file_size && fb(q) != '\n') q++;
if (q < file_size) q++;
p = q;
continue;
}
/* ---- Обычный текстовый параграф: склейка через soft-break ---- */
{
uint8_t line_style = INIT_STYLE_PLAIN;
emit_seg(p, line_style, CK_PLAIN, 0);
uint32_t q = p;
q = scan_join_stream(q, p, 0, CK_PLAIN, &line_style, JOIN_MODE_PLAIN);
if (q >= file_size) {
p = file_size;
} else {
/* q стоит на завершающем newline параграфа.
* Переходим на следующую строку, чтобы внешняя петля её
* корректно переклассифицировала. */
while (q < file_size && fb(q) != '\n') q++;
if (q < file_size) q++;
p = q;
}
}
}
/* emit_seg() рендерит строки с отставанием на одну (см. её
* комментарий) — последняя эмитированная строка файла так и
* остаётся неотрендеренной, дорендериваем её здесь. seg_end для
* неё в render_line_to_cache() берётся как file_size (нет
* "следующей" idx-записи), как и при живом рендере в render_line(). */
if (n_lines > 0) render_line_to_cache((uint16_t)(n_lines - 1));
}
#define EM_NONE 0
#define EM_BOLD 1
#define EM_ITALIC 2
#define EM_UNDER 3
#define EM_CODE 4
#define EM_STRIKE 5
/* Преобразует внутреннее состояние emphasis в экранный атрибут. */
static uint8_t emph_to_attr(uint8_t emph, uint8_t base_attr)
{
switch (emph) {
case EM_BOLD: return ATTR_TEXT_BOLD;
case EM_ITALIC: return ATTR_TEXT_ITALIC;
case EM_UNDER: return ATTR_TEXT_UNDERSORE;
case EM_CODE: return ATTR_TEXT_CODE;
case EM_STRIKE: return ATTR_TEXT_STRIKE;
default: return base_attr;
}
}
/* Единая обработка inline-маркеров в render_line().
* Возврат:
* 0 = маркер не поглощён (символ выводится как есть),
* 1 = маркер поглощён как управляющий (ничего не рисуем),
* 2 = литеральный двойной маркер (`**` или `~~`), нужно вывести две копии out_ch. */
static uint8_t handle_inline_marker(char ch, uint32_t *io_p, uint32_t seg_end,
char prev_ch, uint8_t *io_emph, char *out_ch)
{
uint32_t p = *io_p;
*out_ch = 0;
if (ch == '`') {
if (*io_emph == EM_NONE) {
*io_emph = EM_CODE;
*io_p = p + 1;
return 1;
}
if (*io_emph == EM_CODE) {
*io_emph = EM_NONE;
*io_p = p + 1;
return 1;
}
return 0;
}
if ((ch == '*' || ch == '~') && (p + 1) < seg_end && fb(p + 1) == ch) {
uint8_t pair_emph = (ch == '*') ? EM_BOLD : EM_STRIKE;
char next_ch = ((p + 2) < seg_end) ? fb(p + 2) : '\n';
if (is_emph_flanked(prev_ch, next_ch)) {
if (*io_emph == EM_NONE && ws_or_eol(prev_ch)) {
*io_emph = pair_emph;
*io_p = p + 2;
return 1;
} else if (*io_emph == pair_emph && ws_or_eol_or_delim(next_ch)) {
*io_emph = EM_NONE;
*io_p = p + 2;
return 1;
}
}
/* Невалидный по границам двойной маркер отображаем как литерал. */
*io_p = p + 2;
*out_ch = ch;
return 2;
}
if (ch == '*' || ch == '_') {
char next_ch = ((p + 1) < seg_end) ? fb(p + 1) : '\n';
if (is_emph_flanked(prev_ch, next_ch)) {
uint8_t active = (ch == '*') ? EM_ITALIC : EM_UNDER;
if (*io_emph == EM_NONE && ws_or_eol(prev_ch)) {
*io_emph = active;
*io_p = p + 1;
return 1;
} else if (*io_emph != EM_NONE && ws_or_eol_or_delim(next_ch)) {
*io_emph = EM_NONE;
*io_p = p + 1;
return 1;
}
}
}
return 0;
}
/* Сбрасывает накопленный "прогон" из *col символов одного атрибута
* одним bios_writeattr() вместо посимвольных wrchar(). *need_place
* лениво ставит BIOS-курсор перед первым выводом в текущей строке —
* далее place продвигается сам (verified), повторный bios_set_place
* не нужен пока *col растёт монотонно. */
static void flush_run(uint8_t row, uint8_t *col, const char *buf,
uint8_t len, uint8_t attr, uint8_t *need_place)
{
if (len == 0) return;
/* *col уже указывает на конец прогона (вызывающий цикл инкрементит
* col на каждый символ ещё во время накопления в буфер — это нужно
* для верных проверок col < SCREEN_W до сброса). Поэтому начало
* прогона на экране = *col - len, а не *col; и *col второй раз
* наращивать НЕ нужно — он уже корректен. */
if (*need_place) {
bios_set_place(row, (uint8_t)(*col - len));
*need_place = 0;
}
bios_writeattr(buf, len, attr);
}
static void render_line(uint16_t line_idx, uint8_t row)
{
/* Один idx_get() на line_idx вместо ~13 отдельных is_blank/is_cont/
* is_nowrap/is_code_body/seg_off/get_init_style* вызовов. Каждый из
* них шёл через seg_flags()/seg_off() -> idx_get() -> bank_read(),
* а bank_read() ДВАЖДЫ переключает банк окна 3 (на страницу индекса
* и обратно) плюс memcpy — это оказалось главным узким местом
* рендера (≈700+ переключений банка на кадр), а не сам вывод на
* экран. Все нужные поля (off/flags/style) лежат в одной 8-байтовой
* записи idx_rec_t — читаем её один раз и используем дальше. */
uint8_t have_rec = (line_idx < n_lines);
idx_rec_t rec;
if (have_rec) idx_get(line_idx, &rec);
uint8_t col = 0;
uint8_t base_attr = ATTR_TEXT;
uint8_t is_code = (uint8_t)(have_rec && (rec.flags & IF_CODE));
uint8_t cur_attr = is_code ? ATTR_TEXT_CODE
: styles_map[(have_rec ? rec.style : INIT_STYLE_PLAIN) & 7u];
uint8_t emph = is_code ? EM_CODE
: (uint8_t)((have_rec ? rec.style : INIT_STYLE_PLAIN) & 7u);
uint8_t parse_inline = 1; /* 1: включён парсер выделений, 0: выводим код как есть */
if (have_rec && (rec.flags & IF_BLANK)) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, (uint8_t)(SCREEN_W - col));
return;
}
if (have_rec) {
uint32_t p = rec.off;
uint32_t content_off = p;
uint8_t cont = (uint8_t)((rec.flags & IF_CONT) != 0);
/* Правая граница сегмента: начало следующего сегмента или конец файла. */
uint32_t seg_end;
if ((uint16_t)(line_idx + 1) < n_lines) {
idx_rec_t rec_next;
idx_get((uint16_t)(line_idx + 1), &rec_next);
seg_end = rec_next.off;
} else {
seg_end = file_size;
}
if (seg_end > file_size) seg_end = file_size;
uint8_t kind;
uint8_t quote_stream = 0; /* 1: текущий сегмент относится к потоку цитаты */
/* Для сегмента-продолжения повторяем префикс/отступ
* от первого не-CONT сегмента логической строки. */
if (cont) {
uint16_t first = line_idx;
idx_rec_t rec_first = rec; /* rec.flags здесь уже содержит IF_CONT */
/* Для любого сегмента-продолжения откатываемся к первому
* НЕ-cont сегменту этой логической строки — один idx_get()
* на шаг вместо двух (is_cont + повторный seg_off). */
while (first > 0 && (rec_first.flags & IF_CONT)) {
first--;
idx_get(first, &rec_first);
}
uint32_t p_start = rec_first.off;
uint32_t content_off = p_start;
uint8_t first_kind = classify_line(p_start, &content_off);
uint8_t indent = marker_visible_col(first_kind, p_start, content_off);
if (first_kind == LK_QUOTE) {
/* Для продолжения цитаты нужно помнить тип потока,
* чтобы далее на newline корректно пропускать сырой `>`. */
quote_stream = 1;
uint8_t q = 0;
while (q + 2 < content_off && q < SCREEN_W && fb(p_start + q) == ' ') q++;
if (q > (uint8_t)(SCREEN_W - col)) q = (uint8_t)(SCREEN_W - col);
if (q > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, q);
col = (uint8_t)(col + q);
}
if (col < SCREEN_W) wrchar(col++, row, 0xB3, ATTR_QUOTE_MARKER);
if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
} else if (first_kind == LK_ULIST || first_kind == LK_OLIST) {
uint8_t q = indent;
if (q > (uint8_t)(SCREEN_W - col)) q = (uint8_t)(SCREEN_W - col);
if (q > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, q);
col = (uint8_t)(col + q);
}
}
/* p/content_off уже равны rec.off — внутри cont-блока они не
* менялись (там свои локальные тени с тем же именем),
* пересчитывать через seg_off() заново не нужно. */
}
/* Кодовый блок:
* - строка-разделитель показывается как пустая;
* - тело рисуется «как есть» с ATTR_TEXT_CODE без inline-парсинга.
* is_fence_delim(line_idx) разворачено на месте: cont и p уже
* известны, повторный idx_get()/is_cont() не нужен. */
if (!cont && p + 2 < file_size &&
fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`') {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, (uint8_t)(SCREEN_W - col));
return;
}
if (is_code) {
base_attr = ATTR_TEXT_CODE;
cur_attr = ATTR_TEXT_CODE;
parse_inline = 0;
kind = LK_PLAIN;
quote_stream = 0;
} else if (cont) {
/* Строка-продолжение: префикс уже нарисован выше.
* Содержимое рисуем как plain, без повторной классификации,
* иначе начало слова могло бы ложно стать маркером. */
kind = LK_PLAIN;
} else {
kind = classify_line(p, &content_off);
if (kind == LK_QUOTE) quote_stream = 1;
}
if (kind == LK_HR) {
bios_set_place(row, col);
bios_fillcharattr(0xC4, ATTR_HR, (uint8_t)(SCREEN_W - col));
return;
}
if (kind >= LK_H1 && kind <= LK_H4) {
switch (kind) {
case LK_H1: base_attr = ATTR_TEXT_TITLE1; break;
case LK_H2: base_attr = ATTR_TEXT_TITLE2; break;
case LK_H3: base_attr = ATTR_TEXT_TITLE3; break;
default: base_attr = ATTR_TEXT_TITLE4; break;
}
cur_attr = base_attr;
p = content_off;
} else if (kind == LK_ULIST) {
/* Базовая поддержка вложенности: ведущие пробелы сохраняем,
* затем рисуем маркер списка и пробел перед содержимым. */
uint32_t q = p;
uint32_t qmax = p + SCREEN_W;
while (q + 2 < content_off && q < qmax && fb(q) == ' ') q++;
uint8_t nsp = (uint8_t)(q - p);
if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col);
if (nsp > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, nsp);
col = (uint8_t)(col + nsp);
}
if (col < SCREEN_W) wrchar(col++, row, 0x07, ATTR_LIST_MARKER); /* символ маркера списка */
if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
p = content_off;
} else if (kind == LK_OLIST) {
/* Отступ -> пробелы; цифры и '.' / ')' рисуем как маркер;
* завершающий пробел — обычным атрибутом текста. */
uint32_t q = p;
uint32_t qmax = p + SCREEN_W;
while (q < content_off && q < qmax && fb(q) == ' ') q++;
uint8_t nsp = (uint8_t)(q - p);
if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col);
if (nsp > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, nsp);
col = (uint8_t)(col + nsp);
}
/* Маркер (цифры/'.'/')') читается из fb() — это окно 3,
* BIOS принимает указатели только из #4000..#BFFF, поэтому
* сначала копируем во временный буфер на стеке (окно 2),
* чанками по 8 байт (маркер почти всегда короче, но длинный
* корректно не обрезается, просто шлётся несколькими
* bios_writeattr вместо одного). */
{
char mkbuf[8];
uint8_t first_chunk = 1;
while (q < content_off - 1 && col < SCREEN_W) {
uint8_t n = 0;
while (q < content_off - 1 &&
(uint8_t)(col + n) < SCREEN_W &&
n < sizeof(mkbuf)) {
mkbuf[n] = fb(q);
q++;
n++;
}
if (n == 0) break;
if (first_chunk) { bios_set_place(row, col); first_chunk = 0; }
bios_writeattr(mkbuf, n, ATTR_LIST_MARKER);
col = (uint8_t)(col + n);
}
}
if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
p = content_off;
} else if (kind == LK_QUOTE) {
/* Отступ -> пробелы; затем quote-префикс '│' (0xB3),
* пробел и далее содержимое. */
uint32_t q = p;
uint32_t qmax = p + SCREEN_W;
while (q + 1 < content_off && q < qmax && fb(q) == ' ') q++;
uint8_t nsp = (uint8_t)(q - p);
if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col);
if (nsp > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', ATTR_TEXT, nsp);
col = (uint8_t)(col + nsp);
}
if (col < SCREEN_W) wrchar(col++, row, 0xB3, ATTR_QUOTE_MARKER);
if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
p = content_off;
}
/* Эффективный горизонтальный сдвиг: применяется только к nowrap-строкам. */
uint8_t effective_vx = (rec.flags & IF_NOWRAP) ? viewport_x : 0;
/* cc — видимая колонка внутри области контента
* (после фиксированного префикса строки). */
uint8_t cc = 0;
char prev_ch = ' '; /* используется в проверке границ emphasis */
/* Буферизация "ровного" прогона одного атрибута: символ и атрибут
* внутри этого while переменные (emphasis/tab/soft-wrap), поэтому
* весь цикл одним BIOS-вызовом не свернуть — но между сменами
* атрибута почти всегда идёт протяжённый одноцветный текст, и
* именно его стоит копить в runbuf и сбрасывать одним
* bios_writeattr() вместо посимвольных wrchar().
* runbuf вмещает целую строку экрана — переполнение невозможно,
* т.к. весь цикл и так ограничен col < SCREEN_W. */
char runbuf[SCREEN_W];
uint8_t run_len = 0;
uint8_t run_attr = cur_attr;
uint8_t need_place = 1;
while (p < seg_end && col < SCREEN_W) {
uint8_t soft_join = 0; /* 1: вставлен виртуальный пробел мягкой склейки, p уже сдвинут */
char ch = fb(p);
/* '\'
* перед концом сегмента — это маркер wide-break:
* поглощаем его без вывода (кроме verbatim code-body). */
if (parse_inline && ch == '\\' && (p + 1) < seg_end) {
char nb = fb(p + 1);
if (nb == '\n' || nb == '\r') { p++; continue; }
}
/* Мягкий перенос (перевод строки внутри одного сегмента).
* Рендер должен повторить логику индексатора:
* - quote: пропустить сырой `> ` следующей строки;
* - list/plain: схлопнуть перенос + ведущий отступ в 1 пробел. */
if (parse_inline && (ch == '\n' || ch == '\r')) {
uint32_t next = p + 1;
if (quote_stream && next < seg_end) {
uint32_t next_content = next;
if (classify_line(next, &next_content) == LK_QUOTE) {
/* Мягкая склейка quote-строк: `> ` не рендерим. */
p = next_content;
ch = ' ';
soft_join = 1;
} else {
/* Защита: если внутри quote-сегмента встретился
* не-цитатный перенос, работаем как обычная мягкая склейка. */
while (next < seg_end) {
char ws = fb(next);
if (ws == ' ' || ws == '\t') next++;
else break;
}
if (next > (p + 1)) {
p = next;
soft_join = 1;
}
ch = ' ';
}
} else {
while (next < seg_end) {
char ws = fb(next);
if (ws == ' ' || ws == '\t') next++;
else break;
}
if (next > (p + 1)) {
/* Схлопываем перенос + отступ (ленивое продолжение). */
p = next;
soft_join = 1;
}
ch = ' ';
}
} else if (ch == '\n' || ch == '\r') {
ch = ' ';
}
/* inline-маркеры (**, ~~, *, _, `):
* helper синхронизирует состояние emphasis и возвращает режим
* обработки текущего токена (поглотить/вывести литерал/обычный символ). */
if (parse_inline) {
char lit_ch = 0;
uint8_t mk = handle_inline_marker(ch, &p, seg_end, prev_ch, &emph, &lit_ch);
if (mk == 1) {
/* Атрибут меняется -> сбросить накопленный прогон
* со СТАРЫМ атрибутом, прежде чем переключиться. */
flush_run(row, &col, runbuf, run_len, run_attr, &need_place);
run_len = 0;
cur_attr = emph_to_attr(emph, base_attr);
run_attr = cur_attr;
continue;
}
if (mk == 2) {
if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) {
runbuf[run_len++] = lit_ch;
col++;
}
cc++;
if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) {
runbuf[run_len++] = lit_ch;
col++;
}
cc++;
prev_ch = lit_ch;
continue;
}
}
if (!soft_join) p++;
if (ch == '\t') {
/* Таб расширяется в координатах контента (cc).
* Сначала сбросить накопленный текстовый прогон — таб
* выводится отдельным bios_fillcharattr(), не через runbuf. */
flush_run(row, &col, runbuf, run_len, run_attr, &need_place);
run_len = 0;
uint8_t cc_start = cc;
uint8_t tgt = (uint8_t)((cc & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
uint8_t n_total = (uint8_t)(tgt - cc_start);
uint8_t skip = (effective_vx > cc_start) ? (uint8_t)(effective_vx - cc_start) : 0;
if (skip > n_total) skip = n_total;
uint8_t want = (uint8_t)(n_total - skip);
if (want > (uint8_t)(SCREEN_W - col)) want = (uint8_t)(SCREEN_W - col);
if (want > 0) {
bios_set_place(row, col);
bios_fillcharattr(' ', cur_attr, want);
col = (uint8_t)(col + want);
need_place = 0;
}
cc = (uint8_t)(cc_start + skip + want);
prev_ch = ' ';
} else {
if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) {
runbuf[run_len++] = ch;
col++;
}
cc++;
prev_ch = ch;
}
}
flush_run(row, &col, runbuf, run_len, run_attr, &need_place);
/* Индикатор обрезки справа для nowrap-строки:
* если после текущей позиции есть ещё видимый контент,
* поверх последней колонки рисуем '>'. */
if ((rec.flags & IF_NOWRAP) && col >= SCREEN_W) {
uint32_t pp = p;
uint32_t left_bound = rec.off;
while (pp < seg_end) {
char c = fb(pp);
if (c == '\n' || c == '\r') break;
if (c == '`') { pp++; continue; }
if (c == '*' && (pp + 1) < seg_end && fb(pp + 1) == '*') {
char pc = (pp > left_bound) ? fb(pp - 1) : ' ';
char nc = (pp + 2 < seg_end) ? fb(pp + 2) : '\n';
if (is_emph_flanked(pc, nc)) { pp += 2; continue; }
/* Литерал `**` тоже считается видимым контентом справа. */
} else if (c == '*' || c == '_') {
char pc = (pp > left_bound) ? fb(pp - 1) : ' ';
char nc = (pp + 1 < seg_end) ? fb(pp + 1) : '\n';
if (is_emph_flanked(pc, nc)) { pp++; continue; }
}
wrchar(SCREEN_W - 1, row, '>', ATTR_TRUNC);
break;
}
}
/* Индикатор скрытого контента слева при горизонтальном сдвиге. */
if ((rec.flags & IF_NOWRAP) && viewport_x > 0) {
wrchar(0, row, '<', ATTR_TRUNC);
}
}
/* Дозаполняем остаток строки пробелами, чтобы стереть хвост
* от предыдущего кадра рендера. */
{
uint8_t pad_attr = (parse_inline ? ATTR_TEXT : ATTR_TEXT_CODE);
if (col < SCREEN_W) {
bios_set_place(row, col);
bios_fillcharattr(' ', pad_attr, (uint8_t)(SCREEN_W - col));
}
}
}
/* Пишет одну (char,attr) ячейку в локальный буфер кэш-строки. Молча
* не пишет (и не даёт col перевалить за MAX_CACHE_LINE_LEN), если буфер
* уже заполнен — длинные nowrap-строки просто обрезаются по канону,
* как и раньше обрезались по SCREEN_W при живом рендере. */
static void cc_put(uint8_t *cellbuf, uint8_t *col, char ch, uint8_t attr)
{
if (*col < MAX_CACHE_LINE_LEN) {
uint16_t i = (uint16_t)(*col) * 2u;
cellbuf[i] = (uint8_t)ch;
cellbuf[i + 1] = attr;
(*col)++;
}
}
static void cc_fill(uint8_t *cellbuf, uint8_t *col, char ch, uint8_t attr, uint8_t n)
{
while (n-- > 0) cc_put(cellbuf, col, ch, attr);
}
/* Фаза 2 — рендер-воркер. Адаптация render_line(): та же классификация
* строк/префиксов/inline-парсинг (handle_inline_marker, emphasis state
* machine, soft-wrap join) БЕЗ ИЗМЕНЕНИЙ, но результат идёт не на экран
* (bios_set_place/wrchar/bios_fillcharattr/flush_run), а в локальный
* буфер cellbuf через cc_put/cc_fill, и в конце КОММИТИТСЯ одним
* cache_commit()+cache_dir_put(). Буферизация через runbuf/flush_run
* (нужная в render_line() только чтобы минимизировать число BIOS-
* вызовов) здесь не нужна — пишем посимвольно прямо по месту.
*
* Кэшируется ПОЛНЫЙ контент строки до MAX_CACHE_LINE_LEN ячеек, БЕЗ
* обрезки по SCREEN_W и без среза по viewport_x — это view/scroll-time
* понятия (Фаза 4-5: h-скролл — это смещение начала среза в ТОМ ЖЕ
* кэш-буфере, не повторный рендер). По той же причине здесь нет
* truncation-индикаторов '>'/'<' и финального заполнения остатка
* строки пробелами до SCREEN_W — оба добавляются на этапе вывода. */
static void render_line_to_cache(uint16_t line_idx)
{
cache_rec_t out_rec;
out_rec.flags = 0;
out_rec.reserved = 0;
out_rec.pad[0] = 0;
out_rec.pad[1] = 0;
uint8_t have_rec = (line_idx < n_lines);
idx_rec_t rec;
if (have_rec) idx_get(line_idx, &rec);
if (have_rec) out_rec.flags = rec.flags;
if (!have_rec || (rec.flags & IF_BLANK)) {
out_rec.page = 0;
out_rec.off = 0;
out_rec.len = 0;
cache_dir_put(line_idx, &out_rec);
return;
}
uint8_t col = 0; /* позиция в cellbuf, она же длина уже записанного контента */
uint8_t base_attr = ATTR_TEXT;
uint8_t is_code = (uint8_t)(rec.flags & IF_CODE);
uint8_t cur_attr = is_code ? ATTR_TEXT_CODE
: styles_map[rec.style & 7u];
uint8_t emph = is_code ? EM_CODE : (uint8_t)(rec.style & 7u);
uint8_t parse_inline = 1;
uint8_t cellbuf[MAX_CACHE_LINE_LEN * 2];
uint32_t p = rec.off;
uint32_t content_off = p;
uint8_t cont = (uint8_t)((rec.flags & IF_CONT) != 0);
uint32_t seg_end;
if ((uint16_t)(line_idx + 1) < n_lines) {
idx_rec_t rec_next;
idx_get((uint16_t)(line_idx + 1), &rec_next);
seg_end = rec_next.off;
} else {
seg_end = file_size;
}
if (seg_end > file_size) seg_end = file_size;
uint8_t kind;
uint8_t quote_stream = 0;
if (cont) {
uint16_t first = line_idx;
idx_rec_t rec_first = rec;
while (first > 0 && (rec_first.flags & IF_CONT)) {
first--;
idx_get(first, &rec_first);
}
uint32_t p_start = rec_first.off;
uint32_t content_off2 = p_start;
uint8_t first_kind = classify_line(p_start, &content_off2);
uint8_t indent = marker_visible_col(first_kind, p_start, content_off2);
if (first_kind == LK_QUOTE) {
quote_stream = 1;
uint8_t q = 0;
while (q + 2 < content_off2 && q < SCREEN_W && fb(p_start + q) == ' ') q++;
cc_fill(cellbuf, &col, ' ', ATTR_TEXT, q);
cc_put(cellbuf, &col, 0xB3, ATTR_QUOTE_MARKER);
cc_put(cellbuf, &col, ' ', ATTR_TEXT);
} else if (first_kind == LK_ULIST || first_kind == LK_OLIST) {
cc_fill(cellbuf, &col, ' ', ATTR_TEXT, indent);
}
}
/* Разделитель fenced-блока — пустая строка в кэше (как IF_BLANK). */
if (!cont && p + 2 < file_size &&
fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`') {
out_rec.page = 0;
out_rec.off = 0;
out_rec.len = 0;
cache_dir_put(line_idx, &out_rec);
return;
}
if (is_code) {
base_attr = ATTR_TEXT_CODE;
cur_attr = ATTR_TEXT_CODE;
parse_inline = 0;
kind = LK_PLAIN;
quote_stream = 0;
} else if (cont) {
kind = LK_PLAIN;
} else {
kind = classify_line(p, &content_off);
if (kind == LK_QUOTE) quote_stream = 1;
}
if (kind == LK_HR) {
cc_fill(cellbuf, &col, 0xC4, ATTR_HR, SCREEN_W);
out_rec.len = col;
if (cache_reserve((uint16_t)(col * 2u), &out_rec.page, &out_rec.off)) {
cache_commit(out_rec.page, out_rec.off, cellbuf, (uint16_t)(col * 2u));
} else {
out_rec.page = 0; out_rec.off = 0; out_rec.len = 0;
}
cache_dir_put(line_idx, &out_rec);
return;
}
if (kind >= LK_H1 && kind <= LK_H4) {
switch (kind) {
case LK_H1: base_attr = ATTR_TEXT_TITLE1; break;
case LK_H2: base_attr = ATTR_TEXT_TITLE2; break;
case LK_H3: base_attr = ATTR_TEXT_TITLE3; break;
default: base_attr = ATTR_TEXT_TITLE4; break;
}
cur_attr = base_attr;
p = content_off;
} else if (kind == LK_ULIST) {
uint32_t q = p;
uint32_t qmax = p + SCREEN_W;
while (q + 2 < content_off && q < qmax && fb(q) == ' ') q++;
cc_fill(cellbuf, &col, ' ', ATTR_TEXT, (uint8_t)(q - p));
cc_put(cellbuf, &col, 0x07, ATTR_LIST_MARKER);
cc_put(cellbuf, &col, ' ', ATTR_TEXT);
p = content_off;
} else if (kind == LK_OLIST) {
uint32_t q = p;
uint32_t qmax = p + SCREEN_W;
while (q < content_off && q < qmax && fb(q) == ' ') q++;
cc_fill(cellbuf, &col, ' ', ATTR_TEXT, (uint8_t)(q - p));
/* Маркер (цифры/'.'/')') читаем прямо из fb() — здесь, в отличие
* от render_line(), нет BIOS-вызова с требованием адреса в
* #4000-#BFFF, так что промежуточный mkbuf-чанкинг не нужен. */
while (q < content_off - 1) {
cc_put(cellbuf, &col, fb(q), ATTR_LIST_MARKER);
q++;
}
cc_put(cellbuf, &col, ' ', ATTR_TEXT);
p = content_off;
} else if (kind == LK_QUOTE) {
uint32_t q = p;
uint32_t qmax = p + SCREEN_W;
while (q + 1 < content_off && q < qmax && fb(q) == ' ') q++;
cc_fill(cellbuf, &col, ' ', ATTR_TEXT, (uint8_t)(q - p));
cc_put(cellbuf, &col, 0xB3, ATTR_QUOTE_MARKER);
cc_put(cellbuf, &col, ' ', ATTR_TEXT);
p = content_off;
}
uint8_t cc = 0; /* позиция внутри контента (после префикса), только для табов */
char prev_ch = ' ';
while (p < seg_end && col < MAX_CACHE_LINE_LEN) {
uint8_t soft_join = 0;
char ch = fb(p);
if (parse_inline && ch == '\\' && (p + 1) < seg_end) {
char nb = fb(p + 1);
if (nb == '\n' || nb == '\r') { p++; continue; }
}
if (parse_inline && (ch == '\n' || ch == '\r')) {
uint32_t next = p + 1;
if (quote_stream && next < seg_end) {
uint32_t next_content = next;
if (classify_line(next, &next_content) == LK_QUOTE) {
p = next_content;
ch = ' ';
soft_join = 1;
} else {
while (next < seg_end) {
char ws = fb(next);
if (ws == ' ' || ws == '\t') next++;
else break;
}
if (next > (p + 1)) {
p = next;
soft_join = 1;
}
ch = ' ';
}
} else {
while (next < seg_end) {
char ws = fb(next);
if (ws == ' ' || ws == '\t') next++;
else break;
}
if (next > (p + 1)) {
p = next;
soft_join = 1;
}
ch = ' ';
}
} else if (ch == '\n' || ch == '\r') {
ch = ' ';
}
if (parse_inline) {
char lit_ch = 0;
uint8_t mk = handle_inline_marker(ch, &p, seg_end, prev_ch, &emph, &lit_ch);
if (mk == 1) {
cur_attr = emph_to_attr(emph, base_attr);
continue;
}
if (mk == 2) {
cc_put(cellbuf, &col, lit_ch, cur_attr);
cc++;
cc_put(cellbuf, &col, lit_ch, cur_attr);
cc++;
prev_ch = lit_ch;
continue;
}
}
if (!soft_join) p++;
if (ch == '\t') {
uint8_t cc_start = cc;
uint8_t tgt = (uint8_t)((cc & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
uint8_t want = (uint8_t)(tgt - cc_start);
cc_fill(cellbuf, &col, ' ', cur_attr, want);
cc = tgt;
prev_ch = ' ';
} else {
cc_put(cellbuf, &col, ch, cur_attr);
cc++;
prev_ch = ch;
}
}
out_rec.len = col;
if (col > 0 && cache_reserve((uint16_t)(col * 2u), &out_rec.page, &out_rec.off)) {
cache_commit(out_rec.page, out_rec.off, cellbuf, (uint16_t)(col * 2u));
} else {
out_rec.page = 0; out_rec.off = 0; out_rec.len = 0;
}
cache_dir_put(line_idx, &out_rec);
}
/* Рендерит все 30 строк окна, начиная от top_line. ЖИВОЙ путь (парсинг
* через fb()) — после Фазы 4 используется только для горизонтального
* скролла (scroll_h(), Фаза 5 ещё не мигрировала его на кэш), для
* вертикального скролла и обычной перерисовки см.
* draw_viewport_from_cache() ниже. */
static void render_viewport(void)
{
for (uint8_t i = 0; i < VIEW_H; i++) {
render_line((uint16_t)(top_line + i), (uint8_t)(VIEW_TOP_ROW + i));
}
}
/* Фаза 4 — cache-only draw path: рисует одну строку ИЗ КЭША, без
* единого обращения к fb()/исходному файлу. rec.len==0 — пустая
* строка, остаётся только фон. rec.len>SCREEN_W — nowrap-строка шире
* экрана: показываем первые SCREEN_W ячеек (viewport_x=0, как и
* остальные строки этим путём — горизонтальный скролл пока только
* через scroll_h()/render_viewport(), см. Фазу 5) и индикатор обрезки
* '>' в последней колонке. */
static void draw_line_from_cache(uint16_t line_idx, uint8_t row)
{
cache_rec_t rec;
cache_dir_get(line_idx, &rec);
bios_set_place(row, 0);
bios_fillcharattr(' ', ATTR_TEXT, SCREEN_W);
if (rec.len == 0) return;
uint8_t w = (rec.len > SCREEN_W) ? SCREEN_W : rec.len;
win_rest(row, 0, 1, w, rec.page, rec.off);
if (rec.len > SCREEN_W) {
wrchar(SCREEN_W - 1, row, '>', ATTR_TRUNC);
}
}
/* Перерисовывает все VIEW_H строк окна из кэша — основной путь
* перерисовки видимой области после загрузки файла (Фаза 3-4). */
static void draw_viewport_from_cache(void)
{
for (uint8_t i = 0; i < VIEW_H; i++) {
draw_line_from_cache((uint16_t)(top_line + i), (uint8_t)(VIEW_TOP_ROW + i));
}
}
/* Возвращает процент прокрутки (0..100) относительно доступного диапазона. */
static uint8_t calc_pct(void)
{
uint8_t pct;
if (n_lines <= VIEW_H)
pct = 100;
else
pct = (uint8_t)((uint32_t)top_line * 100UL / (uint32_t)(n_lines - VIEW_H));
return pct;
}
/* Рисует динамическую часть статус-бара без printf.
* Полностью перезаписывает колонки 45..79, чтобы не оставлять «хвосты». */
static void render_status_numbers(void)
{
uint16_t last = top_line + VIEW_H;
if (last > n_lines) last = n_lines;
uint8_t pct = calc_pct();
textattr(ATTR_BAR);
bios_set_place(0, 45);
bios_fillcharattr(' ', ATTR_BAR, (uint8_t)(SCREEN_W - 45));
wrchar(45, 0, 0xB3, ATTR_BAR); /* вертикальный разделитель */
gotoxy(47, 0);
cputs("L ");
dec16(top_line + 1);
cputs("-");
dec16(last);
cputs(" / ");
dec16(n_lines);
wrchar(70, 0, 0xB3, ATTR_BAR); /* вертикальный разделитель */
gotoxy( 73, 0);
dec8(pct);
cputs("%");
}
/* Полностью перерисовывает статус-бар (заголовок + числа). */
static void render_full_status(void)
{
fill_row(0, ATTR_BAR);
/* Размещение в шапке: "MDVIEW", затем слот спиннера и имя файла. */
put_str_attr(1, 0, "MDVIEW", ATTR_BAR);
put_str_attr(10, 0, filename, ATTR_BAR);
render_status_numbers();
}
/* Обновляет только изменяемую числовую часть статус-бара. */
static void render_updated_status(void)
{
render_status_numbers();
}
/* Отрисовывает нижнюю строку меню. */
static void render_menu(void)
{
fill_row(MENU_ROW, ATTR_MENU_T);
put_str_attr(1, MENU_ROW, "F1", ATTR_MENU_K);
put_str_attr(4, MENU_ROW, "Help", ATTR_MENU_T);
/* F2 зарезервирован: режимов wrap/unwrap больше нет. */
put_str_attr(SCREEN_W - 10, MENU_ROW, "F10", ATTR_MENU_K);
put_str_attr(SCREEN_W - 5, MENU_ROW, "Exit", ATTR_MENU_T);
}
/* ==================================================================
* Прокрутка
* ================================================================== */
/* Ограничивает top_line допустимым диапазоном с учётом высоты окна. */
static void clamp_top(void)
{
if (n_lines <= VIEW_H) {
top_line = 0;
} else if (top_line > n_lines - VIEW_H) {
top_line = (uint16_t)(n_lines - VIEW_H);
}
}
/* Прокрутка вверх на n строк с частичной перерисовкой при n == 1. */
static void scroll_up(uint16_t n)
{
uint16_t new_top_line = (top_line >= n) ? (uint16_t)(top_line - n) : 0;
if( new_top_line != top_line) {
top_line = new_top_line;
if (n == 1) {
scroll(0, VIEW_TOP_ROW, SCREEN_W, VIEW_H, 2, 0);
draw_line_from_cache((uint16_t)(top_line), (uint8_t)(VIEW_TOP_ROW));
} else {
draw_viewport_from_cache();
}
}
}
/* Прокрутка вниз на n строк с частичной перерисовкой при n == 1. */
static void scroll_down(uint16_t n)
{
uint16_t new_top_line = (top_line + n < n_lines - VIEW_H) ?
(uint16_t)(top_line + n) : n_lines - VIEW_H;
if( new_top_line != top_line) {
top_line = new_top_line;
if (n == 1) {
scroll(0, VIEW_TOP_ROW, SCREEN_W, VIEW_H, 1, 0);
draw_line_from_cache((uint16_t)(top_line + VIEW_H - 1), (uint8_t)(VIEW_TOP_ROW + VIEW_H - 1));
} else {
clamp_top();
draw_viewport_from_cache();
}
}
}
/* Горизонтальный сдвиг (только если в окне есть nowrap-строки).
* Максимум сдвига ограничен самой широкой nowrap-строкой на экране. */
static void scroll_h(int8_t delta)
{
uint16_t maxw = 0;
for (uint8_t i = 0; i < VIEW_H; i++) {
uint16_t li = (uint16_t)(top_line + i);
if (li >= n_lines) break;
if (!is_nowrap(li)) continue;
uint32_t a = seg_off(li);
uint32_t b = seg_off((uint16_t)(li + 1));
if (b > file_size) b = file_size;
uint16_t w = (b > a) ? (uint16_t)(b - a) : 0; /* приблизительная видимая ширина */
if (w > maxw) maxw = w;
}
if (maxw == 0) return; /* в окне нет nowrap-контента */
/* Максимальный сдвиг = ширина за пределами экрана, в границах uint8. */
uint16_t over = (maxw > SCREEN_W) ? (uint16_t)(maxw - SCREEN_W) : 0;
if (over > 248u) over = 248u;
uint8_t max_vx = (uint8_t)over;
int16_t nx = (int16_t)viewport_x + delta;
if (nx < 0) nx = 0;
if (nx > (int16_t)max_vx) nx = max_vx;
if ((uint8_t)nx != viewport_x) {
viewport_x = (uint8_t)nx;
render_viewport();
}
}
/* ==================================================================
* Вывод ошибки
* ================================================================== */
/* Печатает сообщение об ошибке и ждёт нажатия клавиши. */
static void die(const char *msg)
{
clrscr_attr(ATTR_TEXT);
puts(msg);
puts("Press any key to exit...");
(void)getkey();
}
/* ==================================================================
* Диалог справки (F1)
* ================================================================== */
/* Заполняет одну внутреннюю строку диалога (r=0 — первая строка за рамкой).
* Строка s в кодировке CP866; остаток до края дополняется пробелами. */
static void help_line(uint8_t r, const char *s, uint8_t attr)
{
uint8_t x = HELP_X + 1u;
uint8_t y = HELP_Y + 1u + r;
uint8_t len = (uint8_t)strlen(s);
if (len > HELP_W - 2u) len = HELP_W - 2u;
bios_set_place(y, x);
if (len > 0) bios_writeattr(s, len, attr);
/* place уже продвинут bios_writeattr на len колонок (verified). */
if (len < HELP_W - 2u) {
bios_fillcharattr(' ', ATTR_HELP_BG, (uint8_t)(HELP_W - 2u - len));
}
}
static void show_help(void)
{
/* " Помощь " в CP866 (8 байт) */
static const char title[] = " \x8F\xAE\xAC\xAE\xE9\xEC ";
uint8_t tlen = 8u;
uint8_t lft = (uint8_t)((HELP_W - 2u - tlen) / 2u); /* = 27 */
uint8_t rgt = (uint8_t)(HELP_W - 2u - tlen - lft);
/* Верхняя граница рамки с заголовком по центру.
* place продвигается каждым BIOS-вызовом (verified), поэтому
* достаточно одной установки места на всю строку рамки. */
wrchar(HELP_X, HELP_Y, 0xDA, ATTR_HELP_BDR); /* ┌ */
bios_set_place(HELP_Y, (uint8_t)(HELP_X + 1u));
if (lft > 0) bios_fillcharattr(0xC4, ATTR_HELP_BDR, lft);
if (tlen > 0) bios_writeattr(title, tlen, ATTR_HELP_TIT);
if (rgt > 0) bios_fillcharattr(0xC4, ATTR_HELP_BDR, rgt);
wrchar(HELP_X + HELP_W - 1u, HELP_Y, 0xBF, ATTR_HELP_BDR); /* ┐ */
/* Боковые границы (левый и правый │ для каждой строки тела) */
for (uint8_t r = 1u; r < HELP_H - 1u; r++) {
wrchar(HELP_X, HELP_Y + r, 0xB3, ATTR_HELP_BDR); /* │ */
wrchar(HELP_X + HELP_W - 1u, HELP_Y + r, 0xB3, ATTR_HELP_BDR); /* │ */
}
/* Нижняя граница рамки */
wrchar(HELP_X, HELP_Y + HELP_H - 1u, 0xC0, ATTR_HELP_BDR); /* └ */
bios_set_place((uint8_t)(HELP_Y + HELP_H - 1u), (uint8_t)(HELP_X + 1u));
bios_fillcharattr(0xC4, ATTR_HELP_BDR, (uint8_t)(HELP_W - 2u));
wrchar(HELP_X + HELP_W - 1u, HELP_Y + HELP_H - 1u, 0xD9, ATTR_HELP_BDR); /* ┘ */
/* Содержимое (20 внутренних строк) */
uint8_t r = 0;
help_line(r++, "", ATTR_HELP_BG);
help_line(r++, " MDView v0.2 -- Markdown Viewer for Sprinter", ATTR_HELP_HDR);
help_line(r++, " (c) 2026 Petrov A.G.", ATTR_HELP_BG);
help_line(r++, "", ATTR_HELP_BG);
help_line(r++, " Navigation:", ATTR_HELP_HDR);
help_line(r++, " \x18 \x19 Scroll one line up / down", ATTR_HELP_BG);
help_line(r++, " PgUp PgDn Scroll one page up / down", ATTR_HELP_BG);
help_line(r++, " Home End Jump to beginning / end of document", ATTR_HELP_BG);
help_line(r++, " \x1B \x1A Horizontal pan (code blocks/tables)", ATTR_HELP_BG);
help_line(r++, " Esc F10 Exit", ATTR_HELP_BG);
help_line(r++, "", ATTR_HELP_BG);
help_line(r++, " Markdown elements:", ATTR_HELP_HDR);
help_line(r++, " # ## ### Headings H1-H6", ATTR_HELP_BG);
help_line(r++, " **bold** *italic* `code` ~~strike~~", ATTR_HELP_BG);
help_line(r++, " > quote ``` ... ``` Fenced code block", ATTR_HELP_BG);
help_line(r++, " - * + item 1. 2. Ordered list", ATTR_HELP_BG);
help_line(r++, " |----|----| Tables", ATTR_HELP_BG);
help_line(r++, "", ATTR_HELP_BG);
help_line(r++, " File size: up to 128 KB (EMM). Lines: up to 16384.", ATTR_HELP_BG);
help_line(r++, "", ATTR_HELP_BG);
(void)getkey();
render_full_status();
draw_viewport_from_cache();
render_menu();
}
/* ==================================================================
* Точка входа
* ================================================================== */
int main(int argc, char **argv)
{
const char *path;
if (argc >= 2) {
path = argv[1];
} else {
path = "README.MD";
/* Дефолтный файл отсутствует — выводим подсказку без смены режима экрана. */
{
int chk = open(path, O_RDONLY);
if (chk < 0) {
cputs("MDView v0.2 -- Markdown Viewer for Sprinter\r\n");
cputs("(c) 2026 Petrov A.G.\r\n\r\n");
cputs("Usage: mdview2 <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();
spinner_show(0);
if (n_lines == 0) {
die("mdview: empty file");
unload_file();
return 1;
}
render_full_status();
draw_viewport_from_cache(); /* Фаза 4: рендер уже сделан во время index_lines(), рисуем только из кэша */
for (;;) {
uint16_t k = getkey();
uint8_t ascii = (uint8_t)(k & 0xFF);
uint8_t scan = (uint8_t)((k >> 8) & 0x7F);
if (ascii) {
if (ascii == 0x1B) break; /* клавиша Esc */
continue;
}
switch (scan) {
case KEY_F1: show_help(); break;
case KEY_F10: goto exit_loop;
case KEY_UP: scroll_up(1); break;
case KEY_DOWN: scroll_down(1); break;
case KEY_LEFT: scroll_h(-(int8_t)HPAN_STEP); break;
case KEY_RIGHT: scroll_h(+(int8_t)HPAN_STEP); break;
case KEY_PGUP: scroll_up(VIEW_H); break;
case KEY_PGDN: scroll_down(VIEW_H); break;
case KEY_HOME: top_line = 0;
viewport_x = 0;
draw_viewport_from_cache(); break;
case KEY_END: top_line = (n_lines > VIEW_H)
? (uint16_t)(n_lines - VIEW_H) : 0;
viewport_x = 0;
draw_viewport_from_cache(); break;
default: continue;
}
render_updated_status();
}
exit_loop:
unload_file();
pal_reset(PAL_CGA);
clrscr_attr(ATTR_TEXT);
return 0;
}