Files
Sprinter-SDCC/examples/mdview2/mdview2.c
T
snark13 ffd179e064 mdview2: оптимизация размера — exe 28215 → 25616 (−2599 Б)
Раунд 1 (−1411): --max-allocs 100000 в Makefile (−848 кода) + снятие
всех нулевых инициализаторов file-scope переменных (_INITIALIZER
583→24; _DATA теперь зануляется в crt0).

Раунд 2 (−1188, индексатор 11019→9839): дедупликации в mdview2_index.c:
- classify_line: копия HR-проверки → вызов is_hr_raw;
- next_line() поверх row_end() вместо 9 копий «домотать до \n»;
- inline_marker: emph_to_attr(ls, base) вычисляется один раз (at);
- set_{nowrap,blank,code,hscroll}_cur → set_cur_flags(mask): строка
  code-блока делает один idx_put вместо трёх.

Проверено в MAME (README/UTF8TEST: маркеры, списки, цитаты, таблицы,
code-блоки, F8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:15:43 +03:00

1090 lines
53 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 версия).
*
* Этот файл — ЯДРО (инфраструктура + точка входа). Прикладные подсистемы
* вынесены в отдельные модули (общий интерфейс — mdview2.h):
* mdview2_index.c — парсер/индексатор markdown (построение рендер-кэша)
* mdview2_md.c — представление MD-документа (отрисовка из кэша + скролл)
* mdview2_raw.c — RAW-просмотр исходного текста (F2/F3)
* mdview2_enc.c — кодировки CP866/CP1251/KOI8R + UTF-8 конвертер
* mdview2_table.c — выровненная отрисовка таблиц
* mdview2_help.c — диалог справки (F1)
*
* В ядре остаётся: пул рендер-кэша, доступ к EMM/окну W3 (fb/win_rest),
* примитивы экрана, загрузка файла, наборы документа (doc-slots), кооператив-
* ная loading-loop и диспетчер клавиш, сборка наборов и main().
*
* Разметка экрана (80x32):
* строка 0 — статус-бар (имя файла / диапазон строк / всего / %)
* строки 1..30 — область документа (30 видимых строк текста)
* строка 31 — меню (F1 Help / F2 RAW / F3 Wrap / F8 CodePg / F10 Exit)
*
* Память:
* CODE → W1 (режим sprinter-cc --memory small)
* STACK/HEAP/DATA → W2
* Буфер файла → страницы EMM (до 8 × 16 КБ = 128 КБ),
* отображаемые в W3 по требованию через fb().
*/
#include <stdint.h>
#include <stdio.h>
#include <stdlib.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 <sprinter_exit.h>
#include <palette.h>
#include "mdview2.h" /* геометрия, атрибуты, общий интерфейс модулей */
/* ---- Палитра атрибутов ------------------------------------------- */
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 (общие со всеми модулями).
* Типы записей (idx_rec_t — в mdview2_index.c, cache_rec_t — в mdview2.h),
* словари флагов/стилей (IF_, CK_, INIT_STYLE_) и константы индекса
* (INDEX_RECS_PER_PAGE, MAX_CACHE_LINE_LEN) — в mdview2.h.
*
* В SDCC/z80 критично явно инициализировать static-переменные (`= 0`):
* неинициализированные static-объекты могут конфликтовать по размещению
* и перезаписывать друг друга (см. memory/sdcc_static_storage_gotcha). */
#define MAX_INDEX_PAGES 9u /* максимум 9 * 2048 = 18432 сегментов индекса (256 КБ) */
/* ==================================================================
* Рендер-кэш: директория и пул EMM-страниц
* ==================================================================
* Каждая логическая строка экрана рендерится РОВНО ОДИН РАЗ (Фаза 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 (16384 / CACHE_DIR_REC_SIZE) /* 2048 */
#define MAX_CACHE_DIR_PAGES 9u /* симметрично индексу: 9 * 2048 = 18432 записей */
/* MAX_CACHE_LINE_LEN — в mdview2.h (буфер ячеек g_cells живёт в mdview2_index.c). */
#define MAX_CACHE_CONTENT_PAGES 40u /* кап контента на набор: 40 * 16 КБ = 640 КБ (80 на оба набора) */
static uint8_t cache_dir_blk;
static uint8_t cache_dir_pages;
static uint8_t cache_dir_phys[MAX_CACHE_DIR_PAGES];
static uint8_t cache_content_blk[MAX_CACHE_CONTENT_PAGES]; /* id блока на каждую выделенную страницу */
static uint8_t cache_content_phys[MAX_CACHE_CONTENT_PAGES];
static uint8_t cache_content_n_pages; /* сколько страниц уже выделено */
static uint8_t cache_cur_page_idx; /* индекс текущей страницы записи в cache_content_phys */
static uint16_t cache_cur_off; /* текущее смещение записи в этой странице */
/* Доступ к директории рендер-кэша — тот же приём, что у 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 & 0x07FF) << 3); /* 2047 */
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 & 0x07FF) << 3); /* 2047 */
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; /* extern: всего строк в индексе текущего набора */
uint16_t max_lines; /* extern: ёмкость индекса: index_pages * 2048 */
uint16_t top_line; /* extern: верхняя видимая строка */
uint32_t file_size; /* extern: размер активного буфера документа */
static uint8_t file_blk;
static uint8_t file_pages;
static uint8_t file_phys[MAX_PAGES];
uint8_t index_blk; /* extern: дескриптор EMM-блока индекса */
uint8_t index_pages; /* */
uint8_t index_phys[MAX_INDEX_PAGES]; /* extern: физ. страницы индекса */
uint8_t index_truncated; /* выставляется при исчерпании ёмкости индекса */
uint8_t g_file_clamped; /* extern: файл >256 КБ обрезан до MAX_FILE при загрузке */
/* cur_seg_off/cur_rec (near-состояние индексатора) — в mdview2_index.c. */
uint8_t cur_page = 0xFF; /* extern: 0xFF = в W3 ещё не показана ни одна страница (extern: кодек инвалидирует) */
uint8_t viewport_x; /* extern: горизонтальный сдвиг для nowrap-строк */
char filename[64]; /* extern: статус-бар */
/* Прогрессивная загрузка: рисуем первый экран и пускаем скролл, пока
* index_lines() ещё досканирует файл (кооперативно, без прерываний). */
uint8_t g_view = VIEW_MD; /* extern: активный режим просмотра (существует даже когда доступен только MD режим) */
#if WITH_RAW
uint8_t g_ready; /* extern: 1 когда первичный документ построен — F2 (RAW) рабочий */
static uint8_t g_raw_last = VIEW_RAW_WRAP; /* последний под-режим RAW (возврат по F2) */
#endif
uint8_t g_loading; /* extern: 1 во время index_lines() */
static uint8_t g_first_drawn; /* первый экран уже нарисован */
uint8_t g_f8_enabled; /* extern: можно ли сейчас переключать кодировку */
static uint8_t g_utf_failed; /* UTF-8 набор не удалось построить (нет EMM) — пропускаем UTF-8 в цикле F8 */
uint8_t g_abort; /* extern: F10/Esc во время загрузки → прервать индексацию */
static uint16_t g_last_prog; /* n_lines на последнем обновлении статуса */
/* Кодировки (ENC_*) и g_encoding — в mdview2.h / mdview2_enc.c. */
const uint8_t *g_remap; /* extern: активная таблица ремапа (CP1251/KOI8), 0 = без ремапа (CP866)*/
uint8_t g_scratch_phys; /* extern: 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;
uint8_t orig_file_pages; /* extern: читает кодек (utf_alloc) */
uint32_t orig_file_size; /* extern: читает кодек (utf_convert_more) */
uint8_t orig_file_phys[MAX_PAGES]; /* extern: читает кодек (cv_read) */
/* ----------------- Два сосуществующих набора индекс/кэш -----------------
* 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]; /* built=0 для обоих: _DATA зануляется в crt0 gsinit
* (с 2026-07-04), явный {0}-инициализатор больше
* не нужен — он стоил ~260 байт _INITIALIZER */
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) */
/* Статус-бар, меню и спиннер — в mdview2_status.c. */
/* ==================================================================
* Примитивы экрана, доступ к EMM/окну W3 (fb/win_rest), палитра
* ================================================================== */
/* Печатает строку с указанным атрибутом, начиная с позиции (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 len = (x < SCREEN_W) ? (uint8_t)(SCREEN_W - x) : 0;
len = min(len, strlen(s));
if (len == 0) return;
bios_set_place(y, x);
bios_writeattr_until(s, len, attr, 0);
}
/* Полностью очищает указанную строку экрана символами пробела с attr. */
void fill_row(uint8_t y, uint8_t attr)
{
bios_clearwin(y, 0, 1, SCREEN_W, attr);
}
/* Процент 0..100 = num*100/den на чистой 16-битной арифметике — чтобы НЕ
* тянуть 32-битное деление __divulong (~760 Б) ради показа процента в статусе.
* Оба вызова (MD/RAW) дают операнды, влезающие в 16 бит (≤18432 и ≤1024),
* переполняет только *100 — поэтому масштабируем оба вниз, пока num*100 не
* влезёт в uint16, затем считаем долю циклом-вычитанием (без div-хелпера).
* Инвариант вызовов: num ≤ den; результат всё равно ограничен 100. */
uint8_t pct16(uint16_t num, uint16_t den)
{
if (den == 0) return 0;
while (num > 655) { num >>= 1; den >>= 1; } /* num*100 ≤ 65500 < 65536 */
if (den == 0) return 100; /* страховка от num>den */
uint16_t n = (uint16_t)(num * 100u);
uint8_t p = 0;
while (n >= den && p < 100) { n = (uint16_t)(n - den); p++; }
return p;
}
/* Отображает указанную 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) {
close(fd);
return -2;
}
/* Файл больше MAX_FILE (256 КБ) не отвергаем, а КЛАМПим: читаем первые
* 256 КБ, индексатор допишет строку-предупреждение (TRUNC_FILE). */
g_file_clamped = 0;
if ((uint32_t)sz > MAX_FILE) {
sz = (long)MAX_FILE;
g_file_clamped = 1;
}
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)
{
uint8_t want;
want = (uint8_t)((file_pages >> 1) + 1u);
if (want > MAX_INDEX_PAGES) want = MAX_INDEX_PAGES;
index_blk = mem_alloc_pages(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;
want = (uint8_t)((file_pages >> 1) + 1u);
if (want > MAX_CACHE_DIR_PAGES) want = MAX_CACHE_DIR_PAGES;
cache_dir_blk = mem_alloc_pages(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-slots): сохранение/свап
* ==================================================================
* Два сосуществующих набора (DOC_8BIT / DOC_UTF8) живут как копии всего
* состояния (file/index/cache/позиция) в g_doc[]; «живые» глобалы — это
* активный набор. 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_md.c. */
/* ==================================================================
* Прогрессивная загрузка и диспетчер клавиш (MD)
* ==================================================================
* index_lines() строит индекс кооперативно и периодически зовёт
* progress_tick(): тот рисует первый готовый экран, крутит спиннер и
* обрабатывает клавиши на лету (load_key). Диалог справки (F1) — в
* mdview2_help.c, RAW-навигация — в mdview2_raw.c. */
/* Обработка клавиши во время прогрессивной загрузки: скролл строго в
* пределах готовых строк (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_md_status_numbers();
return;
case KEY_END:
top_line = maxtop;
viewport_x = 0;
draw_viewport_from_cache();
render_md_status_numbers();
return;
case KEY_LEFT:
md_scroll_horizon(-(int8_t)HPAN_STEP);
render_md_status_numbers();
return;
case KEY_RIGHT:
md_scroll_horizon(+(int8_t)HPAN_STEP);
render_md_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_md_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();
// render_raw_status();
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_md_status_numbers();
g_first_drawn = 1;
touched = 1;
}
if (n_lines >= (uint16_t)(g_last_prog + 10)) {
g_last_prog = n_lines;
spinner_tick();
if (g_first_drawn)
render_md_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) */
}
/* ==================================================================
* Вывод ошибки
* ================================================================== */
static void on_exit(void) {
unload_file();
pal_reset(PAL_CGA);
}
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) {
// settextmode(TEXT_MODE_80x32);
clrscr_attr(ATTR_TEXT);
cputs("MDView v0.3 -- Markdown Viewer for Sprinter\r\n" \
"(c) 2026 Petrov A.G.\r\n\r\n" \
"Usage: mdview2 <file.md>\r\n" \
" Displays a Markdown document (CP866/CP1251/KOI8R/UTF8, up to 128 KB).\r\n");
exit(-1);
}
close(chk);
}
}
atexit(on_exit);
/* Копируем путь в 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();
prerender_status();
render_menu();
render_full_status();
render_percent_progress(0);
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("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");
exit(-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");
exit(-1);
}
if (g_abort)
exit(0); /* F10/Esc во время загрузки — корректный выход */
if (n_lines == 0) {
clrscr_attr(ATTR_RESET);
textattr(ATTR_RESET);
cputs("mdview: empty file");
exit(-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 (scan == KEY_F10) {
break;
}
if (scan == KEY_F1) {
show_help();
continue;
} /* show_help сам перерисует */
/* 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();
status_encoding();
render_menu();
render_raw_status();
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;
}
/* цикл CP866→CP1251→KOI8R→UTF8→CP866; набор дособерётся лениво */
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; /* UTF-8 недоступен */
switch_encoding(next);
if (g_abort)
exit(0); /* Esc/F10 во время ленивой досборки */
render_full_status(); /* перерисовка — в MD-режиме */
draw_viewport_from_cache();
continue;
}
switch(g_view) {
case VIEW_MD:
/* MD-режим: F1/F8/F10 здесь, навигация — в md_key() (peer к raw_key). */
md_key(scan); /* навигация MD (сама рисует+статус); незнакомые игнорит */
continue;
#if WITH_RAW
case VIEW_RAW_WRAP:
case VIEW_RAW_HSCROLL:
/* RAW-режим: F3 Wrap/Unwrap, своя навигация; F8 — кодировка; F1/F10. */
/* Wrap ↔ Unwrap */
if (scan == KEY_F3) {
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();
render_raw_status();
raw_draw();
continue;
}
raw_key(scan);
continue;
#endif /* WITH_RAW */
default:
break;
}
}
clrscr_attr(ATTR_TEXT);
exit(0);
return 0;
}