/*
 * mdview — просмотрщик Markdown для Sprinter.
 *
 *   Разметка экрана (80x32):
 *     строка 0      — статус-бар (имя файла / диапазон строк / всего / %)
 *     строки 1..30  — область документа (30 видимых строк текста)
 *     строка 31     — меню (F1 Help / F2 Reserved / F10 Exit)
 *
 *   Навигация:
 *     стрелки, PgUp/PgDn, Home/End, F1 (справка), F10/Esc (выход).
 *     Горизонтальный сдвиг доступен только для nowrap-строк.
 *
 *   Память:
 *     CODE             → W1   (режим sprinter-cc --memory small)
 *     STACK/HEAP/DATA  → W2
 *     Буфер файла      → страницы EMM (до 8 × 16 КБ = 128 КБ),
 *                        отображаемые в W3 по требованию через fb()/map_page().
 */

#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <conio.h>
#include <bios/text.h>
#include <fcntl.h>
#include <unistd.h>
#include <sprinter.h>
#include <sprinter_mem.h>
#include <palette.h>

/* ---- Геометрия экрана -------------------------------------------- */

#define SCREEN_W      80
#define SCREEN_H      32
#define VIEW_TOP_ROW   1
#define VIEW_H        30          /* видимая область: строки 1..30 включительно */
#define MENU_ROW      31
#define TAB_STOP       4

/* ---- Параметры файла и памяти ------------------------------------ */

#define PAGE_BITS    14u
#define PAGE_SIZE    (1u << PAGE_BITS)        /* размер EMM-страницы: 16 КБ */
#define PAGE_MASK    ((uint16_t)(PAGE_SIZE - 1u))
#define MAX_PAGES    8                        /* 8 страниц × 16 КБ = 128 КБ */
#define MAX_FILE     ((uint32_t)MAX_PAGES * PAGE_SIZE)   /* максимальный размер файла: 131072 байт */
// #define MAX_LINES    2048
#define FILE_BUF     ((char *)0xC000)         /* окно W3, куда мапится текущая EMM-страница */

/* ---- Палитра атрибутов ------------------------------------------- */

static const uint8_t md_pallete[16 * 4] = {
    0x00, 0x00, 0x00, 0x00,
    0x60, 0x00, 0x00, 0x00,
    0x00, 0x60, 0x00, 0x00,
    0x60, 0x60, 0x00, 0x00,
    0x00, 0x00, 0x60, 0x00,
    0x60, 0x00, 0x60, 0x00,
    0x00, 0x60, 0x60, 0x00,
    0xC8, 0xC8, 0xC8, 0x00,
    0xA0, 0xA0, 0x80, 0x00,
    0xFF, 0xAA, 0xAA, 0x00,
    0xAA, 0xFF, 0xAA, 0x00,
    0xFF, 0xFF, 0xAA, 0x00,
    0xAA, 0xAA, 0xFF, 0x00,
    0xFF, 0xAA, 0xFF, 0x00,
    0xAA, 0xFF, 0xFF, 0x00,
    0xFF, 0xFF, 0xFF, 0xFF
};

#define ATTR_RESET          COLOR(COLOR_LIGHTGRAY,    COLOR_BLACK)
#define ATTR_TEXT           COLOR(COLOR_LIGHTGRAY,    COLOR_BLUE)
#define ATTR_TEXT_TITLE1    COLOR(COLOR_YELLOW,       COLOR_BLUE)
#define ATTR_TEXT_TITLE2    COLOR(COLOR_LIGHTBLUE,    COLOR_BLUE)
#define ATTR_TEXT_TITLE3    COLOR(COLOR_LIGHTGREEN,   COLOR_BLUE)
#define ATTR_TEXT_TITLE4    COLOR(COLOR_LIGHTGREEN,   COLOR_BLUE)
#define ATTR_TEXT_BOLD      COLOR(COLOR_LIGHTRED,     COLOR_BLUE)
#define ATTR_TEXT_ITALIC    COLOR(COLOR_LIGHTGREEN,   COLOR_BLUE)
#define ATTR_TEXT_UNDERSORE COLOR(COLOR_LIGHTMAGENTA, COLOR_BLUE)
#define ATTR_TEXT_CODE      COLOR(COLOR_WHITE,        COLOR_BLUE)
#define ATTR_TEXT_STRIKE    COLOR(COLOR_DARKGRAY,     COLOR_BLUE)
#define ATTR_LIST_MARKER    COLOR(COLOR_LIGHTCYAN,    COLOR_BLUE)
#define ATTR_QUOTE_MARKER   COLOR(COLOR_CYAN,         COLOR_BLUE)
#define ATTR_HR             COLOR(COLOR_LIGHTGRAY,    COLOR_BLUE)
#define ATTR_TRUNC          COLOR(COLOR_YELLOW,       COLOR_BLUE)
#define ATTR_BAR            COLOR(COLOR_BLACK,        COLOR_LIGHTCYAN)
#define ATTR_BAR_SPINNER    COLOR(COLOR_WHITE,        COLOR_LIGHTCYAN)
#define ATTR_MENU_T         COLOR(COLOR_BLACK,        COLOR_LIGHTCYAN)
#define ATTR_MENU_K         COLOR(COLOR_YELLOW,       COLOR_BLACK)

/* ---- Атрибуты диалога справки ------------------------------------ */

#define ATTR_HELP_BG    COLOR(COLOR_LIGHTGRAY, COLOR_BLACK)
#define ATTR_HELP_BDR   COLOR(COLOR_WHITE,     COLOR_BLACK)
#define ATTR_HELP_TIT   COLOR(COLOR_YELLOW,    COLOR_BLACK)
#define ATTR_HELP_HDR   COLOR(COLOR_WHITE,     COLOR_BLACK)
#define ATTR_HELP_HINT  COLOR(COLOR_YELLOW,    COLOR_BLACK)

/* Геометрия диалога справки (в символьных координатах 80×32). */
#define HELP_X   8u    /* левая граница рамки */
#define HELP_Y   4u    /* верхняя граница рамки */
#define HELP_W  64u    /* ширина рамки (включая │) */
#define HELP_H  22u    /* высота рамки (включая ─) */

/* ---- Глобальное состояние ---------------------------------------- */

/* Файл до 128 КБ хранится в наборе EMM-страниц (до 8 шт. по 16 КБ).
 * В окно W3 одновременно отображается только одна страница (FILE_BUF):
 * функция fb() при необходимости переключает маппинг и кэширует текущую
 * страницу в cur_page, чтобы не делать лишние переключения.
 *
 * Индекс экранных сегментов хранится отдельно в EMM (записи по 8 байт,
 * одна запись на видимую строку). Каждая запись содержит:
 *   - смещение первого байта сегмента в исходном файле,
 *   - флаги (continuation/nowrap/blank/code),
 *   - начальный inline-стиль сегмента.
 * Правая граница сегмента определяется смещением следующей записи.
 * Такой подход освобождает около 11 КБ ближней RAM и снимает старый
 * лимит MAX_LINES для near-массивов.
 */

/* В SDCC/z80 критично явно инициализировать static-переменные (`= 0`).
 * Неинициализированные static-объекты могут конфликтовать по размещению
 * и перезаписывать друг друга. См. заметку memory/sdcc_static_storage_gotcha. */
/* Компактная запись индекса сегмента.
 * Каждый элемент соответствует одной строке рендера в viewport. */
typedef struct idx_rec_s {
    uint32_t off;     /* смещение первого байта исходного текста для сегмента */
    uint8_t  flags;   /* битовые флаги IF_* ниже */
    uint8_t  style;   /* начальный стиль INIT_STYLE_* в начале сегмента */
    uint8_t  pad0;    /* добивка до 8 байт (2048 записей на страницу 16 КБ) */
    uint8_t  pad1;
} idx_rec_t;

#define INDEX_REC_SIZE       8u
#define INDEX_RECS_PER_PAGE  2048u   /* 16384 / 8; запись всегда целиком в одной странице */
#define MAX_INDEX_PAGES      8u      /* максимум 8 * 2048 = 16384 сегментов индекса */

#define IF_CONT    0x01u     /* сегмент является продолжением перенесённой строки */
#define IF_NOWRAP  0x02u     /* строка не переносится (кодовый блок / HR / таблица) */
#define IF_BLANK   0x04u     /* визуально пустая строка */
#define IF_CODE    0x08u     /* тело fenced code-блока (verbatim-режим) */

static uint16_t  n_lines   = 0;
static uint16_t  max_lines = 0;       /* ёмкость индекса: index_pages * 2048 */
static uint16_t  top_line  = 0;
static uint32_t  file_size = 0;
static uint8_t   file_blk  = 0;
static uint8_t   file_pages = 0;
static uint8_t   file_phys[MAX_PAGES] = {0};
static uint8_t   index_blk = 0;
static uint8_t   index_pages = 0;
static uint8_t   index_phys[MAX_INDEX_PAGES] = {0};
static uint8_t   index_truncated = 0; /* выставляется при исчерпании ёмкости индекса */
static uint32_t  cur_seg_off = 0;     /* смещение последнего emit_seg (кэш в near-памяти) */
static idx_rec_t cur_rec;            /* near-копия последней записанной idx-записи */
static uint8_t   cur_page  = 0xFF;    /* 0xFF = в W3 ещё не отображена ни одна страница */
static uint8_t   viewport_x = 0;      /* горизонтальный сдвиг для nowrap-строк */
static char      filename[64] = {0};

#define HPAN_STEP   8u

/* Спиннер в статус-баре (col=8,row=0).
 * Включается во время загрузки/индексации, чтобы показать,
 * что программа занята и не зависла. */
#define SPINNER_COL 8
static const char spinner_chars[4] = { '|', '/', '-', '\\' };
static uint8_t    spinner_phase    = 0;
static uint8_t    spinner_active   = 0;

/* ==================================================================
 *                      Вспомогательные функции
 * ================================================================== */

/* Печатает строку с указанным атрибутом, начиная с позиции (x,y),
 * и останавливается на конце строки или правой границе экрана.
 * `s` — литерал/статический буфер (CODE/DATA, окна 1/2), поэтому его
 * можно передать в BIOS LP_PRINT_LN напрямую, без промежуточной копии. */
static void put_str_attr(uint8_t x, uint8_t y, const char *s, uint8_t attr)
{
    uint8_t maxlen = (x < SCREEN_W) ? (uint8_t)(SCREEN_W - x) : 0;
    uint8_t len    = (uint8_t)strlen(s);
    if (len > maxlen) len = maxlen;
    if (len == 0) return;
    bios_set_place(y, x);
    bios_writeattr(s, len, attr);
}


