Files
kte/TerminalRenderer.cc
T
Claude 93901928da Force a UTF-8 ctype, stable buffer ids, redraw after deferred opens
- main: when the environment's locale is not UTF-8 (LANG unset, C/POSIX),
  switch LC_CTYPE to a UTF-8 locale so ncurses delivers and draws
  multibyte characters instead of dropping them.
- Buffer::Id(): a process-unique id that survives moves and is fresh for
  copies. The terminal renderer's skip and cursor caches and the reload
  confirmation now key on it rather than the buffer's address, which is
  reused after a buffer is closed.
- The reload confirmation is cancelled by any other command, like the
  quit confirmation.
- TerminalFrontend redraws when a deferred open starts a prompt or sets a
  status (e.g. the swap recovery question), not only when a buffer opens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00

665 lines
21 KiB
C++

#include <clocale>
#define _XOPEN_SOURCE_EXTENDED 1
#include <cwchar>
#include <algorithm>
#include <cstdio>
#include <filesystem>
#include <cstdlib>
#include <ncurses.h>
#include <regex>
#include <string>
#include "TerminalRenderer.h"
#include "RegexGuard.h"
#include "TermWidth.h"
#include "Buffer.h"
#include "Editor.h"
#include "Highlight.h"
// Version string expected to be provided by build system as KTE_VERSION_STR
#ifndef KTE_VERSION_STR
# define KTE_VERSION_STR "dev"
#endif
TerminalRenderer::TerminalRenderer() = default;
TerminalRenderer::~TerminalRenderer() = default;
void
TerminalRenderer::Draw(Editor &ed)
{
// Determine terminal size and keep editor dimensions in sync
int rows, cols;
getmaxyx(stdscr, rows, cols);
// Clear screen
erase();
const Buffer *buf = ed.CurrentBuffer();
int content_rows = rows - 1; // last line is status
if (content_rows < 1)
content_rows = 1;
int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area
if (buf) {
// Per-row access only: Rows() rebuilds a string for every line of the
// file after each edit, which made every frame O(file size).
const std::size_t nlines = buf->Nrows();
std::size_t rowoffs = buf->Rowoffs();
std::size_t coloffs = buf->Coloffs();
const int tabw = 8;
// Phase 3: prefetch visible viewport highlights (current terminal area)
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
int fr = static_cast<int>(rowoffs);
int rc = std::max(0, content_rows);
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
}
for (int r = 0; r < content_rows; ++r) {
move(r, 0);
std::size_t li = rowoffs + static_cast<std::size_t>(r);
std::size_t render_col = 0;
std::size_t src_i = 0;
// Compute matches for this line if search highlighting is active
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end)
if (search_mode && li < nlines) {
std::string sline = buf->GetLineString(li);
// If regex search prompt is active (RegexSearch or RegexReplaceFind), use regex to compute highlight ranges
if (ed.PromptActive() && (
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
// Long lines are not highlighted: std::regex recursion
// could overflow the stack (RegexGuard.h).
if (sline.size() <= kte::kRegexRenderLineLimit) try {
std::regex rx(ed.SearchQuery());
kte::ForEachRegexMatch(sline, rx, [&](std::size_t pos, std::size_t len) {
ranges.emplace_back(pos, pos + len);
});
} catch (const std::regex_error &) {
// ignore invalid patterns here; status shows error
}
} else {
const std::string &q = ed.SearchQuery();
std::size_t pos = 0;
while (!q.empty() && (pos = sline.find(q, pos)) != std::string::npos) {
ranges.emplace_back(pos, pos + q.size());
pos += q.size();
}
}
}
auto is_src_in_hl = [&](std::size_t si) -> bool {
if (ranges.empty())
return false;
// ranges are non-overlapping and ordered by construction
// linear scan is fine for now
for (const auto &rg: ranges) {
if (si < rg.first)
break;
if (si >= rg.first && si < rg.second)
return true;
}
return false;
};
// Track current-match to optionally emphasize
const bool has_current = ed.SearchActive() && ed.SearchMatchLen() > 0;
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
// Mark selection (mark -> cursor), in source coordinates
bool sel_active = false;
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
if (buf->MarkSet()) {
sel_sy = buf->MarkCury();
sel_sx = buf->MarkCurx();
sel_ey = buf->Cury();
sel_ex = buf->Curx();
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
std::swap(sel_sy, sel_ey);
std::swap(sel_sx, sel_ex);
}
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
}
// Visual-line selection: full-line selection range
const bool vsel_active = buf->VisualLineActive();
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
auto is_src_in_mark_sel = [&](std::size_t y, std::size_t sx) -> bool {
if (!sel_active)
return false;
if (y < sel_sy || y > sel_ey)
return false;
if (sel_sy == sel_ey)
return sx >= sel_sx && sx < sel_ex;
if (y == sel_sy)
return sx >= sel_sx;
if (y == sel_ey)
return sx < sel_ex;
return true;
};
int written = 0;
if (li < nlines) {
std::string line = buf->GetLineString(li);
const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
const std::size_t vsel_spot_src = vsel_on_line
? std::min(buf->Curx(), line.size())
: 0;
const bool vsel_spot_is_eol = vsel_on_line && vsel_spot_src == line.size();
std::size_t vsel_line_rx = 0;
if (vsel_spot_is_eol) {
// Compute the rendered (column) width of the line so we can highlight a
// single cell at EOL when the spot falls beyond the last character.
std::size_t rc = 0;
std::size_t si = 0;
while (si < line.size()) {
wchar_t wch = L' ';
int wch_len = 1;
std::mbstate_t state = std::mbstate_t();
size_t res = std::mbrtowc(&wch, &line[si], line.size() - si, &state);
if (res == (size_t) -1 || res == (size_t) -2) {
wch = static_cast<unsigned char>(line[si]);
wch_len = 1;
} else if (res == 0) {
wch = L'\0';
wch_len = 1;
} else {
wch_len = static_cast<int>(res);
}
if (wch == L'\t') {
constexpr std::size_t tab_width = 8;
const std::size_t next_tab = tab_width - (rc % tab_width);
rc += next_tab;
} else {
rc += static_cast<std::size_t>(kte::CellWidth(wch));
}
si += static_cast<std::size_t>(wch_len);
}
vsel_line_rx = rc;
}
src_i = 0;
render_col = 0;
// Syntax highlighting: fetch per-line spans (sanitized copy)
std::vector<kte::HighlightSpan> sane_spans;
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
HasHighlighter()) {
kte::LineHighlight lh_val = buf->Highlighter()->GetLine(
*buf, static_cast<int>(li), buf->Version());
// Sanitize defensively: clamp to [0, line.size()], ensure end>=start, drop empties
const std::size_t line_len = line.size();
sane_spans.reserve(lh_val.spans.size());
for (const auto &sp: lh_val.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, static_cast<int>(line_len))));
std::size_t e = static_cast<std::size_t>(std::max(
static_cast<int>(s),
std::min(e_raw, static_cast<int>(line_len))));
if (e <= s)
continue;
sane_spans.push_back(kte::HighlightSpan{
static_cast<int>(s), static_cast<int>(e), sp.kind
});
}
std::sort(sane_spans.begin(), sane_spans.end(),
[](const kte::HighlightSpan &a, const kte::HighlightSpan &b) {
return a.col_start < b.col_start;
});
}
auto token_at = [&](std::size_t src_index) -> kte::TokenKind {
if (sane_spans.empty())
return kte::TokenKind::Default;
int si = static_cast<int>(src_index);
for (const auto &sp: sane_spans) {
if (si < sp.col_start)
break;
if (si >= sp.col_start && si < sp.col_end)
return sp.kind;
}
return kte::TokenKind::Default;
};
auto token_attr = [&](kte::TokenKind k) -> attr_t {
switch (k) {
case kte::TokenKind::Keyword:
case kte::TokenKind::Type:
case kte::TokenKind::Constant:
case kte::TokenKind::Function:
return A_BOLD;
case kte::TokenKind::Comment:
return A_DIM;
case kte::TokenKind::String:
case kte::TokenKind::Char:
case kte::TokenKind::Number:
return A_UNDERLINE;
default:
return 0;
}
};
// Fast-forward to the scroll offset with the draw loop's own skip
// rules (a tab is skipped only if it ends at or before coloffs,
// any other character while it starts before coloffs), cached
// for the row's current content.
if (coloffs > 0 && src_i == 0) {
if (skip_cache_.size() < static_cast<std::size_t>(content_rows))
skip_cache_.resize(static_cast<std::size_t>(content_rows));
SkipCache &sc = skip_cache_[static_cast<std::size_t>(r)];
if (!(sc.valid && sc.buf_id == buf->Id() && sc.version == buf->Version() && sc.row == li &&
sc.coloffs == coloffs)) {
std::size_t s = 0, c = 0;
while (s < line.size()) {
std::mbstate_t st = std::mbstate_t();
wchar_t w = 0;
std::size_t n = std::mbrtowc(&w, &line[s], line.size() - s, &st);
if (n == (size_t) -1 || n == (size_t) -2) {
w = static_cast<unsigned char>(line[s]);
n = 1;
} else if (n == 0) {
w = L'\0';
n = 1;
}
if (w == L'\t') {
const std::size_t next_tab = tabw - (c % tabw);
if (c + next_tab > coloffs)
break;
c += next_tab;
} else {
if (c >= coloffs)
break;
c += static_cast<std::size_t>(kte::CellWidth(w));
}
s += n;
}
sc = SkipCache{buf->Id(), buf->Version(), li, coloffs, s, c, true};
}
src_i = sc.src;
render_col = sc.col;
}
while (written < cols) {
bool from_src = false;
wchar_t wch = L' ';
int wch_len = 1;
int disp_w = 1;
if (src_i < line.size()) {
// Decode UTF-8
std::mbstate_t state = std::mbstate_t();
size_t res = std::mbrtowc(
&wch, &line[src_i], line.size() - src_i, &state);
if (res == (size_t) -1 || res == (size_t) -2) {
// Invalid or incomplete; treat as single byte
wch = static_cast<unsigned char>(line[src_i]);
wch_len = 1;
} else if (res == 0) {
wch = L'\0';
wch_len = 1;
} else {
wch_len = static_cast<int>(res);
}
if (wch == L'\t') {
std::size_t next_tab = tabw - (render_col % tabw);
if (render_col + next_tab <= coloffs) {
render_col += next_tab;
src_i += wch_len;
continue;
}
// Emit spaces for tab
if (render_col < coloffs) {
// skip to coloffs
std::size_t to_skip = std::min<std::size_t>(
next_tab, coloffs - render_col);
render_col += to_skip;
next_tab -= to_skip;
}
// Now render visible spaces
while (next_tab > 0 && written < cols) {
bool in_mark = is_src_in_mark_sel(li, src_i);
bool in_vsel =
vsel_on_line && !vsel_spot_is_eol && src_i ==
vsel_spot_src;
bool in_sel = in_mark || in_vsel;
bool in_hl = search_mode && is_src_in_hl(src_i);
bool in_cur =
has_current && li == cur_my && src_i >= cur_mx
&&
src_i < cur_mend;
attr_t a = A_NORMAL;
a |= token_attr(token_at(src_i));
if (in_sel) {
a |= A_REVERSE;
} else {
if (in_hl)
a |= A_STANDOUT;
if (in_cur)
a |= A_BOLD;
}
attrset(a);
addch(' ');
++written;
++render_col;
--next_tab;
}
src_i += wch_len;
continue;
} else {
// normal char (controls are drawn as ^X or '?')
disp_w = kte::CellWidth(wch);
if (render_col < coloffs) {
render_col += disp_w;
src_i += wch_len;
continue;
}
from_src = true;
}
} else {
// beyond EOL, fill spaces
wch = L' ';
wch_len = 1;
disp_w = 1;
from_src = false;
}
if (written + disp_w > cols) {
// would overflow, just break
break;
}
bool in_mark = from_src && is_src_in_mark_sel(li, src_i);
bool in_vsel = false;
if (vsel_on_line) {
if (from_src) {
in_vsel = !vsel_spot_is_eol && src_i == vsel_spot_src;
} else {
in_vsel = vsel_spot_is_eol && render_col == vsel_line_rx;
}
}
bool in_sel = in_mark || in_vsel;
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
src_i < cur_mend;
attr_t a = A_NORMAL;
if (from_src)
a |= token_attr(token_at(src_i));
if (in_sel) {
a |= A_REVERSE;
} else {
if (in_hl)
a |= A_STANDOUT;
if (in_cur)
a |= A_BOLD;
}
attrset(a);
if (from_src && kte::IsCaretControl(wch)) {
addch('^');
addch(wch == 0x7f ? '?' : static_cast<chtype>(wch + 0x40));
} else if (from_src && kte::IsC1Control(wch)) {
addch('?');
} else if (from_src) {
cchar_t cch;
wchar_t warr[2] = {wch, L'\0'};
setcchar(&cch, warr, 0, 0, nullptr);
add_wch(&cch);
} else {
addch(' ');
}
written += disp_w;
render_col += disp_w;
if (from_src)
src_i += wch_len;
if (src_i >= line.size() && written >= cols)
break;
}
}
attrset(A_NORMAL);
clrtoeol();
}
// Place terminal cursor at logical position accounting for tabs and coloffs.
// Recompute the rendered X using the same logic as the drawing loop to avoid
// any drift between the command-layer computation and the terminal renderer.
std::size_t cy = buf->Cury();
std::size_t cx = buf->Curx();
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
std::size_t rx_recomputed = 0;
if (cy < nlines && cursor_cache_.valid && cursor_cache_.buf_id == buf->Id() &&
cursor_cache_.version == buf->Version() && cursor_cache_.row == cy && cursor_cache_.cx == cx) {
rx_recomputed = cursor_cache_.rx;
} else if (cy < nlines) {
const std::string line_for_cursor = buf->GetLineString(cy);
std::size_t src_i_cur = 0;
std::size_t render_col_cur = 0;
while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) {
std::mbstate_t state = std::mbstate_t();
wchar_t wch;
size_t res = std::mbrtowc(
&wch, &line_for_cursor[src_i_cur], line_for_cursor.size() - src_i_cur,
&state);
// Same decoding and widths as the drawing loop above.
std::size_t len = res;
if (res == (size_t) -1 || res == (size_t) -2) {
wch = static_cast<unsigned char>(line_for_cursor[src_i_cur]);
len = 1;
} else if (res == 0) {
wch = L'\0';
len = 1;
}
if (wch == L'\t') {
std::size_t next_tab = tabw - (render_col_cur % tabw);
render_col_cur += next_tab;
} else {
render_col_cur += static_cast<std::size_t>(kte::CellWidth(wch));
}
src_i_cur += len;
}
rx_recomputed = render_col_cur;
cursor_cache_ = CursorCache{buf->Id(), buf->Version(), cy, cx, rx_recomputed, true};
}
int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs());
if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) {
// remember where to leave the terminal cursor after status is drawn
saved_cur_y = cur_y;
saved_cur_x = cur_x;
move(cur_y, cur_x);
}
} else {
mvaddstr(0, 0, "[no buffer]");
}
// Status line (inverse)
move(rows - 1, 0);
attron(A_REVERSE);
// Fill the status line with spaces first
for (int i = 0; i < cols; ++i)
addch(' ');
// If a prompt is active, replace the status bar with the full prompt text
if (ed.PromptActive()) {
// Build prompt text: "Label: text" and shorten HOME path for file-related prompts
std::string label = ed.PromptLabel();
std::string ptext = ed.PromptText();
auto kind = ed.CurrentPromptKind();
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
kind == Editor::PromptKind::Chdir) {
const char *home_c = std::getenv("HOME");
if (home_c && *home_c) {
std::string home(home_c);
// Ensure we match only at the start
if (ptext.rfind(home, 0) == 0) {
std::string rest = ptext.substr(home.size());
if (rest.empty())
ptext = "~";
else if (rest[0] == '/' || rest[0] == '\\')
ptext = std::string("~") + rest;
}
}
}
// Prefer keeping the tail of the filename visible when it exceeds the window
std::string msg;
if (kind == Editor::PromptKind::Command) {
msg = ": ";
} else if (!label.empty()) {
msg = label + ": ";
}
// When dealing with file-related prompts, left-trim the filename text so the tail stays visible
if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind ==
Editor::PromptKind::Chdir) && cols > 0) {
int avail = cols - static_cast<int>(msg.size());
if (avail <= 0) {
// No room for label; fall back to showing the rightmost portion of the whole string
std::string whole = msg + ptext;
if ((int) whole.size() > cols)
whole = whole.substr(whole.size() - cols);
msg = whole;
} else {
if ((int) ptext.size() > avail) {
ptext = ptext.substr(ptext.size() - avail);
}
msg += ptext;
}
} else {
// Non-file prompts: simple concatenation and clip by terminal
msg += ptext;
}
// Draw left-aligned, clipped to width
if (!msg.empty())
mvaddnstr(rows - 1, 0, msg.c_str(), std::max(0, cols));
// End status rendering for prompt mode
attroff(A_REVERSE);
// Restore logical cursor position in content area
if (saved_cur_y >= 0 && saved_cur_x >= 0)
move(saved_cur_y, saved_cur_x);
return;
}
// Build left segment
std::string left;
{
const char *app = "kte";
left.reserve(256);
left += app;
left += " ";
left += KTE_VERSION_STR; // already includes leading 'v'
const Buffer *b = buf;
std::string fname;
if (b) {
try {
fname = ed.DisplayNameFor(*b);
} catch (...) {
fname = b->Filename();
try {
fname = std::filesystem::path(fname).filename().string();
} catch (...) {}
}
} else {
fname = "[no name]";
}
left += " ";
// Insert buffer position prefix "[x/N] " before filename
{
std::size_t total = ed.BufferCount();
if (total > 0) {
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // human-friendly 1-based
left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1));
left += "/";
left += std::to_string(static_cast<unsigned long long>(total));
left += "] ";
}
}
left += fname;
if (b && b->Dirty())
left += " *";
// Append read-only indicator
if (b && b->IsReadOnly())
left += " [RO]";
// Append total line count as "<n>L"
if (b) {
unsigned long lcount = static_cast<unsigned long>(b->Nrows());
left += " ";
left += std::to_string(lcount);
left += "L";
}
}
// Build right segment (cursor and mark)
std::string right;
{
int row1 = 0, col1 = 0;
int mrow1 = 0, mcol1 = 0;
bool have_mark = false;
if (buf) {
row1 = static_cast<int>(buf->Cury()) + 1;
col1 = static_cast<int>(buf->Curx()) + 1;
if (buf->MarkSet()) {
have_mark = true;
mrow1 = static_cast<int>(buf->MarkCury()) + 1;
mcol1 = static_cast<int>(buf->MarkCurx()) + 1;
}
}
char rbuf[128];
if (have_mark)
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: %d,%d", row1, col1, mrow1, mcol1);
else
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: not set", row1, col1);
right = rbuf;
}
// Compute placements with truncation rules: prioritize left and right; middle gets remaining
int rlen = static_cast<int>(right.size());
if (rlen > cols) {
// Hard clip right if too long
right = right.substr(static_cast<std::size_t>(rlen - cols), static_cast<std::size_t>(cols));
rlen = cols;
}
int left_max = std::max(0, cols - rlen - 1); // leave at least 1 space between left and right areas
int llen = static_cast<int>(left.size());
if (llen > left_max)
llen = left_max;
// Draw left
if (llen > 0)
mvaddnstr(rows - 1, 0, left.c_str(), llen);
// Draw right, flush to end
int rstart = std::max(0, cols - rlen);
if (rlen > 0)
mvaddnstr(rows - 1, rstart, right.c_str(), rlen);
// Middle message
const std::string &msg = ed.Status();
if (!msg.empty()) {
int mid_start = llen + 1; // one space after left
int mid_end = rstart - 1; // one space before right
if (mid_end >= mid_start) {
int avail = mid_end - mid_start + 1;
int mlen = static_cast<int>(msg.size());
int mdraw = std::min(avail, mlen);
int mstart = mid_start + std::max(0, (avail - mdraw) / 2); // center within middle area
mvaddnstr(rows - 1, mstart, msg.c_str(), mdraw);
}
}
attroff(A_REVERSE);
// Restore terminal cursor to the content position so a visible caret
// remains in the editing area (not on the status line).
if (saved_cur_y >= 0 && saved_cur_x >= 0) {
move(saved_cur_y, saved_cur_x);
}
refresh();
}