2 Commits

Author SHA1 Message Date
snark13 13ef8d2fa5 mdview2: выровненная отрисовка таблиц с рамкой
Таблица обрабатывается как блок в два прохода:
- проход 1: границы блока + ОТРЕНДЕРЕННЫЕ ширины колонок (мерим тем же
  inline_scan, что и при отрисовке — невидимые маркеры стиля **/код не
  раздувают столбцы);
- проход 2: верхняя рамка ┌┬┐ → строки данных │ ячейка<pad> │ → разделитель
  заголовка ├┼┤ (из строки |---|) → нижняя рамка └┴┘.

Колонки выровнены по содержимому, рамка CP866 box-drawing. Широкая таблица
остаётся nowrap+hscroll. Ячейки разбиваются cell-итератором (без массивов
на стеке); пустой g_cells под измерение освобождается ручным флашем
pending-сегмента (флаг g_skip_flush в emit_seg, чтобы не флашить повторно).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 22:23:57 +03:00
snark13 68201dca44 mdview2: 4 улучшения рендеринга markdown
1. Экранирование пунктуации: \* \_ \` \[ … → литерал, маркером НЕ считается
   (CommonMark ASCII-punctuation). Напр. "**...FILE\***" → болд "...FILE*".
   Не действует внутри инлайн-кода.
2. [x] / [ ] — ровно один символ в квадратных скобках → болд (нестандартно,
   для читабельного отображения task-list checkbox-ов). Не внутри кода.
3. Соседние пункты списка: если следующий имеет МЕНЬШИЙ отступ (dedent),
   пустую строку между ними больше не подавляем — пункты визуально разделены.
4. Абзац с ведущим отступом: все его перенесённые строки получают такой же
   отступ (continuation-префикс из col пробелов, как у списка/цитаты).

Реализация — в обоих inline-сканерах (inline_scan/scan_join_stream) + ветках
index_lines; новый helper is_escapable() и leading_spaces().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:38:54 +03:00
+219 -16
View File
@@ -489,6 +489,14 @@ static uint8_t is_emph_flanked(char prev_ch, char next_ch)
return (uint8_t)(ws_or_eol(prev_ch) != ws_or_eol_or_delim(next_ch));
}
/* Символ можно экранировать обратным слешем (\X): любая печатная ASCII-
* пунктуация (как в CommonMark). \* \_ \` \[ и т.п. → литерал X. */
static uint8_t is_escapable(char c)
{
return (uint8_t)(c >= '!' && c <= '~' &&
!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')));
}
/* Тип сегмента-продолжения, передаваемый в emit_seg().
* Явно в индексе не хранится: рендер при необходимости восстанавливает
* тип через classify_line(), но параметр оставлен для читаемости вызовов. */
@@ -795,6 +803,7 @@ static uint8_t marker_visible_col(uint8_t kind, uint32_t p_start, uint32_t conte
static uint8_t g_cells[MAX_CACHE_LINE_LEN * 2];
static uint8_t g_ncells = 0; /* ячеек в текущем сегменте */
static uint8_t g_ncells_at_space = 0; /* снимок g_ncells на последнем пробеле (для усечения при переносе) */
static uint8_t g_skip_flush = 0; /* следующий emit_seg пропустит lag-флаш (pending уже зафлашен вручную, напр. перед таблицей) */
/* Пишет одну (char,attr)-ячейку в g_cells, молча игнорируя переполнение
* MAX_CACHE_LINE_LEN. Оставлена ФУНКЦИЕЙ намеренно: инлайн в сильно
@@ -848,7 +857,10 @@ static void emit_seg(uint32_t off, uint8_t style, uint8_t ckind, uint8_t cont)
cur_seg_off = off;
n_lines++;
/* Флаш ячеек ПРЕДЫДУЩего сегмента (lag-1): он полностью собран форвард-сканером. */
if (n_lines >= 2) seg_commit_cells((uint16_t)(n_lines - 2));
if (n_lines >= 2) {
if (g_skip_flush) g_skip_flush = 0; /* pending уже зафлашен вручную */
else seg_commit_cells((uint16_t)(n_lines - 2));
}
g_ncells = 0; /* буфер ячеек под новый сегмент */
g_ncells_at_space = 0;
}
@@ -865,6 +877,15 @@ static uint8_t is_line_blank(uint32_t p)
return 1;
}
/* Число ведущих пробелов строки (отступ). Используется для отделения
* dedent-пунктов списка (#3) и для отступа обычного абзаца (#4). */
static uint8_t leading_spaces(uint32_t p)
{
uint8_t n = 0;
while (p < file_size && fb(p) == ' ' && n < 250) { n++; p++; }
return n;
}
/* Быстрая проверка границы fenced-блока по маркеру ``` .
* Ведущие пробелы/табы пропускаются — fence может быть с отступом
* (например, ``` ```c ``` внутри элемента списка с отступом 2+ пробела).
@@ -936,6 +957,21 @@ static uint8_t inline_scan(uint32_t q, uint32_t q_end, uint8_t col,
continue;
}
/* Экранированная пунктуация: \* \_ \` … → литерал (не внутри кода). */
if (line_style != INIT_STYLE_CODE && ch == '\\' && (q + 1) < q_end && is_escapable(fb(q + 1))) {
char nb = fb(q + 1);
gc_put(nb, emph_to_attr(line_style, base));
seg_col++; q += 2; prev_ch = nb; continue;
}
/* [x] — ровно один символ в скобках → болд (task-list checkbox; не внутри кода). */
if (line_style != INIT_STYLE_CODE && ch == '[' && (q + 2) < q_end && fb(q + 2) == ']') {
char mid = fb(q + 1);
gc_put('[', ATTR_TEXT_BOLD);
gc_put(mid, ATTR_TEXT_BOLD);
gc_put(']', ATTR_TEXT_BOLD);
seg_col += 3; q += 3; prev_ch = ']'; continue;
}
if (ch == '`') {
if (line_style == INIT_STYLE_PLAIN) { line_style = INIT_STYLE_CODE; q++; continue; }
if (line_style == INIT_STYLE_CODE) { line_style = INIT_STYLE_PLAIN; q++; continue; }
@@ -1143,6 +1179,21 @@ static uint32_t scan_join_stream(uint32_t q, uint32_t para_start, uint8_t col,
}
}
/* Экранированная пунктуация: \* \_ \` … → литерал (не внутри кода). */
if (line_style != INIT_STYLE_CODE && ch == '\\' && (q + 1) < file_size && is_escapable(fb(q + 1))) {
char nb = fb(q + 1);
gc_put(nb, styles_map[line_style]);
seg_col++; q += 2; prev_ch = nb; continue;
}
/* [x] — ровно один символ в скобках → болд (task-list checkbox; не внутри кода). */
if (line_style != INIT_STYLE_CODE && ch == '[' && (q + 2) < file_size && fb(q + 2) == ']') {
char mid = fb(q + 1);
gc_put('[', ATTR_TEXT_BOLD);
gc_put(mid, ATTR_TEXT_BOLD);
gc_put(']', ATTR_TEXT_BOLD);
seg_col += 3; q += 3; prev_ch = ']'; continue;
}
if (ch == '`') {
/* Как handle_inline_marker в render: backtick переключает code-режим
* ТОЛЬКО из PLAIN или CODE; если активен эмфазис (bold/italic/...),
@@ -1261,9 +1312,10 @@ static uint32_t scan_join_stream(uint32_t q, uint32_t para_start, uint8_t col,
uint8_t wrap_style = use_ls ? last_space_style : line_style;
if (use_ls) g_ncells = g_ncells_at_space; /* усечь до пробела */
emit_seg(wrap_at, wrap_style, ckind, 1);
/* Префикс continuation-сегмента (как render в cont-блоке): список
* col пробелов; цитата — отступ + маркер 0xB3 + пробел; plain — без префикса. */
if (ckind == CK_LIST) {
/* Префикс continuation-сегмента: список и отступленный абзац (#4)
* col пробелов выравнивания; цитата — отступ + маркер 0xB3 + пробел.
* Для обычного абзаца без отступа col==0 → префикса нет. */
if (ckind == CK_LIST || ckind == CK_PLAIN) {
gc_fill(' ', ATTR_TEXT, col);
} else if (ckind == CK_QUOTE) {
uint32_t content_off2 = para_start + col;
@@ -1291,6 +1343,102 @@ static uint32_t scan_join_stream(uint32_t q, uint32_t para_start, uint8_t col,
return q;
}
/* ====================== Таблицы: выровненная отрисовка ==================== */
#define TBL_MAX_COLS 16
#define TBL_ATTR ATTR_TEXT
/* CP866 light box-drawing. */
#define TBL_H 0xC4 /* ─ */
#define TBL_V 0xB3 /* │ */
#define TBL_TL 0xDA /* ┌ */
#define TBL_TM 0xC2 /* ┬ */
#define TBL_TR 0xBF /* ┐ */
#define TBL_ML 0xC3 /* ├ */
#define TBL_MM 0xC5 /* ┼ */
#define TBL_MR 0xB4 /* ┤ */
#define TBL_BL 0xC0 /* └ */
#define TBL_BM 0xC1 /* ┴ */
#define TBL_BR 0xD9 /* ┘ */
/* Смещение завершающего '\n' строки (или file_size). */
static uint32_t row_end(uint32_t p)
{
while (p < file_size && fb(p) != '\n') p++;
return p;
}
/* Начало контента первой ячейки строки (после ведущих пробелов и '|'). */
static uint32_t table_first_cell(uint32_t row_start)
{
uint32_t p = row_start;
while (p < file_size && fb(p) == ' ') p++;
if (p < file_size && fb(p) == '|') p++;
return p;
}
/* Следующая ячейка: [*cs,*ce) — обрезанный диапазон; *pp продвигается за '|'.
* 0 — ячеек больше нет (включая хвостовую пустоту после последнего '|'). */
static uint8_t table_next_cell(uint32_t *pp, uint32_t lineend, uint32_t *cs, uint32_t *ce)
{
uint32_t p = *pp;
if (p >= lineend) return 0;
uint32_t a = p;
while (p < lineend && fb(p) != '|') p++;
uint32_t b = p;
uint8_t had_pipe = (uint8_t)(p < lineend && fb(p) == '|');
if (had_pipe) p++;
*pp = p;
while (a < b && fb(a) == ' ') a++;
while (b > a && fb(b - 1) == ' ') b--;
if (!had_pipe && a == b) return 0;
*cs = a; *ce = b;
return 1;
}
/* Строка-разделитель (|---|:-:|): каждая ячейка непустая и только '-'/':'. */
static uint8_t table_is_sep_row(uint32_t row_start, uint32_t lineend)
{
uint32_t p = table_first_cell(row_start);
uint32_t cs, ce;
uint8_t n = 0;
while (table_next_cell(&p, lineend, &cs, &ce)) {
if (cs >= ce) return 0;
for (uint32_t q = cs; q < ce; q++) { char c = fb(q); if (c != '-' && c != ':') return 0; }
n++;
}
return (uint8_t)(n > 0);
}
/* Горизонтальная рамка в g_cells: left + (H×(w+2) + mid|right) по колонкам. */
static void table_border(const uint8_t *widths, uint8_t ncols, char left, char mid, char right)
{
gc_put(left, TBL_ATTR);
for (uint8_t c = 0; c < ncols; c++) {
gc_fill(TBL_H, TBL_ATTR, (uint8_t)(widths[c] + 2));
gc_put((c == (uint8_t)(ncols - 1)) ? right : mid, TBL_ATTR);
}
}
/* Строка данных в g_cells: │ <ячейка, добитая до widths[c]> │ … */
static void table_data_row(uint32_t row_start, uint32_t lineend, const uint8_t *widths, uint8_t ncols)
{
uint32_t p = table_first_cell(row_start);
uint32_t cs, ce;
gc_put(TBL_V, TBL_ATTR);
for (uint8_t c = 0; c < ncols; c++) {
uint8_t got = table_next_cell(&p, lineend, &cs, &ce);
gc_put(' ', TBL_ATTR);
uint8_t rendered = 0;
if (got && ce > cs) {
uint8_t before = g_ncells;
(void)inline_scan(cs, ce, 0, CK_OTHER, INIT_STYLE_PLAIN, ATTR_TEXT, 1);
rendered = (uint8_t)(g_ncells - before);
}
if (rendered < widths[c]) gc_fill(' ', ATTR_TEXT, (uint8_t)(widths[c] - rendered));
gc_put(' ', TBL_ATTR);
gc_put(TBL_V, TBL_ATTR);
}
}
/* Строит индекс сегментов рендера по загруженному файлу. */
static void index_lines(void)
{
@@ -1369,16 +1517,58 @@ static void index_lines(void)
/* ---- строка таблицы (| ... |): один nowrap-сегмент на исходную строку ---- */
if (is_table_raw(p)) {
emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
set_nowrap_cur();
set_hscroll_cur(); /* таблица скроллится целым блоком */
{
uint32_t line_end = p;
while (line_end < file_size && fb(line_end) != '\n') line_end++;
(void)inline_scan(p, line_end, 0, CK_OTHER, INIT_STYLE_PLAIN, ATTR_TEXT, 1);
/* Освобождаем g_cells под измерение ширин: коммитим pending-сегмент
* сейчас; первый emit_seg таблицы пропустит свой lag-флаш. */
if (n_lines >= 1) { seg_commit_cells((uint16_t)(n_lines - 1)); g_skip_flush = 1; }
g_ncells = 0;
/* Проход 1: границы блока + ОТРЕНДЕРЕННЫЕ ширины колонок (мерим тем же
* inline_scan, что и при отрисовке — невидимые маркеры стиля не считаются). */
uint8_t widths[TBL_MAX_COLS];
for (uint8_t i = 0; i < TBL_MAX_COLS; i++) widths[i] = 0;
uint8_t ncols = 0;
uint32_t r = p;
while (r < file_size && is_table_raw(r)) {
uint32_t le = row_end(r);
uint8_t sep = table_is_sep_row(r, le);
uint32_t cp = table_first_cell(r);
uint32_t cs, ce;
uint8_t c = 0;
while (c < TBL_MAX_COLS && table_next_cell(&cp, le, &cs, &ce)) {
if (!sep && ce > cs) {
g_ncells = 0;
(void)inline_scan(cs, ce, 0, CK_OTHER, INIT_STYLE_PLAIN, ATTR_TEXT, 1);
if (g_ncells > widths[c]) widths[c] = g_ncells;
}
while (p < file_size && fb(p) != '\n') p++;
if (p < file_size) p++;
c++;
}
if (c > ncols) ncols = c;
r = (le < file_size) ? le + 1 : le;
}
g_ncells = 0; /* чисто для верхней рамки */
uint32_t tbl_end = r;
if (ncols == 0) ncols = 1;
/* Проход 2: верхняя рамка → строки/разделитель → нижняя рамка. */
emit_seg(p, INIT_STYLE_PLAIN, CK_OTHER, 0);
set_nowrap_cur(); set_hscroll_cur();
table_border(widths, ncols, TBL_TL, TBL_TM, TBL_TR);
r = p;
while (r < tbl_end) {
uint32_t le = row_end(r);
emit_seg(r, INIT_STYLE_PLAIN, CK_OTHER, 0);
set_nowrap_cur(); set_hscroll_cur();
if (table_is_sep_row(r, le)) table_border(widths, ncols, TBL_ML, TBL_MM, TBL_MR);
else table_data_row(r, le, widths, ncols);
r = (le < file_size) ? le + 1 : le;
}
emit_seg(tbl_end, INIT_STYLE_PLAIN, CK_OTHER, 0);
set_nowrap_cur(); set_hscroll_cur();
table_border(widths, ncols, TBL_BL, TBL_BM, TBL_BR);
p = tbl_end;
continue;
}
@@ -1449,11 +1639,16 @@ static void index_lines(void)
uint32_t d = t;
uint8_t nk = classify_line(t, &d);
if (nk == LK_ULIST || nk == LK_OLIST) {
/* #3: подавляем blank только если следующий пункт того же
* уровня или глубже; при МЕНЬШЕМ отступе (dedent) пустую
* строку оставляем — пункты должны визуально разделиться. */
if (leading_spaces(t) >= leading_spaces(p)) {
p = t; /* подавляем единственную пустую строку */
continue;
}
}
}
}
p = q;
continue;
@@ -1485,10 +1680,18 @@ static void index_lines(void)
/* ---- Обычный текстовый параграф: склейка через soft-break ---- */
{
uint8_t line_style = INIT_STYLE_PLAIN;
emit_seg(p, line_style, CK_PLAIN, 0);
/* #4: если абзац начинается с отступа — все его перенесённые строки
* получают такой же отступ (как continuation-префикс списка, но без
* маркера). Контент сканируем после отступа. Патологически большой
* отступ игнорируем. */
uint8_t indent = leading_spaces(p);
if (indent >= SCREEN_W / 2) indent = 0;
uint32_t q = p;
q = scan_join_stream(q, p, 0, CK_PLAIN, &line_style, JOIN_MODE_PLAIN);
emit_seg(p, line_style, CK_PLAIN, 0);
if (indent) gc_fill(' ', ATTR_TEXT, indent); /* префикс первого сегмента */
uint32_t q = p + indent;
q = scan_join_stream(q, p, indent, CK_PLAIN, &line_style, JOIN_MODE_PLAIN);
if (q >= file_size) {
p = file_size;