/* Полностью очищает указанную строку экрана символами пробела с attr. */
static void fill_row(uint8_t y, uint8_t attr)
{
    bios_set_place(y, 0);
    bios_fillcharattr(' ', attr, SCREEN_W);
}

/* Продвигает спиннер на один кадр.
 * Если спиннер выключен — функция ничего не делает. */
static void spinner_tick(void)
{
    if (!spinner_active) return;
    wrchar(SPINNER_COL, 0, spinner_chars[spinner_phase & 3], ATTR_BAR_SPINNER);
    spinner_phase++;
}

/* Включает/выключает спиннер.
 * При выключении очищает его позицию пробелом в статус-баре. */
static void spinner_show(uint8_t on)
{
    spinner_active = on;
    if (!on) wrchar(SPINNER_COL, 0, ' ', ATTR_BAR);
}

/* Отображает указанную EMM-страницу файла в окно W3.
 * Если эта страница уже активна, переключение не выполняется. */
static inline void map_page(uint8_t page)
{
    if (page != cur_page) {
        /* file_phys[] позволяет не вызывать mem_get_page() на каждом свопе. */
        sprinter_page_w3(file_phys[page]);
        cur_page = page;
    }
}
/* Читает один байт логического файла по смещению p.
 * При переходе между страницами автоматически переключает W3.
 * Проверка границ выполняется вызывающей стороной.
 */

static char fb(uint32_t p) 
{
    uint8_t *pb = (uint8_t *)&p;
    uint8_t page = (uint8_t)((pb[1] >> 6) | (pb[2] << 2));
    uint16_t off = (uint16_t)(((uint16_t)(pb[1] & 0x3F) << 8) | pb[0]);
    map_page(page);
    return FILE_BUF[off];
/*    
    // p = HLDE
    (void)p;

    __asm 
; uint16_t off = (uint16_t)(((uint16_t)(pb[1] | 0xC0) << 8) | pb[0]);
        ld    a, d
        or    a, #0xC0
        ld    b, a
        ld    c, e
        
; uint8_t page = (uint8_t)((pb[1] >> 6) | (pb[2] << 2));
        ld    a, l                  ;; pb[2] << 2
        add   a, a
        add   a, a
        ld    e, a                  ;; e = pb[2] << 2
        
        ld    a, d                  ;; pb[1] >> 6
        rlca                
        rlca
        and   a, #0x03

        or    a, e                  ;; a = (pb[2] << 2) | (pb[1] >> 6)
        ld    e, a
; map_page(page);
; if (page != cur_page) {
        ld    a, (_cur_page)
        cp    e
        jr    Z, __fbl0
; cur_page = page;
        ld    a, e
        ld    (_cur_page), a
; sprinter_page_w3(file_phys[page]);
        ld    hl, #_file_phys
        ld    d, #0x00
        add   hl, de
        ld    a, (hl)
        out   (__io_page_w3), a
; }
__fbl0:
; return FILE_BUF[off];
        ld    a, (bc)
        ret
    __endasm;
*/    
}

/* Для проверки границ выделения считаем пробел/таб/конец строки/0 пробельными. */
static uint8_t ws_or_eol(char c)
{
    return (uint8_t)(c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == 0);
}

static uint8_t ws_or_eol_or_delim(char c)
{
    return (uint8_t)(c == ' ' || c == ',' || c == '.' || c == '!' || c == '?' || c == ':' || c == ';' || c == '/'
        || c == '\t' || c == '\n' || c == '\r' || c == 0);
}

/* Маркер inline-выделения (*, **, _) распознаётся только если
 * пробельная граница есть ровно с одной стороны (XOR-правило).
 * Это защищает выражения вроде "2 * 3", "2 ** 3" и внутрисловные
 * случаи (например COLOR_YELLOW) от ложного форматирования. */
static uint8_t is_emph_flanked(char prev_ch, char next_ch)
{
    return (uint8_t)(ws_or_eol(prev_ch) != ws_or_eol_or_delim(next_ch));
}

/* Тип сегмента-продолжения, передаваемый в emit_seg().
 * Явно в индексе не хранится: рендер при необходимости восстанавливает
 * тип через classify_line(), но параметр оставлен для читаемости вызовов. */
#define CK_PLAIN  0
#define CK_QUOTE  1
#define CK_LIST   2
#define CK_OTHER  3

/* Доступ к индексу в EMM.
 * Размер записи 8 байт, поэтому запись не пересекает границу страницы
 * и bank_read/bank_write работают с цельным блоком. */
static void idx_get(uint16_t idx, idx_rec_t *r)
{
    uint8_t  page = (uint8_t)(idx >> 11);            /* номер страницы: idx / 2048 */
    uint16_t off  = (uint16_t)((idx & 2047u) << 3);  /* смещение записи: (idx % 2048) * 8 */
    bank_read(index_phys[page], off, r, INDEX_REC_SIZE);
}

static void idx_put(uint16_t idx, const idx_rec_t *r)
{
    uint8_t  page = (uint8_t)(idx >> 11);
    uint16_t off  = (uint16_t)((idx & 2047u) << 3);
    bank_write(index_phys[page], off, r, INDEX_REC_SIZE);
}

/* Чтение полей индекса для рендера и навигации (построчный доступ). */
static uint8_t seg_flags(uint16_t idx)
{
    idx_rec_t r;
    if (idx >= n_lines) return 0;
    idx_get(idx, &r);
    return r.flags;
}

static uint32_t seg_off(uint16_t idx)
{
    idx_rec_t r;
    if (idx >= n_lines) return file_size;
    idx_get(idx, &r);
    return r.off;
}

static uint8_t is_cont(uint16_t idx)     { return (uint8_t)((seg_flags(idx) & IF_CONT)   != 0); }
static uint8_t is_nowrap(uint16_t idx)   { return (uint8_t)((seg_flags(idx) & IF_NOWRAP) != 0); }
static uint8_t is_blank(uint16_t idx)    { return (uint8_t)((seg_flags(idx) & IF_BLANK)  != 0); }
static uint8_t is_code_body(uint16_t idx){ return (uint8_t)((seg_flags(idx) & IF_CODE)   != 0); }

/* Установка флагов всегда идёт в последний emit_seg (n_lines-1).
 * Используется near-копия cur_rec, чтобы не делать обратное чтение из банка. */
static void set_nowrap_cur(void) { cur_rec.flags |= IF_NOWRAP; idx_put((uint16_t)(n_lines - 1), &cur_rec); }
static void set_blank_cur(void)  { cur_rec.flags |= IF_BLANK;  idx_put((uint16_t)(n_lines - 1), &cur_rec); }
static void set_code_cur(void)   { cur_rec.flags |= IF_CODE;   idx_put((uint16_t)(n_lines - 1), &cur_rec); }

/* Загружает 16 текстовых атрибутов в палитры ink/paper/blink. */
static void set_pallete(void) {
    uint8_t buff[64];
    for(int i = 0; i < 16; i++) {
        for(int j = 0; j < 16; j++) {
            buff[j * 4] = md_pallete[i * 4];
            buff[j * 4 + 1] = md_pallete[i * 4 + 1];
            buff[j * 4 + 2] = md_pallete[i * 4 + 2];
            buff[j * 4 + 3] = md_pallete[i * 4 + 3];
        }

        text_pal_load(TEXT_PAL_INK,         i * 16, 16, md_pallete);
        text_pal_load(TEXT_PAL_BLINK_INK,   i * 16, 16, md_pallete);
        text_pal_load(TEXT_PAL_PAPER,       i * 16, 16, buff);
        text_pal_load(TEXT_PAL_BLINK_PAPER, i * 16, 8, buff + 32);   
        text_pal_load(TEXT_PAL_BLINK_PAPER, i * 16 + 8, 8, buff + 32);   
    }
}

/* ==================================================================
 *                    Загрузка/выгрузка файла
 * ================================================================== */

/* Загружает файл в EMM-страницы и подготавливает EMM-индекс.
 * Возврат: 0 — успех, отрицательное значение — код ошибки. */
static int load_file(const char *path)
{
    int fd = open(path, O_RDONLY);
    if (fd < 0) return -1;

    long sz = lseek(fd, 0, SEEK_END);
    if (sz < 0 || (uint32_t)sz > MAX_FILE) {
        close(fd);
        return -2;
    }
    file_size = (uint32_t)sz;
    lseek(fd, 0, SEEK_SET);

    /* Выделяем ровно столько EMM-страниц, сколько нужно под файл. */
    file_pages = (uint8_t)((file_size + PAGE_SIZE - 1u) / PAGE_SIZE);
    if (file_pages == 0) file_pages = 1;
    file_blk = mem_alloc_pages(file_pages);
    if (file_blk == 0) {
        close(fd);
        return -3;
    }

    /* Читаем файл кусками по 16 КБ, последовательно маппя страницы в W3.
     * На последней итерации читаем остаток меньше PAGE_SIZE. */
    uint32_t remaining = file_size;
    for (uint8_t page = 0; page < file_pages; page++) {
        spinner_tick();
        uint8_t phys = mem_get_page(file_blk, page);
        file_phys[page] = phys;
        sprinter_page_w3(phys);
        cur_page = page;
        uint16_t chunk = (remaining > PAGE_SIZE)
                       ? (uint16_t)PAGE_SIZE
                       : (uint16_t)remaining;
        int n = read(fd, FILE_BUF, chunk);
        if (n != (int)chunk) {
            close(fd);
            return -4;
        }
        remaining -= chunk;
    }
    close(fd);

    /* Выделяем блок индекса в EMM (8 байт на сегмент, 2048 записей/страница).
     * Размер берем с запасом относительно файла; если памяти мало —
     * уменьшаем количество страниц индекса до доступного значения. */
    {
        uint8_t want = (uint8_t)(file_pages + 1u);
        if (want > MAX_INDEX_PAGES) want = MAX_INDEX_PAGES;
        index_blk = 0;
        while (want >= 1u) {
            index_blk = mem_alloc_pages(want);
            if (index_blk) break;
            want--;
        }
        if (index_blk == 0) return -5;
        index_pages = want;
        for (uint8_t i = 0; i < index_pages; i++)
            index_phys[i] = mem_get_page(index_blk, i);
        max_lines = (uint16_t)index_pages * INDEX_RECS_PER_PAGE;
    }
    return 0;
}

