#include #define _XOPEN_SOURCE_EXTENDED 1 #include #include #include #include #include #include #include #include "TerminalRenderer.h" #include "SearchHighlight.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 = static_cast(kte::kTabWidth); // Phase 3: prefetch visible viewport highlights (current terminal area) if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) { int fr = static_cast(rowoffs); int rc = std::max(0, content_rows); buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version()); } // Search matches: the pattern is compiled once per frame. const kte::SearchHighlight search_hl(ed); std::vector > ranges; // [start, end) for (int r = 0; r < content_rows; ++r) { move(r, 0); std::size_t li = rowoffs + static_cast(r); std::size_t render_col = 0; std::size_t src_i = 0; // Per-row copy: GetLineView() would materialize the whole buffer // after every edit. const std::string line = li < nlines ? buf->GetLineString(li) : std::string(); search_hl.Ranges(line, ranges); 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) { 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(line[si]); wch_len = 1; } else if (res == 0) { wch = L'\0'; wch_len = 1; } else { wch_len = static_cast(res); } if (wch == L'\t') { constexpr std::size_t tab_width = kte::kTabWidth; const std::size_t next_tab = tab_width - (rc % tab_width); rc += next_tab; } else { rc += static_cast(kte::CellWidth(wch)); } si += static_cast(wch_len); } vsel_line_rx = rc; } src_i = 0; render_col = 0; // Syntax highlighting: fetch per-line spans (sanitized copy) std::vector sane_spans; if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()-> HasHighlighter()) { const kte::LineHighlight &lh_val = buf->Highlighter()->GetLine( *buf, static_cast(li), buf->Version()); kte::SanitizeSpans(line, lh_val.spans, sane_spans); } // Called left to right while drawing: walk the spans with a // cursor (rescanning from the first span for every byte was // quadratic on long highlighted lines). std::size_t span_i = 0; auto token_at = [&](std::size_t src_index) -> kte::TokenKind { const int si = static_cast(src_index); if (span_i > 0 && span_i <= sane_spans.size() && si < sane_spans[span_i - 1].col_start) span_i = 0; // moved backwards: restart while (span_i < sane_spans.size() && sane_spans[span_i].col_end <= si) ++span_i; if (span_i < sane_spans.size() && sane_spans[span_i].col_start <= si) return sane_spans[span_i].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(content_rows)) skip_cache_.resize(static_cast(content_rows)); SkipCache &sc = skip_cache_[static_cast(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(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(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(line[src_i]); wch_len = 1; } else if (res == 0) { wch = L'\0'; wch_len = 1; } else { wch_len = static_cast(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( 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_hl.Active() && 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_hl.Active() && 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(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(cy) - static_cast(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(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(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(rx_recomputed) - static_cast(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(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(idx1)); left += "/"; left += std::to_string(static_cast(total)); left += "] "; } } left += fname; if (b && b->Dirty()) left += " *"; // Append read-only indicator if (b && b->IsReadOnly()) left += " [RO]"; // Append total line count as "L" if (b) { unsigned long lcount = static_cast(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(buf->Cury()) + 1; col1 = static_cast(buf->Curx()) + 1; if (buf->MarkSet()) { have_mark = true; mrow1 = static_cast(buf->MarkCury()) + 1; mcol1 = static_cast(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(right.size()); if (rlen > cols) { // Hard clip right if too long right = right.substr(static_cast(rlen - cols), static_cast(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(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(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(); }