Files
Sprinter-SDCC/examples/mdview/mdview.c
T
snark13 982af12710 mdview: multiline list items with lazy continuation and blank-line grouping
- List items (UL/OL) can now span multiple source lines: non-marker
  lines are joined into the current item as lazy continuation.
- Continuation-line leading indentation is trimmed before joining so
  wrapped item text is separated by a single space.
- A single blank line between adjacent list markers is suppressed
  (same visual list), while 2+ blank lines still produce a separator.

Co-Authored-By: Oz <oz-agent@warp.dev>
2026-06-07 19:13:02 +03:00

1750 lines
67 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* mdview — markdown text viewer for Sprinter.
*
* Layout (80x32):
* row 0 — status bar (filename / line range / total / %)
* rows 1..30 — viewport (30 lines of document text)
* row 31 — menu bar (F1 Help / F2 Wrap / F10 Exit)
*
* Navigation: arrows, PgUp/PgDn, Home/End, F1 help, F2 wrap toggle,
* F10/Esc exit. In Unwrap mode ←/→ pan horizontally.
*
* Memory:
* CODE → W1 (sprinter-cc --memory small)
* STACK/HEAP/DATA → W2
* File buffer → EMM pages (up to 8 × 16 KB = 128 KB) mapped
* into W3 at 0xC000 on demand via fb()/map_page().
*/
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <conio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sprinter.h>
#include <sprinter_mem.h>
#include <palette.h>
/* ---- screen geometry ---------------------------------------------- */
#define SCREEN_W 80
#define SCREEN_H 32
#define VIEW_TOP_ROW 1
#define VIEW_H 30 /* rows 1..30 inclusive */
#define MENU_ROW 31
#define TAB_STOP 4
/* ---- file/memory layout ------------------------------------------- */
#define PAGE_BITS 14u
#define PAGE_SIZE (1u << PAGE_BITS) /* 16 KB */
#define PAGE_MASK ((uint16_t)(PAGE_SIZE - 1u))
#define MAX_PAGES 8 /* 8 × 16 KB = 128 KB */
#define MAX_FILE ((uint32_t)MAX_PAGES * PAGE_SIZE) /* 131072 */
#define MAX_LINES 2048
#define FILE_BUF ((char *)0xC000) /* W3-mapped EMM page */
/* ---- attribute palette -------------------------------------------- */
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
};
// static uint8_t md_pallete[16 * 4] = {
// 0x00,0x00,0x00, 0x00,
// 0x10,0x10,0x10, 0x00,
// 0x20,0x20,0x20, 0x00,
// 0x30,0x30,0x30, 0x00,
// 0x40,0x40,0x40, 0x00,
// 0x50,0x50,0x50, 0x00,
// 0x60,0x60,0x60, 0x00,
// 0x70,0x70,0x70, 0x00,
// 0x80,0x80,0x80, 0x00,
// 0x90,0x90,0x90, 0x00,
// 0xA0,0xA0,0xA0, 0x00,
// 0xB0,0xB0,0xB0, 0x00,
// 0xC0,0xC0,0xC0, 0x00,
// 0xD0,0xD0,0xD0, 0x00,
// 0xE0,0xE0,0xE0, 0x00,
// 0xF0,0xF0,0xF0, 0x00
// };
#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)
/* ---- global state ------------------------------------------------- */
/* Files up to MAX_FILE = 128 KB are supported by spreading content
* across up to 8 EMM pages. Only one page is mapped into W3 at any
* given time (FILE_BUF); fb() swaps pages on demand and tracks the
* currently-mapped one in cur_page to avoid redundant OUTs.
*
* The screen-segment index is a separate EMM block of 8-byte records
* (one per VISIBLE viewport row), accessed through W3 via idx_get/idx_put.
* Each record holds the segment's start offset, flags (cont/nowrap/blank/
* code) and the initial inline style; the right bound is the next record's
* offset. This frees ~11 KB of near RAM and lifts the old 2048-line cap.
*/
/* NOTE: explicit `= 0` initialisers are MANDATORY on SDCC z80.
* Uninitialised static storage (`static T x;`) doesn't actually allocate
* space — multiple such declarations end up at the SAME address and
* silently overwrite each other. See memory/sdcc_static_storage_gotcha. */
/* Screen-segment index. One compact record per VISIBLE viewport row,
* stored in a dedicated EMM block (through W3) rather than scarce near
* RAM. This both removes the old MAX_LINES near-array cap and frees
* ~11 KB of W2 for code/stack. */
typedef struct idx_rec_s {
uint32_t off; /* first source byte rendered by this segment */
uint8_t flags; /* IF_* bits below */
uint8_t style; /* INIT_STYLE_* active at segment start */
uint8_t pad0; /* padding → 8-byte record (2048 records / 16 KB page) */
uint8_t pad1;
} idx_rec_t;
#define INDEX_REC_SIZE 8u
#define INDEX_RECS_PER_PAGE 2048u /* 16384 / 8 — records never straddle a page */
#define MAX_INDEX_PAGES 8u /* 8 * 2048 = 16384 segments max */
#define IF_CONT 0x01u /* continuation of a wrapped logical line */
#define IF_NOWRAP 0x02u /* must not wrap (code block / HR / table) */
#define IF_BLANK 0x04u /* visually blank row */
#define IF_CODE 0x08u /* fenced code-block body (verbatim style) */
static uint16_t n_lines = 0;
static uint16_t max_lines = 0; /* index capacity = 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; /* set when the index capacity is exhausted */
static uint32_t cur_seg_off = 0; /* off of the most-recent emitted seg (near; no bank read-back) */
static idx_rec_t cur_rec; /* mirror of the most-recent emitted record (near) */
static uint8_t cur_page = 0xFF; /* 0xFF = no page mapped yet */
static uint8_t viewport_x = 0; /* horizontal pan (for nowrap lines) */
static char filename[64] = {0};
#define HPAN_STEP 8u
/* Spinner on the title bar (col=8, row=0): a small animated glyph
* shown while load_file / index_lines run, so the user knows the
* viewer is busy and not frozen. */
#define SPINNER_COL 8
static const char spinner_chars[4] = { '|', '/', '-', '\\' };
static uint8_t spinner_phase = 0;
static uint8_t spinner_active = 0;
/* ==================================================================
* tiny helpers
* ================================================================== */
static void put_str_attr(uint8_t x, uint8_t y, const char *s, uint8_t attr)
{
while (*s && x < SCREEN_W) {
wrchar(x++, y, *s++, attr);
}
}
static void put_str(uint8_t x, uint8_t y, const char *s)
{
gotoxy(x, y);
puts(s);
}
static void fill_row(uint8_t y, uint8_t attr)
{
for (uint8_t c = 0; c < SCREEN_W; c++) {
wrchar(c, y, ' ', attr);
}
}
/* Advance the spinner by one frame. No-op when inactive — safe to call
* unconditionally from any loop that wants to show "still working". */
static void spinner_tick(void)
{
if (!spinner_active) return;
wrchar(SPINNER_COL, 0, spinner_chars[spinner_phase & 3], ATTR_BAR_SPINNER);
spinner_phase++;
}
/* Enable / disable the spinner. Disabling also wipes the glyph from the
* title bar so the slot looks like a plain space. */
static void spinner_show(uint8_t on)
{
spinner_active = on;
if (!on) wrchar(SPINNER_COL, 0, ' ', ATTR_BAR);
}
/* Map the EMM page that holds file offset >= page * PAGE_SIZE into W3.
* No-op if already mapped. */
static inline void map_page(uint8_t page)
{
if (page != cur_page) {
/* file_phys[] saves a BIOS mem_get_page() call on every page swap. */
sprinter_page_w3(file_phys[page]);
cur_page = page;
}
}
/* Read one byte from the logical file at byte offset `p`. Swaps the W3
* mapping if `p` falls into a different page than the one currently
* mapped. Bounds checking is the caller's job.
*
* Hot path: index_lines() calls this for almost every input byte. On z80
* 32-bit shifts/masks are helper calls, so we decode page/offset from the
* little-endian byte layout of `p`: page = bits 14..16, offset = bits 0..13. */
static inline 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];
}
/* Treat space/tab/EOL/NUL as whitespace for emphasis flanking checks. */
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 emphasis marker (asterisk, double-asterisk, underscore) is
* recognised only when ONE side is whitespace/EOL and the other is not
* (XOR). This treats "2 * 3" and "2 ** 3" (whitespace both sides) as
* literal, as well as intraword cases like COLOR_YELLOW (no whitespace
* either side), while keeping *italic*, **bold**, _under_ working. */
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));
}
/* Continuation kind passed to emit_seg(); no longer stored (render derives
* the kind from classify_line()), kept only as call-site argument names. */
#define CK_PLAIN 0
#define CK_QUOTE 1
#define CK_LIST 2
#define CK_OTHER 3
/* EMM index accessors. Records are 8 bytes and aligned within an index
* page, so bank_read/bank_write never split a record across a page. */
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);
}
/* Field readers used by render & navigation (per-row, not per-byte). */
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); }
/* Flag setters target the MOST-RECENT emitted seg (n_lines-1) through the
* near mirror cur_rec, so the scan never reads the index back from the bank. */
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_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];
// buff[j * 4] = i * 16;
// buff[j * 4 + 1] = i * 16;
// buff[j * 4 + 2] = i * 16;
// buff[j * 4 + 3] = 0;
}
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);
}
}
/* ==================================================================
* file loading
* ================================================================== */
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);
/* Allocate exactly the number of EMM pages we need (1..MAX_PAGES). */
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;
}
/* Read the file 16 KB at a time, mapping each page into W3 in turn.
* `remaining` decreases by exactly PAGE_SIZE on each non-final iter;
* the final iter reads whatever is left. */
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);
/* Allocate the EMM index block (8 B/segment, 2048 records per 16 KB
* page). Size it a little larger than the file so even dense files
* index fully; back off if EMM is tight. max_lines becomes the cap. */
{
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;
}
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;
}
}
/* ==================================================================
* line indexing
* ================================================================== */
/* Returns 1 iff the seg at index `idx` is the start of a logical line
* that begins with ``` (a fenced-code block delimiter). Continuation
* segs are never fence delimiters. */
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
/* Indexed by init_style & 7; order must match INIT_STYLE_* / EM_* below. */
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];
}
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);
}
/* Returns 1 iff the logical line at file offset `p_start` should NOT be
* wrapped — it must render on a single seg even if longer than SCREEN_W.
* Currently covers fence delimiters and horizontal rules; tables will be
* added when Phase 4 tables / Phase 6 table layout lands. */
static uint8_t is_nowrap_line(uint32_t p_start)
{
if (p_start + 2 < file_size &&
fb(p_start) == '`' &&
fb(p_start + 1) == '`' &&
fb(p_start + 2) == '`') {
return 1;
}
char c0 = fb(p_start);
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 1;
}
return 0;
}
/* Line kind returned by classify_line(). */
#define LK_PLAIN 0
#define LK_H1 1
#define LK_H2 2
#define LK_H3 3
#define LK_H4 4 /* also used for H5/H6 */
#define LK_HR 5
#define LK_ULIST 6 /* "- ", "* ", "+ " bullet */
#define LK_OLIST 7 /* "12. " or "12) " number */
#define LK_QUOTE 8 /* "> " blockquote */
/* Classify a logical line.
* - Headers: 1..4 leading '#' followed by space/tab (H5+/H6 collapse to H4).
* - Horizontal rule: line containing only '-', '*' or '_' (one kind, >=3
* of them), optionally separated by spaces/tabs. Matches `---`, `***`,
* `___`, `- - -`, etc. Checked BEFORE ulist so that `- - -` wins.
* - Unordered list: "- ", "* " or "+ " followed by content.
* - Ordered list: one or more digits followed by '.' or ')' then space.
* - Blockquote: '>' optionally followed by space.
* - Otherwise plain text.
* For headers/lists/quotes, *out_content is updated to the offset of the
* first content byte (past the marker and its trailing space).
*/
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);
/* Header? */
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 */
}
}
/* Bare '#' with no space → not a header, fall through. */
}
/* Horizontal rule? (must precede ulist so `- - -` wins over `- `) */
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;
}
/* For lists and blockquotes we additionally allow leading spaces
* (light nested-list support). HR / header / fence delim stay
* strict col-0 — done above with `c0`. */
uint32_t lp = p_start;
while (lp < file_size && fb(lp) == ' ') lp++;
char cl = (lp < file_size) ? fb(lp) : 0;
/* Unordered list? */
if ((cl == '-' || cl == '*' || cl == '+') &&
(lp + 1) < file_size &&
fb(lp + 1) == ' ') {
*out_content = lp + 2;
return LK_ULIST;
}
/* Ordered list? */
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;
}
}
}
/* Blockquote? */
if (cl == '>') {
uint32_t p = lp + 1;
if (p < file_size && fb(p) == ' ') p++;
*out_content = p;
return LK_QUOTE;
}
return LK_PLAIN;
}
/* Visible column count for the marker prefix when rendered, including
* any leading indent for nested lists/quotes (the render side keeps the
* 1 byte = 1 visible col invariant for those, so content_off - p_start
* is the visible width). */
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 (skipped) */
}
}
/* Emit a new segment entry into the EMM index. cur_rec (near) mirrors the
* just-written record so set_*_cur() can add flags without a bank read-back,
* and cur_seg_off feeds the per-byte wrap logic without reading the index. */
static void emit_seg(uint32_t off, uint8_t style, uint8_t ckind, uint8_t cont)
{
(void)ckind; /* kind is derived at render time */
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;
}
/* A table row: the first non-space character on the line is '|'. Leading
* spaces are allowed. Table rows are nowrap and never merged. */
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;
}
/* Shared inline-emphasis / wrap scanner for a single line or paragraph stream.
* Starts at `q`, processes until `q_end` (or paragraph break for plain text),
* emits continuation segs as needed. `col` is the starting visible column
* (0 for plain/headers, marker width for list/quote). `ckind` is the
* continuation-kind for wrap segs. Returns the final `line_style` (for
* paragraph streams that carry emphasis across soft/hard breaks). */
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;
char prev_ch = ' ';
uint8_t seg_col = col;
while (q < q_end && n_lines < max_lines) {
char ch = fb(q);
if (ch == '\n') {
/* hard break (already checked by caller) or stream end */
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;
seg_col++; q++; prev_ch = ' ';
} else {
seg_col++; q++; prev_ch = ch;
}
if (seg_col >= SCREEN_W) {
uint32_t wrap_at = (last_space != 0xFFFFFFFFu && last_space > cur_seg_off)
? last_space : q;
if (wrap_at >= q_end || wrap_at == cur_seg_off) break;
emit_seg(wrap_at, line_style, ckind, 1);
seg_col = col; last_space = 0xFFFFFFFFu; prev_ch = ' '; q = wrap_at;
}
}
return line_style;
}
static void index_lines(void)
{
uint8_t in_block = 0;
n_lines = 0;
index_truncated = 0;
cur_seg_off = 0xFFFFFFFFu; /* no segment emitted yet */
if (file_size == 0) return;
uint32_t p = 0;
while (p < file_size && n_lines < max_lines) {
if ((n_lines & 15) == 0) spinner_tick();
/* Blank line = paragraph separator. Emit one empty screen row
* (only if the previous emitted line was not already blank). */
if (is_line_blank(p)) {
while (p < file_size && fb(p) != '\n') p++;
if (p < file_size) p++;
/* Collapse a run of blank lines to a single blank row. */
if (n_lines == 0 || !(cur_rec.flags & IF_BLANK)) {
emit_seg(p, INIT_STYLE_PLAIN, CK_PLAIN, 0);
set_blank_cur();
}
continue;
}
/* ---- fenced code block (delimiter or body) ---- */
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;
}
/* ---- horizontal rule ---- */
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;
}
/* ---- table row (| ... |): one nowrap seg per source line ---- */
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;
}
/* ---- classify current line ---- */
uint32_t content_off = p;
uint8_t kind = classify_line(p, &content_off);
/* ---- Header: single line, not merged into paragraphs ---- */
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;
}
/* ---- List item: may span multiple source lines ---- */
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 item_start = p;
uint32_t q = content_off;
uint32_t last_space = 0xFFFFFFFFu;
char prev_ch = ' ';
uint8_t seg_col = col;
emit_seg(p, line_style, CK_LIST, 0);
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;
/* Blank line ends the current list item. */
if (is_line_blank(next)) break;
/* Block boundaries also end the item. */
if (is_fence_raw(next) || is_hr_raw(next) || is_table_raw(next)) break;
/* Next list marker starts a new item; same for quote/header/hr. */
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;
}
/* Lazy continuation: join the next non-marker line with
* one space; trim its leading indentation first. */
while (next < file_size) {
char ws = fb(next);
if (ws == ' ' || ws == '\t') next++;
else break;
}
q = next;
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;
else last_space = q + 1;
seg_col++;
if (!soft_break) q++; else q++;
prev_ch = ' ';
} else {
seg_col++;
if (!soft_break) q++; else q++;
prev_ch = ch;
}
if (seg_col >= SCREEN_W) {
uint32_t wrap_at = (last_space != 0xFFFFFFFFu && last_space > cur_seg_off)
? last_space : q;
if (wrap_at >= file_size || wrap_at == cur_seg_off) break;
emit_seg(wrap_at, line_style, CK_LIST, 1);
seg_col = col; last_space = 0xFFFFFFFFu; prev_ch = ' '; q = wrap_at;
}
}
/* Move to start of the next physical line after the item. */
if (q < file_size) {
while (q < file_size && fb(q) != '\n') q++;
if (q < file_size) q++;
}
/* One blank line between adjacent list markers is suppressed
* (same list). Two+ blank lines keep a visible separator. */
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; /* suppress the single blank row */
continue;
}
}
}
p = q;
if (p < item_start) p = item_start;
continue;
}
/* ---- Quote line: single line, not merged ---- */
if (kind == LK_QUOTE) {
uint8_t col = marker_visible_col(kind, p, content_off);
emit_seg(p, INIT_STYLE_PLAIN, CK_QUOTE, 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, col, CK_QUOTE, INIT_STYLE_PLAIN);
while (p < file_size && fb(p) != '\n') p++;
if (p < file_size) p++;
continue;
}
/* ---- Plain text paragraph: merge across soft breaks ---- */
{
uint8_t line_style = INIT_STYLE_PLAIN;
emit_seg(p, line_style, CK_PLAIN, 0);
uint32_t q = p;
uint32_t last_space = 0xFFFFFFFFu;
char prev_ch = ' ';
uint8_t seg_col = 0;
while (q < file_size && n_lines < max_lines) {
char ch = fb(q);
uint8_t soft_break = 0;
if (ch == '\n') {
/* Paragraph break? */
if (q + 1 >= file_size) break;
if (fb(q + 1) == '\n') break; /* blank line */
uint32_t next = q + 1;
if (is_fence_raw(next)) break;
if (is_hr_raw(next)) break;
if (is_table_raw(next)) break;
uint32_t dummy;
if (classify_line(next, &dummy) != LK_PLAIN) break;
if (is_line_blank(next)) break;
/* Wide break? Two trailing spaces/tabs OR a trailing
* backslash before the newline force a line break inside
* the paragraph (the inline style is preserved). */
uint8_t is_hard = 0;
if (q >= p + 1) {
char b1 = fb(q - 1);
if (b1 == '\\') {
is_hard = 1;
} else if (q >= p + 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, CK_PLAIN, 0);
last_space = 0xFFFFFFFFu; prev_ch = ' '; seg_col = 0;
continue;
}
/* Soft break — treat as a single space. */
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;
else last_space = q + 1;
seg_col++;
if (!soft_break) q++; else q++;
prev_ch = ' ';
} else {
seg_col++;
if (!soft_break) q++; else q++;
prev_ch = ch;
}
if (seg_col >= SCREEN_W) {
uint32_t wrap_at = (last_space != 0xFFFFFFFFu && last_space > cur_seg_off)
? last_space : q;
if (wrap_at >= file_size || wrap_at == cur_seg_off) break;
emit_seg(wrap_at, line_style, CK_PLAIN, 1);
seg_col = 0; last_space = 0xFFFFFFFFu; prev_ch = ' '; q = wrap_at;
}
}
if (q >= file_size) {
p = file_size;
} else {
/* q sits on the paragraph's trailing newline; resume at the
* next line so the outer loop classifies it. A following
* blank line then becomes the single separating blank row
* (previously it was swallowed, gluing paragraphs together). */
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
static void render_line(uint16_t line_idx, uint8_t row)
{
uint8_t col = 0;
uint8_t base_attr = ATTR_TEXT;
uint8_t cur_attr = is_code_body(line_idx) ? ATTR_TEXT_CODE : get_init_style(line_idx); // ATTR_TEXT;
uint8_t emph = is_code_body(line_idx) ? EM_CODE : get_init_style_raw(line_idx);
uint8_t parse_inline = 1; /* skip inline parsing for fenced code body */
if (is_blank(line_idx)) {
while (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
return;
}
if (line_idx < n_lines) {
uint32_t p = seg_off(line_idx);
uint32_t content_off = p;
uint8_t cont = is_cont(line_idx);
/* Right bound for this seg: start of next seg, or file end. */
uint32_t seg_end = seg_off((uint16_t)(line_idx + 1));
if (seg_end > file_size) seg_end = file_size;
uint8_t kind;
/* Continuation seg → repeat prefix/indent from the first non-CONT
* segment of this logical line, then render plain text. */
if (cont) {
uint16_t first = line_idx;
while (first > 0 && is_cont((uint16_t)(first - 1))) first--;
uint32_t p_start = seg_off(first);
uint32_t content_off = p_start;
uint8_t kind = classify_line(p_start, &content_off);
uint8_t indent = marker_visible_col(kind, p_start, content_off);
if (kind == LK_QUOTE) {
uint8_t q = 0;
while (q + 2 < content_off && col < SCREEN_W && fb(p_start + q) == ' ') {
wrchar(col++, row, ' ', ATTR_TEXT);
q++;
}
if (col < SCREEN_W) wrchar(col++, row, 0xB3, ATTR_QUOTE_MARKER);
if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
} else if (kind == LK_ULIST || kind == LK_OLIST) {
uint8_t q = 0;
while (q < indent && col < SCREEN_W) {
wrchar(col++, row, ' ', ATTR_TEXT);
q++;
}
}
/* start rendering from the wrapped segment offset */
p = seg_off(line_idx);
content_off = p;
/* For cont segs, inline parsing continues normally. */
}
/* Fenced code block: delimiter line → blank row; body lines render
* verbatim in ATTR_TEXT_CODE without any inline-emphasis parsing. */
if (is_fence_delim(line_idx)) {
while (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
return;
}
if (is_code_body(line_idx)) {
base_attr = ATTR_TEXT_CODE;
cur_attr = ATTR_TEXT_CODE;
parse_inline = 0;
kind = LK_PLAIN;
} else if (cont) {
/* Continuation row: the parent prefix/indent is already drawn
* above. Render the wrapped content as plain text — do NOT
* re-classify it, or a wrapped word beginning with '-', '#',
* '>' etc. would be mis-drawn as a new marker. */
kind = LK_PLAIN;
} else {
kind = classify_line(p, &content_off);
}
if (kind == LK_HR) {
while (col < SCREEN_W) wrchar(col++, row, 0xC4, ATTR_HR);
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) {
/* Light nested-list support: any leading spaces before '-' / '*'
* / '+' render as plain spaces (preserves nesting visually);
* then a bullet + a content space. */
uint32_t q = p;
while (q + 2 < content_off && col < SCREEN_W && fb(q) == ' ') {
wrchar(col++, row, ' ', ATTR_TEXT);
q++;
}
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) {
/* Indent (if any) → spaces; digits and '.' or ')' in marker
* colour; trailing space in plain. */
uint32_t q = p;
while (q < content_off && col < SCREEN_W && fb(q) == ' ') {
wrchar(col++, row, ' ', ATTR_TEXT);
q++;
}
while (q < content_off - 1 && col < SCREEN_W) {
wrchar(col++, row, fb(q), ATTR_LIST_MARKER);
q++;
}
if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
p = content_off;
} else if (kind == LK_QUOTE) {
/* Indent (if any) → spaces; '│' (0xB3) prefix in marker
* colour, then a space, then content. */
uint32_t q = p;
while (q + 1 < content_off && col < SCREEN_W && fb(q) == ' ') {
wrchar(col++, row, ' ', ATTR_TEXT);
q++;
}
if (col < SCREEN_W) wrchar(col++, row, 0xB3, ATTR_QUOTE_MARKER);
if (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
p = content_off;
}
/* Effective horizontal offset: only nowrap lines honour viewport_x. */
uint8_t effective_vx = is_nowrap(line_idx) ? viewport_x : 0;
/* `cc` is the VISIBLE column inside the content area (the part
* after any fixed marker). For nowrap lines, chars at cc < viewport_x
* are skipped so the user can pan horizontally. */
uint8_t cc = 0;
char prev_ch = ' '; /* for `_` flanking check */
while (p < seg_end && col < SCREEN_W) {
char ch = fb(p);
/* A backslash immediately before this segment's end-of-line is a
* wide-break marker (index_lines split here) — consume it without
* drawing. Not applied to verbatim code bodies. */
if (parse_inline && ch == '\\' && (p + 1) < seg_end) {
char nb = fb(p + 1);
if (nb == '\n' || nb == '\r') { p++; continue; }
}
/* Soft break (newline inside a paragraph segment) — render as a
* single space. Hard breaks are already separate segments. */
if (ch == '\n' || ch == '\r') ch = ' ';
/* --- inline emphasis markers (consumed, not rendered) ---
* Asterisk / double-asterisk / underscore are markers only
* when XOR-flanked (whitespace on exactly one side). Keeps
* COLOR_YELLOW, intraword slashes, "2 * 3", "2 ** 3" etc.
* as literal text. A marker that would conflict with the
* currently-active style is still consumed (zero-width)
* so the index_lines column count stays in sync with the
* rendered output. Skipped entirely inside fenced code
* blocks (parse_inline=0). */
if (parse_inline) {
/* `**` (bold) */
if (ch == '*' && (p + 1) < seg_end && fb(p + 1) == '*') {
char next_ch = ((p + 2) < seg_end) ? fb(p + 2) : '\n';
if (is_emph_flanked(prev_ch, next_ch)) {
if (emph == EM_NONE && ws_or_eol(prev_ch)) {
emph = EM_BOLD; cur_attr = ATTR_TEXT_BOLD;
p += 2; continue;
} else if (emph == EM_BOLD && ws_or_eol_or_delim(next_ch)) {
emph = EM_NONE; cur_attr = base_attr;
p += 2; continue;
}
}
/* literal `**` — render BOTH stars, do NOT fall
* through to single-`*` handler. */
p += 2;
if (cc >= effective_vx && col < SCREEN_W) wrchar(col++, row, '*', cur_attr);
cc++;
if (cc >= effective_vx && col < SCREEN_W) wrchar(col++, row, '*', cur_attr);
cc++;
prev_ch = '*';
continue;
}
/* `~~` (strikethrough) — same flanking rule as `**` */
if (ch == '~' && (p + 1) < seg_end && fb(p + 1) == '~') {
char next_ch = ((p + 2) < seg_end) ? fb(p + 2) : '\n';
if (is_emph_flanked(prev_ch, next_ch)) {
if (emph == EM_NONE && ws_or_eol(prev_ch)) {
emph = EM_STRIKE; cur_attr = ATTR_TEXT_STRIKE;
p += 2; continue;
} else if (emph == EM_STRIKE && ws_or_eol_or_delim(next_ch)) {
emph = EM_NONE; cur_attr = base_attr;
p += 2; continue;
}
}
/* literal `~~` — render both tildes */
p += 2;
if (cc >= effective_vx && col < SCREEN_W) wrchar(col++, row, '~', cur_attr);
cc++;
if (cc >= effective_vx && col < SCREEN_W) wrchar(col++, row, '~', cur_attr);
cc++;
prev_ch = '~';
continue;
}
/* `*` (italic) and `_` (underline) — same flanking rule */
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;
uint8_t active_attr = (ch == '*') ? ATTR_TEXT_ITALIC : ATTR_TEXT_UNDERSORE;
if (emph == EM_NONE && ws_or_eol(prev_ch)) {
emph = active; cur_attr = active_attr;
p++; continue;
} else if (emph != EM_NONE && ws_or_eol_or_delim(next_ch)) {
emph = EM_NONE; cur_attr = base_attr;
p++; continue;
}
}
/* intraword / both-flanked — fall through, literal */
}
if (ch == '`') {
if (emph == EM_NONE) {
emph = EM_CODE; cur_attr = ATTR_TEXT_CODE;
p++; continue;
}
if (emph == EM_CODE) {
emph = EM_NONE; cur_attr = base_attr;
p++; continue;
}
}
}
p++;
if (ch == '\t') {
/* Tab expands in CONTENT-col space (cc), each generated
* space is then conditionally rendered through hpan. */
uint8_t tgt = (uint8_t)((cc & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
while (cc < tgt && col < SCREEN_W) {
if (cc >= effective_vx) wrchar(col++, row, ' ', cur_attr);
cc++;
}
prev_ch = ' ';
} else {
if (cc >= effective_vx && col < SCREEN_W) {
wrchar(col++, row, ch, cur_attr);
}
cc++;
prev_ch = ch;
}
}
/* Right-edge truncation indicator for nowrap lines: when the screen
* ran out (col == SCREEN_W) and this row is nowrap, peek forward
* past any zero-width inline markers — if there's still real visible
* content before the end of the line, overlay the last cell with '>'. */
if (is_nowrap(line_idx) && col >= SCREEN_W) {
uint32_t pp = p;
uint32_t left_bound = seg_off(line_idx);
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; }
/* literal `**` — there IS visible content */
} 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;
}
}
/* Left-edge indicator: when panned right, show that nowrap content
* is hidden off the left side. Overlays column 0. */
if (is_nowrap(line_idx) && viewport_x > 0) {
wrchar(0, row, '<', ATTR_TRUNC);
}
}
/* Pad rest of the row so previous render content is wiped. For code-
* body rows use the code attribute so the block extends to the right
* edge — visually distinguishes the block from surrounding text. */
{
uint8_t pad_attr = (parse_inline ? ATTR_TEXT : ATTR_TEXT_CODE);
while (col < SCREEN_W) wrchar(col++, row, ' ', pad_attr);
}
}
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));
}
}
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;
}
static void render_full_status(void)
{
uint16_t last = top_line + VIEW_H;
if (last > n_lines) last = n_lines;
uint8_t pct = calc_pct();
fill_row(0, ATTR_BAR);
gotoxy(1, 0);
/* "MDVIEW", then col 7 = space, col 8 = spinner slot, col 9 = space,
* col 10+ = filename. Spinner_tick draws over col 8 in ATTR_BAR. */
printf("MDVIEW %s", filename);
gotoxy(45, 0);
printf("\xB3 L %5u-%u / %u", (top_line + 1), last, n_lines);
gotoxy(70, 0);
printf("\xB3 %3u%%", pct);
}
static void render_updated_status(void)
{
uint16_t last = top_line + VIEW_H;
if (last > n_lines) last = n_lines;
uint8_t pct = calc_pct();
gotoxy(45, 0);
printf("\xB3 L %5u-%u / %u", (top_line + 1), last, n_lines);
gotoxy(70, 0);
printf("\xB3 %3u%%", pct);
}
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 slot removed — no wrap/unwrap toggle any more. */
put_str_attr(SCREEN_W - 10, MENU_ROW, "F10", ATTR_MENU_K);
put_str_attr(SCREEN_W - 5, MENU_ROW, "Exit", ATTR_MENU_T);
}
/* ==================================================================
* scrolling
* ================================================================== */
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);
}
}
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);
render_line((uint16_t)(top_line), (uint8_t)(VIEW_TOP_ROW));
} else {
render_viewport();
}
}
}
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);
render_line((uint16_t)(top_line + VIEW_H - 1), (uint8_t)(VIEW_TOP_ROW + VIEW_H - 1));
} else {
clamp_top();
render_viewport();
}
}
}
/* Horizontal pan (only when the viewport contains nowrap lines). The pan
* is bounded by the widest nowrap segment currently on screen, so the user
* cannot scroll past the end of the longest code/table line. */
static void scroll_h(int8_t delta)
{
uint16_t maxw = 0;
for (uint8_t i = 0; i < VIEW_H; i++) {
uint16_t li = (uint16_t)(top_line + i);
if (li >= n_lines) break;
if (!is_nowrap(li)) continue;
uint32_t a = seg_off(li);
uint32_t b = seg_off((uint16_t)(li + 1));
if (b > file_size) b = file_size;
uint16_t w = (b > a) ? (uint16_t)(b - a) : 0; /* ~visible width */
if (w > maxw) maxw = w;
}
if (maxw == 0) return; /* no nowrap content in view */
/* Max pan = width beyond the screen, capped to the uint8 pan range. */
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();
}
}
/* Toggle wrap/truncate and rebuild the seg index. Tries to preserve
* the user's scroll position by remembering the byte offset of the
* current top seg and finding the seg that contains that offset after
* the rebuild. Also resets horizontal pan — wrap mode doesn't use it. */
static void toggle_wrap(void)
{
/* Removed — no longer needed. Kept as stub to avoid
* linker warnings if something still calls it. */
(void)0;
}
/* ==================================================================
* help screen
* ================================================================== */
static const char * const help_lines[] = {
"",
" MDVIEW v0.1 - Markdown text viewer for Sprinter",
" =================================================",
"",
" Navigation:",
" Up / Down - scroll one line",
" PgUp / PgDn - scroll one page",
" Home - go to top of document",
" End - go to bottom of document",
" Left / Right - pan horizontally (nowrap lines only)",
"",
" Other:",
" F1 - show this help",
" F2 - (reserved)",
" F10 / Esc - exit",
"",
" Press any key to return...",
(const char *)0
};
static void help_screen(void)
{
clrscr_attr(ATTR_TEXT);
for (uint8_t i = 0; help_lines[i]; i++) {
put_str_attr(0, (uint8_t)(i + 1), help_lines[i], ATTR_TEXT);
}
(void)getkey();
clrscr_attr(ATTR_TEXT);
render_menu();
render_full_status();
render_viewport();
}
/* ==================================================================
* error
* ================================================================== */
static void die(const char *msg)
{
clrscr_attr(ATTR_TEXT);
puts(msg);
puts("Press any key to exit...");
(void)getkey();
}
/* ==================================================================
* main
* ================================================================== */
int main(int argc, char **argv)
{
const char *path;
if (argc >= 2) path = argv[1];
else path = "SAMPLE.MD";
/* Copy path → static filename[] for the status bar. */
{
uint8_t i = 0;
while (i < (uint8_t)(sizeof(filename) - 1) && path[i]) {
filename[i] = path[i];
i++;
}
filename[i] = 0;
}
/* Bring the UI up FIRST so the user sees the menu + title bar (with
* a spinner) immediately, instead of a black screen while we read
* the file and index lines. */
set_videotextmode(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_TEXT);
puts("mdview: cannot load file");
puts(path);
switch (rc) {
case -1: puts("(open failed)"); break;
case -2: puts("(size > 128K or seek failed)"); break;
case -3: puts("(mem_alloc_pages failed)"); break;
case -4: puts("(short read)"); break;
case -5: puts("(index alloc failed)"); break;
}
puts("Press any key to exit...");
(void)getkey();
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_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;
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;
}