/* Освобождает выделенные EMM-блоки файла и индекса. */
static void unload_file(void)
{
    if (file_blk) {
        mem_free_block(file_blk);
        file_blk = 0;
    }
    if (index_blk) {
        mem_free_block(index_blk);
        index_blk = 0;
    }
}

/* ==================================================================
 *                       Индексация строк
 * ================================================================== */

/* Возвращает 1, если сегмент idx — это старт логической строки,
 * и эта строка начинается с ``` (граница блока кода).
 * Сегменты-продолжения никогда не считаются такими границами. */
static uint8_t is_fence_delim(uint16_t idx)
{
    if (idx >= n_lines) return 0;
    if (is_cont(idx)) return 0;
    uint32_t off = seg_off(idx);
    if (off + 2 >= file_size) return 0;
    return (uint8_t)(fb(off)     == '`' &&
                     fb(off + 1) == '`' &&
                     fb(off + 2) == '`');
}

#define INIT_STYLE_PLAIN   0x0
#define INIT_STYLE_BOLD    0x1
#define INIT_STYLE_ITALIC  0x2
#define INIT_STYLE_UNDER   0x3
#define INIT_STYLE_CODE    0x4
#define INIT_STYLE_STRIKE  0x5

/* Таблица сопоставления init-style -> атрибут.
 * Порядок должен совпадать с INIT_STYLE_* / EM_* ниже. */
static const uint8_t styles_map[] = {
    ATTR_TEXT, ATTR_TEXT_BOLD, ATTR_TEXT_ITALIC, ATTR_TEXT_UNDERSORE, ATTR_TEXT_CODE, ATTR_TEXT_STRIKE
};

/* Возвращает стартовый атрибут сегмента из индексной записи. */
static uint8_t get_init_style(uint16_t idx)
{
    idx_rec_t r;
    if (idx >= n_lines) return styles_map[INIT_STYLE_PLAIN];
    idx_get(idx, &r);
    return styles_map[r.style & 7u];
}

/* Возвращает «сырой» INIT_STYLE_* из индексной записи. */
static uint8_t get_init_style_raw(uint16_t idx)
{
    idx_rec_t r;
    if (idx >= n_lines) return INIT_STYLE_PLAIN;
    idx_get(idx, &r);
    return (uint8_t)(r.style & 7u);
}


/* Тип строки, который возвращает classify_line(). */
#define LK_PLAIN  0
#define LK_H1     1
#define LK_H2     2
#define LK_H3     3
#define LK_H4     4   /* также используется для H5/H6 */
#define LK_HR     5
#define LK_ULIST  6   /* маркер ненумерованного списка */
#define LK_OLIST  7   /* маркер нумерованного списка */
#define LK_QUOTE  8   /* маркер цитаты */

/* Классифицирует логическую строку.
 *   - Заголовки: 1..4 символа '#' и затем пробел/таб (H5/H6 сводятся к H4).
 *   - Горизонтальная линия: только '-', '*' или '_' (одного вида, >=3),
 *     возможно с пробелами/табами между символами.
 *   - Ненумерованный список: "- ", "* " или "+ " перед содержимым.
 *   - Нумерованный список: цифры + '.'/ ')' + пробел.
 *   - Цитата: '>' с опциональным пробелом.
 *   - Иначе обычный текст.
 * Для заголовков/списков/цитат out_content указывает на первый байт
 * содержимого после маркера и завершающего пробела. */
static uint8_t classify_line(uint32_t p_start, uint32_t *out_content)
{
    if (p_start >= file_size) return LK_PLAIN;

    char c0 = fb(p_start);

    /* Заголовок? */
    if (c0 == '#') {
        uint8_t  lvl = 0;
        uint32_t p   = p_start;
        while (p < file_size && fb(p) == '#' && lvl < 6) {
            lvl++; p++;
        }
        if (p < file_size) {
            char ch = fb(p);
            if (ch == ' ' || ch == '\t') {
                p++;
                *out_content = p;
                if (lvl > 4) lvl = 4;
                return lvl;             /* LK_H1 .. LK_H4 */
            }
        }
        /* Символ '#' без пробела после него не считается заголовком. */
    }

    /* Горизонтальный разделитель.
     * Проверяется до списков, чтобы `- - -` не стало маркером списка. */
    if (c0 == '-' || c0 == '*' || c0 == '_') {
        char    marker = 0;
        uint8_t count  = 0;
        uint8_t ok     = 1;
        uint32_t p = p_start;
        while (p < file_size) {
            char ch = fb(p++);
            if (ch == '\n' || ch == '\r') break;
            if (ch == ' ' || ch == '\t') continue;
            if (ch != '-' && ch != '*' && ch != '_') { ok = 0; break; }
            if (marker == 0) marker = ch;
            else if (ch != marker)                   { ok = 0; break; }
            count++;
        }
        if (ok && count >= 3) return LK_HR;
    }

    /* Для списков/цитат дополнительно допускаем ведущие пробелы
     * (базовая поддержка вложенности). */
    uint32_t lp = p_start;
    while (lp < file_size && fb(lp) == ' ') lp++;
    char cl = (lp < file_size) ? fb(lp) : 0;

    /* Ненумерованный список? */
    if ((cl == '-' || cl == '*' || cl == '+') &&
        (lp + 1) < file_size &&
        fb(lp + 1) == ' ') {
        *out_content = lp + 2;
        return LK_ULIST;
    }

    /* Нумерованный список? */
    if (cl >= '0' && cl <= '9') {
        uint32_t p = lp;
        while (p < file_size) {
            char ch = fb(p);
            if (ch < '0' || ch > '9') break;
            p++;
        }
        if (p < file_size) {
            char ch = fb(p);
            if ((ch == '.' || ch == ')') &&
                (p + 1) < file_size && fb(p + 1) == ' ') {
                *out_content = p + 2;
                return LK_OLIST;
            }
        }
    }

    /* Цитата? */
    if (cl == '>') {
        uint32_t p = lp + 1;
        if (p < file_size && fb(p) == ' ') p++;
        *out_content = p;
        return LK_QUOTE;
    }

    return LK_PLAIN;
}

/* Возвращает видимую ширину префикса (отступ + маркер) в колонках. */
static uint8_t marker_visible_col(uint8_t kind, uint32_t p_start, uint32_t content_off)
{
    switch (kind) {
        case LK_ULIST:
        case LK_QUOTE:
        case LK_OLIST: return (uint8_t)(content_off - p_start);
        default:       return 0;                          /* plain/headers спец-префикса не имеют */
    }
}

/* Добавляет запись сегмента в EMM-индекс.
 * near-копии cur_rec/cur_seg_off используются для быстрых последующих
 * операций без повторного чтения последней записи из банка. */
static void emit_seg(uint32_t off, uint8_t style, uint8_t ckind, uint8_t cont)
{
    (void)ckind;                       /* тип сегмента восстанавливается на этапе рендера */
    if (n_lines >= max_lines) { index_truncated = 1; return; }
    cur_rec.off   = off;
    cur_rec.flags = (uint8_t)(cont ? IF_CONT : 0);
    cur_rec.style = (uint8_t)(style & 7u);
    cur_rec.pad0  = 0;
    cur_rec.pad1  = 0;
    idx_put(n_lines, &cur_rec);
    cur_seg_off = off;
    n_lines++;
}

/* Проверяет, что физическая строка пуста (только пробелы/таб + перевод строки). */
static uint8_t is_line_blank(uint32_t p)
{
    while (p < file_size) {
        char c = fb(p);
        if (c == '\n') return 1;
        if (c != ' ' && c != '\t') return 0;
        p++;
    }
    return 1;
}

