Files
Sprinter-SDCC/examples/mdview/mdview.c
T
snark13 737c974400 Add mdview markdown viewer, reorganize tests/examples and libc layout
- Split tests/ (libc feature tests) and examples/ (real apps); shared
  app.mk in repo root, was examples/example.mk
- libc/io/* split into libc/{conio,env,errno,file,mouse,string,sys,
  time,video}/ — clearer module boundaries
- New examples/mdview/: markdown viewer (Phases 1-5 + light nested
  lists). Headers (H1-H4), HR, ulist/olist/quote with nesting via
  leading spaces, fenced code blocks, inline emphasis (bold/italic/
  underscore/code), wrap/unwrap mode with soft wrap (F2), horizontal
  pan (← →) with '>' truncation indicator
- libc additions: scroll() in conio (ESTEX SCROLL), strlwr/strupr,
  gets() test
- Makefile updates across tests/ for the new shared app.mk path

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 22:23:36 +03:00

910 lines
32 KiB
C

/*
* mdview — markdown text viewer for Sprinter (Phase 1: plain text).
*
* 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 ... F10 Exit)
*
* Navigation: arrows, PgUp/PgDn, Home/End, F1 help, F10/Esc exit.
*
* Memory:
* CODE → W1 (sprinter-cc --memory small)
* STACK/HEAP/DATA → W2
* File buffer → EMM page mapped into W3 at 0xC000 (≤ 16 KB)
*/
#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>
/* ---- 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 MAX_FILE 16384
#define MAX_LINES 2048
#define FILE_BUF ((char *)0xC000) /* W3-mapped EMM page */
/* ---- attribute palette -------------------------------------------- */
#define ATTR_TEXT COLOR(COLOR_LIGHTGRAY, COLOR_BLACK)
#define ATTR_TEXT_TITLE1 COLOR(COLOR_YELLOW, COLOR_BLACK)
#define ATTR_TEXT_TITLE2 COLOR(COLOR_LIGHTBLUE, COLOR_BLACK)
#define ATTR_TEXT_TITLE3 COLOR(COLOR_LIGHTGREEN, COLOR_BLACK)
#define ATTR_TEXT_TITLE4 COLOR(COLOR_DARKGRAY, COLOR_BLACK)
#define ATTR_TEXT_BOLD COLOR(COLOR_WHITE, COLOR_BLUE)
#define ATTR_TEXT_ITALIC COLOR(COLOR_LIGHTGREEN, COLOR_BROWN)
#define ATTR_TEXT_UNDERSORE COLOR(COLOR_LIGHTBLUE, COLOR_GREEN)
#define ATTR_TEXT_CODE COLOR(COLOR_WHITE, COLOR_LIGHTGRAY)
#define ATTR_LIST_MARKER COLOR(COLOR_LIGHTCYAN, COLOR_BLACK)
#define ATTR_QUOTE_MARKER COLOR(COLOR_LIGHTMAGENTA,COLOR_BLACK)
#define ATTR_HR COLOR(COLOR_DARKGRAY, COLOR_BLACK)
#define ATTR_TRUNC COLOR(COLOR_WHITE, COLOR_BLACK)
#define ATTR_BAR COLOR(COLOR_WHITE, COLOR_BLUE)
#define ATTR_MENU_T COLOR(COLOR_WHITE, COLOR_BLUE)
#define ATTR_MENU_K COLOR(COLOR_YELLOW, COLOR_BLUE)
/* ---- global state ------------------------------------------------- */
/* line_offset[] semantics depend on wrap_mode:
* wrap_mode == 0 (truncate): one entry per logical line, value = byte
* offset inside FILE_BUF.
* wrap_mode == 1 (wrap): one entry per VISIBLE viewport row. Low
* 14 bits = byte offset of segment start;
* bit 15 (OFF_CONT) marks a continuation of
* the previous logical line — rendered as
* plain text without markdown classification.
* MAX_FILE = 16 KB so offsets fit in 14 bits.
*/
#define OFF_MASK 0x3FFFu
#define OFF_CONT 0x8000u
/* 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. */
static uint16_t line_offset[MAX_LINES] = {0};
/* Bitmap: seg i (non-CONT) is inside a fenced code block (between two
* ``` lines). The fence delimiter lines themselves are NOT marked here —
* they are detected dynamically via a 3-byte prefix check. */
static uint8_t in_code[MAX_LINES / 8] = {0};
static uint16_t n_lines = 0;
static uint16_t top_line = 0;
static uint16_t file_size = 0;
static uint8_t file_blk = 0;
static uint8_t wrap_mode = 1; /* default: wrap on */
static uint8_t viewport_x = 0; /* horizontal pan (truncate mode only) */
static char filename[64] = {0};
#define HPAN_STEP 8u
/* ==================================================================
* 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);
}
}
/* ==================================================================
* 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 || sz > MAX_FILE) {
close(fd);
return -2;
}
file_size = (uint16_t)sz;
lseek(fd, 0, SEEK_SET);
file_blk = mem_alloc_pages(1);
if (file_blk == 0) {
close(fd);
return -3;
}
uint8_t phys = mem_get_page(file_blk, 0);
sprinter_page_w3(phys);
int n = read(fd, FILE_BUF, file_size);
close(fd);
if (n != (int)file_size) {
return -4;
}
return 0;
}
static void unload_file(void)
{
if (file_blk) {
mem_free_block(file_blk);
file_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;
uint16_t raw = line_offset[idx];
if (raw & OFF_CONT) return 0;
uint16_t off = raw & OFF_MASK;
if ((uint32_t)off + 2 >= (uint32_t)file_size) return 0;
return (uint8_t)(FILE_BUF[off] == '`' &&
FILE_BUF[off+1] == '`' &&
FILE_BUF[off+2] == '`');
}
static uint8_t is_code_body(uint16_t idx)
{
if (idx >= n_lines) return 0;
return (uint8_t)((in_code[idx >> 3] >> (idx & 7)) & 1u);
}
/* 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(uint16_t p_start)
{
if ((uint32_t)p_start + 2 < (uint32_t)file_size &&
FILE_BUF[p_start] == '`' &&
FILE_BUF[p_start + 1] == '`' &&
FILE_BUF[p_start + 2] == '`') {
return 1;
}
char c0 = FILE_BUF[p_start];
if (c0 == '-' || c0 == '*' || c0 == '_') {
char marker = 0;
uint8_t count = 0;
uint8_t ok = 1;
uint16_t p = p_start;
while (p < file_size) {
char ch = FILE_BUF[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(uint16_t p_start, uint16_t *out_content)
{
if (p_start >= file_size) return LK_PLAIN;
char c0 = FILE_BUF[p_start];
/* Header? */
if (c0 == '#') {
uint8_t lvl = 0;
uint16_t p = p_start;
while (p < file_size && FILE_BUF[p] == '#' && lvl < 6) {
lvl++; p++;
}
if (p < file_size && (FILE_BUF[p] == ' ' || FILE_BUF[p] == '\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;
uint16_t p = p_start;
while (p < file_size) {
char ch = FILE_BUF[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`. */
uint16_t lp = p_start;
while (lp < file_size && FILE_BUF[lp] == ' ') lp++;
char cl = (lp < file_size) ? FILE_BUF[lp] : 0;
/* Unordered list? */
if ((cl == '-' || cl == '*' || cl == '+') &&
(lp + 1) < file_size &&
FILE_BUF[lp + 1] == ' ') {
*out_content = (uint16_t)(lp + 2);
return LK_ULIST;
}
/* Ordered list? */
if (cl >= '0' && cl <= '9') {
uint16_t p = lp;
while (p < file_size && FILE_BUF[p] >= '0' && FILE_BUF[p] <= '9') p++;
if (p < file_size && (FILE_BUF[p] == '.' || FILE_BUF[p] == ')') &&
(p + 1) < file_size && FILE_BUF[p + 1] == ' ') {
*out_content = (uint16_t)(p + 2);
return LK_OLIST;
}
}
/* Blockquote? */
if (cl == '>') {
uint16_t p = (uint16_t)(lp + 1);
if (p < file_size && FILE_BUF[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, uint16_t p_start, uint16_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) */
}
}
static void index_lines(void)
{
n_lines = 0;
memset(in_code, 0, sizeof(in_code));
if (file_size == 0) return;
if (!wrap_mode) {
/* Truncate: one entry per logical line. */
line_offset[n_lines++] = 0;
for (uint16_t p = 0; p < file_size; p++) {
if (FILE_BUF[p] == '\n' &&
p + 1 < file_size && n_lines < MAX_LINES) {
line_offset[n_lines++] = (uint16_t)(p + 1);
}
}
} else {
/* Wrap: walk logical lines, split each into one or more segs. */
uint16_t p = 0;
while (p < file_size && n_lines < MAX_LINES) {
/* Find end of logical line (\n or EOF). */
uint16_t line_end = p;
while (line_end < file_size && FILE_BUF[line_end] != '\n') line_end++;
/* Emit the first seg of this logical line (no CONT). */
line_offset[n_lines++] = p;
if (!is_nowrap_line(p)) {
/* Determine marker prefix and starting visible column. */
uint16_t content_off = p;
uint8_t kind = classify_line(p, &content_off);
uint8_t col = marker_visible_col(kind, p, content_off);
uint16_t q;
if (kind == LK_H1 || kind == LK_H2 || kind == LK_H3 || kind == LK_H4 ||
kind == LK_ULIST || kind == LK_OLIST || kind == LK_QUOTE) {
q = content_off;
} else {
q = p;
}
uint16_t last_space = 0xFFFFu; /* byte pos AFTER last space */
while (q < line_end && n_lines < MAX_LINES) {
char ch = FILE_BUF[q];
/* Inline emphasis markers are zero-width. */
if (ch == '*' || ch == '_' || ch == '`') {
q++;
continue;
}
if (ch == '\t') {
uint8_t tgt = (uint8_t)((col & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
if (tgt > SCREEN_W) tgt = SCREEN_W;
col = tgt;
q++;
} else {
if (ch == ' ') last_space = (uint16_t)(q + 1);
col++;
q++;
}
if (col >= SCREEN_W && q < line_end) {
uint16_t wrap_at = (last_space != 0xFFFFu &&
last_space > (line_offset[n_lines - 1] & OFF_MASK))
? last_space : q;
if (wrap_at >= line_end) break;
line_offset[n_lines++] = (uint16_t)(wrap_at | OFF_CONT);
col = 0;
last_space = 0xFFFFu;
q = wrap_at;
}
}
}
p = (line_end < file_size) ? (uint16_t)(line_end + 1) : line_end;
}
}
/* Fence/code-body bitmap pass — only non-CONT segs participate. */
{
uint8_t in_block = 0;
for (uint16_t i = 0; i < n_lines; i++) {
if (line_offset[i] & OFF_CONT) continue;
if (is_fence_delim(i)) {
in_block = (uint8_t)!in_block;
} else if (in_block) {
in_code[i >> 3] |= (uint8_t)(1u << (i & 7));
}
}
}
}
/* ==================================================================
* rendering
* ================================================================== */
/* Inline emphasis tracking (one style active at a time, no nesting). */
#define EM_NONE 0
#define EM_BOLD 1
#define EM_ITALIC 2
#define EM_UNDER 3
#define EM_CODE 4
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 = ATTR_TEXT;
uint8_t emph = EM_NONE;
uint8_t parse_inline = 1; /* skip inline parsing for fenced code body */
if (line_idx < n_lines) {
uint16_t raw = line_offset[line_idx];
uint16_t p = raw & OFF_MASK;
uint16_t content_off = p;
uint8_t is_cont = (raw & OFF_CONT) ? 1 : 0;
/* Right bound for this seg: start of next seg, or file end. */
uint16_t seg_end = (line_idx + 1 < n_lines)
? (uint16_t)(line_offset[line_idx + 1] & OFF_MASK)
: file_size;
if (seg_end > file_size) seg_end = file_size;
uint8_t kind;
/* Continuation seg → render plain text from the seg's offset up to
* either the next seg's offset or end-of-line. No markdown
* classification, no marker prefixes, inline markers stay literal
* (we drop them silently so the visible output matches the wrap
* algorithm's column count). */
if (is_cont) {
while (p < seg_end && col < SCREEN_W) {
char ch = FILE_BUF[p++];
if (ch == '\n' || ch == '\r') break;
if (ch == '*' || ch == '_' || ch == '`') continue;
if (ch == '\t') {
uint8_t tgt = (uint8_t)((col & (uint8_t)~(TAB_STOP - 1)) + TAB_STOP);
if (tgt > SCREEN_W) tgt = SCREEN_W;
while (col < tgt) wrchar(col++, row, ' ', ATTR_TEXT);
} else {
wrchar(col++, row, ch, ATTR_TEXT);
}
}
while (col < SCREEN_W) wrchar(col++, row, ' ', ATTR_TEXT);
return;
}
/* 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 {
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. */
uint16_t q = p;
while (q + 2 < content_off && col < SCREEN_W && FILE_BUF[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. */
uint16_t q = p;
while (q < content_off && col < SCREEN_W && FILE_BUF[q] == ' ') {
wrchar(col++, row, ' ', ATTR_TEXT);
q++;
}
while (q < content_off - 1 && col < SCREEN_W) {
wrchar(col++, row, FILE_BUF[q++], ATTR_LIST_MARKER);
}
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. */
uint16_t q = p;
while (q + 1 < content_off && col < SCREEN_W && FILE_BUF[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;
}
/* `cc` is the VISIBLE column inside the content area (the part
* after any fixed marker). When viewport_x > 0 (only possible in
* truncate mode), chars at cc < viewport_x are skipped silently
* so the user can pan past the marker. The marker itself was
* already rendered above at fixed screen cols 0..(col-1). */
uint8_t cc = 0;
while (p < seg_end && col < SCREEN_W) {
char ch = FILE_BUF[p];
if (ch == '\n' || ch == '\r') break;
/* --- inline emphasis markers (consumed, not rendered) ---
* Only one style active at a time. A marker that would conflict
* with the currently active style is rendered as a literal char.
* Skipped entirely inside fenced code blocks (parse_inline=0).
*/
if (parse_inline) {
if (ch == '*' && (p + 1) < file_size && FILE_BUF[p + 1] == '*') {
if (emph == EM_NONE) {
emph = EM_BOLD; cur_attr = ATTR_TEXT_BOLD;
p += 2; continue;
}
if (emph == EM_BOLD) {
emph = EM_NONE; cur_attr = base_attr;
p += 2; continue;
}
/* conflict (italic/under active) — fall through, treat
* the first '*' as literal. */
}
if (ch == '*') {
if (emph == EM_NONE) {
emph = EM_ITALIC; cur_attr = ATTR_TEXT_ITALIC;
p++; continue;
}
if (emph == EM_ITALIC) {
emph = EM_NONE; cur_attr = base_attr;
p++; continue;
}
}
if (ch == '_') {
if (emph == EM_NONE) {
emph = EM_UNDER; cur_attr = ATTR_TEXT_UNDERSORE;
p++; continue;
}
if (emph == EM_UNDER) {
emph = EM_NONE; cur_attr = base_attr;
p++; continue;
}
}
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 >= viewport_x) wrchar(col++, row, ' ', cur_attr);
cc++;
}
} else {
if (cc >= viewport_x && col < SCREEN_W) {
wrchar(col++, row, ch, cur_attr);
}
cc++;
}
}
/* Right-edge truncation indicator: when in truncate mode and the
* main loop stopped because the screen ran out (col == SCREEN_W),
* peek forward past any zero-width inline markers — if there's
* still real visible content before the end of the line, overlay
* the last screen cell with a bright '>'. */
if (!wrap_mode && col >= SCREEN_W) {
uint16_t pp = p;
while (pp < seg_end) {
char c = FILE_BUF[pp];
if (c == '\n' || c == '\r') break;
if (c == '*' || c == '_' || c == '`') { pp++; continue; }
wrchar(SCREEN_W - 1, row, '>', ATTR_TRUNC);
break;
}
}
}
/* 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);
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);
put_str_attr(10, MENU_ROW, "F2", ATTR_MENU_K);
/* Label reflects what F2 will DO: when wrap is on, F2 turns it off. */
put_str_attr(13, MENU_ROW, wrap_mode ? "Unwrap" : "Wrap ", ATTR_MENU_T);
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 (truncate mode only). Each step is HPAN_STEP visible
* columns; clamped to [0, 240] — enough for any real-world long line
* within a 16 KB file. */
static void scroll_h(int8_t delta)
{
if (wrap_mode) return;
int16_t nx = (int16_t)viewport_x + delta;
if (nx < 0) nx = 0;
if (nx > 240) nx = 240;
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)
{
uint16_t top_off = (top_line < n_lines)
? (uint16_t)(line_offset[top_line] & OFF_MASK) : 0;
wrap_mode = wrap_mode ? 0u : 1u;
viewport_x = 0;
index_lines();
/* Largest i such that the i-th seg's offset is ≤ top_off. */
uint16_t i = 0;
while ((uint16_t)(i + 1) < n_lines &&
(uint16_t)(line_offset[i + 1] & OFF_MASK) <= top_off) {
i++;
}
top_line = i;
clamp_top();
render_menu();
render_full_status();
render_viewport();
}
/* ==================================================================
* 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 (Unwrap mode only)",
"",
" Other:",
" F1 - show this help",
" F2 - toggle wrap / unwrap of long lines",
" 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;
}
int rc = load_file(path);
if (rc < 0) {
clrscr_attr(ATTR_TEXT);
puts("mdview: cannot load file");
puts(path);
switch (rc) {
case -1: puts("(open failed)"); break;
case -2: puts("(size > 16K or seek failed)"); break;
case -3: puts("(mem_alloc_pages failed)"); break;
case -4: puts("(short read)"); break;
}
puts("Press any key to exit...");
(void)getkey();
return 1;
}
index_lines();
if (n_lines == 0) {
die("mdview: empty file");
unload_file();
return 1;
}
set_videotextmode(TEXT_MODE_80x32);
clrscr_attr(ATTR_TEXT);
render_menu();
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_F1: help_screen(); break;
case KEY_F2: toggle_wrap(); break;
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;
render_viewport(); break;
case KEY_END: top_line = (n_lines > VIEW_H)
? (uint16_t)(n_lines - VIEW_H) : 0;
render_viewport(); break;
default: continue;
}
render_updated_status();
}
exit_loop:
unload_file();
clrscr_attr(ATTR_TEXT);
return 0;
}