Files
Sprinter-SDCC/examples/mdview2/mdview2.c
T
snark13 c0dd1621e6 mdview2: расщепление монолита на модули (6 файлов, размер-нейтрально)
Вынос подсистем из mdview2.c в отдельные C-модули для читаемости и
навигации. mdview2.c: 2740 → 1062 строк (-61%); ядро теперь чистая
инфраструктура (cache-пул, EMM/fb, загрузка файла, doc-slot, loading-loop,
build_doc, main).

Модули (через EXTRA_SRCS, общий интерфейс в mdview2.h):
- mdview2_help.c   — диалог справки F1
- mdview2_table.c  — выровненная отрисовка таблиц
- mdview2_enc.c    — кодировки CP866/CP1251/KOI8R + UTF-8 конвертер
- mdview2_view.c   — отрисовка области/статус-бара/меню + прокрутка
- mdview2_index.c  — парсер/индексатор markdown (сердце приложения)
  (mdview2_raw.c был выделен ранее)

Чистый перенос static→extern; приватное состояние подсистем осталось
приватным. Размер: exe 28090 → 28112 (+22 Б / +0.08% — codegen-шум на
двух сильно связанных модулях enc/index). Сборка чистая, smoke-тест ОК.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:53:04 +03:00

1063 lines
54 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 "mdview2.h" /* геометрия, атрибуты, extern для модуля RAW */
/* ---- Палитра атрибутов ------------------------------------------- */
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
};
/* Атрибуты экрана/справки и геометрия — в mdview2.h (общие с модулем RAW).
* Диалог справки (F1) вынесен в модуль mdview2_help.c. */
/* ---- Глобальное состояние ---------------------------------------- */
/* Файл до 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. */
/* idx_rec_t + INDEX_REC_SIZE — в mdview2_index.c; INDEX_RECS_PER_PAGE — в mdview2.h. */
#define MAX_INDEX_PAGES 8u /* максимум 8 * 2048 = 16384 сегментов индекса */
/* Флаги IF_* сегмента — в mdview2.h (общие с индексатором/кэшем/вью). */
/* ==================================================================
* Фаза 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-строки урезаются; индикатор обрезки
* добавляется на этапе вывода из кэша, не при рендере в кэш).
*/
/* cache_rec_t (РОВНО 8 байт, см. sprinter_winrest_format) — в mdview2.h. */
#define CACHE_DIR_REC_SIZE ((uint16_t)sizeof(cache_rec_t))
#define CACHE_DIR_RECS_PER_PAGE 2048u
#define MAX_CACHE_DIR_PAGES 8u
/* MAX_CACHE_LINE_LEN — в mdview2.h (буфер ячеек g_cells живёт в mdview2_index.c). */
#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. */
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);
}
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 кончилась. */
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. */
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. */
/* emph_to_attr — в mdview2_index.c. */
uint16_t n_lines = 0; /* extern: вью читает */
uint16_t max_lines = 0; /* ёмкость индекса: index_pages * 2048 */
uint16_t top_line = 0; /* extern: вью/скролл */
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};
uint8_t index_blk = 0;
uint8_t index_pages = 0;
uint8_t index_phys[MAX_INDEX_PAGES] = {0};
uint8_t index_truncated = 0; /* выставляется при исчерпании ёмкости индекса */
/* cur_seg_off/cur_rec (near-состояние индексатора) — в mdview2_index.c. */
uint8_t cur_page = 0xFF; /* 0xFF = в W3 ещё не показана ни одна страница (extern: кодек инвалидирует) */
uint8_t viewport_x = 0; /* горизонтальный сдвиг для nowrap-строк (extern: вью) */
char filename[64] = {0}; /* extern: статус-бар */
/* Прогрессивная загрузка: рисуем первый экран и пускаем скролл, пока
* index_lines() ещё досканирует файл (кооперативно, без прерываний). */
#if WITH_RAW
uint8_t g_view = VIEW_MD; /* режим просмотра (общий с mdview2_raw.c) */
uint8_t g_ready = 0; /* 1 когда первичный документ построен — F2 (RAW) рабочий (extern: вью) */
static uint8_t g_raw_last = VIEW_RAW_WRAP; /* последний под-режим RAW (возврат по F2) */
#endif
uint8_t g_loading = 0; /* 1 во время index_lines() (extern: вью) */
static uint8_t g_first_drawn = 0; /* первый экран уже нарисован */
uint8_t g_f8_enabled = 0; /* можно ли сейчас переключать кодировку (extern: вью-меню) */
static uint8_t g_utf_failed = 0; /* UTF-8 набор не удалось построить (нет EMM) — пропускаем UTF-8 в цикле F8 */
uint8_t g_abort = 0; /* F10/Esc во время загрузки → прервать индексацию */
static uint16_t g_last_prog = 0; /* n_lines на последнем обновлении статуса */
/* progress_tick — объявлена в mdview2.h (определена ниже, зовётся из index_lines). */
/* Кодировки (ENC_*) и g_encoding — в mdview2.h / mdview2_enc.c. */
const uint8_t *g_remap = 0; /* активная таблица ремапа (CP1251/KOI8), 0 = без ремапа */
uint8_t g_scratch_phys = 0; /* EMM-страница под ремап-буфер строки для win_rest */
/* UTF-8 набор (utf_blk/utf_pages/utf_phys/utf_size/utf_avail, конвертер и его
* состояние) — в mdview2_enc.c; разделяемые символы и API — в mdview2.h.
* build_doc() ниже перенаправляет живой file_* на utf_phys и оркеструет
* инкрементальную конвертацию (utf_conv_reset/utf_convert_more). */
/* Снимок исходного (8-битного) файлового буфера — набор DOC_8BIT читает его
* как есть, набор DOC_UTF8 читает utf_phys. */
static uint8_t orig_file_blk = 0;
uint8_t orig_file_pages = 0; /* extern: читает кодек (utf_alloc) */
uint32_t orig_file_size = 0; /* extern: читает кодек (utf_convert_more) */
uint8_t orig_file_phys[MAX_PAGES] = {0}; /* extern: читает кодек (cv_read) */
/* ---------- Два сосуществующих набора индекс/кэш (Фазы 4-5) ----------
* DOC_8BIT — исходные байты (CP866/CP1251/KOI8R, различаются ремапом на
* отрисовке); DOC_UTF8 — UTF-8, декодированный в CP866. Оба строятся eager
* при старте (кооперативно), поэтому F8 переключает активный набор мгновенно.
* fb()/idx/cache-аксессоры работают по «живым» глобалам; doc_switch() свапает
* их с сохранённой копией нужного набора. */
typedef struct {
uint8_t built;
uint8_t enc; /* кодировка отображения этого набора */
uint8_t file_blk, file_pages;
uint32_t file_size;
uint8_t file_phys[MAX_PAGES];
uint8_t index_blk, index_pages, index_truncated;
uint16_t n_lines, max_lines;
uint8_t index_phys[MAX_INDEX_PAGES];
uint8_t cache_dir_blk, cache_dir_pages;
uint8_t cache_dir_phys[MAX_CACHE_DIR_PAGES];
uint8_t cache_content_n_pages, cache_cur_page_idx;
uint16_t cache_cur_off;
uint8_t cache_content_blk[MAX_CACHE_CONTENT_PAGES];
uint8_t cache_content_phys[MAX_CACHE_CONTENT_PAGES];
uint16_t top_line;
uint8_t viewport_x;
} docset_t;
#define DOC_8BIT 0
#define DOC_UTF8 1
static docset_t g_doc[2] = {{0}, {0}}; /* ОБЯЗАТЕЛЬНО инициализировать: built=0 для обоих
* (SDCC z80 не обнуляет неинициализированные статики
* надёжно — см. memory/sdcc_static_storage_gotcha) */
static uint8_t g_active_doc = DOC_8BIT; /* какой набор сейчас в живых глобалах */
static uint8_t g_disp_doc = DOC_8BIT; /* какой набор показан пользователю */
static void doc_switch(uint8_t target); /* forward (нужна в progress_tick) */
#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 напрямую, без промежуточной копии. */
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. */
void fill_row(uint8_t y, uint8_t attr)
{
// bios_set_place(y, 0);
// bios_fillcharattr(' ', attr, SCREEN_W);
bios_clearwin(y, 0, 1, SCREEN_W, attr);
}
/* Продвигает спиннер на один кадр.
* Если спиннер выключен — функция ничего не делает. */
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, можно клобберить. */
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. */
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;
}
/* Char-хелперы, idx_get/idx_put/seg_off, set_*_cur — в mdview2_index.c. */
/* Загружает 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-страницы (без индекса/кэша — те
* выделяются отдельно на КАЖДЫЙ набор через alloc_set_storage()).
* Возврат: 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);
return 0;
}
/* Выделяет в живые глобалы блок индекса + директорию рендер-кэша под текущий
* набор. file_pages должно быть уже выставлено (размер набора). Сбрасывает
* пул контента в пустое состояние (страницы доращиваются лениво при рендере).
* Возврат: 0 — успех, -5/-6 — нехватка EMM. */
static int alloc_set_storage(void)
{
/* Блок индекса (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;
}
/* Директория рендер-кэша — та же ёмкость, что у индекса. */
{
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);
}
/* Свежий пул контента под этот набор. */
cache_content_n_pages = 0;
cache_cur_page_idx = 0;
cache_cur_off = 0;
return 0;
}
static void doc_save_live(uint8_t i); /* forward */
/* Освобождает все EMM-блоки обоих наборов (индекс/директория/пул контента)
* плюс два файловых буфера (исходный 8-битный и UTF-8). */
static void unload_file(void)
{
doc_save_live(g_active_doc); /* зафиксировать живой набор в g_doc */
for (uint8_t s = 0; s < 2; s++) {
docset_t *d = &g_doc[s];
if (d->index_blk) { mem_free_block(d->index_blk); d->index_blk = 0; }
if (d->cache_dir_blk) { mem_free_block(d->cache_dir_blk); d->cache_dir_blk = 0; }
for (uint8_t i = 0; i < d->cache_content_n_pages; i++)
if (d->cache_content_blk[i]) mem_free_block(d->cache_content_blk[i]);
d->cache_content_n_pages = 0;
}
if (orig_file_blk) { mem_free_block(orig_file_blk); orig_file_blk = 0; }
if (utf_blk) { mem_free_block(utf_blk); utf_blk = 0; }
file_blk = 0;
}
/* Парсер/индексатор (classify_line, scan_join_stream, inline_scan, index_lines, emph_to_attr) — в mdview2_index.c. */
/* Таблицы ремапа, set_encoding, кодировки и UTF-8 конвертер — в mdview2_enc.c. */
/* ---------- Сохранение/восстановление/переключение наборов ----------
* doc_save_live: живые глобалы → g_doc[i]. doc_load_live: g_doc[i] → живые
* глобалы (+ инвалидация маппинга fb() и активной кодировки). */
static void doc_save_live(uint8_t i)
{
docset_t *d = &g_doc[i];
d->enc = g_encoding;
d->file_blk = file_blk; d->file_pages = file_pages; d->file_size = file_size;
memcpy(d->file_phys, file_phys, MAX_PAGES);
d->index_blk = index_blk; d->index_pages = index_pages; d->index_truncated = index_truncated;
d->n_lines = n_lines; d->max_lines = max_lines;
memcpy(d->index_phys, index_phys, MAX_INDEX_PAGES);
d->cache_dir_blk = cache_dir_blk; d->cache_dir_pages = cache_dir_pages;
memcpy(d->cache_dir_phys, cache_dir_phys, MAX_CACHE_DIR_PAGES);
d->cache_content_n_pages = cache_content_n_pages;
d->cache_cur_page_idx = cache_cur_page_idx; d->cache_cur_off = cache_cur_off;
memcpy(d->cache_content_blk, cache_content_blk, MAX_CACHE_CONTENT_PAGES);
memcpy(d->cache_content_phys, cache_content_phys, MAX_CACHE_CONTENT_PAGES);
d->top_line = top_line; d->viewport_x = viewport_x;
}
static void doc_load_live(uint8_t i)
{
docset_t *d = &g_doc[i];
file_blk = d->file_blk; file_pages = d->file_pages; file_size = d->file_size;
memcpy(file_phys, d->file_phys, MAX_PAGES);
index_blk = d->index_blk; index_pages = d->index_pages; index_truncated = d->index_truncated;
n_lines = d->n_lines; max_lines = d->max_lines;
memcpy(index_phys, d->index_phys, MAX_INDEX_PAGES);
cache_dir_blk = d->cache_dir_blk; cache_dir_pages = d->cache_dir_pages;
memcpy(cache_dir_phys, d->cache_dir_phys, MAX_CACHE_DIR_PAGES);
cache_content_n_pages = d->cache_content_n_pages;
cache_cur_page_idx = d->cache_cur_page_idx; cache_cur_off = d->cache_cur_off;
memcpy(cache_content_blk, d->cache_content_blk, MAX_CACHE_CONTENT_PAGES);
memcpy(cache_content_phys, d->cache_content_phys, MAX_CACHE_CONTENT_PAGES);
top_line = d->top_line; viewport_x = d->viewport_x;
cur_page = 0xFF; /* следующий fb() пере-смаппит страницу */
set_encoding(d->enc);
}
static void doc_switch(uint8_t target)
{
if (target == g_active_doc) return;
doc_save_live(g_active_doc);
doc_load_live(target);
g_active_doc = target;
}
/* Автоопределение/имя кодировки, ремап-вывод и UTF-8→CP866 конвертер — в mdview2_enc.c. */
/* Отрисовка области/статус-бара/меню и прокрутка — в модуле mdview2_view.c. */
/* ==================================================================
* Вывод ошибки
* ================================================================== */
/* Печатает сообщение об ошибке и ждёт нажатия клавиши. */
static void die(const char *msg)
{
clrscr_attr(ATTR_TEXT);
puts(msg);
puts("Press any key to exit...");
(void)getkey();
}
/* Диалог справки (F1) — в модуле mdview2_help.c (show_help). */
/* Обработка клавиши во время прогрессивной загрузки: скролл строго в
* пределах готовых строк (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;
case KEY_F8: /* во время сборки крутим ТОЛЬКО 8-бит и только если набор 8-битный
* (ремап на отрисовке; индексация не зависит от кодировки) */
if (g_f8_enabled && g_active_doc == DOC_8BIT) {
uint8_t nx = (g_encoding == ENC_CP866) ? ENC_CP1251 :
(g_encoding == ENC_CP1251) ? ENC_KOI8R : ENC_CP866;
set_encoding(nx); g_doc[DOC_8BIT].enc = nx;
draw_viewport_from_cache();
render_full_status();
}
return;
default: return;
}
if (nt > maxtop) nt = maxtop;
if (nt != top_line) {
top_line = nt;
draw_viewport_from_cache();
render_status_numbers();
}
}
/* Кооперативный «тик» прогрессивной загрузки — вызывается из index_lines(). */
void progress_tick(void)
{
#if WITH_RAW
/* В RAW-режиме буфер документа готов сразу после конвертации (до индекса),
* поэтому первый экран RAW можно показать немедленно — рисуем raw_draw, а
* не MD-вид (иначе мелькал бы MD). Индекс при этом строится дальше (нужен
* для MD-вида). */
if (g_view != VIEW_MD) {
if (!g_first_drawn) {
raw_draw();
raw_status_numbers();
g_first_drawn = 1;
cur_page = 0xFF; /* raw_draw сбил W3-маппинг, на который опирается fb() */
}
spinner_tick();
return;
}
#endif
/* Прогрессивная отрисовка строящегося (всегда показываемого) набора. */
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;
}
/* ==================================================================
* Точка входа
* ================================================================== */
/* Строит набор `slot` кооперативно (index_lines + progress_tick).
* visible=1 — первичный (показываемый): прогрессивная отрисовка первого экрана
* и скролл по готовым строкам. visible=0 — вторичный: index_lines строит его в
* живых глобалах, а progress_tick через свап показывает/листает уже готовый
* g_disp_doc. Возврат: 0 — успех, <0 — нехватка EMM (-5/-6/-7).
*
* UTF-8 набор конвертируется ИНКРЕМЕНТАЛЬНО: выделяем страницы, переключаем
* живой буфер на utf и запускаем конвертацию первого чанка; дальше index_lines
* докручивает её впереди позиции чтения (g_utf_building). Конвертер читает
* оригинал из orig_file_phys[] через cv_read (не через fb, который теперь
* указывает на utf). */
static int build_doc(uint8_t slot, uint8_t enc, uint8_t visible)
{
if (slot == DOC_UTF8) {
if (!utf_avail) { /* первый вход: выделить страницы */
if (!utf_alloc()) return -7; /* выделяет страницы + сбрасывает конвертер */
utf_avail = 1;
}
file_blk = utf_blk; file_pages = utf_pages;
memcpy(file_phys, utf_phys, MAX_PAGES);
if (g_utf_building) {
file_size = 0;
cur_page = 0xFF;
utf_convert_more(CONV_MARGIN); /* первый чанк — для первого экрана */
} else {
file_size = utf_size;
}
} else {
file_blk = orig_file_blk; file_pages = orig_file_pages; file_size = orig_file_size;
memcpy(file_phys, orig_file_phys, MAX_PAGES);
}
cur_page = 0xFF;
int rc = alloc_set_storage();
if (rc < 0) return rc;
set_encoding(enc);
top_line = 0; viewport_x = 0;
g_active_doc = slot;
if (visible) { g_disp_doc = slot; g_first_drawn = 0; }
g_loading = 1;
g_last_prog = 0;
index_lines();
g_loading = 0;
/* Зафиксировать конвертацию (на случай обрыва индекса по max_lines до конца
* исходника) — иначе g_utf_building остался бы 1 и сломал бы следующую сборку. */
if (g_utf_building) { utf_size = file_size; g_utf_building = 0; }
g_doc[slot].built = 1;
doc_save_live(slot);
return 0;
}
/* Переключает кодировку на `next` (вызывается из главного цикла по F8).
* 8-бит↔8-бит внутри одного набора — мгновенный ремап; переход в другой набор
* — мгновенный doc_switch, если он построен, иначе ЛЕНИВАЯ сборка по месту
* (со спиннером), результат кэшируется. */
/* Меняет кодировку: тот же набор — ремап; другой готовый — doc_switch;
* непостроенный — ЛЕНИВАЯ ТИХАЯ сборка (спиннер, без отрисовки документа).
* НЕ перерисовывает документ/статус — это делает вызывающий в своём режиме
* (MD: draw_viewport_from_cache; RAW: raw_draw), чтобы не мелькал чужой вид. */
static void switch_encoding(uint8_t next)
{
uint8_t target = (next == ENC_UTF8) ? DOC_UTF8 : DOC_8BIT;
if (target == g_active_doc) { /* тот же набор — только ремап */
set_encoding(next); g_doc[target].enc = next;
} else if (g_doc[target].built) { /* набор готов — мгновенный свап */
doc_switch(target); g_disp_doc = target;
set_encoding(next); g_doc[target].enc = next;
} else { /* строим набор лениво */
doc_save_live(g_active_doc); /* сохранить текущий (позиция и т.п.) */
#if WITH_RAW
g_ready = 0; render_menu(); /* идёт индексация — прячем F2 */
#endif
spinner_show(1);
int rc = build_doc(target, next, 1); /* visible: прогрессивный показ первого экрана
* (progress_tick рисует по текущему виду — MD/RAW) */
spinner_show(0);
#if WITH_RAW
g_ready = 1; render_menu();
#endif
if (rc < 0) { /* нет EMM — откат на текущий набор */
if (target == DOC_UTF8) g_utf_failed = 1;
doc_load_live(g_active_doc);
return;
}
g_disp_doc = target; /* build_doc уже выставил active/enc */
}
clamp_top(); /* привести top_line к новому набору (для MD) */
}
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;
}
/* Scratch-страница под ремап-буфер строки (win_rest_remap). */
g_scratch_phys = mem_get_page(mem_alloc_pages(1), 0);
/* Оба набора пока не построены (страховка к инициализатору g_doc). */
g_doc[DOC_8BIT].built = 0;
g_doc[DOC_UTF8].built = 0;
/* Снимок исходного 8-битного буфера (его читает набор DOC_8BIT). */
orig_file_blk = file_blk; orig_file_pages = file_pages; orig_file_size = file_size;
memcpy(orig_file_phys, file_phys, MAX_PAGES);
/* Детекция кодировки по оригиналу. UTF-8 конвертацию НЕ делаем здесь —
* она дорогая (полный проход) и нужна только для UTF-8 набора, который
* строится через build_doc лениво. Так первый экран рисуется по индексации
* первичного набора без лишнего прохода. */
uint8_t det = detect_encoding();
/* Первичный (показываемый) набор — по детекции; второй строится ЛЕНИВО по
* первому F8 (switch_encoding). Во время сборки 8-битного первичного F8
* разрешён (ремап 8-бит, см. load_key) → показываем метку F8; для UTF-8
* первичного переключаться пока некуда → метку прячем. */
uint8_t prim_enc = det;
uint8_t prim_doc = (prim_enc == ENC_UTF8) ? DOC_UTF8 : DOC_8BIT;
g_f8_enabled = (prim_doc == DOC_8BIT);
render_menu();
rc = build_doc(prim_doc, prim_enc, 1); /* первичный — видимый */
if (rc < 0 && prim_doc == DOC_UTF8) { /* UTF-8 не вышел (нет EMM) → 8-бит */
prim_doc = DOC_8BIT; prim_enc = ENC_CP866;
rc = build_doc(DOC_8BIT, ENC_CP866, 1);
}
if (rc < 0) {
spinner_show(0);
clrscr_attr(ATTR_RESET);
textattr(ATTR_RESET);
cputs("MDView: index/cache alloc failed\n\r\n\r");
return 1;
}
if (g_abort) goto exit_loop; /* F10/Esc во время загрузки — корректный выход */
if (n_lines == 0) {
die("mdview: empty file");
unload_file();
return 1;
}
/* Первичный готов — гасим спиннер, включаем F8 (второй набор дособерётся
* лениво по первому переключению) и рисуем финальный статус с ПОЛНЫМ
* счётчиком строк. */
spinner_show(0);
g_f8_enabled = 1;
#if WITH_RAW
g_ready = 1; /* документ построен — теперь доступен F2 (RAW) */
#endif
render_menu();
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;
}
#if WITH_RAW
/* F2 — тумблер RAW ↔ MD (рабочий только после постройки документа). */
if (scan == KEY_F2) {
if (!g_ready) continue;
if (g_view == VIEW_MD) {
g_view = g_raw_last; /* → RAW (последний под-режим) */
raw_seed_from(top_line); /* позиция из MD */
raw_screen_init();
render_full_status(); render_menu();
raw_status_numbers(); raw_draw();
} else {
g_raw_last = g_view; /* запомнить под-режим RAW */
g_view = VIEW_MD;
clamp_top();
render_full_status(); render_menu();
draw_viewport_from_cache();
}
continue;
}
/* RAW-режим: F3 Wrap/Unwrap, своя навигация; F8 — кодировка; F1/F10. */
if (g_view != VIEW_MD) {
if (scan == KEY_F10) goto exit_loop;
if (scan == KEY_F1) {
show_help();
raw_screen_init();
render_full_status(); render_menu();
raw_status_numbers(); raw_draw();
continue;
}
if (scan == KEY_F3) { /* Wrap ↔ Unwrap */
g_view = (g_view == VIEW_RAW_WRAP) ? VIEW_RAW_HSCROLL : VIEW_RAW_WRAP;
g_raw_last = g_view;
raw_renorm();
raw_screen_init();
render_menu();
raw_status_numbers(); raw_draw();
continue;
}
if (scan == KEY_F8) {
uint8_t next = (g_encoding == ENC_CP866) ? ENC_CP1251 :
(g_encoding == ENC_CP1251) ? ENC_KOI8R :
(g_encoding == ENC_KOI8R) ? ENC_UTF8 : ENC_CP866;
if (next == ENC_UTF8 && g_utf_failed) next = ENC_CP866;
uint8_t target = (next == ENC_UTF8) ? DOC_UTF8 : DOC_8BIT;
/* другой буфер (в/из UTF-8) → в начало ДО сборки, чтобы прогрессивный
* raw_draw в progress_tick показывал верх нового набора. */
if (target != g_active_doc) raw_home();
switch_encoding(next); /* меняет набор/ремап */
if (g_abort) goto exit_loop;
render_full_status(); raw_status_numbers(); raw_draw();
continue;
}
raw_key(scan);
continue;
}
#endif /* WITH_RAW */
switch (scan) {
case KEY_F1: show_help(); break;
case KEY_F8: { /* цикл CP866→CP1251→KOI8R→UTF8→CP866; нужный набор дособерётся лениво */
uint8_t next = (g_encoding == ENC_CP866) ? ENC_CP1251 :
(g_encoding == ENC_CP1251) ? ENC_KOI8R :
(g_encoding == ENC_KOI8R) ? ENC_UTF8 : ENC_CP866;
if (next == ENC_UTF8 && g_utf_failed) next = ENC_CP866; /* UTF-8 недоступен */
switch_encoding(next);
if (g_abort) goto exit_loop; /* Esc/F10 во время ленивой досборки */
render_full_status(); /* перерисовка — в MD-режиме */
draw_viewport_from_cache();
continue;
}
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;
}