/* Быстрая проверка начала блока кода по маркеру ``` . */
static uint8_t is_fence_raw(uint32_t p)
{
    if (p + 2 >= file_size) return 0;
    return (fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`');
}

/* Быстрая проверка горизонтального разделителя (`---`, `***`, `___`). */
static uint8_t is_hr_raw(uint32_t p)
{
    char c0 = fb(p);
    if (c0 != '-' && c0 != '*' && c0 != '_') return 0;
    char marker = 0;
    uint8_t count = 0, ok = 1;
    uint32_t hr_p = p;
    while (hr_p < file_size) {
        char ch = fb(hr_p++);
        if (ch == '\n' || ch == '\r') break;
        if (ch == ' ' || ch == '\t') continue;
        if (ch != '-' && ch != '*' && ch != '_') { ok = 0; break; }
        if (marker == 0) marker = ch;
        else if (ch != marker) { ok = 0; break; }
        count++;
    }
    return ok && count >= 3;
}

/* Строка таблицы: первый непустой символ — '|'.
 * Ведущие пробелы разрешены; такие строки не склеиваются и не переносятся. */
static uint8_t is_table_raw(uint32_t p)
{
    while (p < file_size) {
        char c = fb(p);
        if (c == ' ' || c == '\t') { p++; continue; }
        return (uint8_t)(c == '|');
    }
    return 0;
}

/* Общий сканер inline-форматирования и переносов.
 * Идёт от q до q_end, учитывает стартовую колонку col и при необходимости
 * эмитит continuation-сегменты. Возвращает итоговый line_style. */
static uint8_t inline_scan(uint32_t q, uint32_t q_end, uint8_t col,
                           uint8_t ckind, uint8_t line_style)
{
    uint32_t last_space = 0xFFFFFFFFu;
    uint8_t  last_space_style = line_style; /* снимок line_style на момент last_space */
    char prev_ch = ' ';
    uint8_t seg_col = col;

    while (q < q_end && n_lines < max_lines) {
        char ch = fb(q);

        if (ch == '\n') {
            /* Жёсткий перенос (уже проверен снаружи) или конец потока. */
            q++;
            emit_seg(q, line_style, ckind, (ckind == CK_LIST || ckind == CK_QUOTE) ? 1 : 0);
            last_space = 0xFFFFFFFFu; prev_ch = ' '; seg_col = col;
            continue;
        }

        if (ch == '`') {
            if (line_style == INIT_STYLE_CODE) line_style = INIT_STYLE_PLAIN;
            else line_style = INIT_STYLE_CODE;
            q++; continue;
        }
        if (ch == '*' && (q + 1) < q_end && fb(q + 1) == '*') {
            char next_ch = ((q + 2) < q_end) ? fb(q + 2) : '\n';
            if (is_emph_flanked(prev_ch, next_ch)) {
                if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
                    line_style = INIT_STYLE_BOLD; q += 2; continue;
                } else if (line_style == INIT_STYLE_BOLD && ws_or_eol_or_delim(next_ch)) {
                    line_style = INIT_STYLE_PLAIN; q += 2; continue;
                }
            }
            seg_col += 2; q += 2; prev_ch = '*'; continue;
        }
        if (ch == '~' && (q + 1) < q_end && fb(q + 1) == '~') {
            char next_ch = ((q + 2) < q_end) ? fb(q + 2) : '\n';
            if (is_emph_flanked(prev_ch, next_ch)) {
                if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
                    line_style = INIT_STYLE_STRIKE; q += 2; continue;
                } else if (line_style == INIT_STYLE_STRIKE && ws_or_eol_or_delim(next_ch)) {
                    line_style = INIT_STYLE_PLAIN; q += 2; continue;
                }
            }
            seg_col += 2; q += 2; prev_ch = '~'; continue;
        }
        if (ch == '*' || ch == '_') {
            char next_ch = ((q + 1) < q_end) ? fb(q + 1) : '\n';
            if (is_emph_flanked(prev_ch, next_ch)) {
                uint8_t new_style = (ch == '*') ? INIT_STYLE_ITALIC : INIT_STYLE_UNDER;
                if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
                    line_style = new_style; q++; continue;
                } else if (line_style == new_style && ws_or_eol_or_delim(next_ch)) {
                    line_style = INIT_STYLE_PLAIN; q++; continue;
                }
            }
            seg_col++; q++; prev_ch = ch; continue;
        }
        if (ch == '\t') {
            uint8_t tgt = (uint8_t)((seg_col & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
            if (tgt > SCREEN_W) tgt = SCREEN_W;
            seg_col = tgt; q++; prev_ch = ' '; continue;
        }
        if (ch == ' ') {
            last_space = q + 1;
            last_space_style = line_style;
            seg_col++; q++; prev_ch = ' ';
        } else {
            seg_col++; q++; prev_ch = ch;
        }
        if (seg_col >= SCREEN_W) {
            uint8_t  use_ls  = (uint8_t)(last_space != 0xFFFFFFFFu && last_space > cur_seg_off);
            uint32_t wrap_at = use_ls ? last_space : q;
            if (wrap_at >= q_end || wrap_at == cur_seg_off) break;
            uint8_t wrap_style = use_ls ? last_space_style : line_style;
            emit_seg(wrap_at, wrap_style, ckind, 1);
            seg_col = col; last_space = 0xFFFFFFFFu; prev_ch = ' '; q = wrap_at;
            /* Откатываем line_style к снимку на момент wrap_at: диапазон
             * [wrap_at, q_old) будет пересканирован заново, и переключающие
             * символы (обратная кавычка, звёздочка, подчёркивание, тильда)
             * внутри него иначе применились бы дважды — именно так возникал
             * баг: code-маркер на границе переноса ломает цвет следующего слова. */
            line_style = wrap_style;
        }
    }
    return line_style;
}

#define JOIN_MODE_LIST   1u
#define JOIN_MODE_QUOTE  2u
#define JOIN_MODE_PLAIN  3u

/* Общий потоковый сканер для index_lines().
 * Убирает дублирование между ветками list/quote/plain:
 *   - mode задаёт правила обработки перевода строки,
 *   - остальная inline-логика (emphasis/таб/перенос) едина.
 * Возвращает позицию q, на которой сканер остановился, и обновляет стиль. */
static uint32_t scan_join_stream(uint32_t q, uint32_t para_start, uint8_t col,
                                 uint8_t ckind, uint8_t *io_line_style, uint8_t mode)
{
    uint32_t last_space = 0xFFFFFFFFu;
    uint8_t  last_space_style = *io_line_style; /* снимок line_style на момент last_space */
    char prev_ch = ' ';
    uint8_t seg_col = col;
    uint8_t line_style = *io_line_style;

    while (q < file_size && n_lines < max_lines) {
        char ch = fb(q);
        uint8_t soft_break = 0;

        if (ch == '\n') {
            uint32_t next = q + 1;
            if (next >= file_size) break;

            if (mode == JOIN_MODE_LIST) {
                if (is_line_blank(next)) break;
                if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break;

                {
                    uint32_t next_content = next;
                    uint8_t next_kind = classify_line(next, &next_content);
                    if (next_kind == LK_ULIST || next_kind == LK_OLIST ||
                        next_kind == LK_QUOTE ||
                        (next_kind >= LK_H1 && next_kind <= LK_H4) ||
                        next_kind == LK_HR) {
                        break;
                    }
                }
                while (next < file_size) {
                    char ws = fb(next);
                    if (ws == ' ' || ws == '\t') next++;
                    else break;
                }
                q = next;
                soft_break = 1;
                ch = ' ';
            } else if (mode == JOIN_MODE_QUOTE) {
                if (is_line_blank(next)) break;
                if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break;

                {
                    uint32_t next_content = next;
                    uint8_t next_kind = classify_line(next, &next_content);
                    if (next_kind != LK_QUOTE) break;

                    {
                        uint32_t next_line_end = next_content;
                        while (next_line_end < file_size && fb(next_line_end) != '\n') next_line_end++;

                        {
                            uint8_t empty_quote = 1;
                            for (uint32_t z = next_content; z < next_line_end; z++) {
                                char c = fb(z);
                                if (c != ' ' && c != '\t') { empty_quote = 0; break; }
                            }
                            if (empty_quote) break;
                        }

                        {
                            uint32_t t = next_content;
                            while (t < next_line_end && (fb(t) == ' ' || fb(t) == '\t')) t++;
                            if (t < next_line_end && fb(t) == '>') break;
                        }

                        while (next_content < next_line_end) {
                            char ws = fb(next_content);
                            if (ws == ' ' || ws == '\t') next_content++;
                            else break;
                        }
                        if (next_content < next_line_end && fb(next_content) == '>') {
                            next_content++;
                            if (next_content < next_line_end && fb(next_content) == ' ') next_content++;
                        }
                        q = next_content;
                        soft_break = 1;
                        ch = ' ';
                    }
                }
            } else { /* JOIN_MODE_PLAIN */
                if (fb(next) == '\n') break;
                if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break;
                {
                    uint32_t dummy;
                    if (classify_line(next, &dummy) != LK_PLAIN) break;
                }
                if (is_line_blank(next)) break;

                {
                    uint8_t is_hard = 0;
                    if (q >= para_start + 1) {
                        char b1 = fb(q - 1);
                        if (b1 == '\\') {
                            is_hard = 1;
                        } else if (q >= para_start + 2) {
                            char b2 = fb(q - 2);
                            if ((b1 == ' ' || b1 == '\t') && (b2 == ' ' || b2 == '\t'))
                                is_hard = 1;
                        }
                    }
                    if (is_hard) {
                        q++;
                        emit_seg(q, line_style, ckind, 0);
                        last_space = 0xFFFFFFFFu;
                        prev_ch = ' ';
                        seg_col = col;
                        continue;
                    }
                }

                q++;
                soft_break = 1;
                ch = ' ';
            }
        }

        if (ch == '`') {
            if (line_style == INIT_STYLE_CODE) line_style = INIT_STYLE_PLAIN;
            else line_style = INIT_STYLE_CODE;
            if (!soft_break) q++;
            continue;
        }
        if (ch == '*' && (q + 1) < file_size && fb(q + 1) == '*') {
            char next_ch = ((q + 2) < file_size) ? fb(q + 2) : '\n';
            if (is_emph_flanked(prev_ch, next_ch)) {
                if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
                    line_style = INIT_STYLE_BOLD;
                    if (!soft_break) q += 2; else q++;
                    continue;
                } else if (line_style == INIT_STYLE_BOLD && ws_or_eol_or_delim(next_ch)) {
                    line_style = INIT_STYLE_PLAIN;
                    if (!soft_break) q += 2; else q++;
                    continue;
                }
            }
            seg_col += 2;
            if (!soft_break) q += 2; else q++;
            prev_ch = '*';
            continue;
        }
        if (ch == '~' && (q + 1) < file_size && fb(q + 1) == '~') {
            char next_ch = ((q + 2) < file_size) ? fb(q + 2) : '\n';
            if (is_emph_flanked(prev_ch, next_ch)) {
                if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
                    line_style = INIT_STYLE_STRIKE;
                    if (!soft_break) q += 2; else q++;
                    continue;
                } else if (line_style == INIT_STYLE_STRIKE && ws_or_eol_or_delim(next_ch)) {
                    line_style = INIT_STYLE_PLAIN;
                    if (!soft_break) q += 2; else q++;
                    continue;
                }
            }
            seg_col += 2;
            if (!soft_break) q += 2; else q++;
            prev_ch = '~';
            continue;
        }
        if (ch == '*' || ch == '_') {
            char next_ch = ((q + 1) < file_size) ? fb(q + 1) : '\n';
            if (is_emph_flanked(prev_ch, next_ch)) {
                uint8_t new_style = (ch == '*') ? INIT_STYLE_ITALIC : INIT_STYLE_UNDER;
                if (line_style == INIT_STYLE_PLAIN && ws_or_eol(prev_ch)) {
                    line_style = new_style;
                    if (!soft_break) q++; else q++;
                    continue;
                } else if (line_style == new_style && ws_or_eol_or_delim(next_ch)) {
                    line_style = INIT_STYLE_PLAIN;
                    if (!soft_break) q++; else q++;
                    continue;
                }
            }
            seg_col++;
            if (!soft_break) q++; else q++;
            prev_ch = ch;
            continue;
        }
        if (ch == '\t') {
            uint8_t tgt = (uint8_t)((seg_col & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
            if (tgt > SCREEN_W) tgt = SCREEN_W;
            seg_col = tgt;
            if (!soft_break) q++; else q++;
            prev_ch = ' ';
            continue;
        }
        if (ch == ' ') {
            if (soft_break) {
                last_space = q;
                last_space_style = line_style;
                seg_col++;
            } else {
                last_space = q + 1;
                last_space_style = line_style;
                seg_col++;
                q++;
            }
            prev_ch = ' ';
        } else {
            seg_col++;
            if (!soft_break) q++; else q++;
            prev_ch = ch;
        }
        if (seg_col >= SCREEN_W) {
            uint8_t  use_ls  = (uint8_t)(last_space != 0xFFFFFFFFu && last_space > cur_seg_off);
            uint32_t wrap_at = use_ls ? last_space : q;
            if (wrap_at >= file_size || wrap_at == cur_seg_off) break;
            uint8_t wrap_style = use_ls ? last_space_style : line_style;
            emit_seg(wrap_at, wrap_style, ckind, 1);
            seg_col = col;
            last_space = 0xFFFFFFFFu;
            prev_ch = ' ';
            q = wrap_at;
            /* Откатываем line_style к снимку на момент wrap_at: диапазон
             * [wrap_at, q_old) будет пересканирован заново, и переключающие
             * символы (обратная кавычка, звёздочка, подчёркивание, тильда)
             * внутри него иначе применились бы дважды — именно так возникал
             * баг: code-маркер на границе переноса ломает цвет следующего слова (mdview.c, отчёт пользователя
             * 2026-06-23). */
            line_style = wrap_style;
        }
    }

    *io_line_style = line_style;
    return q;
}

