/* * 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 #include #include #include #include #include #include #include #include #include /* ---- Геометрия экрана -------------------------------------------- */ #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_CYAN, COLOR_BLUE) #define ATTR_BOX COLOR(COLOR_CYAN, COLOR_BLUE) /* рамка таблиц; отдельный (не контентный) attr — чтобы ремап кодировок не трогал box-глифы */ #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 26u /* высота рамки (включая ─) */ /* ---- Глобальное состояние ---------------------------------------- */ /* Файл до 128 КБ хранится в наборе EMM-страниц (до 8 шт. по 16 КБ). * В окно W3 одновременно отображается только одна страница (FILE_BUF): * функция fb() при необходимости переключает маппинг и кэширует текущую * страницу в cur_page, чтобы не делать лишние переключения. * * Индекс экранных сегментов хранится отдельно в EMM (записи по 8 байт, * одна запись на видимую строку). Каждая запись содержит: * - смещение первого байта сегмента в исходном файле, * - флаги (continuation/nowrap/blank/code), * - начальный inline-стиль сегмента. * Правая граница сегмента определяется смещением следующей записи. * Такой подход освобождает около 11 КБ ближней RAM и снимает старый * лимит MAX_LINES для near-массивов. */ /* В SDCC/z80 критично явно инициализировать static-переменные (`= 0`). * Неинициализированные static-объекты могут конфликтовать по размещению * и перезаписывать друг друга. См. заметку memory/sdcc_static_storage_gotcha. */ /* Компактная запись индекса сегмента. * Каждый элемент соответствует одной строке рендера в viewport. */ typedef struct idx_rec_s { uint32_t off; /* смещение первого байта исходного текста для сегмента */ uint8_t flags; /* битовые флаги IF_* ниже */ uint8_t style; /* начальный стиль INIT_STYLE_* в начале сегмента */ uint8_t pad0; /* добивка до 8 байт (2048 записей на страницу 16 КБ) */ uint8_t pad1; } idx_rec_t; #define INDEX_REC_SIZE 8u #define INDEX_RECS_PER_PAGE 2048u /* 16384 / 8; запись всегда целиком в одной странице */ #define MAX_INDEX_PAGES 8u /* максимум 8 * 2048 = 16384 сегментов индекса */ #define IF_CONT 0x01u /* сегмент является продолжением перенесённой строки */ #define IF_NOWRAP 0x02u /* строка не переносится (кодовый блок / HR / таблица) */ #define IF_BLANK 0x04u /* визуально пустая строка */ #define IF_CODE 0x08u /* тело fenced code-блока (verbatim-режим) */ #define IF_HSCROLL 0x10u /* блок горизонтально скроллируется ЦЕЛИКОМ (код/таблица), * включая строки короче 80 — двигаются как единый блок. * НЕ ставится на HR и границы fence (им нечего прятать). */ /* ================================================================== * Фаза 1 (mdview2): директория и пул рендер-кэша * ================================================================== * Каждая логическая строка экрана рендерится РОВНО ОДИН РАЗ (Фаза 2) * в готовый буфер и больше никогда не парсится повторно при скролле. * Содержимое живёт в ОТДЕЛЬНОМ пуле EMM-страниц (cache_content_*), * не путать с idx_rec_t/index_phys[] выше — те остаются неизменными, * ими пользуется рендер-воркер ПРИ построении кэша (Фаза 2), и их * нельзя перезатирать, т.к. воркер для одной строки может заглядывать * в idx-записи СОСЕДНИХ строк (откат cont-сегментов). * * ЕДИНЫЙ формат для ВСЕХ строк (решено 2026-06-23 — изначальный план * различал тип 1/тип 2 по экономии памяти, но строки таблиц — IF_NOWRAP, * но НЕ IF_CODE — всё равно проходят inline-парсер и могут содержать * несколько атрибутов, так что "один стиль" для них неверно; бюджет EMM * (см. sprinter_emm_budget) с большим запасом покрывает (char,attr) * для всех строк без исключения): контент — `len` пар (char,attr), * `len` — реальная длина в ЯЧЕЙКАХ без паддинга до SCREEN_W, капается на * 255 (патологически длинные nowrap-строки урезаются; индикатор обрезки * добавляется на этапе вывода из кэша, не при рендере в кэш). */ /* РОВНО 8 байт — критично: cache_dir_get/put адресуют запись сдвигом * idx<<3 (умножение на 8 через сдвиг, а не на sizeof()), так что * структура ОБЯЗАНА быть именно 8 байт, а не "8 минус случайный паддинг * компилятора". (СSDCC z80 НЕ паддит структуры сама по себе, поэтому * 1+2+1+1+1 = 6 байт без явных pad-полей превратились бы в 6, а не 8 — * фактический баг был в том, что без 2 явных pad оно было 7, а * CACHE_DIR_REC_SIZE был захардкожен как 8: bank_read/bank_write писали * 8 байт в 7-байтный буфер на стеке, затирая соседнюю переменную * вызывающей функции — это была настоящая причина "квирка WINREST", * не компилятор/эмулятор/DSS, см. memory/sprinter_winrest_format.md.) */ typedef struct cache_rec_s { uint8_t page; /* EMM-страница рендер-кэша (физический номер) */ uint16_t off; /* смещение в странице, 0..16383 (байты, не ячейки) */ uint8_t len; /* длина контента в ЯЧЕЙКАХ (char,attr пар), 0..255; 0 = пустая/HR строка без сохранённого контента */ uint8_t flags; /* копия IF_NOWRAP/IF_CODE/IF_BLANK на момент рендера */ uint8_t reserved; /* не используется (раньше — attr для типа 2, см. план) */ uint8_t pad[2]; /* явный резерв, добивка до 8 байт — см. комментарий выше */ } cache_rec_t; #define CACHE_DIR_REC_SIZE ((uint16_t)sizeof(cache_rec_t)) #define CACHE_DIR_RECS_PER_PAGE 2048u #define MAX_CACHE_DIR_PAGES 8u #define MAX_CACHE_LINE_LEN 255u /* кап длины контента одной строки, в ячейках (char,attr пара = 2 байта) */ #define MAX_CACHE_CONTENT_PAGES 64u /* запас: до 1 МБ кэша контента */ static uint8_t cache_dir_blk = 0; static uint8_t cache_dir_pages = 0; static uint8_t cache_dir_phys[MAX_CACHE_DIR_PAGES] = {0}; static uint8_t cache_content_blk[MAX_CACHE_CONTENT_PAGES] = {0}; /* id блока на каждую выделенную страницу */ static uint8_t cache_content_phys[MAX_CACHE_CONTENT_PAGES] = {0}; static uint8_t cache_content_n_pages = 0; /* сколько страниц уже выделено */ static uint8_t cache_cur_page_idx = 0; /* индекс текущей страницы записи в cache_content_phys */ static uint16_t cache_cur_off = 0; /* текущее смещение записи в этой странице */ /* Доступ к директории рендер-кэша — тот же приём, что у idx_get/idx_put. */ static void cache_dir_get(uint16_t idx, cache_rec_t *r) { uint8_t page = (uint8_t)(idx >> 11); uint16_t off = (uint16_t)((idx & 2047u) << 3); bank_read(cache_dir_phys[page], off, r, CACHE_DIR_REC_SIZE); } static void cache_dir_put(uint16_t idx, const cache_rec_t *r) { uint8_t page = (uint8_t)(idx >> 11); uint16_t off = (uint16_t)((idx & 2047u) << 3); bank_write(cache_dir_phys[page], off, r, CACHE_DIR_REC_SIZE); } /* Резервирует nbytes подряд в пуле контента рендер-кэша. * Запись никогда не разбивается через границу страницы: если остаток * текущей страницы недостаточен — переходим на следующую целиком, * жертвуя остатком (проще и безопаснее, чем page-crossing в fb()). * При нехватке текущего пула — лениво доращиваем его на 1 страницу. * Возврат: 1 — успех (out_page и out_off заполнены), 0 — EMM кончилась. */ static uint8_t cache_reserve(uint16_t nbytes, uint8_t *out_page, uint16_t *out_off) { if (nbytes == 0 || nbytes > PAGE_SIZE) return 0; if (cache_content_n_pages == 0 || (uint16_t)(cache_cur_off + nbytes) > PAGE_SIZE) { /* Нужна новая страница: либо пул ещё пуст, либо текущая страница * не вмещает запись целиком. */ uint8_t next_idx = (cache_content_n_pages == 0) ? 0 : (uint8_t)(cache_cur_page_idx + 1); if (next_idx >= cache_content_n_pages) { if (cache_content_n_pages >= MAX_CACHE_CONTENT_PAGES) return 0; uint8_t blk = mem_alloc_pages(1); if (blk == 0) return 0; cache_content_blk[cache_content_n_pages] = blk; cache_content_phys[cache_content_n_pages] = mem_get_page(blk, 0); cache_content_n_pages++; } cache_cur_page_idx = next_idx; cache_cur_off = 0; } *out_page = cache_content_phys[cache_cur_page_idx]; *out_off = cache_cur_off; cache_cur_off = (uint16_t)(cache_cur_off + nbytes); return 1; } /* Копирует уже готовый буфер (рендер одной строки, собранный в W1/W2 — * НЕ в W3, чтобы не конфликтовать с fb()/чтением исходника во время * рендера) в зарезервированное место в пуле контента. Один bank_write * на строку, а не по байту. len — РОВНО как у cache_reserve()/bank_write * — uint16_t, не uint8_t: строка до MAX_CACHE_LINE_LEN=255 ячеек даёт * до 510 байт (char,attr), это уже не лезет в uint8_t. */ static void cache_commit(uint8_t page, uint16_t off, const void *buf, uint16_t len) { bank_write(page, off, buf, len); } /* emph_to_attr определена ниже (рядом с inline-логикой), нужна в inline_scan. */ static uint8_t emph_to_attr(uint8_t emph, uint8_t base_attr); static uint16_t n_lines = 0; static uint16_t max_lines = 0; /* ёмкость индекса: index_pages * 2048 */ static uint16_t top_line = 0; static uint32_t file_size = 0; static uint8_t file_blk = 0; static uint8_t file_pages = 0; static uint8_t file_phys[MAX_PAGES] = {0}; static uint8_t index_blk = 0; static uint8_t index_pages = 0; static uint8_t index_phys[MAX_INDEX_PAGES] = {0}; static uint8_t index_truncated = 0; /* выставляется при исчерпании ёмкости индекса */ static uint32_t cur_seg_off = 0; /* смещение последнего emit_seg (кэш в near-памяти) */ static idx_rec_t cur_rec; /* near-копия последней записанной idx-записи */ static uint8_t cur_page = 0xFF; /* 0xFF = в W3 ещё не отображена ни одна страница */ static uint8_t viewport_x = 0; /* горизонтальный сдвиг для nowrap-строк */ static char filename[64] = {0}; /* Прогрессивная загрузка: рисуем первый экран и пускаем скролл, пока * index_lines() ещё досканирует файл (кооперативно, без прерываний). */ static uint8_t g_loading = 0; /* 1 во время index_lines() */ static uint8_t g_first_drawn = 0; /* первый экран уже нарисован */ static uint8_t g_f8_enabled = 0; /* можно ли сейчас переключать кодировку (метка F8 в меню + F8 в load_key) */ static uint8_t g_utf_failed = 0; /* UTF-8 набор не удалось построить (нет EMM) — пропускаем UTF-8 в цикле F8 */ static uint8_t g_abort = 0; /* F10/Esc во время загрузки → прервать индексацию */ static uint16_t g_last_prog = 0; /* n_lines на последнем обновлении статуса */ static void progress_tick(void); /* определена ниже (нужна в index_lines) */ /* Кодировки. 8-битные (CP866/CP1251/KOI8R) — общая структура, различаются * только ремапом глифов [128-255] на этапе отрисовки; UTF-8 — отдельный набор. */ #define ENC_CP866 0 #define ENC_CP1251 1 #define ENC_KOI8R 2 #define ENC_UTF8 3 static uint8_t g_encoding = ENC_CP866; /* активная кодировка */ static const uint8_t *g_remap = 0; /* активная таблица ремапа (CP1251/KOI8), 0 = без ремапа */ static uint8_t g_scratch_phys = 0; /* EMM-страница под ремап-буфер строки для win_rest */ /* UTF-8 набор (Фаза 2): src декодируется в отдельный блок EMM-страниц как * чистый CP866. После конвертации активный буфер файла (file_phys/file_size/ * file_pages, который читает fb()) перенаправляется на эти страницы, и весь * существующий конвейер индексации/кэша работает по ним без изменений — * конвертированный поток это уже CP866, ремап на отрисовке не нужен. */ static uint8_t utf_blk = 0; static uint8_t utf_pages = 0; static uint8_t utf_phys[MAX_PAGES] = {0}; static uint32_t utf_size = 0; static uint8_t utf_avail = 0; /* 1 = UTF-8 набор удалось сконвертировать/построить */ /* Снимок исходного (8-битного) файлового буфера — набор DOC_8BIT читает его * как есть, набор DOC_UTF8 читает utf_phys. */ static uint8_t orig_file_blk = 0; static uint8_t orig_file_pages = 0; static uint32_t orig_file_size = 0; static uint8_t orig_file_phys[MAX_PAGES] = {0}; /* ---------- Два сосуществующих набора индекс/кэш (Фазы 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 set_encoding(uint8_t enc); /* forward (нужна в doc_load_live) */ 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 напрямую, без промежуточной копии. */ static void put_str_attr(uint8_t x, uint8_t y, const char *s, uint8_t attr) { uint8_t maxlen = (x < SCREEN_W) ? (uint8_t)(SCREEN_W - x) : 0; uint8_t len = (uint8_t)strlen(s); if (len > maxlen) len = maxlen; if (len == 0) return; bios_set_place(y, x); bios_writeattr(s, len, attr); } /* Полностью очищает указанную строку экрана символами пробела с attr. */ static void fill_row(uint8_t y, uint8_t attr) { bios_set_place(y, 0); bios_fillcharattr(' ', attr, SCREEN_W); } /* Продвигает спиннер на один кадр. * Если спиннер выключен — функция ничего не делает. */ static void spinner_tick(void) { if (!spinner_active) return; wrchar(SPINNER_COL, 0, spinner_chars[spinner_phase & 3], ATTR_BAR_SPINNER); spinner_phase++; } /* Включает/выключает спиннер. * При выключении очищает его позицию пробелом в статус-баре. */ static void spinner_show(uint8_t on) { spinner_active = on; if (!on) wrchar(SPINNER_COL, 0, ' ', ATTR_BAR); } /* Отображает указанную EMM-страницу файла в окно W3. * Если эта страница уже активна, переключение не выполняется. */ static inline void map_page(uint8_t page) { if (page != cur_page) { /* file_phys[] позволяет не вызывать mem_get_page() на каждом свопе. */ sprinter_page_w3(file_phys[page]); cur_page = page; } } /* Читает один байт логического файла по смещению p. * При переходе между страницами автоматически переключает W3. * Проверка границ выполняется вызывающей стороной. * * Эквивалент C-кода (оставлен для справки): * uint8_t *pb = (uint8_t *)&p; * uint8_t page = (pb[1] >> 6) | (pb[2] << 2); * uint16_t off = ((pb[1] & 0x3F) << 8) | pb[0]; * map_page(page); // if (page!=cur_page){cur_page=page; OUT W3,file_phys[page];} * return FILE_BUF[off]; // *(0xC000 + off) * * Ручной asm — это горячая примитивная операция (вызывается сотни тысяч раз * при индексации 50КБ-файла); C-версия тратила вызов функции + 32-битную * арифметику на КАЖДЫЙ байт. Раскладка __sdcccall(1): аргумент uint32_t в * HLDE (E=pb[0], D=pb[1], L=pb[2], H=pb[3] — проверено sdcc -S), возврат * char в A. Координация маппинга с bank_read/bank_write держится на том, * что _io_page_w3 — это ПОРТ (__sfr 0xE2): здесь пишем его через OUT, * bank_read читает через IN — общий аппаратный регистр, без теневой RAM. * NB: __naked обязателен (внутри собственный ret); IX/IY не трогаем * (callee-saved), BC/DE/HL/AF — caller-saved, можно клобберить. */ static char fb(uint32_t p) __naked { (void)p; /* p = HLDE; доступ только из asm */ __asm ; off -> BC = 0xC000 + ((pb[1]&0x3F)<<8 | pb[0]) ; D=pb[1], E=pb[0] ld a, d or a, #0xC0 ; (D|0xC0) == (D&0x3F)|0xC0 — старший байт окна W3 ld b, a ld c, e ; C = pb[0] ; page = (pb[1]>>6) | (pb[2]<<2) ; L=pb[2] ld a, l add a, a add a, a ; A = pb[2]<<2 ld e, a ; E свободен (pb[0] уже сохранён в C) ld a, d rlca rlca and a, #0x03 ; A = pb[1]>>6 or a, e ; A = page ld e, a ; E = page ; map_page(page) ld a, (_cur_page) cp e jr Z, fb_same ld a, e ld (_cur_page), a ld hl, #_file_phys ld d, #0x00 add hl, de ; hl = &file_phys[page] ld a, (hl) out (#0xE2), a ; OUT PORT_PAGE_W3 (== _io_page_w3 sfr) fb_same: ld a, (bc) ; A = *(0xC000 + off) ret __endasm; } /* ESTEX WINREST (5Ah, RST 0x10) — копирует готовый буфер (char,attr) * из смещения `off` ВНУТРИ EMM-страницы `page` прямо в текстовое окно * экрана, минуя любые BIOS print-вызовы. Формат буфера подтверждён * эмпирически в MAME (см. memory/sprinter_winrest_format.md и * tests/winrest/winrest.c): пары (char,attr) по ячейкам, по строкам, * шаг строки = width*2, без паддинга. row/col — левый верхний угол * вывода на экране, h/w — высота/ширина в символах. * * Регистровая раскладка SDCC __sdcccall(1) для (row,col,h,w,page,off) * проверена через `sdcc -S` (пробный non-naked враппер): A=row, L=col, * стек [SP+2]=h [SP+3]=w [SP+4]=page [SP+5..6]=off (little-endian). * * История: до 2026-06-23 `off` не было — IX всегда указывал на самое * начало страницы (#0xC000), это работало только пока в кэше было * РОВНО ОДНА запись на странице со смещением 0 (Фаза 1 self-test). * Когда Фаза 2 начала пакетировать много строк на одной странице с * РАЗНЫМИ off, всё стало читать с начала страницы (т.е. контент первой * записи) независимо от реальной строки — добавлен off, IX = 0xC000+off. */ static void win_rest(uint8_t row, uint8_t col, uint8_t h, uint8_t w, uint8_t page, uint16_t off) __naked { (void)row; (void)col; (void)h; (void)w; (void)page; (void)off; __asm ld iy, #2 add iy, sp ld d, a ; D = row ld e, l ; E = col push ix ; сохранить IX вызывающей функции (callee-saved) ld l, 3 (iy) ; off (младший байт), SP+5 ld h, 4 (iy) ; off (старший байт), SP+6 ld bc, #0xC000 add hl, bc push hl pop ix ; IX = 0xC000 + off ld h, 0 (iy) ; H = height ld a, 1 (iy) ; width -> L ld l, a ld a, 2 (iy) ; page -> B ld b, a ld c, #0x5A ; ESTEX WINREST di rst #0x10 ei pop ix ; восстановить IX вызывающей функции pop hl ; адрес возврата inc sp inc sp inc sp inc sp inc sp ; снять h,w,page,off (1+1+1+2 байт) со стека jp (hl) __endasm; } /* Для проверки границ выделения считаем пробел/таб/конец строки/0 пробельными. */ static uint8_t ws_or_eol(char c) { return (uint8_t)(c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == 0); } static uint8_t ws_or_eol_or_delim(char c) { return (uint8_t)(c == ' ' || c == ',' || c == '.' || c == '!' || c == '?' || c == ':' || c == ';' || c == '/' || c == '\t' || c == '\n' || c == '\r' || c == 0); } /* Маркер inline-выделения (*, **, _) распознаётся только если * пробельная граница есть ровно с одной стороны (XOR-правило). * Это защищает выражения вроде "2 * 3", "2 ** 3" и внутрисловные * случаи (например COLOR_YELLOW) от ложного форматирования. */ static uint8_t is_emph_flanked(char prev_ch, char next_ch) { return (uint8_t)(ws_or_eol(prev_ch) != ws_or_eol_or_delim(next_ch)); } /* Символ можно экранировать обратным слешем (\X): любая печатная ASCII- * пунктуация (как в CommonMark). \* \_ \` \[ и т.п. → литерал X. */ static uint8_t is_escapable(char c) { return (uint8_t)(c >= '!' && c <= '~' && !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'))); } /* Тип сегмента-продолжения, передаваемый в emit_seg(). * Явно в индексе не хранится: рендер при необходимости восстанавливает * тип через classify_line(), но параметр оставлен для читаемости вызовов. */ #define CK_PLAIN 0 #define CK_QUOTE 1 #define CK_LIST 2 #define CK_OTHER 3 /* Доступ к индексу в EMM. * Размер записи 8 байт, поэтому запись не пересекает границу страницы * и bank_read/bank_write работают с цельным блоком. */ static void idx_get(uint16_t idx, idx_rec_t *r) { uint8_t page = (uint8_t)(idx >> 11); /* номер страницы: idx / 2048 */ uint16_t off = (uint16_t)((idx & 2047u) << 3); /* смещение записи: (idx % 2048) * 8 */ bank_read(index_phys[page], off, r, INDEX_REC_SIZE); } static void idx_put(uint16_t idx, const idx_rec_t *r) { uint8_t page = (uint8_t)(idx >> 11); uint16_t off = (uint16_t)((idx & 2047u) << 3); bank_write(index_phys[page], off, r, INDEX_REC_SIZE); } static uint32_t seg_off(uint16_t idx) { idx_rec_t r; if (idx >= n_lines) return file_size; idx_get(idx, &r); return r.off; } /* Установка флагов всегда идёт в последний emit_seg (n_lines-1). * Используется near-копия cur_rec, чтобы не делать обратное чтение из банка. */ static void set_nowrap_cur(void) { cur_rec.flags |= IF_NOWRAP; idx_put((uint16_t)(n_lines - 1), &cur_rec); } static void set_blank_cur(void) { cur_rec.flags |= IF_BLANK; idx_put((uint16_t)(n_lines - 1), &cur_rec); } static void set_code_cur(void) { cur_rec.flags |= IF_CODE; idx_put((uint16_t)(n_lines - 1), &cur_rec); } static void set_hscroll_cur(void){ cur_rec.flags |= IF_HSCROLL; idx_put((uint16_t)(n_lines - 1), &cur_rec); } /* Загружает 16 текстовых атрибутов в палитры ink/paper/blink. */ static void set_pallete(void) { uint8_t buff[64]; for(int i = 0; i < 16; i++) { for(int j = 0; j < 16; j++) { buff[j * 4] = md_pallete[i * 4]; buff[j * 4 + 1] = md_pallete[i * 4 + 1]; buff[j * 4 + 2] = md_pallete[i * 4 + 2]; buff[j * 4 + 3] = md_pallete[i * 4 + 3]; } text_pal_load(TEXT_PAL_INK, i * 16, 16, md_pallete); text_pal_load(TEXT_PAL_BLINK_INK, i * 16, 16, md_pallete); text_pal_load(TEXT_PAL_PAPER, i * 16, 16, buff); text_pal_load(TEXT_PAL_BLINK_PAPER, i * 16, 8, buff + 32); text_pal_load(TEXT_PAL_BLINK_PAPER, i * 16 + 8, 8, buff + 32); } } /* ================================================================== * Загрузка/выгрузка файла * ================================================================== */ /* Загружает исходные байты файла в EMM-страницы (без индекса/кэша — те * выделяются отдельно на КАЖДЫЙ набор через 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; } /* ================================================================== * Индексация строк * ================================================================== */ #define INIT_STYLE_PLAIN 0x0 #define INIT_STYLE_BOLD 0x1 #define INIT_STYLE_ITALIC 0x2 #define INIT_STYLE_UNDER 0x3 #define INIT_STYLE_CODE 0x4 #define INIT_STYLE_STRIKE 0x5 /* Таблица сопоставления init-style -> атрибут. * Порядок должен совпадать с INIT_STYLE_* / EM_* ниже. */ static const uint8_t styles_map[] = { ATTR_TEXT, ATTR_TEXT_BOLD, ATTR_TEXT_ITALIC, ATTR_TEXT_UNDERSORE, ATTR_TEXT_CODE, ATTR_TEXT_STRIKE }; /* Тип строки, который возвращает classify_line(). */ #define LK_PLAIN 0 #define LK_H1 1 #define LK_H2 2 #define LK_H3 3 #define LK_H4 4 /* также используется для H5/H6 */ #define LK_HR 5 #define LK_ULIST 6 /* маркер ненумерованного списка */ #define LK_OLIST 7 /* маркер нумерованного списка */ #define LK_QUOTE 8 /* маркер цитаты */ /* Классифицирует логическую строку. * - Заголовки: 1..4 символа '#' и затем пробел/таб (H5/H6 сводятся к H4). * - Горизонтальная линия: только '-', '*' или '_' (одного вида, >=3), * возможно с пробелами/табами между символами. * - Ненумерованный список: "- ", "* " или "+ " перед содержимым. * - Нумерованный список: цифры + '.'/ ')' + пробел. * - Цитата: '>' с опциональным пробелом. * - Иначе обычный текст. * Для заголовков/списков/цитат out_content указывает на первый байт * содержимого после маркера и завершающего пробела. */ static uint8_t classify_line(uint32_t p_start, uint32_t *out_content) { if (p_start >= file_size) return LK_PLAIN; char c0 = fb(p_start); /* Заголовок? */ if (c0 == '#') { uint8_t lvl = 0; uint32_t p = p_start; while (p < file_size && fb(p) == '#' && lvl < 6) { lvl++; p++; } if (p < file_size) { char ch = fb(p); if (ch == ' ' || ch == '\t') { p++; *out_content = p; if (lvl > 4) lvl = 4; return lvl; /* LK_H1 .. LK_H4 */ } } /* Символ '#' без пробела после него не считается заголовком. */ } /* Горизонтальный разделитель. * Проверяется до списков, чтобы `- - -` не стало маркером списка. */ if (c0 == '-' || c0 == '*' || c0 == '_') { char marker = 0; uint8_t count = 0; uint8_t ok = 1; uint32_t p = p_start; while (p < file_size) { char ch = fb(p++); if (ch == '\n' || ch == '\r') break; if (ch == ' ' || ch == '\t') continue; if (ch != '-' && ch != '*' && ch != '_') { ok = 0; break; } if (marker == 0) marker = ch; else if (ch != marker) { ok = 0; break; } count++; } if (ok && count >= 3) return LK_HR; } /* Для списков/цитат дополнительно допускаем ведущие пробелы * (базовая поддержка вложенности). */ uint32_t lp = p_start; while (lp < file_size && fb(lp) == ' ') lp++; char cl = (lp < file_size) ? fb(lp) : 0; /* Ненумерованный список? */ if ((cl == '-' || cl == '*' || cl == '+') && (lp + 1) < file_size && fb(lp + 1) == ' ') { *out_content = lp + 2; return LK_ULIST; } /* Нумерованный список? */ if (cl >= '0' && cl <= '9') { uint32_t p = lp; while (p < file_size) { char ch = fb(p); if (ch < '0' || ch > '9') break; p++; } if (p < file_size) { char ch = fb(p); if ((ch == '.' || ch == ')') && (p + 1) < file_size && fb(p + 1) == ' ') { *out_content = p + 2; return LK_OLIST; } } } /* Цитата? */ if (cl == '>') { uint32_t p = lp + 1; if (p < file_size && fb(p) == ' ') p++; *out_content = p; return LK_QUOTE; } return LK_PLAIN; } /* Возвращает видимую ширину префикса (отступ + маркер) в колонках. */ static uint8_t marker_visible_col(uint8_t kind, uint32_t p_start, uint32_t content_off) { switch (kind) { case LK_ULIST: case LK_QUOTE: case LK_OLIST: return (uint8_t)(content_off - p_start); default: return 0; /* plain/headers спец-префикса не имеют */ } } /* ================== Merge: сборка ячеек на этапе индекс-скана ============== * Форвард-сканер (scan_join_stream/inline_scan + прямые emit'ы в index_lines) * собирает готовые (char,attr)-ячейки в g_cells ПО ХОДУ единственного прохода * по файлу — без отдельного второго скана. На каждом emit_seg ячейки * ПРЕДЫДУЩего сегмента флашатся в кэш (lag-1: к этому моменту сегмент уже * полностью просканирован); последний сегмент флашится после цикла index_lines. * g_ncells_at_space — снимок длины на последнем пробеле для усечения буфера * при переносе строки (отбрасываем уехавшее за границу слово). */ static uint8_t g_cells[MAX_CACHE_LINE_LEN * 2]; static uint8_t g_ncells = 0; /* ячеек в текущем сегменте */ static uint8_t g_ncells_at_space = 0; /* снимок g_ncells на последнем пробеле (для усечения при переносе) */ static uint8_t g_skip_flush = 0; /* следующий emit_seg пропустит lag-флаш (pending уже зафлашен вручную, напр. перед таблицей) */ /* Пишет одну (char,attr)-ячейку в g_cells, молча игнорируя переполнение * MAX_CACHE_LINE_LEN. Оставлена ФУНКЦИЕЙ намеренно: инлайн в сильно * регистро-нагруженный скан-цикл на Z80/SDCC увеличивает спиллы и замедляет * (проверено замером — макро-инлайн дал регрессию ~11→13с). */ static void gc_put(char ch, uint8_t attr) { if (g_ncells < MAX_CACHE_LINE_LEN) { uint16_t i = (uint16_t)g_ncells * 2u; g_cells[i] = (uint8_t)ch; g_cells[i + 1] = attr; g_ncells++; } } static void gc_fill(char ch, uint8_t attr, uint8_t n) { while (n-- > 0) gc_put(ch, attr); } /* Пишет g_cells как контент кэша для line_idx: flags берём из idx-записи, * пустой буфер/blank → len 0; иначе резервируем место в пуле и коммитим. */ static void seg_commit_cells(uint16_t line_idx) { cache_rec_t out_rec; out_rec.flags = 0; out_rec.reserved = 0; out_rec.pad[0] = 0; out_rec.pad[1] = 0; idx_rec_t r; uint8_t have = (uint8_t)(line_idx < n_lines); if (have) { idx_get(line_idx, &r); out_rec.flags = r.flags; } if (!have || (r.flags & IF_BLANK) || g_ncells == 0) { out_rec.page = 0; out_rec.off = 0; out_rec.len = 0; cache_dir_put(line_idx, &out_rec); return; } out_rec.len = g_ncells; if (cache_reserve((uint16_t)(g_ncells * 2u), &out_rec.page, &out_rec.off)) { cache_commit(out_rec.page, out_rec.off, g_cells, (uint16_t)(g_ncells * 2u)); } else { out_rec.page = 0; out_rec.off = 0; out_rec.len = 0; } cache_dir_put(line_idx, &out_rec); } static void emit_seg(uint32_t off, uint8_t style, uint8_t ckind, uint8_t cont) { (void)ckind; /* тип сегмента восстанавливается флагами idx-записи */ if (n_lines >= max_lines) { index_truncated = 1; return; } cur_rec.off = off; cur_rec.flags = (uint8_t)(cont ? IF_CONT : 0); cur_rec.style = (uint8_t)(style & 7u); cur_rec.pad0 = 0; cur_rec.pad1 = 0; idx_put(n_lines, &cur_rec); cur_seg_off = off; n_lines++; /* Флаш ячеек ПРЕДЫДУЩего сегмента (lag-1): он полностью собран форвард-сканером. */ if (n_lines >= 2) { if (g_skip_flush) g_skip_flush = 0; /* pending уже зафлашен вручную */ else seg_commit_cells((uint16_t)(n_lines - 2)); } g_ncells = 0; /* буфер ячеек под новый сегмент */ g_ncells_at_space = 0; } /* Проверяет, что физическая строка пуста (только пробелы/таб + перевод строки). */ static uint8_t is_line_blank(uint32_t p) { while (p < file_size) { char c = fb(p); if (c == '\n') return 1; if (c != ' ' && c != '\t') return 0; p++; } return 1; } /* Число ведущих пробелов строки (отступ). Используется для отделения * dedent-пунктов списка (#3) и для отступа обычного абзаца (#4). */ static uint8_t leading_spaces(uint32_t p) { uint8_t n = 0; while (p < file_size && fb(p) == ' ' && n < 250) { n++; p++; } return n; } /* Быстрая проверка границы fenced-блока по маркеру ``` . * Ведущие пробелы/табы пропускаются — fence может быть с отступом * (например, ``` ```c ``` внутри элемента списка с отступом 2+ пробела). * Язык после бэктиков (```c, ```sh) игнорируется: достаточно трёх ` подряд. */ static uint8_t is_fence_raw(uint32_t p) { while (p < file_size && (fb(p) == ' ' || fb(p) == '\t')) p++; if (p + 2 >= file_size) return 0; return (fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`'); } /* Быстрая проверка горизонтального разделителя (`---`, `***`, `___`). */ static uint8_t is_hr_raw(uint32_t p) { char c0 = fb(p); if (c0 != '-' && c0 != '*' && c0 != '_') return 0; char marker = 0; uint8_t count = 0, ok = 1; uint32_t hr_p = p; while (hr_p < file_size) { char ch = fb(hr_p++); if (ch == '\n' || ch == '\r') break; if (ch == ' ' || ch == '\t') continue; if (ch != '-' && ch != '*' && ch != '_') { ok = 0; break; } if (marker == 0) marker = ch; else if (ch != marker) { ok = 0; break; } count++; } return ok && count >= 3; } /* Строка таблицы: первый непустой символ — '|'. * Ведущие пробелы разрешены; такие строки не склеиваются и не переносятся. */ static uint8_t is_table_raw(uint32_t p) { while (p < file_size) { char c = fb(p); if (c == ' ' || c == '\t') { p++; continue; } return (uint8_t)(c == '|'); } return 0; } /* Разбор одного inline-МАРКЕРА в позиции *pq (< bound): \X, [x], `, **, ~~, * *, _, \t. Общий код для inline_scan() и scan_join_stream(). Возврат: 1 — * маркер обработан (вызывающий делает continue), 0 — символ обычный (пробел/ * текст), его кладёт сам вызывающий (и проверяет перенос). Меняет * *pq/*pls/*pseg/*pprev. attr берётся как emph_to_attr(line_style, base) — * для join base=ATTR_TEXT, что тождественно styles_map[line_style]. */ static uint8_t inline_marker(uint32_t *pq, uint32_t bound, uint8_t *pls, uint8_t *pseg, char *pprev, uint8_t base) { uint32_t q = *pq; uint8_t ls = *pls; char ch = fb(q); if (ls != INIT_STYLE_CODE && ch == '\\' && (q + 1) < bound && is_escapable(fb(q + 1))) { char nb = fb(q + 1); gc_put(nb, emph_to_attr(ls, base)); *pseg += 1; *pq = q + 2; *pprev = nb; return 1; } if (ls != INIT_STYLE_CODE && ch == '[' && (q + 2) < bound && fb(q + 2) == ']') { gc_put('[', ATTR_TEXT_BOLD); gc_put(fb(q + 1), ATTR_TEXT_BOLD); gc_put(']', ATTR_TEXT_BOLD); *pseg += 3; *pq = q + 3; *pprev = ']'; return 1; } if (ch == '`') { if (ls == INIT_STYLE_PLAIN) { *pls = INIT_STYLE_CODE; *pq = q + 1; return 1; } if (ls == INIT_STYLE_CODE) { *pls = INIT_STYLE_PLAIN; *pq = q + 1; return 1; } gc_put('`', emph_to_attr(ls, base)); *pseg += 1; *pq = q + 1; *pprev = '`'; return 1; } if (ch == '*' && (q + 1) < bound && fb(q + 1) == '*') { char nx = ((q + 2) < bound) ? fb(q + 2) : '\n'; if (is_emph_flanked(*pprev, nx)) { if (ls == INIT_STYLE_PLAIN && ws_or_eol(*pprev)) { *pls = INIT_STYLE_BOLD; *pq = q + 2; return 1; } if (ls == INIT_STYLE_BOLD && ws_or_eol_or_delim(nx)) { *pls = INIT_STYLE_PLAIN; *pq = q + 2; return 1; } } gc_put('*', emph_to_attr(ls, base)); gc_put('*', emph_to_attr(ls, base)); *pseg += 2; *pq = q + 2; *pprev = '*'; return 1; } if (ch == '~' && (q + 1) < bound && fb(q + 1) == '~') { char nx = ((q + 2) < bound) ? fb(q + 2) : '\n'; if (is_emph_flanked(*pprev, nx)) { if (ls == INIT_STYLE_PLAIN && ws_or_eol(*pprev)) { *pls = INIT_STYLE_STRIKE; *pq = q + 2; return 1; } if (ls == INIT_STYLE_STRIKE && ws_or_eol_or_delim(nx)) { *pls = INIT_STYLE_PLAIN; *pq = q + 2; return 1; } } gc_put('~', emph_to_attr(ls, base)); gc_put('~', emph_to_attr(ls, base)); *pseg += 2; *pq = q + 2; *pprev = '~'; return 1; } if (ch == '*' || ch == '_') { char nx = ((q + 1) < bound) ? fb(q + 1) : '\n'; if (is_emph_flanked(*pprev, nx)) { uint8_t ns = (ch == '*') ? INIT_STYLE_ITALIC : INIT_STYLE_UNDER; if (ls == INIT_STYLE_PLAIN && ws_or_eol(*pprev)) { *pls = ns; *pq = q + 1; return 1; } if (ls != INIT_STYLE_PLAIN && ws_or_eol_or_delim(nx)) { *pls = INIT_STYLE_PLAIN; *pq = q + 1; return 1; } } gc_put(ch, emph_to_attr(ls, base)); *pseg += 1; *pq = q + 1; *pprev = ch; return 1; } if (ch == '\t') { uint8_t old_sc = *pseg; uint8_t tgt_full = (uint8_t)((old_sc & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP); uint8_t tgt = tgt_full; if (tgt > SCREEN_W) tgt = SCREEN_W; gc_fill(' ', emph_to_attr(ls, base), (uint8_t)(tgt_full - old_sc)); *pseg = tgt; *pq = q + 1; *pprev = ' '; return 1; } return 0; /* пробел или обычный символ — обрабатывает вызывающий */ } /* Общий сканер inline-форматирования и переносов. * Идёт от q до q_end, учитывает стартовую колонку col и при необходимости * эмитит continuation-сегменты. Возвращает итоговый line_style. */ static uint8_t inline_scan(uint32_t q, uint32_t q_end, uint8_t col, uint8_t ckind, uint8_t line_style, uint8_t base_attr, uint8_t nowrap) { uint32_t last_space = 0xFFFFFFFFu; uint8_t last_space_style = line_style; /* снимок line_style на момент last_space */ char prev_ch = ' '; uint8_t seg_col = col; /* base — атрибут «обычного» текста сегмента. Для заголовка первый сегмент * имеет title-attr, а continuation-сегменты render рисует как PLAIN * (ATTR_TEXT, kind=LK_PLAIN при cont) — переключаем base после 1-го переноса. */ uint8_t base = base_attr; while (q < q_end && n_lines < max_lines) { char ch = fb(q); if (ch == '\n') { /* Жёсткий перенос (уже проверен снаружи) или конец потока. */ q++; emit_seg(q, line_style, ckind, (ckind == CK_LIST || ckind == CK_QUOTE) ? 1 : 0); base = ATTR_TEXT; last_space = 0xFFFFFFFFu; prev_ch = ' '; seg_col = col; continue; } /* Inline-маркеры (\X [x] ` ** ~~ * _ \t) — общий разбор. */ if (inline_marker(&q, q_end, &line_style, &seg_col, &prev_ch, base)) continue; if (ch == ' ') { gc_put(' ', emph_to_attr(line_style, base)); last_space = q + 1; last_space_style = line_style; g_ncells_at_space = g_ncells; seg_col++; q++; prev_ch = ' '; } else { gc_put(ch, emph_to_attr(line_style, base)); seg_col++; q++; prev_ch = ch; } if (!nowrap && seg_col >= SCREEN_W) { uint8_t use_ls = (uint8_t)(last_space != 0xFFFFFFFFu && last_space > cur_seg_off); uint32_t wrap_at = use_ls ? last_space : q; if (wrap_at >= q_end || wrap_at == cur_seg_off) break; uint8_t wrap_style = use_ls ? last_space_style : line_style; if (use_ls) g_ncells = g_ncells_at_space; emit_seg(wrap_at, wrap_style, ckind, 1); base = ATTR_TEXT; /* continuation заголовка → PLAIN */ seg_col = col; last_space = 0xFFFFFFFFu; prev_ch = ' '; q = wrap_at; line_style = wrap_style; } } return line_style; } #define JOIN_MODE_LIST 1u #define JOIN_MODE_QUOTE 2u #define JOIN_MODE_PLAIN 3u /* Общий потоковый сканер для index_lines(). * Убирает дублирование между ветками list/quote/plain: * - mode задаёт правила обработки перевода строки, * - остальная inline-логика (emphasis/таб/перенос) едина. * Возвращает позицию q, на которой сканер остановился, и обновляет стиль. */ static uint32_t scan_join_stream(uint32_t q, uint32_t para_start, uint8_t col, uint8_t ckind, uint8_t *io_line_style, uint8_t mode) { uint32_t last_space = 0xFFFFFFFFu; uint8_t last_space_style = *io_line_style; /* снимок line_style на момент last_space */ char prev_ch = ' '; uint8_t seg_col = col; uint8_t line_style = *io_line_style; while (q < file_size && n_lines < max_lines) { char ch = fb(q); uint8_t soft_break = 0; if (ch == '\n') { uint32_t next = q + 1; if (next >= file_size) break; if (mode == JOIN_MODE_LIST) { if (is_line_blank(next)) break; if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break; { uint32_t next_content = next; uint8_t next_kind = classify_line(next, &next_content); if (next_kind == LK_ULIST || next_kind == LK_OLIST || next_kind == LK_QUOTE || (next_kind >= LK_H1 && next_kind <= LK_H4) || next_kind == LK_HR) { break; } } while (next < file_size) { char ws = fb(next); if (ws == ' ' || ws == '\t') next++; else break; } q = next; soft_break = 1; ch = ' '; } else if (mode == JOIN_MODE_QUOTE) { if (is_line_blank(next)) break; if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break; { uint32_t next_content = next; uint8_t next_kind = classify_line(next, &next_content); if (next_kind != LK_QUOTE) break; { uint32_t next_line_end = next_content; while (next_line_end < file_size && fb(next_line_end) != '\n') next_line_end++; { uint8_t empty_quote = 1; for (uint32_t z = next_content; z < next_line_end; z++) { char c = fb(z); if (c != ' ' && c != '\t') { empty_quote = 0; break; } } if (empty_quote) break; } { uint32_t t = next_content; while (t < next_line_end && (fb(t) == ' ' || fb(t) == '\t')) t++; if (t < next_line_end && fb(t) == '>') break; } while (next_content < next_line_end) { char ws = fb(next_content); if (ws == ' ' || ws == '\t') next_content++; else break; } if (next_content < next_line_end && fb(next_content) == '>') { next_content++; if (next_content < next_line_end && fb(next_content) == ' ') next_content++; } q = next_content; soft_break = 1; ch = ' '; } } } else { /* JOIN_MODE_PLAIN */ if (fb(next) == '\n') break; if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break; { uint32_t dummy; if (classify_line(next, &dummy) != LK_PLAIN) break; } if (is_line_blank(next)) break; { uint8_t is_hard = 0; if (q >= para_start + 1) { char b1 = fb(q - 1); if (b1 == '\\') { is_hard = 1; } else if (q >= para_start + 2) { char b2 = fb(q - 2); if ((b1 == ' ' || b1 == '\t') && (b2 == ' ' || b2 == '\t')) is_hard = 1; } } if (is_hard) { q++; emit_seg(q, line_style, ckind, 0); last_space = 0xFFFFFFFFu; prev_ch = ' '; seg_col = col; continue; } } q++; /* как render: пропускаем ведущие пробелы/табы строки-продолжения, * склейка даёт РОВНО один пробел (иначе merge эмитит отступ как лишние ячейки) */ while (q < file_size && (fb(q) == ' ' || fb(q) == '\t')) q++; soft_break = 1; ch = ' '; } } /* Inline-маркеры (\X [x] ` ** ~~ * _ \t) — общий разбор; при * soft_break ch==' ' (синтетический пробел склейки) — маркеры * пропускаем, его кладёт ветка пробела ниже. */ if (!soft_break && inline_marker(&q, file_size, &line_style, &seg_col, &prev_ch, ATTR_TEXT)) continue; if (ch == ' ') { gc_put(' ', styles_map[line_style]); if (soft_break) { last_space = q; last_space_style = line_style; seg_col++; } else { last_space = q + 1; last_space_style = line_style; seg_col++; q++; } g_ncells_at_space = g_ncells; /* снимок для усечения */ prev_ch = ' '; } else { gc_put(ch, styles_map[line_style]); seg_col++; q++; /* soft_break здесь всегда 0 (ch!=' ') */ prev_ch = ch; } if (seg_col >= SCREEN_W) { uint8_t use_ls = (uint8_t)(last_space != 0xFFFFFFFFu && last_space > cur_seg_off); uint32_t wrap_at = use_ls ? last_space : q; if (wrap_at >= file_size || wrap_at == cur_seg_off) break; uint8_t wrap_style = use_ls ? last_space_style : line_style; if (use_ls) g_ncells = g_ncells_at_space; /* усечь до пробела */ emit_seg(wrap_at, wrap_style, ckind, 1); /* Префикс continuation-сегмента: список и отступленный абзац (#4) — * col пробелов выравнивания; цитата — отступ + маркер 0xB3 + пробел. * Для обычного абзаца без отступа col==0 → префикса нет. */ if (ckind == CK_LIST || ckind == CK_PLAIN) { gc_fill(' ', ATTR_TEXT, col); } else if (ckind == CK_QUOTE) { uint32_t content_off2 = para_start + col; uint8_t qsp = 0; while (qsp + 2 < content_off2 && qsp < SCREEN_W && fb(para_start + qsp) == ' ') qsp++; gc_fill(' ', ATTR_TEXT, qsp); gc_put(0xB3, ATTR_QUOTE_MARKER); gc_put(' ', ATTR_TEXT); } seg_col = col; last_space = 0xFFFFFFFFu; prev_ch = ' '; q = wrap_at; /* Откатываем line_style к снимку на момент wrap_at: диапазон * [wrap_at, q_old) будет пересканирован заново, и переключающие * символы (обратная кавычка, звёздочка, подчёркивание, тильда) * внутри него иначе применились бы дважды — именно так возникал * баг: code-маркер на границе переноса ломает цвет следующего слова (mdview.c, отчёт пользователя * 2026-06-23). */ line_style = wrap_style; } } *io_line_style = line_style; return q; } /* ====================== Таблицы: выровненная отрисовка ==================== */ #define TBL_MAX_COLS 16 #define TBL_ATTR ATTR_BOX /* CP866 light box-drawing. */ #define TBL_H 0xC4 /* ─ */ #define TBL_V 0xB3 /* │ */ #define TBL_TL 0xDA /* ┌ */ #define TBL_TM 0xC2 /* ┬ */ #define TBL_TR 0xBF /* ┐ */ #define TBL_ML 0xC3 /* ├ */ #define TBL_MM 0xC5 /* ┼ */ #define TBL_MR 0xB4 /* ┤ */ #define TBL_BL 0xC0 /* └ */ #define TBL_BM 0xC1 /* ┴ */ #define TBL_BR 0xD9 /* ┘ */ /* Смещение завершающего '\n' строки (или file_size). */ static uint32_t row_end(uint32_t p) { while (p < file_size && fb(p) != '\n') p++; return p; } /* Начало контента первой ячейки строки (после ведущих пробелов и '|'). */ static uint32_t table_first_cell(uint32_t row_start) { uint32_t p = row_start; while (p < file_size && fb(p) == ' ') p++; if (p < file_size && fb(p) == '|') p++; return p; } /* Следующая ячейка: [*cs,*ce) — обрезанный диапазон; *pp продвигается за '|'. * 0 — ячеек больше нет (включая хвостовую пустоту после последнего '|'). */ static uint8_t table_next_cell(uint32_t *pp, uint32_t lineend, uint32_t *cs, uint32_t *ce) { uint32_t p = *pp; if (p >= lineend) return 0; uint32_t a = p; while (p < lineend && fb(p) != '|') p++; uint32_t b = p; uint8_t had_pipe = (uint8_t)(p < lineend && fb(p) == '|'); if (had_pipe) p++; *pp = p; while (a < b && fb(a) == ' ') a++; while (b > a && fb(b - 1) == ' ') b--; if (!had_pipe && a == b) return 0; *cs = a; *ce = b; return 1; } /* Строка-разделитель (|---|:-:|): каждая ячейка непустая и только '-'/':'. */ static uint8_t table_is_sep_row(uint32_t row_start, uint32_t lineend) { uint32_t p = table_first_cell(row_start); uint32_t cs, ce; uint8_t n = 0; while (table_next_cell(&p, lineend, &cs, &ce)) { if (cs >= ce) return 0; for (uint32_t q = cs; q < ce; q++) { char c = fb(q); if (c != '-' && c != ':') return 0; } n++; } return (uint8_t)(n > 0); } /* Горизонтальная рамка в g_cells: left + (H×(w+2) + mid|right) по колонкам. */ static void table_border(const uint8_t *widths, uint8_t ncols, char left, char mid, char right) { gc_put(left, TBL_ATTR); for (uint8_t c = 0; c < ncols; c++) { gc_fill(TBL_H, TBL_ATTR, (uint8_t)(widths[c] + 2)); gc_put((c == (uint8_t)(ncols - 1)) ? right : mid, TBL_ATTR); } } /* Строка данных в g_cells: │ <ячейка, добитая до widths[c]> │ … */ static void table_data_row(uint32_t row_start, uint32_t lineend, const uint8_t *widths, uint8_t ncols) { uint32_t p = table_first_cell(row_start); uint32_t cs, ce; gc_put(TBL_V, TBL_ATTR); for (uint8_t c = 0; c < ncols; c++) { uint8_t got = table_next_cell(&p, lineend, &cs, &ce); gc_put(' ', TBL_ATTR); uint8_t rendered = 0; if (got && ce > cs) { uint8_t before = g_ncells; (void)inline_scan(cs, ce, 0, CK_OTHER, INIT_STYLE_PLAIN, ATTR_TEXT, 1); rendered = (uint8_t)(g_ncells - before); } if (rendered < widths[c]) gc_fill(' ', ATTR_TEXT, (uint8_t)(widths[c] - rendered)); gc_put(' ', TBL_ATTR); gc_put(TBL_V, TBL_ATTR); } } /* Строит индекс сегментов рендера по загруженному файлу. */ static void index_lines(void) { uint8_t in_block = 0; n_lines = 0; index_truncated = 0; cur_seg_off = 0xFFFFFFFFu; /* пока не эмитили ни одного сегмента */ if (file_size == 0) return; uint32_t p = 0; while (p < file_size && n_lines < max_lines && !g_abort) { if (g_loading) progress_tick(); /* первый экран + статус + скролл по готовым данным */ else if ((n_lines & 15) == 0) spinner_tick(); /* Пустая строка — разделитель параграфов. * В индекс добавляем не более одной пустой экранной строки подряд. */ if (is_line_blank(p)) { while (p < file_size && fb(p) != '\n') p++; if (p < file_size) p++; /* Серия пустых строк схлопывается в одну визуальную строку. */ if (n_lines == 0 || !(cur_rec.flags & IF_BLANK)) { emit_seg(p, INIT_STYLE_PLAIN, CK_PLAIN, 0); set_blank_cur(); } continue; } /* ---- fenced code-блок (граница или тело) ---- */ if (is_fence_raw(p)) { in_block = !in_block; emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); set_nowrap_cur(); while (p < file_size && fb(p) != '\n') p++; if (p < file_size) p++; continue; } if (in_block) { emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); set_nowrap_cur(); set_code_cur(); set_hscroll_cur(); /* код скроллится целым блоком */ { uint8_t ccx = 0; uint32_t pp = p; while (pp < file_size && fb(pp) != '\n') { char ch = fb(pp); if (ch == '\t') { uint8_t tgt = (uint8_t)((ccx & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP); gc_fill(' ', ATTR_TEXT_CODE, (uint8_t)(tgt - ccx)); ccx = tgt; } else if (ch == '\r') { gc_put(' ', ATTR_TEXT_CODE); ccx++; /* как render: \r → пробел */ } else { gc_put(ch, ATTR_TEXT_CODE); ccx++; } pp++; } } while (p < file_size && fb(p) != '\n') p++; if (p < file_size) p++; continue; } /* ---- горизонтальный разделитель ---- */ if (is_hr_raw(p)) { emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); set_nowrap_cur(); gc_fill(0xC4, ATTR_HR, SCREEN_W); while (p < file_size && fb(p) != '\n') p++; if (p < file_size) p++; continue; } /* ---- строка таблицы (| ... |): один nowrap-сегмент на исходную строку ---- */ if (is_table_raw(p)) { /* Освобождаем g_cells под измерение ширин: коммитим pending-сегмент * сейчас; первый emit_seg таблицы пропустит свой lag-флаш. */ if (n_lines >= 1) { seg_commit_cells((uint16_t)(n_lines - 1)); g_skip_flush = 1; } g_ncells = 0; /* Проход 1: границы блока + ОТРЕНДЕРЕННЫЕ ширины колонок (мерим тем же * inline_scan, что и при отрисовке — невидимые маркеры стиля не считаются). */ uint8_t widths[TBL_MAX_COLS]; for (uint8_t i = 0; i < TBL_MAX_COLS; i++) widths[i] = 0; uint8_t ncols = 0; uint32_t r = p; while (r < file_size && is_table_raw(r)) { uint32_t le = row_end(r); uint8_t sep = table_is_sep_row(r, le); uint32_t cp = table_first_cell(r); uint32_t cs, ce; uint8_t c = 0; while (c < TBL_MAX_COLS && table_next_cell(&cp, le, &cs, &ce)) { if (!sep && ce > cs) { g_ncells = 0; (void)inline_scan(cs, ce, 0, CK_OTHER, INIT_STYLE_PLAIN, ATTR_TEXT, 1); if (g_ncells > widths[c]) widths[c] = g_ncells; } c++; } if (c > ncols) ncols = c; r = (le < file_size) ? le + 1 : le; } g_ncells = 0; /* чисто для верхней рамки */ uint32_t tbl_end = r; if (ncols == 0) ncols = 1; /* Проход 2: верхняя рамка → строки/разделитель → нижняя рамка. */ emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); set_nowrap_cur(); set_hscroll_cur(); table_border(widths, ncols, TBL_TL, TBL_TM, TBL_TR); r = p; while (r < tbl_end) { uint32_t le = row_end(r); emit_seg(r, INIT_STYLE_PLAIN, CK_OTHER, 0); set_nowrap_cur(); set_hscroll_cur(); if (table_is_sep_row(r, le)) table_border(widths, ncols, TBL_ML, TBL_MM, TBL_MR); else table_data_row(r, le, widths, ncols); r = (le < file_size) ? le + 1 : le; } emit_seg(tbl_end, INIT_STYLE_PLAIN, CK_OTHER, 0); set_nowrap_cur(); set_hscroll_cur(); table_border(widths, ncols, TBL_BL, TBL_BM, TBL_BR); p = tbl_end; continue; } /* ---- классификация текущей строки ---- */ uint32_t content_off = p; uint8_t kind = classify_line(p, &content_off); /* ---- Заголовок: отдельная строка, не склеивается с параграфом ---- */ if (kind >= LK_H1 && kind <= LK_H4) { uint8_t hbase; /* title-attr, как в render */ switch (kind) { case LK_H1: hbase = ATTR_TEXT_TITLE1; break; case LK_H2: hbase = ATTR_TEXT_TITLE2; break; case LK_H3: hbase = ATTR_TEXT_TITLE3; break; default: hbase = ATTR_TEXT_TITLE4; break; } emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0); uint32_t line_end = content_off; while (line_end < file_size && fb(line_end) != '\n') line_end++; (void)inline_scan(content_off, line_end, 0, CK_OTHER, INIT_STYLE_PLAIN, hbase, 0); while (p < file_size && fb(p) != '\n') p++; if (p < file_size) p++; continue; } /* ---- Пункт списка: может занимать несколько физических строк ---- */ if (kind == LK_ULIST || kind == LK_OLIST) { uint8_t col = marker_visible_col(kind, p, content_off); uint8_t line_style = INIT_STYLE_PLAIN; uint32_t q = content_off; emit_seg(p, line_style, CK_LIST, 0); /* Префикс ПЕРВОГО сегмента: отступ + маркер + пробел (как render). */ { uint32_t qq = p, qmax = p + SCREEN_W; if (kind == LK_ULIST) { while (qq + 2 < content_off && qq < qmax && fb(qq) == ' ') qq++; gc_fill(' ', ATTR_TEXT, (uint8_t)(qq - p)); gc_put(0x07, ATTR_LIST_MARKER); gc_put(' ', ATTR_TEXT); } else { while (qq < content_off && qq < qmax && fb(qq) == ' ') qq++; gc_fill(' ', ATTR_TEXT, (uint8_t)(qq - p)); while (qq < content_off - 1) { gc_put(fb(qq), ATTR_LIST_MARKER); qq++; } gc_put(' ', ATTR_TEXT); } } q = scan_join_stream(q, p, col, CK_LIST, &line_style, JOIN_MODE_LIST); /* Переходим к началу следующей физической строки после пункта. */ if (q < file_size) { while (q < file_size && fb(q) != '\n') q++; if (q < file_size) q++; } /* Одна пустая строка между соседними маркерами списка подавляется. * Две и более пустых строки оставляют видимый разделитель. */ if (q < file_size && is_line_blank(q)) { uint32_t t = q; uint8_t blanks = 0; while (t < file_size && is_line_blank(t)) { while (t < file_size && fb(t) != '\n') t++; if (t < file_size) t++; blanks++; // if (blanks >= 2) break; } if (blanks == 1 && t < file_size) { uint32_t d = t; uint8_t nk = classify_line(t, &d); if (nk == LK_ULIST || nk == LK_OLIST) { /* #3: подавляем blank только если следующий пункт того же * уровня или глубже; при МЕНЬШЕМ отступе (dedent) пустую * строку оставляем — пункты должны визуально разделиться. */ if (leading_spaces(t) >= leading_spaces(p)) { p = t; /* подавляем единственную пустую строку */ continue; } } } } p = q; continue; } /* ---- Параграф цитаты: может покрывать несколько физических строк ---- */ if (kind == LK_QUOTE) { uint8_t col = marker_visible_col(kind, p, content_off); uint8_t line_style = INIT_STYLE_PLAIN; uint32_t q = content_off; emit_seg(p, line_style, CK_QUOTE, 0); /* Префикс ПЕРВОГО сегмента: отступ + маркер 0xB3 + пробел (как render LK_QUOTE). */ { uint32_t qq = p, qmax = p + SCREEN_W; while (qq + 1 < content_off && qq < qmax && fb(qq) == ' ') qq++; gc_fill(' ', ATTR_TEXT, (uint8_t)(qq - p)); gc_put(0xB3, ATTR_QUOTE_MARKER); gc_put(' ', ATTR_TEXT); } q = scan_join_stream(q, p, col, CK_QUOTE, &line_style, JOIN_MODE_QUOTE); while (q < file_size && fb(q) != '\n') q++; if (q < file_size) q++; p = q; continue; } /* ---- Обычный текстовый параграф: склейка через soft-break ---- */ { uint8_t line_style = INIT_STYLE_PLAIN; /* #4: если абзац начинается с отступа — все его перенесённые строки * получают такой же отступ (как continuation-префикс списка, но без * маркера). Контент сканируем после отступа. Патологически большой * отступ игнорируем. */ uint8_t indent = leading_spaces(p); if (indent >= SCREEN_W / 2) indent = 0; emit_seg(p, line_style, CK_PLAIN, 0); if (indent) gc_fill(' ', ATTR_TEXT, indent); /* префикс первого сегмента */ uint32_t q = p + indent; q = scan_join_stream(q, p, indent, CK_PLAIN, &line_style, JOIN_MODE_PLAIN); if (q >= file_size) { p = file_size; } else { /* q стоит на завершающем newline параграфа. * Переходим на следующую строку, чтобы внешняя петля её * корректно переклассифицировала. */ while (q < file_size && fb(q) != '\n') q++; if (q < file_size) q++; p = q; } } } /* emit_seg() флашит ячейки с отставанием на одну строку (см. её * комментарий) — последняя эмитированная строка файла так и остаётся * незафлашенной, её ячейки ещё в g_cells; коммитим здесь. */ if (n_lines > 0) seg_commit_cells((uint16_t)(n_lines - 1)); } #define EM_NONE 0 #define EM_BOLD 1 #define EM_ITALIC 2 #define EM_UNDER 3 #define EM_CODE 4 #define EM_STRIKE 5 /* Преобразует внутреннее состояние emphasis в экранный атрибут. */ static uint8_t emph_to_attr(uint8_t emph, uint8_t base_attr) { switch (emph) { case EM_BOLD: return ATTR_TEXT_BOLD; case EM_ITALIC: return ATTR_TEXT_ITALIC; case EM_UNDER: return ATTR_TEXT_UNDERSORE; case EM_CODE: return ATTR_TEXT_CODE; case EM_STRIKE: return ATTR_TEXT_STRIKE; default: return base_attr; } } /* Таблицы ремапа СТАРШИХ байтов [128-255] в CP866. Индексируются (ch-0x80): * младшие 128 (ASCII) — identity, в ремапе не участвуют (win_rest_remap * трогает только ch>=0x80), поэтому в таблицах их нет. Кириллица + ходовая * пунктуация; неизвестное → '?' (0x3F). Сгенерированы Python codecs. */ static const uint8_t cp1251_to_866[128] = { /* 80 */ 0x3F, 0x3F, 0x27, 0x3F, 0x22, 0x2E, 0x3F, 0x3F, 0x3F, 0x3F, 0x3F, 0x3C, 0x3F, 0x3F, 0x3F, 0x3F, /* 90 */ 0x3F, 0x27, 0x27, 0x22, 0x22, 0x07, 0x2D, 0x2D, 0x3F, 0x3F, 0x3F, 0x3E, 0x3F, 0x3F, 0x3F, 0x3F, /* A0 */ 0xFF, 0xF6, 0xF7, 0x3F, 0xFD, 0x3F, 0x3F, 0x3F, 0xF0, 0x63, 0xF2, 0x3C, 0x3F, 0x3F, 0x72, 0xF4, /* B0 */ 0xF8, 0x3F, 0x3F, 0x3F, 0x3F, 0x3F, 0x3F, 0xFA, 0xF1, 0xFC, 0xF3, 0x3E, 0x3F, 0x3F, 0x3F, 0xF5, /* C0 */ 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, /* D0 */ 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F, /* E0 */ 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, /* F0 */ 0xE0, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, }; static const uint8_t koi8r_to_866[128] = { /* 80 */ 0xC4, 0xB3, 0xDA, 0xBF, 0xC0, 0xD9, 0xC3, 0xB4, 0xC2, 0xC1, 0xC5, 0xDF, 0xDC, 0xDB, 0xDD, 0xDE, /* 90 */ 0xB0, 0xB1, 0xB2, 0x3F, 0xFE, 0xF9, 0xFB, 0x3F, 0x3F, 0x3F, 0xFF, 0x3F, 0xF8, 0x3F, 0xFA, 0x3F, /* A0 */ 0xCD, 0xBA, 0xD5, 0xF1, 0xD6, 0xC9, 0xB8, 0xB7, 0xBB, 0xD4, 0xD3, 0xC8, 0xBE, 0xBD, 0xBC, 0xC6, /* B0 */ 0xC7, 0xCC, 0xB5, 0xF0, 0xB6, 0xB9, 0xD1, 0xD2, 0xCB, 0xCF, 0xD0, 0xCA, 0xD8, 0xD7, 0xCE, 0x63, /* C0 */ 0xEE, 0xA0, 0xA1, 0xE6, 0xA4, 0xA5, 0xE4, 0xA3, 0xE5, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, /* D0 */ 0xAF, 0xEF, 0xE0, 0xE1, 0xE2, 0xE3, 0xA6, 0xA2, 0xEC, 0xEB, 0xA7, 0xE8, 0xED, 0xE9, 0xE7, 0xEA, /* E0 */ 0x9E, 0x80, 0x81, 0x96, 0x84, 0x85, 0x94, 0x83, 0x95, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, /* F0 */ 0x8F, 0x9F, 0x90, 0x91, 0x92, 0x93, 0x86, 0x82, 0x9C, 0x9B, 0x87, 0x98, 0x9D, 0x99, 0x97, 0x9A, }; /* Устанавливает активную кодировку и таблицу ремапа (для 8-битных). */ static void set_encoding(uint8_t enc) { g_encoding = enc; g_remap = (enc == ENC_CP1251) ? cp1251_to_866 : (enc == ENC_KOI8R) ? koi8r_to_866 : (const uint8_t *)0; } /* ---------- Сохранение/восстановление/переключение наборов ---------- * 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; } /* Самые ходовые строчные русские буквы (о е а и н т с р в л) — их байты * различают 8-битные кодировки по частоте. */ static const uint8_t common866 [10] = {0xAE,0xA5,0xA0,0xA8,0xAD,0xE2,0xE1,0xE0,0xA2,0xAB}; static const uint8_t common1251[10] = {0xEE,0xE5,0xE0,0xE8,0xED,0xF2,0xF1,0xF0,0xE2,0xEB}; static const uint8_t commonkoi [10] = {0xCF,0xC5,0xC1,0xC9,0xCE,0xD4,0xD3,0xD2,0xD7,0xCC}; static uint8_t in_set10(const uint8_t *s, uint8_t b) { for (uint8_t i = 0; i < 10; i++) if (s[i] == b) return 1; return 0; } /* Автоопределение кодировки дешёвым сканом байтов (до построения индекса). * BOM → UTF8; иначе валидность UTF-8 (структура multibyte); иначе 8-бит по * частоте ходовых букв; фолбэк CP866. */ static uint8_t detect_encoding(void) { if (file_size >= 3 && (uint8_t)fb(0) == 0xEF && (uint8_t)fb(1) == 0xBB && (uint8_t)fb(2) == 0xBF) return ENC_UTF8; uint32_t n = file_size; if (n > 4096u) n = 4096u; /* сэмпл: первые 4 КБ — детекции хватает */ uint8_t utf_ok = 1, has_mb = 0, has_high = 0, cont = 0; uint16_t s866 = 0, s1251 = 0, skoi = 0; for (uint32_t p = 0; p < n; p++) { uint8_t b = (uint8_t)fb(p); if (b < 0x80) { if (cont) { utf_ok = 0; cont = 0; } continue; } has_high = 1; if (in_set10(common866, b)) s866++; if (in_set10(common1251, b)) s1251++; if (in_set10(commonkoi, b)) skoi++; if (cont) { if ((b & 0xC0) == 0x80) cont--; else { utf_ok = 0; cont = 0; } } else if (b >= 0xC2 && b <= 0xDF) { cont = 1; has_mb = 1; } else if (b >= 0xE0 && b <= 0xEF) { cont = 2; has_mb = 1; } else if (b >= 0xF0 && b <= 0xF4) { cont = 3; has_mb = 1; } else utf_ok = 0; /* битый лид/одиночный континюэйшн */ } /* Незавершённая multibyte-последовательность на КОНЦЕ — нарушение только * если это настоящий EOF; на границе сэмпла (n= s866 && s1251 >= skoi) return ENC_CP1251; if (skoi >= s866) return ENC_KOI8R; return ENC_CP866; } static const char *enc_name(uint8_t enc) { return (enc == ENC_CP1251) ? "CP1251" : (enc == ENC_KOI8R) ? "KOI8-R" : (enc == ENC_UTF8) ? "UTF-8 " : "CP866 "; } /* Контентный глиф (ремапим при смене кодировки) vs структурный (рамка/HR/ * маркеры — уже CP866, не трогаем). Различаем по attr. */ static uint8_t attr_is_content(uint8_t a) { return (uint8_t)(a != ATTR_BOX && a != ATTR_HR && a != ATTR_LIST_MARKER && a != ATTR_QUOTE_MARKER); } /* Вывод среза строки кэша с ремапом контентных глифов [128-255] через * g_remap (CP1251/KOI8). Без активной таблицы — прямой win_rest. */ static void win_rest_remap(uint8_t row, uint8_t w, uint8_t page, uint16_t off) { if (!g_remap || w == 0) { win_rest(row, 0, 1, w, page, off); return; } uint8_t buf[SCREEN_W * 2]; bank_read(page, off, buf, (uint16_t)w * 2u); for (uint8_t i = 0; i < w; i++) { uint8_t ch = buf[(uint16_t)i * 2u]; if (ch >= 0x80 && attr_is_content(buf[(uint16_t)i * 2u + 1u])) buf[(uint16_t)i * 2u] = g_remap[ch - 0x80]; /* таблицы хранят только старшие 128 */ } bank_write(g_scratch_phys, 0, buf, (uint16_t)w * 2u); win_rest(row, 0, 1, w, g_scratch_phys, 0); } /* ========================= UTF-8 → CP866 (Фаза 2) ======================== */ /* Кириллица U+0400..U+045F → CP866. Русский набор + Ё/ё и часть украинских * (Є є Ї ї Ў ў), которые есть в CP866; пропуски → '?' (0x3F). */ static const uint8_t utf_cyr_to_866[96] = { /* 0400 */ 0x3F, 0xF0, 0x3F, 0x3F, 0xF2, 0x3F, 0x3F, 0xF4, 0x3F, 0x3F, 0x3F, 0x3F, 0x3F, 0x3F, 0xF6, 0x3F, /* 0410 */ 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8A, 0x8B, 0x8C, 0x8D, 0x8E, 0x8F, /* 0420 */ 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0x9B, 0x9C, 0x9D, 0x9E, 0x9F, /* 0430 */ 0xA0, 0xA1, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xAB, 0xAC, 0xAD, 0xAE, 0xAF, /* 0440 */ 0xE0, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xEB, 0xEC, 0xED, 0xEE, 0xEF, /* 0450 */ 0x3F, 0xF1, 0x3F, 0x3F, 0xF3, 0x3F, 0x3F, 0xF5, 0x3F, 0x3F, 0x3F, 0x3F, 0x3F, 0x3F, 0xF7, 0x3F, }; /* Поток вывода конвертера: пишем в utf_phys чанками по странице, буфер в * near-памяти, флаш через один bank_write (не побайтовый swap W3). */ static uint8_t conv_buf[256]; static uint16_t conv_n = 0; /* байт в буфере, ещё не сброшено */ static uint8_t conv_page = 0; /* индекс текущей страницы в utf_phys */ static uint16_t conv_off = 0; /* уже сброшенное смещение в этой странице */ static void conv_flush(void) { if (conv_n) { bank_write(utf_phys[conv_page], conv_off, conv_buf, conv_n); conv_off = (uint16_t)(conv_off + conv_n); conv_n = 0; } } /* Добавляет один CP866-байт в выходной поток. Запись никогда не пересекает * границу страницы (256 делит 16384 нацело). */ static void conv_put(uint8_t b) { if ((uint16_t)(conv_off + conv_n) == PAGE_SIZE) { /* страница заполнена */ conv_flush(); conv_page++; conv_off = 0; } conv_buf[conv_n++] = b; if (conv_n == (uint16_t)sizeof(conv_buf)) conv_flush(); } /* Подстановки одиночных не-кириллических символов UTF-8 → один CP866-байт. * Таблица вместо switch (экономия кода); линейный поиск дёшев — вызывается * лишь при конвертации на редких символьных кодпойнтах. Чтобы добавить новый * символ — достаточно дописать одну строку {кодпойнт, байт-CP866}. * (Символы с НЕ-1:1 заменой, напр. … → "...", обрабатываются отдельно ниже.) */ typedef struct { uint16_t utf8; uint8_t cp866; } utf_sym_t; static const utf_sym_t utf_sym[] = { {0x00A0, 0x20}, {0x00AB, 0x3C}, {0x00BB, 0x3E}, /* nbsp « » → space < > */ {0x00B0, 0xF8}, {0x00B7, 0xFA}, /* ° · */ {0x2013, 0x2D}, {0x2014, 0x2D}, /* – — → '-' */ {0x2018, 0x27}, {0x2019, 0x27}, /* ‘ ’ → '\'' */ {0x201C, 0x22}, {0x201D, 0x22}, /* “ ” → '"' */ {0x2022, 0xF9}, {0x2116, 0xFC}, /* • → ∙ № */ {0x2190, 0x1B}, {0x2191, 0x18}, {0x2192, 0x1A}, {0x2193, 0x19}, /* ← ↑ → ↓ */ {0x2500, 0xC4}, {0x2502, 0xB3}, {0x250C, 0xDA}, {0x2510, 0xBF}, /* ─ │ ┌ ┐ */ {0x2514, 0xC0}, {0x2518, 0xD9}, {0x251C, 0xC3}, {0x2524, 0xB4}, /* └ ┘ ├ ┤ */ {0x252C, 0xC2}, {0x2534, 0xC1}, {0x253C, 0xC5}, /* ┬ ┴ ┼ */ {0x2580, 0xDF}, {0x2584, 0xDC}, {0x2588, 0xDB}, /* ▀ ▄ █ */ {0x2591, 0xB0}, {0x2592, 0xB1}, {0x2593, 0xB2}, {0x25A0, 0xFE}, /* ░ ▒ ▓ ■ */ {0x2713, 0xFB}, {0x2714, 0xFB}, /* ✓ ✔ → галка */ {0x2715, 0x78}, {0x2717, 0x78}, {0x2718, 0x78}, /* ✕ ✗ ✘ → 'x' */ }; #define UTF_SYM_N (uint8_t)(sizeof(utf_sym) / sizeof(utf_sym[0])) /* Кодпойнт → CP866-глиф(ы). Кириллица по таблице; спецслучаи (…, BOM) явно; * остальные ходовые символы по таблице utf_sym[]; прочее → '?'. */ static void conv_emit_cp(uint32_t cp) { if (cp < 0x80) { conv_put((uint8_t)cp); return; } if (cp >= 0x0400 && cp <= 0x045F) { conv_put(utf_cyr_to_866[cp - 0x0400]); return; } if (cp == 0x2026) { conv_put('.'); conv_put('.'); conv_put('.'); return; } /* … → "..." */ if (cp == 0xFEFF) return; /* BOM/ZWNBSP — выкинуть */ for (uint8_t i = 0; i < UTF_SYM_N; i++) if (utf_sym[i].utf8 == (uint16_t)cp) { conv_put(utf_sym[i].cp866); return; } conv_put('?'); } /* Декодирует активный (исходный UTF-8) буфер в utf_phys как CP866. * Битые/обрезанные последовательности → '?'; 4-байтные (эмодзи и т.п.) → '?'. */ static void utf8_convert(void) { conv_n = 0; conv_page = 0; conv_off = 0; uint32_t n = file_size; uint32_t p = 0; while (p < n) { if ((uint16_t)((uint16_t)p & 0x0FFFu) == 0) spinner_tick(); uint8_t b = (uint8_t)fb(p++); if (b < 0x80) { conv_emit_cp(b); continue; } uint32_t cp; uint8_t need; if ((b & 0xE0) == 0xC0) { cp = (uint32_t)(b & 0x1F); need = 1; } else if ((b & 0xF0) == 0xE0) { cp = (uint32_t)(b & 0x0F); need = 2; } else if ((b & 0xF8) == 0xF0) { cp = (uint32_t)(b & 0x07); need = 3; } else { conv_put('?'); continue; } /* одиночный континюэйшн/битый лид */ uint8_t ok = 1; for (uint8_t k = 0; k < need; k++) { if (p >= n) { ok = 0; break; } uint8_t cb = (uint8_t)fb(p); if ((cb & 0xC0) != 0x80) { ok = 0; break; } cp = (cp << 6) | (uint32_t)(cb & 0x3F); p++; } if (!ok || need == 3) { conv_put('?'); continue; } /* битое / 4-байтное */ conv_emit_cp(cp); } conv_flush(); utf_size = (uint32_t)conv_page * PAGE_SIZE + conv_off; } /* Выделяет страницы под UTF-8 набор и конвертирует туда исходник (читает * оригинал — вызывать, когда живой буфер = исходные байты). НЕ перенаправляет * активный буфер: это делает build_doc() при сборке набора DOC_UTF8. * Конвертированный поток ≤ исходного (кириллица 2 байта → 1), хватает того же * числа страниц. Возврат: 1 — успех, 0 — нет EMM. */ static uint8_t alloc_and_convert_utf8(void) { utf_pages = file_pages; utf_blk = mem_alloc_pages(utf_pages); if (utf_blk == 0) return 0; for (uint8_t i = 0; i < utf_pages; i++) utf_phys[i] = mem_get_page(utf_blk, i); utf8_convert(); /* читает оригинал (active) → utf_phys */ return 1; } /* Фаза 4-5 — cache-only draw path: рисует одну строку ИЗ КЭША, без * единого обращения к fb()/исходному файлу. Горизонтальный скролл для * nowrap-строк (Фаза 5) — это просто смещение начала среза на vx*2 * байт ВНУТРИ ТОГО ЖЕ кэш-буфера (cache_reserve гарантирует, что вся * строка лежит в одной странице, так что off+vx*2 не пересекает * границу страницы); не nowrap-строки игнорируют viewport_x, как и * раньше в живом render_line() (там это называлось effective_vx). */ static void draw_line_from_cache(uint16_t line_idx, uint8_t row) { cache_rec_t rec; cache_dir_get(line_idx, &rec); bios_set_place(row, 0); bios_fillcharattr(' ', ATTR_TEXT, SCREEN_W); /* Горизонтальный сдвиг применяется ко ВСЕМ строкам hscroll-блока (код, * таблицы) — по ТИПУ, а не по длине: блок двигается целиком, включая * строки короче 80 (короткая строка, ушедшая за левый край, просто * показывает маркер '<' на пустом месте). HR и границы fence не имеют * IF_HSCROLL и не сдвигаются. */ uint8_t vx = (rec.flags & IF_HSCROLL) ? viewport_x : 0; if (rec.len > vx) { uint16_t remaining = (uint16_t)(rec.len - vx); uint8_t w = (remaining > SCREEN_W) ? SCREEN_W : (uint8_t)remaining; uint16_t off = (uint16_t)(rec.off + (uint16_t)vx * 2u); win_rest_remap(row, w, rec.page, off); if (remaining > SCREEN_W) { wrchar(SCREEN_W - 1, row, '>', ATTR_TRUNC); } } if (vx > 0) { wrchar(0, row, '<', ATTR_TRUNC); } } /* Перерисовывает все VIEW_H строк окна из кэша — основной путь * перерисовки видимой области после загрузки файла (Фаза 3-4). */ static void draw_viewport_from_cache(void) { for (uint8_t i = 0; i < VIEW_H; i++) { draw_line_from_cache((uint16_t)(top_line + i), (uint8_t)(VIEW_TOP_ROW + i)); } } /* Число строк, готовых к показу. Во время загрузки последняя эмитированная * строка ещё в g_cells (не зафлашена), поэтому доступны [0..n_lines-2]. */ static uint16_t drawable_lines(void) { if (g_loading) return (n_lines >= 1) ? (uint16_t)(n_lines - 1) : 0; return n_lines; } /* Возвращает процент прокрутки (0..100) относительно доступного диапазона. */ static uint8_t calc_pct(void) { uint16_t total = drawable_lines(); uint8_t pct; if (total <= VIEW_H) pct = 100; else pct = (uint8_t)((uint32_t)top_line * 100UL / (uint32_t)(total - VIEW_H)); return pct; } /* Рисует динамическую часть статус-бара без printf. * Полностью перезаписывает колонки 45..79, чтобы не оставлять «хвосты». */ static void render_status_numbers(void) { uint16_t total = drawable_lines(); uint16_t last = top_line + VIEW_H; if (last > total) last = total; uint8_t pct = calc_pct(); textattr(ATTR_BAR); bios_set_place(0, 45); bios_fillcharattr(' ', ATTR_BAR, (uint8_t)(SCREEN_W - 45)); wrchar(45, 0, 0xB3, ATTR_BAR); /* вертикальный разделитель */ gotoxy(47, 0); cputs("L "); dec16(top_line + 1); cputs("-"); dec16(last); cputs(" / "); dec16(total); if (g_loading) cputs("..."); /* ещё грузится */ wrchar(70, 0, 0xB3, ATTR_BAR); /* вертикальный разделитель */ gotoxy( 73, 0); dec8(pct); cputs("%"); } /* Полностью перерисовывает статус-бар (заголовок + числа). */ 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); put_str_attr(37, 0, enc_name(g_encoding), ATTR_BAR); /* текущая кодировка */ render_status_numbers(); } /* Обновляет только изменяемую числовую часть статус-бара. */ static void render_updated_status(void) { render_status_numbers(); } /* Отрисовывает нижнюю строку меню. */ static void render_menu(void) { /* Строка меню разбита на 10 блоков по 8 колонок: метку Fn кладём в блок * (n-1)*8, чтобы позиции соответствовали номерам клавиш (F1→0, F8→7, * F10→9). Пока заполнены только F1/F8/F10. */ fill_row(MENU_ROW, ATTR_MENU_T); put_str_attr(0 * 8 + 1, MENU_ROW, "F1", ATTR_MENU_K); put_str_attr(0 * 8 + 4, MENU_ROW, "Help", ATTR_MENU_T); if (g_f8_enabled) { /* F8 показываем только когда переключение кодировки возможно */ put_str_attr(7 * 8 + 1, MENU_ROW, "F8", ATTR_MENU_K); put_str_attr(7 * 8 + 4, MENU_ROW, "Codepage", ATTR_MENU_T); } put_str_attr(9 * 8 + 0, MENU_ROW, "F10", ATTR_MENU_K); put_str_attr(9 * 8 + 4, MENU_ROW, "Exit", ATTR_MENU_T); } /* ================================================================== * Прокрутка * ================================================================== */ /* Ограничивает top_line допустимым диапазоном с учётом высоты окна. */ static void clamp_top(void) { if (n_lines <= VIEW_H) { top_line = 0; } else if (top_line > n_lines - VIEW_H) { top_line = (uint16_t)(n_lines - VIEW_H); } } /* Прокрутка вверх на n строк с частичной перерисовкой при n == 1. */ static void scroll_up(uint16_t n) { uint16_t new_top_line = (top_line >= n) ? (uint16_t)(top_line - n) : 0; if( new_top_line != top_line) { top_line = new_top_line; if (n == 1) { scroll(0, VIEW_TOP_ROW, SCREEN_W, VIEW_H, 2, 0); draw_line_from_cache((uint16_t)(top_line), (uint8_t)(VIEW_TOP_ROW)); } else { draw_viewport_from_cache(); } } } /* Прокрутка вниз на n строк с частичной перерисовкой при n == 1. */ static void scroll_down(uint16_t n) { uint16_t new_top_line = (top_line + n < n_lines - VIEW_H) ? (uint16_t)(top_line + n) : n_lines - VIEW_H; if( new_top_line != top_line) { top_line = new_top_line; if (n == 1) { scroll(0, VIEW_TOP_ROW, SCREEN_W, VIEW_H, 1, 0); draw_line_from_cache((uint16_t)(top_line + VIEW_H - 1), (uint8_t)(VIEW_TOP_ROW + VIEW_H - 1)); } else { clamp_top(); draw_viewport_from_cache(); } } } /* Горизонтальный сдвиг (только если в окне есть nowrap-строки). * Максимум сдвига ограничен самой широкой nowrap-строкой на экране. * Фаза 5: ширина берётся прямо из директории кэша (rec.len — уже * посчитанная реальная длина в ячейках после рендера), без единого * обращения к fb()/seg_off()/исходному файлу. */ static void scroll_h(int8_t delta) { uint16_t maxw = 0; for (uint8_t i = 0; i < VIEW_H; i++) { uint16_t li = (uint16_t)(top_line + i); if (li >= n_lines) break; cache_rec_t rec; cache_dir_get(li, &rec); if (!(rec.flags & IF_HSCROLL)) continue; /* только код/таблицы */ if (rec.len > maxw) maxw = rec.len; /* самая широкая строка блока */ } if (maxw == 0) return; /* нет строк шире экрана */ /* Максимальный сдвиг = ширина за пределами экрана, в границах uint8. */ uint16_t over = (maxw > SCREEN_W) ? (uint16_t)(maxw - SCREEN_W) : 0; if (over > 248u) over = 248u; uint8_t max_vx = (uint8_t)over; int16_t nx = (int16_t)viewport_x + delta; if (nx < 0) nx = 0; if (nx > (int16_t)max_vx) nx = max_vx; /* ПОРЯДОК ВАЖЕН: записываем viewport_x ДО сравнения, а сравниваем уже * сохранённую копию old_vx с new_vx. Иначе SDCC генерирует для * `if (new_vx != viewport_x) viewport_x = new_vx;` такой код: * ld a, new_vx ; sub a,(viewport_x) ; jr Z,.. ; ld (viewport_x),a * — т.е. для записи переиспользует регистр A, испорченный вычитанием в * сравнении, и кладёт в viewport_x не new_vx, а (new_vx - old_vx). * (asm-дамп 2026-06-24 подтвердил; даёт 10-8=2, 10-2=8 — ровно * наблюдавшийся "прыжок" скролла.) Записав viewport_x первой, мы убираем * портящий sub с пути записи; сравнение ниже только решает, перерисовывать * ли экран. */ uint8_t new_vx = (uint8_t)nx; uint8_t old_vx = viewport_x; viewport_x = new_vx; if (new_vx != old_vx) draw_viewport_from_cache(); } /* ================================================================== * Вывод ошибки * ================================================================== */ /* Печатает сообщение об ошибке и ждёт нажатия клавиши. */ static void die(const char *msg) { clrscr_attr(ATTR_TEXT); puts(msg); puts("Press any key to exit..."); (void)getkey(); } /* ================================================================== * Диалог справки (F1) * ================================================================== */ /* Заполняет одну внутреннюю строку диалога (r=0 — первая строка за рамкой). * Строка s в кодировке CP866; остаток до края дополняется пробелами. */ static void help_line(uint8_t r, const char *s, uint8_t attr) { uint8_t x = HELP_X + 1u; uint8_t y = HELP_Y + 1u + r; uint8_t len = (uint8_t)strlen(s); if (len > HELP_W - 2u) len = HELP_W - 2u; bios_set_place(y, x); if (len > 0) bios_writeattr(s, len, attr); /* place уже продвинут bios_writeattr на len колонок (verified). */ if (len < HELP_W - 2u) { bios_fillcharattr(' ', ATTR_HELP_BG, (uint8_t)(HELP_W - 2u - len)); } } static void show_help(void) { /* " Помощь " в CP866 (8 байт) */ static const char title[] = " \x8F\xAE\xAC\xAE\xE9\xEC "; uint8_t tlen = 8u; uint8_t lft = (uint8_t)((HELP_W - 2u - tlen) / 2u); /* = 27 */ uint8_t rgt = (uint8_t)(HELP_W - 2u - tlen - lft); /* Верхняя граница рамки с заголовком по центру. * place продвигается каждым BIOS-вызовом (verified), поэтому * достаточно одной установки места на всю строку рамки. */ wrchar(HELP_X, HELP_Y, 0xDA, ATTR_HELP_BDR); /* ┌ */ bios_set_place(HELP_Y, (uint8_t)(HELP_X + 1u)); if (lft > 0) bios_fillcharattr(0xC4, ATTR_HELP_BDR, lft); if (tlen > 0) bios_writeattr(title, tlen, ATTR_HELP_TIT); if (rgt > 0) bios_fillcharattr(0xC4, ATTR_HELP_BDR, rgt); wrchar(HELP_X + HELP_W - 1u, HELP_Y, 0xBF, ATTR_HELP_BDR); /* ┐ */ /* Боковые границы (левый и правый │ для каждой строки тела) */ for (uint8_t r = 1u; r < HELP_H - 1u; r++) { wrchar(HELP_X, HELP_Y + r, 0xB3, ATTR_HELP_BDR); /* │ */ wrchar(HELP_X + HELP_W - 1u, HELP_Y + r, 0xB3, ATTR_HELP_BDR); /* │ */ } /* Нижняя граница рамки */ wrchar(HELP_X, HELP_Y + HELP_H - 1u, 0xC0, ATTR_HELP_BDR); /* └ */ bios_set_place((uint8_t)(HELP_Y + HELP_H - 1u), (uint8_t)(HELP_X + 1u)); bios_fillcharattr(0xC4, ATTR_HELP_BDR, (uint8_t)(HELP_W - 2u)); wrchar(HELP_X + HELP_W - 1u, HELP_Y + HELP_H - 1u, 0xD9, ATTR_HELP_BDR); /* ┘ */ /* Содержимое (20 внутренних строк) */ uint8_t r = 0; help_line(r++, "", ATTR_HELP_BG); help_line(r++, " MDView v0.2 -- Markdown Viewer for Sprinter", ATTR_HELP_HDR); help_line(r++, " (c) 2026 Petrov A.G.", ATTR_HELP_BG); help_line(r++, "", ATTR_HELP_BG); help_line(r++, " Navigation:", ATTR_HELP_HDR); help_line(r++, " \x18 \x19 Scroll one line up / down", ATTR_HELP_BG); help_line(r++, " PgUp PgDn Scroll one page up / down", ATTR_HELP_BG); help_line(r++, " Home End Jump to beginning / end of document", ATTR_HELP_BG); help_line(r++, " \x1B \x1A Horizontal pan (code blocks/tables)", ATTR_HELP_BG); help_line(r++, " Esc F10 Exit", ATTR_HELP_BG); help_line(r++, "", ATTR_HELP_BG); help_line(r++, " Markdown elements:", ATTR_HELP_HDR); help_line(r++, " # ## ### Headings H1-H6", ATTR_HELP_BG); help_line(r++, " **bold** *italic* `code` ~~strike~~", ATTR_HELP_BG); help_line(r++, " > quote ``` ... ``` Fenced code block", ATTR_HELP_BG); help_line(r++, " - * + item 1. 2. Ordered list", ATTR_HELP_BG); help_line(r++, " |----|----| Tables", ATTR_HELP_BG); help_line(r++, "", ATTR_HELP_BG); help_line(r++, " Encoding:", ATTR_HELP_HDR); help_line(r++, " F8 Cycle CP866 / CP1251 / KOI8-R / UTF-8", ATTR_HELP_BG); help_line(r++, " Auto-detected on open; F8 to override", ATTR_HELP_BG); help_line(r++, "", ATTR_HELP_BG); help_line(r++, " File size: up to 128 KB (EMM). Lines: up to 16384.", ATTR_HELP_BG); help_line(r++, "", ATTR_HELP_BG); (void)getkey(); render_full_status(); draw_viewport_from_cache(); render_menu(); } /* Обработка клавиши во время прогрессивной загрузки: скролл строго в * пределах готовых строк (drawable_lines); End = последняя готовая страница; * F1 = справка (show_help сам блокирует и перерисовывает). */ static void load_key(uint16_t k) { uint8_t ascii = (uint8_t)(k & 0xFF); uint8_t scan = (uint8_t)((k >> 8) & 0x7F); uint16_t dr = drawable_lines(); uint16_t maxtop = (dr > VIEW_H) ? (uint16_t)(dr - VIEW_H) : 0; uint16_t nt = top_line; if (ascii == 0x1B || scan == KEY_F10) { g_abort = 1; return; } /* Esc/F10 → выход */ if (ascii) return; /* прочие ascii игнорируем */ switch (scan) { case KEY_UP: nt = (top_line >= 1) ? (uint16_t)(top_line - 1) : 0; break; case KEY_DOWN: nt = top_line + 1; break; case KEY_PGUP: nt = (top_line >= VIEW_H) ? (uint16_t)(top_line - VIEW_H) : 0; break; case KEY_PGDN: nt = top_line + VIEW_H; break; case KEY_HOME: top_line = 0; viewport_x = 0; draw_viewport_from_cache(); render_status_numbers(); return; case KEY_END: top_line = maxtop; viewport_x = 0; draw_viewport_from_cache(); render_status_numbers(); return; case KEY_LEFT: scroll_h(-(int8_t)HPAN_STEP); render_status_numbers(); return; case KEY_RIGHT: scroll_h(+(int8_t)HPAN_STEP); render_status_numbers(); return; case KEY_F1: show_help(); return; 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(). */ static void progress_tick(void) { /* Прогрессивная отрисовка строящегося (всегда показываемого) набора. */ uint8_t touched = 0; if (!g_first_drawn && drawable_lines() >= VIEW_H) { draw_viewport_from_cache(); render_status_numbers(); g_first_drawn = 1; touched = 1; } if (n_lines >= (uint16_t)(g_last_prog + 20)) { g_last_prog = n_lines; spinner_tick(); if (g_first_drawn) render_status_numbers(); touched = 1; } if (g_first_drawn && kbhit()) { load_key(getkey()); touched = 1; } /* Отрисовка/WINREST/BIOS могли сбить маппинг W3, на который опирается fb() * при индексации → форсируем ре-маппинг страницы файла на следующем fb(). */ if (touched) cur_page = 0xFF; } /* ================================================================== * Точка входа * ================================================================== */ /* Строит набор `slot` кооперативно (index_lines + progress_tick). * visible=1 — первичный (показываемый): прогрессивная отрисовка первого экрана * и скролл по готовым строкам. visible=0 — вторичный: index_lines строит его в * живых глобалах, а progress_tick через свап показывает/листает уже готовый * g_disp_doc. Возврат: 0 — успех, <0 — нехватка EMM (-5/-6/-7). * * UTF-8 конвертация делается ЛЕНИВО здесь, а не до первого экрана: при входе * (для DOC_UTF8 ещё не сконвертированного) живой буфер обязан быть = оригинал * (8-битные байты) — utf8_convert читает его через fb(). Это так и в случае * первичного UTF-8 (сразу после load_file), и вторичного (после сборки * первичного 8-битного, чей буфер = оригинал). */ static int build_doc(uint8_t slot, uint8_t enc, uint8_t visible) { if (slot == DOC_UTF8) { if (!utf_avail) { /* конвертируем сейчас (live = оригинал) */ if (!alloc_and_convert_utf8()) return -7; utf_avail = 1; } file_blk = utf_blk; file_pages = utf_pages; file_size = utf_size; memcpy(file_phys, utf_phys, MAX_PAGES); } 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; g_doc[slot].built = 1; doc_save_live(slot); return 0; } /* Переключает кодировку на `next` (вызывается из главного цикла по F8). * 8-бит↔8-бит внутри одного набора — мгновенный ремап; переход в другой набор * — мгновенный doc_switch, если он построен, иначе ЛЕНИВАЯ сборка по месту * (со спиннером), результат кэшируется. */ 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); /* сохранить текущий (позиция и т.п.) */ spinner_show(1); int rc = build_doc(target, next, 1); /* visible: рисует прогрессивно */ spinner_show(0); if (rc < 0) { /* нет EMM — откат на текущий набор */ if (target == DOC_UTF8) g_utf_failed = 1; doc_load_live(g_active_doc); clamp_top(); draw_viewport_from_cache(); render_full_status(); return; } g_disp_doc = target; /* build_doc уже выставил active/enc */ } clamp_top(); draw_viewport_from_cache(); render_full_status(); } 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 \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; 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; } 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 во время ленивой досборки */ 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; }