/* Строит индекс сегментов рендера по загруженному файлу. */
static void index_lines(void)
{
    uint8_t in_block = 0;
    n_lines = 0;
    index_truncated = 0;
    cur_seg_off = 0xFFFFFFFFu;           /* пока не эмитили ни одного сегмента */
    if (file_size == 0) return;

    uint32_t p = 0;
    while (p < file_size && n_lines < max_lines) {
        
        if ((n_lines & 15) == 0) 
            spinner_tick();

        /* Пустая строка — разделитель параграфов.
         * В индекс добавляем не более одной пустой экранной строки подряд. */
        if (is_line_blank(p)) {
            while (p < file_size && fb(p) != '\n') p++;
            if (p < file_size) p++;
            /* Серия пустых строк схлопывается в одну визуальную строку. */
            if (n_lines == 0 || !(cur_rec.flags & IF_BLANK)) {
                emit_seg(p, INIT_STYLE_PLAIN, CK_PLAIN, 0);
                set_blank_cur();
            }
            continue;
        }

        /* ---- fenced code-блок (граница или тело) ---- */
        if (is_fence_raw(p)) {
            in_block = !in_block;
            emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
            set_nowrap_cur();
            while (p < file_size && fb(p) != '\n') p++;
            if (p < file_size) p++;
            continue;
        }
        if (in_block) {
            emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
            set_nowrap_cur();
            set_code_cur();
            while (p < file_size && fb(p) != '\n') p++;
            if (p < file_size) p++;
            continue;
        }

        /* ---- горизонтальный разделитель ---- */
        if (is_hr_raw(p)) {
            emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
            set_nowrap_cur();
            while (p < file_size && fb(p) != '\n') p++;
            if (p < file_size) p++;
            continue;
        }

        /* ---- строка таблицы (| ... |): один nowrap-сегмент на исходную строку ---- */
        if (is_table_raw(p)) {
            emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
            set_nowrap_cur();
            while (p < file_size && fb(p) != '\n') p++;
            if (p < file_size) p++;
            continue;
        }

        /* ---- классификация текущей строки ---- */
        uint32_t content_off = p;
        uint8_t kind = classify_line(p, &content_off);

        /* ---- Заголовок: отдельная строка, не склеивается с параграфом ---- */
        if (kind >= LK_H1 && kind <= LK_H4) {
            emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
            uint32_t line_end = content_off;
            while (line_end < file_size && fb(line_end) != '\n') line_end++;
            (void)inline_scan(content_off, line_end, 0, CK_OTHER, INIT_STYLE_PLAIN);
            while (p < file_size && fb(p) != '\n') p++;
            if (p < file_size) p++;
            continue;
        }

        /* ---- Пункт списка: может занимать несколько физических строк ---- */
        if (kind == LK_ULIST || kind == LK_OLIST) {
            uint8_t  col = marker_visible_col(kind, p, content_off);
            uint8_t  line_style = INIT_STYLE_PLAIN;
            uint32_t q = content_off;

            emit_seg(p, line_style, CK_LIST, 0);
            q = scan_join_stream(q, p, col, CK_LIST, &line_style, JOIN_MODE_LIST);

            /* Переходим к началу следующей физической строки после пункта. */
            if (q < file_size) {
                while (q < file_size && fb(q) != '\n') q++;
                if (q < file_size) q++;
            }

            /* Одна пустая строка между соседними маркерами списка подавляется.
             * Две и более пустых строки оставляют видимый разделитель. */
            if (q < file_size && is_line_blank(q)) {
                uint32_t t = q;
                uint8_t blanks = 0;
                while (t < file_size && is_line_blank(t)) {
                    while (t < file_size && fb(t) != '\n') t++;
                    if (t < file_size) t++;
                    blanks++;
                    // if (blanks >= 2) break;
                }
                if (blanks == 1 && t < file_size) {
                    uint32_t d = t;
                    uint8_t nk = classify_line(t, &d);
                    if (nk == LK_ULIST || nk == LK_OLIST) {
                        p = t;             /* подавляем единственную пустую строку */
                        continue;
                    }
                }
            }

            p = q;
            continue;
        }

        /* ---- Параграф цитаты: может покрывать несколько физических строк ---- */
        if (kind == LK_QUOTE) {
            uint8_t  col = marker_visible_col(kind, p, content_off);
            uint8_t  line_style = INIT_STYLE_PLAIN;
            uint32_t q = content_off;

            emit_seg(p, line_style, CK_QUOTE, 0);
            q = scan_join_stream(q, p, col, CK_QUOTE, &line_style, JOIN_MODE_QUOTE);

            while (q < file_size && fb(q) != '\n') q++;
            if (q < file_size) q++;
            p = q;
            continue;
        }

        /* ---- Обычный текстовый параграф: склейка через soft-break ---- */
        {
            uint8_t line_style = INIT_STYLE_PLAIN;
            emit_seg(p, line_style, CK_PLAIN, 0);

            uint32_t q = p;
            q = scan_join_stream(q, p, 0, CK_PLAIN, &line_style, JOIN_MODE_PLAIN);

            if (q >= file_size) {
                p = file_size;
            } else {
                /* q стоит на завершающем newline параграфа.
                 * Переходим на следующую строку, чтобы внешняя петля её
                 * корректно переклассифицировала. */
                while (q < file_size && fb(q) != '\n') q++;
                if (q < file_size) q++;
                p = q;
            }
        }
    }
}
#define EM_NONE   0
#define EM_BOLD   1
#define EM_ITALIC 2
#define EM_UNDER  3
#define EM_CODE   4
#define EM_STRIKE 5

/* Преобразует внутреннее состояние emphasis в экранный атрибут. */
static uint8_t emph_to_attr(uint8_t emph, uint8_t base_attr)
{
    switch (emph) {
        case EM_BOLD:   return ATTR_TEXT_BOLD;
        case EM_ITALIC: return ATTR_TEXT_ITALIC;
        case EM_UNDER:  return ATTR_TEXT_UNDERSORE;
        case EM_CODE:   return ATTR_TEXT_CODE;
        case EM_STRIKE: return ATTR_TEXT_STRIKE;
        default:        return base_attr;
    }
}

/* Единая обработка inline-маркеров в render_line().
 * Возврат:
 *   0 = маркер не поглощён (символ выводится как есть),
 *   1 = маркер поглощён как управляющий (ничего не рисуем),
 *   2 = литеральный двойной маркер (`**` или `~~`), нужно вывести две копии out_ch. */
static uint8_t handle_inline_marker(char ch, uint32_t *io_p, uint32_t seg_end,
                                    char prev_ch, uint8_t *io_emph, char *out_ch)
{
    uint32_t p = *io_p;

    *out_ch = 0;

    if (ch == '`') {
        if (*io_emph == EM_NONE) {
            *io_emph = EM_CODE;
            *io_p = p + 1;
            return 1;
        }
        if (*io_emph == EM_CODE) {
            *io_emph = EM_NONE;
            *io_p = p + 1;
            return 1;
        }
        return 0;
    }

    if ((ch == '*' || ch == '~') && (p + 1) < seg_end && fb(p + 1) == ch) {
        uint8_t pair_emph = (ch == '*') ? EM_BOLD : EM_STRIKE;
        char next_ch = ((p + 2) < seg_end) ? fb(p + 2) : '\n';

        if (is_emph_flanked(prev_ch, next_ch)) {
            if (*io_emph == EM_NONE && ws_or_eol(prev_ch)) {
                *io_emph = pair_emph;
                *io_p = p + 2;
                return 1;
            } else if (*io_emph == pair_emph && ws_or_eol_or_delim(next_ch)) {
                *io_emph = EM_NONE;
                *io_p = p + 2;
                return 1;
            }
        }

        /* Невалидный по границам двойной маркер отображаем как литерал. */
        *io_p = p + 2;
        *out_ch = ch;
        return 2;
    }

    if (ch == '*' || ch == '_') {
        char next_ch = ((p + 1) < seg_end) ? fb(p + 1) : '\n';
        if (is_emph_flanked(prev_ch, next_ch)) {
            uint8_t active = (ch == '*') ? EM_ITALIC : EM_UNDER;
            if (*io_emph == EM_NONE && ws_or_eol(prev_ch)) {
                *io_emph = active;
                *io_p = p + 1;
                return 1;
            } else if (*io_emph != EM_NONE && ws_or_eol_or_delim(next_ch)) {
                *io_emph = EM_NONE;
                *io_p = p + 1;
                return 1;
            }
        }
    }

    return 0;
}

/* Сбрасывает накопленный "прогон" из *col символов одного атрибута
 * одним bios_writeattr() вместо посимвольных wrchar().  *need_place
 * лениво ставит BIOS-курсор перед первым выводом в текущей строке —
 * далее place продвигается сам (verified), повторный bios_set_place
 * не нужен пока *col растёт монотонно. */
static void flush_run(uint8_t row, uint8_t *col, const char *buf,
                       uint8_t len, uint8_t attr, uint8_t *need_place)
{
    if (len == 0) return;
    /* *col уже указывает на конец прогона (вызывающий цикл инкрементит
     * col на каждый символ ещё во время накопления в буфер — это нужно
     * для верных проверок col < SCREEN_W до сброса). Поэтому начало
     * прогона на экране = *col - len, а не *col; и *col второй раз
     * наращивать НЕ нужно — он уже корректен. */
    if (*need_place) {
        bios_set_place(row, (uint8_t)(*col - len));
        *need_place = 0;
    }
    bios_writeattr(buf, len, attr);
}

static void render_line(uint16_t line_idx, uint8_t row)
{
    /* Один idx_get() на line_idx вместо ~13 отдельных is_blank/is_cont/
     * is_nowrap/is_code_body/seg_off/get_init_style* вызовов. Каждый из
     * них шёл через seg_flags()/seg_off() -> idx_get() -> bank_read(),
     * а bank_read() ДВАЖДЫ переключает банк окна 3 (на страницу индекса
     * и обратно) плюс memcpy — это оказалось главным узким местом
     * рендера (≈700+ переключений банка на кадр), а не сам вывод на
     * экран. Все нужные поля (off/flags/style) лежат в одной 8-байтовой
     * записи idx_rec_t — читаем её один раз и используем дальше. */
    uint8_t   have_rec = (line_idx < n_lines);
    idx_rec_t rec;
    if (have_rec) idx_get(line_idx, &rec);

    uint8_t col       = 0;
    uint8_t base_attr = ATTR_TEXT;
    uint8_t is_code   = (uint8_t)(have_rec && (rec.flags & IF_CODE));
    uint8_t cur_attr  = is_code ? ATTR_TEXT_CODE
                                 : styles_map[(have_rec ? rec.style : INIT_STYLE_PLAIN) & 7u];
    uint8_t emph      = is_code ? EM_CODE
                                 : (uint8_t)((have_rec ? rec.style : INIT_STYLE_PLAIN) & 7u);
    uint8_t parse_inline = 1;   /* 1: включён парсер выделений, 0: выводим код как есть */

    if (have_rec && (rec.flags & IF_BLANK)) {
        bios_set_place(row, col);
        bios_fillcharattr(' ', ATTR_TEXT, (uint8_t)(SCREEN_W - col));
        return;
    }

    if (have_rec) {
        uint32_t p           = rec.off;
        uint32_t content_off = p;
        uint8_t  cont        = (uint8_t)((rec.flags & IF_CONT) != 0);
        /* Правая граница сегмента: начало следующего сегмента или конец файла. */
        uint32_t seg_end;
        if ((uint16_t)(line_idx + 1) < n_lines) {
            idx_rec_t rec_next;
            idx_get((uint16_t)(line_idx + 1), &rec_next);
            seg_end = rec_next.off;
        } else {
            seg_end = file_size;
        }
        if (seg_end > file_size) seg_end = file_size;
        uint8_t  kind;
        uint8_t  quote_stream = 0; /* 1: текущий сегмент относится к потоку цитаты */

        /* Для сегмента-продолжения повторяем префикс/отступ
         * от первого не-CONT сегмента логической строки. */
        if (cont) {
            uint16_t  first     = line_idx;
            idx_rec_t rec_first = rec; /* rec.flags здесь уже содержит IF_CONT */
            /* Для любого сегмента-продолжения откатываемся к первому
             * НЕ-cont сегменту этой логической строки — один idx_get()
             * на шаг вместо двух (is_cont + повторный seg_off). */
            while (first > 0 && (rec_first.flags & IF_CONT)) {
                first--;
                idx_get(first, &rec_first);
            }
            uint32_t p_start = rec_first.off;
            uint32_t content_off = p_start;
            uint8_t first_kind = classify_line(p_start, &content_off);
            uint8_t indent = marker_visible_col(first_kind, p_start, content_off);
            if (first_kind == LK_QUOTE) {
                /* Для продолжения цитаты нужно помнить тип потока,
                 * чтобы далее на newline корректно пропускать сырой `>`. */
                quote_stream = 1;
                uint8_t q = 0;
                while (q + 2 < content_off && q < SCREEN_W && fb(p_start + q) == ' ') q++;
                if (q > (uint8_t)(SCREEN_W - col)) q = (uint8_t)(SCREEN_W - col);
                if (q > 0) {
                    bios_set_place(row, col);
                    bios_fillcharattr(' ', ATTR_TEXT, q);
                    col = (uint8_t)(col + q);
                }
                if (col < SCREEN_W) wrchar(col++, row, 0xB3, ATTR_QUOTE_MARKER);
                if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
            } else if (first_kind == LK_ULIST || first_kind == LK_OLIST) {
                uint8_t q = indent;
                if (q > (uint8_t)(SCREEN_W - col)) q = (uint8_t)(SCREEN_W - col);
                if (q > 0) {
                    bios_set_place(row, col);
                    bios_fillcharattr(' ', ATTR_TEXT, q);
                    col = (uint8_t)(col + q);
                }
            }
            /* p/content_off уже равны rec.off — внутри cont-блока они не
             * менялись (там свои локальные тени с тем же именем),
             * пересчитывать через seg_off() заново не нужно. */
        }

        /* Кодовый блок:
         *   - строка-разделитель показывается как пустая;
         *   - тело рисуется «как есть» с ATTR_TEXT_CODE без inline-парсинга.
         * is_fence_delim(line_idx) разворачено на месте: cont и p уже
         * известны, повторный idx_get()/is_cont() не нужен. */
        if (!cont && p + 2 < file_size &&
            fb(p) == '`' && fb(p + 1) == '`' && fb(p + 2) == '`') {
            bios_set_place(row, col);
            bios_fillcharattr(' ', ATTR_TEXT, (uint8_t)(SCREEN_W - col));
            return;
        }
        if (is_code) {
            base_attr    = ATTR_TEXT_CODE;
            cur_attr     = ATTR_TEXT_CODE;
            parse_inline = 0;
            kind         = LK_PLAIN;
            quote_stream = 0;
        } else if (cont) {
            /* Строка-продолжение: префикс уже нарисован выше.
             * Содержимое рисуем как plain, без повторной классификации,
             * иначе начало слова могло бы ложно стать маркером. */
            kind = LK_PLAIN;
        } else {
            kind = classify_line(p, &content_off);
            if (kind == LK_QUOTE) quote_stream = 1;
        }

        if (kind == LK_HR) {
            bios_set_place(row, col);
            bios_fillcharattr(0xC4, ATTR_HR, (uint8_t)(SCREEN_W - col));
            return;
        }
        if (kind >= LK_H1 && kind <= LK_H4) {
            switch (kind) {
                case LK_H1: base_attr = ATTR_TEXT_TITLE1; break;
                case LK_H2: base_attr = ATTR_TEXT_TITLE2; break;
                case LK_H3: base_attr = ATTR_TEXT_TITLE3; break;
                default:    base_attr = ATTR_TEXT_TITLE4; break;
            }
            cur_attr = base_attr;
            p = content_off;
        } else if (kind == LK_ULIST) {
            /* Базовая поддержка вложенности: ведущие пробелы сохраняем,
             * затем рисуем маркер списка и пробел перед содержимым. */
            uint32_t q    = p;
            uint32_t qmax = p + SCREEN_W;
            while (q + 2 < content_off && q < qmax && fb(q) == ' ') q++;
            uint8_t nsp = (uint8_t)(q - p);
            if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col);
            if (nsp > 0) {
                bios_set_place(row, col);
                bios_fillcharattr(' ', ATTR_TEXT, nsp);
                col = (uint8_t)(col + nsp);
            }
            if (col < SCREEN_W) wrchar(col++, row, 0x07, ATTR_LIST_MARKER);   /* символ маркера списка */
            if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
            p = content_off;
        } else if (kind == LK_OLIST) {
            /* Отступ -> пробелы; цифры и '.' / ')' рисуем как маркер;
             * завершающий пробел — обычным атрибутом текста. */
            uint32_t q    = p;
            uint32_t qmax = p + SCREEN_W;
            while (q < content_off && q < qmax && fb(q) == ' ') q++;
            uint8_t nsp = (uint8_t)(q - p);
            if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col);
            if (nsp > 0) {
                bios_set_place(row, col);
                bios_fillcharattr(' ', ATTR_TEXT, nsp);
                col = (uint8_t)(col + nsp);
            }
            /* Маркер (цифры/'.'/')') читается из fb() — это окно 3,
             * BIOS принимает указатели только из #4000..#BFFF, поэтому
             * сначала копируем во временный буфер на стеке (окно 2),
             * чанками по 8 байт (маркер почти всегда короче, но длинный
             * корректно не обрезается, просто шлётся несколькими
             * bios_writeattr вместо одного). */
            {
                char    mkbuf[8];
                uint8_t first_chunk = 1;
                while (q < content_off - 1 && col < SCREEN_W) {
                    uint8_t n = 0;
                    while (q < content_off - 1 &&
                           (uint8_t)(col + n) < SCREEN_W &&
                           n < sizeof(mkbuf)) {
                        mkbuf[n] = fb(q);
                        q++;
                        n++;
                    }
                    if (n == 0) break;
                    if (first_chunk) { bios_set_place(row, col); first_chunk = 0; }
                    bios_writeattr(mkbuf, n, ATTR_LIST_MARKER);
                    col = (uint8_t)(col + n);
                }
            }
            if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
            p = content_off;
        } else if (kind == LK_QUOTE) {
            /* Отступ -> пробелы; затем quote-префикс '│' (0xB3),
             * пробел и далее содержимое. */
            uint32_t q    = p;
            uint32_t qmax = p + SCREEN_W;
            while (q + 1 < content_off && q < qmax && fb(q) == ' ') q++;
            uint8_t nsp = (uint8_t)(q - p);
            if (nsp > (uint8_t)(SCREEN_W - col)) nsp = (uint8_t)(SCREEN_W - col);
            if (nsp > 0) {
                bios_set_place(row, col);
                bios_fillcharattr(' ', ATTR_TEXT, nsp);
                col = (uint8_t)(col + nsp);
            }
            if (col < SCREEN_W) wrchar(col++, row, 0xB3, ATTR_QUOTE_MARKER);
            if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
            p = content_off;
        }

        /* Эффективный горизонтальный сдвиг: применяется только к nowrap-строкам. */
        uint8_t effective_vx = (rec.flags & IF_NOWRAP) ? viewport_x : 0;
        /* cc — видимая колонка внутри области контента
         * (после фиксированного префикса строки). */
        uint8_t cc = 0;
        char prev_ch = ' ';     /* используется в проверке границ emphasis */

        /* Буферизация "ровного" прогона одного атрибута: символ и атрибут
         * внутри этого while переменные (emphasis/tab/soft-wrap), поэтому
         * весь цикл одним BIOS-вызовом не свернуть — но между сменами
         * атрибута почти всегда идёт протяжённый одноцветный текст, и
         * именно его стоит копить в runbuf и сбрасывать одним
         * bios_writeattr() вместо посимвольных wrchar().
         * runbuf вмещает целую строку экрана — переполнение невозможно,
         * т.к. весь цикл и так ограничен col < SCREEN_W. */
        char    runbuf[SCREEN_W];
        uint8_t run_len    = 0;
        uint8_t run_attr   = cur_attr;
        uint8_t need_place = 1;

        while (p < seg_end && col < SCREEN_W) {
            uint8_t soft_join = 0; /* 1: вставлен виртуальный пробел мягкой склейки, p уже сдвинут */
            char ch = fb(p);
            /* '\'
             * перед концом сегмента — это маркер wide-break:
             * поглощаем его без вывода (кроме verbatim code-body). */
            if (parse_inline && ch == '\\' && (p + 1) < seg_end) {
                char nb = fb(p + 1);
                if (nb == '\n' || nb == '\r') { p++; continue; }
            }
            /* Мягкий перенос (перевод строки внутри одного сегмента).
             * Рендер должен повторить логику индексатора:
             *   - quote: пропустить сырой `> ` следующей строки;
             *   - list/plain: схлопнуть перенос + ведущий отступ в 1 пробел. */
            if (parse_inline && (ch == '\n' || ch == '\r')) {
                uint32_t next = p + 1;
                if (quote_stream && next < seg_end) {
                    uint32_t next_content = next;
                    if (classify_line(next, &next_content) == LK_QUOTE) {
                        /* Мягкая склейка quote-строк: `> ` не рендерим. */
                        p = next_content;
                        ch = ' ';
                        soft_join = 1;
                    } else {
                        /* Защита: если внутри quote-сегмента встретился
                         * не-цитатный перенос, работаем как обычная мягкая склейка. */
                        while (next < seg_end) {
                            char ws = fb(next);
                            if (ws == ' ' || ws == '\t') next++;
                            else break;
                        }
                        if (next > (p + 1)) {
                            p = next;
                            soft_join = 1;
                        }
                        ch = ' ';
                    }
                } else {
                    while (next < seg_end) {
                        char ws = fb(next);
                        if (ws == ' ' || ws == '\t') next++;
                        else break;
                    }
                    if (next > (p + 1)) {
                        /* Схлопываем перенос + отступ (ленивое продолжение). */
                        p = next;
                        soft_join = 1;
                    }
                    ch = ' ';
                }
            } else if (ch == '\n' || ch == '\r') {
                ch = ' ';
            }

            /* inline-маркеры (**, ~~, *, _, `):
             * helper синхронизирует состояние emphasis и возвращает режим
             * обработки текущего токена (поглотить/вывести литерал/обычный символ). */
            if (parse_inline) {
                char lit_ch = 0;
                uint8_t mk = handle_inline_marker(ch, &p, seg_end, prev_ch, &emph, &lit_ch);
                if (mk == 1) {
                    /* Атрибут меняется -> сбросить накопленный прогон
                     * со СТАРЫМ атрибутом, прежде чем переключиться. */
                    flush_run(row, &col, runbuf, run_len, run_attr, &need_place);
                    run_len = 0;
                    cur_attr = emph_to_attr(emph, base_attr);
                    run_attr = cur_attr;
                    continue;
                }
                if (mk == 2) {
                    if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) {
                        runbuf[run_len++] = lit_ch;
                        col++;
                    }
                    cc++;
                    if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) {
                        runbuf[run_len++] = lit_ch;
                        col++;
                    }
                    cc++;
                    prev_ch = lit_ch;
                    continue;
                }
            }

            if (!soft_join) p++;
            if (ch == '\t') {
                /* Таб расширяется в координатах контента (cc).
                 * Сначала сбросить накопленный текстовый прогон — таб
                 * выводится отдельным bios_fillcharattr(), не через runbuf. */
                flush_run(row, &col, runbuf, run_len, run_attr, &need_place);
                run_len = 0;

                uint8_t cc_start = cc;
                uint8_t tgt      = (uint8_t)((cc & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
                uint8_t n_total  = (uint8_t)(tgt - cc_start);
                uint8_t skip     = (effective_vx > cc_start) ? (uint8_t)(effective_vx - cc_start) : 0;
                if (skip > n_total) skip = n_total;
                uint8_t want = (uint8_t)(n_total - skip);
                if (want > (uint8_t)(SCREEN_W - col)) want = (uint8_t)(SCREEN_W - col);
                if (want > 0) {
                    bios_set_place(row, col);
                    bios_fillcharattr(' ', cur_attr, want);
                    col = (uint8_t)(col + want);
                    need_place = 0;
                }
                cc = (uint8_t)(cc_start + skip + want);
                prev_ch = ' ';
            } else {
                if (cc >= effective_vx && col < SCREEN_W && run_len < sizeof(runbuf)) {
                    runbuf[run_len++] = ch;
                    col++;
                }
                cc++;
                prev_ch = ch;
            }
        }
        flush_run(row, &col, runbuf, run_len, run_attr, &need_place);

        /* Индикатор обрезки справа для nowrap-строки:
         * если после текущей позиции есть ещё видимый контент,
         * поверх последней колонки рисуем '>'. */
        if ((rec.flags & IF_NOWRAP) && col >= SCREEN_W) {
            uint32_t pp = p;
            uint32_t left_bound = rec.off;
            while (pp < seg_end) {
                char c = fb(pp);
                if (c == '\n' || c == '\r') break;
                if (c == '`') { pp++; continue; }
                if (c == '*' && (pp + 1) < seg_end && fb(pp + 1) == '*') {
                    char pc = (pp > left_bound) ? fb(pp - 1) : ' ';
                    char nc = (pp + 2 < seg_end) ? fb(pp + 2) : '\n';
                    if (is_emph_flanked(pc, nc)) { pp += 2; continue; }
                    /* Литерал `**` тоже считается видимым контентом справа. */
                } else if (c == '*' || c == '_') {
                    char pc = (pp > left_bound) ? fb(pp - 1) : ' ';
                    char nc = (pp + 1 < seg_end) ? fb(pp + 1) : '\n';
                    if (is_emph_flanked(pc, nc)) { pp++; continue; }
                }
                wrchar(SCREEN_W - 1, row, '>', ATTR_TRUNC);
                break;
            }
        }

        /* Индикатор скрытого контента слева при горизонтальном сдвиге. */
        if ((rec.flags & IF_NOWRAP) && viewport_x > 0) {
            wrchar(0, row, '<', ATTR_TRUNC);
        }
    }
    /* Дозаполняем остаток строки пробелами, чтобы стереть хвост
     * от предыдущего кадра рендера. */
    {
        uint8_t pad_attr = (parse_inline ? ATTR_TEXT : ATTR_TEXT_CODE);
        if (col < SCREEN_W) {
            bios_set_place(row, col);
            bios_fillcharattr(' ', pad_attr, (uint8_t)(SCREEN_W - col));
        }
    }
}

/* Рендерит все 30 строк окна, начиная от top_line. */
static void render_viewport(void)
{
    for (uint8_t i = 0; i < VIEW_H; i++) {
        render_line((uint16_t)(top_line + i), (uint8_t)(VIEW_TOP_ROW + i));
    }
}

/* Возвращает процент прокрутки (0..100) относительно доступного диапазона. */
static uint8_t calc_pct(void) 
{
    uint8_t pct;
    if (n_lines <= VIEW_H) 
        pct = 100;
    else 
        pct = (uint8_t)((uint32_t)top_line * 100UL / (uint32_t)(n_lines - VIEW_H));
    return pct;
}

/* Рисует динамическую часть статус-бара без printf.
 * Полностью перезаписывает колонки 45..79, чтобы не оставлять «хвосты». */
static void render_status_numbers(void)
{
    uint16_t last = top_line + VIEW_H;
    if (last > n_lines) last = n_lines;
    uint8_t pct = calc_pct();

    textattr(ATTR_BAR);
    bios_set_place(0, 45);
    bios_fillcharattr(' ', ATTR_BAR, (uint8_t)(SCREEN_W - 45));

    wrchar(45, 0, 0xB3, ATTR_BAR);     /* вертикальный разделитель */
    gotoxy(47, 0);
    cputs("L ");
    dec16(top_line + 1);
    cputs("-");
    dec16(last);
    cputs(" / ");
    dec16(n_lines);


    wrchar(70, 0, 0xB3, ATTR_BAR);     /* вертикальный разделитель */
    gotoxy( 73, 0);
    dec8(pct);
    cputs("%");
}

/* Полностью перерисовывает статус-бар (заголовок + числа). */
static void render_full_status(void)
{

    fill_row(0, ATTR_BAR);
    /* Размещение в шапке: "MDVIEW", затем слот спиннера и имя файла. */
    put_str_attr(1, 0, "MDVIEW", ATTR_BAR);
    put_str_attr(10, 0, filename, ATTR_BAR);
    render_status_numbers();
}

/* Обновляет только изменяемую числовую часть статус-бара. */
static void render_updated_status(void)
{
    render_status_numbers();
}

/* Отрисовывает нижнюю строку меню. */
static void render_menu(void)
{
    fill_row(MENU_ROW, ATTR_MENU_T);
    put_str_attr(1,  MENU_ROW, "F1", ATTR_MENU_K);
    put_str_attr(4,  MENU_ROW, "Help", ATTR_MENU_T);
    /* F2 зарезервирован: режимов wrap/unwrap больше нет. */
    put_str_attr(SCREEN_W - 10, MENU_ROW, "F10", ATTR_MENU_K);
    put_str_attr(SCREEN_W -  5, MENU_ROW, "Exit", ATTR_MENU_T);
}

/* ==================================================================
 *                          Прокрутка
 * ================================================================== */

/* Ограничивает top_line допустимым диапазоном с учётом высоты окна. */
static void clamp_top(void)
{
    if (n_lines <= VIEW_H) {
        top_line = 0;
    } else if (top_line > n_lines - VIEW_H) {
        top_line = (uint16_t)(n_lines - VIEW_H);
    }
}

/* Прокрутка вверх на n строк с частичной перерисовкой при n == 1. */
static void md_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);
            render_line((uint16_t)(top_line), (uint8_t)(VIEW_TOP_ROW));
        } else {
            render_viewport();
        }
    }
}

/* Прокрутка вниз на n строк с частичной перерисовкой при n == 1. */
static void md_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);
            render_line((uint16_t)(top_line + VIEW_H - 1), (uint8_t)(VIEW_TOP_ROW + VIEW_H - 1));
        } else {
            clamp_top();
            render_viewport();
        }
    }
}

/* Горизонтальный сдвиг (только если в окне есть nowrap-строки).
 * Максимум сдвига ограничен самой широкой nowrap-строкой на экране. */
static void md_scroll_horizon(int8_t delta)
{
    uint16_t maxw = 0;
    for (uint8_t i = 0; i < VIEW_H; i++) {
        uint16_t li = (uint16_t)(top_line + i);
        if (li >= n_lines) break;
        if (!is_nowrap(li)) continue;
        uint32_t a = seg_off(li);
        uint32_t b = seg_off((uint16_t)(li + 1));
        if (b > file_size) b = file_size;
        uint16_t w = (b > a) ? (uint16_t)(b - a) : 0;   /* приблизительная видимая ширина */
        if (w > maxw) maxw = w;
    }
    if (maxw == 0) return;                              /* в окне нет nowrap-контента */

    /* Максимальный сдвиг = ширина за пределами экрана, в границах uint8. */
    uint16_t over = (maxw > SCREEN_W) ? (uint16_t)(maxw - SCREEN_W) : 0;
    if (over > 248u) over = 248u;
    uint8_t max_vx = (uint8_t)over;

    int16_t nx = (int16_t)viewport_x + delta;
    if (nx < 0) nx = 0;
    if (nx > (int16_t)max_vx) nx = max_vx;
    if ((uint8_t)nx != viewport_x) {
        viewport_x = (uint8_t)nx;
        render_viewport();
    }
}


/* ==================================================================
 *                         Вывод ошибки
 * ================================================================== */

/* Печатает сообщение об ошибке и ждёт нажатия клавиши. */
static void die(const char *msg)
{
    clrscr_attr(ATTR_TEXT);
    puts(msg);
    puts("Press any key to exit...");
    (void)getkey();
}

/* ==================================================================
 *                         Диалог справки (F1)
 * ================================================================== */

/* Заполняет одну внутреннюю строку диалога (r=0 — первая строка за рамкой).
 * Строка s в кодировке CP866; остаток до края дополняется пробелами. */
static void help_line(uint8_t r, const char *s, uint8_t attr)
{
    uint8_t x   = HELP_X + 1u;
    uint8_t y   = HELP_Y + 1u + r;
    uint8_t len = (uint8_t)strlen(s);
    if (len > HELP_W - 2u) len = HELP_W - 2u;

    bios_set_place(y, x);
    if (len > 0) bios_writeattr(s, len, attr);
    /* place уже продвинут bios_writeattr на len колонок (verified). */
    if (len < HELP_W - 2u) {
        bios_fillcharattr(' ', ATTR_HELP_BG, (uint8_t)(HELP_W - 2u - len));
    }
}

static void show_help(void)
{
    /* " Помощь " в CP866 (8 байт) */
    static const char title[] = " \x8F\xAE\xAC\xAE\xE9\xEC ";
    uint8_t tlen = 8u;
    uint8_t lft  = (uint8_t)((HELP_W - 2u - tlen) / 2u);  /* = 27 */
    uint8_t rgt  = (uint8_t)(HELP_W - 2u - tlen - lft);
    /* Верхняя граница рамки с заголовком по центру.
     * place продвигается каждым BIOS-вызовом (verified), поэтому
     * достаточно одной установки места на всю строку рамки. */
    wrchar(HELP_X, HELP_Y, 0xDA, ATTR_HELP_BDR);           /* ┌ */
    bios_set_place(HELP_Y, (uint8_t)(HELP_X + 1u));
    if (lft  > 0) bios_fillcharattr(0xC4, ATTR_HELP_BDR, lft);
    if (tlen > 0) bios_writeattr(title, tlen, ATTR_HELP_TIT);
    if (rgt  > 0) bios_fillcharattr(0xC4, ATTR_HELP_BDR, rgt);
    wrchar(HELP_X + HELP_W - 1u, HELP_Y, 0xBF, ATTR_HELP_BDR);  /* ┐ */

    /* Боковые границы (левый и правый │ для каждой строки тела) */
    for (uint8_t r = 1u; r < HELP_H - 1u; r++) {
        wrchar(HELP_X,                  HELP_Y + r, 0xB3, ATTR_HELP_BDR);  /* │ */
        wrchar(HELP_X + HELP_W - 1u,   HELP_Y + r, 0xB3, ATTR_HELP_BDR);  /* │ */
    }

    /* Нижняя граница рамки */
    wrchar(HELP_X, HELP_Y + HELP_H - 1u, 0xC0, ATTR_HELP_BDR);  /* └ */
    bios_set_place((uint8_t)(HELP_Y + HELP_H - 1u), (uint8_t)(HELP_X + 1u));
    bios_fillcharattr(0xC4, ATTR_HELP_BDR, (uint8_t)(HELP_W - 2u));
    wrchar(HELP_X + HELP_W - 1u, HELP_Y + HELP_H - 1u, 0xD9, ATTR_HELP_BDR);  /* ┘ */

    /* Содержимое (20 внутренних строк) */
    uint8_t r = 0;
    help_line(r++, "",                                                          ATTR_HELP_BG);
    help_line(r++, "  MDView v0.2  --  Markdown Viewer for Sprinter",           ATTR_HELP_HDR);
    help_line(r++, "                 (c) 2026  Petrov A.G.",                    ATTR_HELP_BG);
    help_line(r++, "",                                                          ATTR_HELP_BG);
    help_line(r++, "  Navigation:",                                             ATTR_HELP_HDR);
    help_line(r++, "    \x18 \x19         Scroll one line up / down",           ATTR_HELP_BG);
    help_line(r++, "    PgUp PgDn   Scroll one page up / down",                 ATTR_HELP_BG);
    help_line(r++, "    Home End    Jump to beginning / end of document",       ATTR_HELP_BG);
    help_line(r++, "    \x1B \x1A         Horizontal pan (code blocks/tables)", ATTR_HELP_BG);
    help_line(r++, "    Esc  F10    Exit",                                      ATTR_HELP_BG);
    help_line(r++, "",                                                          ATTR_HELP_BG);
    help_line(r++, "  Markdown elements:",                                      ATTR_HELP_HDR);
    help_line(r++, "    # ## ###    Headings H1-H6",                            ATTR_HELP_BG);
    help_line(r++, "    **bold**   *italic*   `code`   ~~strike~~",             ATTR_HELP_BG);
    help_line(r++, "    > quote    ``` ... ```  Fenced code block",             ATTR_HELP_BG);
    help_line(r++, "    - * + item   1. 2.  Ordered list",                      ATTR_HELP_BG);
    help_line(r++, "    |----|----|   Tables",                                  ATTR_HELP_BG);
    help_line(r++, "",                                                          ATTR_HELP_BG);
    help_line(r++, "  File size: up to 128 KB (EMM).  Lines: up to 16384.",     ATTR_HELP_BG);
    help_line(r++, "",                                                          ATTR_HELP_BG);

    (void)getkey();

    render_full_status();
    render_viewport();
    render_menu();
}

/* ==================================================================
 *                            Точка входа
 * ================================================================== */

int main(int argc, char **argv)
{
    const char *path;
    if (argc >= 2) {
        path = argv[1];
    } else {
        path = "README.MD";
        /* Дефолтный файл отсутствует — выводим подсказку без смены режима экрана. */
        {
            int chk = open(path, O_RDONLY);
            if (chk < 0) {
                cputs("MDView v0.2  --  Markdown Viewer for Sprinter\r\n");
                cputs("(c) 2026 Petrov A.G.\r\n\r\n");
                cputs("Usage:  mdview <file.md>\r\n");
                cputs("        Displays a Markdown document (CP866, up to 128 KB).\r\n");
                return 1;
            }
            close(chk);
        }
    }

    /* Копируем путь в static filename[] для отображения в статус-баре. */
    {
        uint8_t i = 0;
        while (i < (uint8_t)(sizeof(filename) - 1) && path[i]) {
            filename[i] = path[i];
            i++;
        }
        filename[i] = 0;
    }

    /* Сначала поднимаем UI, чтобы пользователь видел шапку/меню/спиннер
     * уже во время загрузки файла и построения индекса. */
    settextmode(TEXT_MODE_80x32);
    clrscr_attr(ATTR_TEXT);
    set_pallete();
    render_menu();
    render_full_status();
    spinner_show(1);

    int rc = load_file(path);
    if (rc < 0) {
        spinner_show(0);
        clrscr_attr(ATTR_RESET);
        textattr(ATTR_RESET);
        cputs("MDView: cannot load file '");
        cputs(path);
        cputs("'\n\rError: ");
        switch (rc) {
            case -1: cputs("open failed");                break;
            case -2: cputs("size > 128K or seek failed"); break;
            case -3: cputs("mem_alloc_pages failed");     break;
            case -4: cputs("short read");                 break;
            case -5: cputs("index alloc failed");         break;
        }
        cputs("\n\r\n\r");
        return 1;
    }
    index_lines();
    spinner_show(0);
    if (n_lines == 0) {
        die("mdview: empty file");
        unload_file();
        return 1;
    }

    render_full_status();
    render_viewport();

    for (;;) {
        uint16_t k = getkey();
        uint8_t  ascii = (uint8_t)(k & 0xFF);
        uint8_t  scan  = (uint8_t)((k >> 8) & 0x7F);

        if (ascii) {
            if (ascii == 0x1B) break;       /* клавиша Esc */
            continue;
        }
        switch (scan) {
            case KEY_F1:    show_help();                   break;
            case KEY_F10:   goto exit_loop;
            case KEY_UP:    md_scroll_up(1);                  break;
            case KEY_DOWN:  md_scroll_down(1);                break;
            case KEY_LEFT:  md_scroll_horizon(-(int8_t)HPAN_STEP);  break;
            case KEY_RIGHT: md_scroll_horizon(+(int8_t)HPAN_STEP);  break;
            case KEY_PGUP:  md_scroll_up(VIEW_H);             break;
            case KEY_PGDN:  md_scroll_down(VIEW_H);           break;
            case KEY_HOME:  top_line = 0; 
                            viewport_x = 0;
                            render_viewport();             break;
            case KEY_END:   top_line = (n_lines > VIEW_H)
                                        ? (uint16_t)(n_lines - VIEW_H) : 0;
                            viewport_x = 0;
                            render_viewport();             break;
            default:        continue;
        }
        render_updated_status();
    }
exit_loop:
    unload_file();
    pal_reset(PAL_CGA);
    clrscr_attr(ATTR_TEXT);
    return 0;
}
