aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-05-04 15:04:54 +0200
committerGitHub Enterprise <[email protected]>2026-05-04 15:04:54 +0200
commit03bac7c302eaa5a335b2162a5a0d59c30d3b6f49 (patch)
tree8cefbf680fe85f839bbd69a9ea37cdbe20063f34
parentzen CLI: project-* commands → 'project <sub>' subcommands (#1026) (diff)
downloadarchived-zen-03bac7c302eaa5a335b2162a5a0d59c30d3b6f49.tar.xz
archived-zen-03bac7c302eaa5a335b2162a5a0d59c30d3b6f49.zip
Tui picker fixes (#1027)
- **Viewport scrolling.** Cap rendered rows to the visible terminal height and track a scroll offset that follows the selection, so long lists no longer overflow the screen and corrupt the cursor-up redraw. Hint shows `[i/N]` when the list exceeds the viewport. - **Single-write frame rendering.** Each frame is built into one `ExtendableStringBuilder` and emitted via `TuiWrite`. On Windows, `TuiWrite` routes through `WriteConsoleW` when stdout is a console, so a frame is one syscall instead of one per `printf` — eliminates the visible per-character repaint. - **All `consoletui` helpers go through `TuiWrite`.** `TuiCursorHome`, `TuiSetScrollRegion`, `TuiResetScrollRegion`, `TuiMoveCursor`, `TuiSaveCursor`, `TuiRestoreCursor`, `TuiEraseLine`, `TuiShowCursor`, and the alternate-screen enter/exit pair now bypass the CRT on Windows consoles, matching the picker. `TuiFlush` remains an unconditional `fflush(stdout)` so callers that mixed `printf` output earlier in a sequence still drain correctly. - **Width detection fix.** `TuiConsoleColumns` now reports the visible window width rather than the screen-buffer width, so labels sized to it don't wrap on legacy cmd.exe configs where the buffer is wider than the window. - **PgUp / PgDn.** Jump by one viewport, clamped to the list ends. `VK_PRIOR` / `VK_NEXT` on Windows; `ESC[5~` / `ESC[6~` on POSIX. - **Terminal resize handling.** Enable `ENABLE_WINDOW_INPUT` on stdin (Windows) and install a `SIGWINCH` handler without `SA_RESTART` (POSIX) so the blocking key read returns a new `ConsoleKey::Resize`. The picker recomputes viewport/label budgets, clears the visible screen, and redraws as a fresh first frame; pre-picker output stays in scrollback. - **Centralized label truncation.** The picker truncates item labels to fit the current terminal width (cols minus the 3-column indicator), walking back to a UTF-8 codepoint boundary so multi-byte sequences are never split. The hand-rolled width-aware truncation in `history_cmd::BuildLabel` and `ui_cmd` is removed; callers hand the picker the full label and let it clip.
-rw-r--r--src/zen/cmds/history_cmd.cpp25
-rw-r--r--src/zen/cmds/ui_cmd.cpp23
-rw-r--r--src/zenutil/consoletui.cpp412
3 files changed, 347 insertions, 113 deletions
diff --git a/src/zen/cmds/history_cmd.cpp b/src/zen/cmds/history_cmd.cpp
index 27faae1eb..2ee325d98 100644
--- a/src/zen/cmds/history_cmd.cpp
+++ b/src/zen/cmds/history_cmd.cpp
@@ -48,30 +48,14 @@ namespace {
return true;
}
- std::string BuildLabel(const HistoryRecord& Rec, int32_t TerminalCols)
+ std::string BuildLabel(const HistoryRecord& Rec)
{
- constexpr int32_t kIndicator = 3; // " > " or " " prefix from TuiPickOne
- constexpr int32_t kEllipsis = 3; // "..."
-
std::string Exe = Rec.Exe.empty() ? std::string("?") : Rec.Exe;
std::string Lbl = fmt::format("{} {:<9} pid {:<7}", Rec.Ts, Exe, Rec.Pid);
-
if (!Rec.CmdLine.empty())
{
- int32_t Available = TerminalCols - kIndicator - 2 - static_cast<int32_t>(Lbl.size());
- if (Available > kEllipsis)
- {
- Lbl += " ";
- if (static_cast<int32_t>(Rec.CmdLine.size()) <= Available)
- {
- Lbl += Rec.CmdLine;
- }
- else
- {
- Lbl.append(Rec.CmdLine, 0, static_cast<size_t>(Available - kEllipsis));
- Lbl += "...";
- }
- }
+ Lbl += " ";
+ Lbl += Rec.CmdLine;
}
return Lbl;
}
@@ -179,12 +163,11 @@ HistoryCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv)
return;
}
- const int32_t Cols = static_cast<int32_t>(TuiConsoleColumns());
std::vector<std::string> Labels;
Labels.reserve(Filtered.size());
for (const HistoryRecord& Rec : Filtered)
{
- Labels.push_back(BuildLabel(Rec, Cols));
+ Labels.push_back(BuildLabel(Rec));
}
int Selected = TuiPickOne("Recent invocations. Select one to re-run:", Labels);
diff --git a/src/zen/cmds/ui_cmd.cpp b/src/zen/cmds/ui_cmd.cpp
index 53dbb22da..5fc242541 100644
--- a/src/zen/cmds/ui_cmd.cpp
+++ b/src/zen/cmds/ui_cmd.cpp
@@ -129,33 +129,14 @@ UiCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv)
Labels.reserve(Servers.size() + 1);
Labels.push_back(fmt::format("(all {} instances)", Servers.size()));
- const int32_t Cols = static_cast<int32_t>(TuiConsoleColumns());
- constexpr int32_t kIndicator = 3; // " > " or " " prefix
- constexpr int32_t kSeparator = 2; // " " before cmdline
- constexpr int32_t kEllipsis = 3; // "..."
-
for (const auto& Server : Servers)
{
std::string Label = fmt::format("port {:<5} pid {:<7} session {}", Server.Port, Server.Pid, Server.SessionId);
-
if (!Server.CmdLine.empty())
{
- int32_t Available = Cols - kIndicator - kSeparator - static_cast<int32_t>(Label.size());
- if (Available > kEllipsis)
- {
- Label += " ";
- if (static_cast<int32_t>(Server.CmdLine.size()) <= Available)
- {
- Label += Server.CmdLine;
- }
- else
- {
- Label.append(Server.CmdLine, 0, static_cast<size_t>(Available - kEllipsis));
- Label += "...";
- }
- }
+ Label += " ";
+ Label += Server.CmdLine;
}
-
Labels.push_back(std::move(Label));
}
diff --git a/src/zenutil/consoletui.cpp b/src/zenutil/consoletui.cpp
index 10e8abb31..f9109c318 100644
--- a/src/zenutil/consoletui.cpp
+++ b/src/zenutil/consoletui.cpp
@@ -2,8 +2,11 @@
#include <zenutil/consoletui.h>
+#include <zencore/string.h>
#include <zencore/zencore.h>
+#include <fmt/format.h>
+
#if ZEN_PLATFORM_WINDOWS
# include <zencore/windows.h>
#else
@@ -13,8 +16,14 @@
# include <unistd.h>
#endif
+#include <algorithm>
#include <cstdio>
+#if !ZEN_PLATFORM_WINDOWS
+# include <cerrno>
+# include <csignal>
+#endif
+
namespace zen {
//////////////////////////////////////////////////////////////////////////
@@ -53,13 +62,44 @@ private:
UINT m_OldCP;
};
+// RAII guard: ORs additional flags into the console input mode and restores the
+// original mode on destruction. Used to enable ENABLE_WINDOW_INPUT so that
+// WINDOW_BUFFER_SIZE_EVENT records are delivered to ReadConsoleInputA.
+class ConsoleInputModeGuard
+{
+public:
+ ConsoleInputModeGuard(HANDLE Handle, DWORD AddFlags) : m_Handle(Handle)
+ {
+ if (GetConsoleMode(Handle, &m_OldMode) && SetConsoleMode(Handle, m_OldMode | AddFlags))
+ {
+ m_Valid = true;
+ }
+ }
+
+ ~ConsoleInputModeGuard()
+ {
+ if (m_Valid)
+ {
+ SetConsoleMode(m_Handle, m_OldMode);
+ }
+ }
+
+private:
+ HANDLE m_Handle;
+ DWORD m_OldMode = 0;
+ bool m_Valid = false;
+};
+
enum class ConsoleKey
{
Unknown,
ArrowUp,
ArrowDown,
+ PageUp,
+ PageDown,
Enter,
Escape,
+ Resize,
};
static ConsoleKey
@@ -74,6 +114,10 @@ ReadKey()
{
return ConsoleKey::Escape; // treat read error as cancel
}
+ if (Record.EventType == WINDOW_BUFFER_SIZE_EVENT)
+ {
+ return ConsoleKey::Resize;
+ }
if (Record.EventType == KEY_EVENT && Record.Event.KeyEvent.bKeyDown)
{
switch (Record.Event.KeyEvent.wVirtualKeyCode)
@@ -82,6 +126,10 @@ ReadKey()
return ConsoleKey::ArrowUp;
case VK_DOWN:
return ConsoleKey::ArrowDown;
+ case VK_PRIOR:
+ return ConsoleKey::PageUp;
+ case VK_NEXT:
+ return ConsoleKey::PageDown;
case VK_RETURN:
return ConsoleKey::Enter;
case VK_ESCAPE:
@@ -168,21 +216,70 @@ ReadByteWithTimeout(int TimeoutMs)
static struct termios s_SavedAttrs = {};
static bool s_InLiveMode = false;
+// SIGWINCH delivery: the handler sets a flag, ReadKey treats a read() that
+// returns EINTR with the flag set as a Resize event.
+static volatile sig_atomic_t s_ResizePending = 0;
+
+static void
+SigwinchHandler(int)
+{
+ s_ResizePending = 1;
+}
+
+// RAII guard: installs a SIGWINCH handler without SA_RESTART so that the
+// blocking read() in ReadKey returns EINTR when the terminal is resized.
+class SigwinchGuard
+{
+public:
+ SigwinchGuard()
+ {
+ struct sigaction Action = {};
+ Action.sa_handler = SigwinchHandler;
+ Action.sa_flags = 0; // intentionally NOT SA_RESTART
+ sigemptyset(&Action.sa_mask);
+ if (sigaction(SIGWINCH, &Action, &m_OldAction) == 0)
+ {
+ m_Valid = true;
+ }
+ }
+
+ ~SigwinchGuard()
+ {
+ if (m_Valid)
+ {
+ sigaction(SIGWINCH, &m_OldAction, nullptr);
+ }
+ }
+
+private:
+ struct sigaction m_OldAction = {};
+ bool m_Valid = false;
+};
+
enum class ConsoleKey
{
Unknown,
ArrowUp,
ArrowDown,
+ PageUp,
+ PageDown,
Enter,
Escape,
+ Resize,
};
static ConsoleKey
ReadKey()
{
unsigned char c = 0;
- if (read(STDIN_FILENO, &c, 1) != 1)
+ ssize_t n = read(STDIN_FILENO, &c, 1);
+ if (n != 1)
{
+ if (n < 0 && errno == EINTR && s_ResizePending)
+ {
+ s_ResizePending = 0;
+ return ConsoleKey::Resize;
+ }
return ConsoleKey::Escape; // treat read error as cancel
}
@@ -200,6 +297,17 @@ ReadKey()
{
return ConsoleKey::ArrowDown;
}
+ // PageUp / PageDown arrive as ESC[5~ and ESC[6~ respectively.
+ if (Final == '5' || Final == '6')
+ {
+ const ConsoleKey Mapped = (Final == '5') ? ConsoleKey::PageUp : ConsoleKey::PageDown;
+ int Tilde = ReadByteWithTimeout(50);
+ if (Tilde == '~')
+ {
+ return Mapped;
+ }
+ return ConsoleKey::Unknown;
+ }
}
return ConsoleKey::Escape;
}
@@ -224,7 +332,10 @@ TuiConsoleColumns(uint32_t Default)
CONSOLE_SCREEN_BUFFER_INFO Csbi = {};
if (GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &Csbi))
{
- return static_cast<uint32_t>(Csbi.dwSize.X);
+ // Use visible window width, not buffer width — legacy cmd.exe configs may have
+ // a buffer wider than the window, which would cause wrapping for callers that
+ // size their output to this value.
+ return static_cast<uint32_t>(Csbi.srWindow.Right - Csbi.srWindow.Left + 1);
}
#else
struct winsize Ws = {};
@@ -274,67 +385,166 @@ TuiPickOne(std::string_view Title, std::span<const std::string> Items)
#if ZEN_PLATFORM_WINDOWS
ConsoleCodePageGuard CodePageGuard(CP_UTF8);
+ // Enable WINDOW_BUFFER_SIZE_EVENT delivery so ReadKey can observe terminal
+ // resizes; default mode does not include ENABLE_WINDOW_INPUT.
+ ConsoleInputModeGuard InputModeGuard(GetStdHandle(STD_INPUT_HANDLE), ENABLE_WINDOW_INPUT);
#else
RawModeGuard RawMode;
if (!RawMode.IsValid())
{
return -1;
}
+ SigwinchGuard ResizeGuard;
#endif
- const int Count = static_cast<int>(Items.size());
- int SelectedIndex = 0;
-
- printf("\n%.*s\n\n", static_cast<int>(Title.size()), Title.data());
+ const int Count = static_cast<int>(Items.size());
+ if (Count == 0)
+ {
+ return -1;
+ }
- // Hide cursor during interaction
- printf("\033[?25l");
+ int SelectedIndex = 0;
+
+ // Frame layout (in rows): top blank, title, blank, ViewportRows of items,
+ // blank, hint — each terminated with \n. We must leave at least one row
+ // of slack between the frame and the bottom of the terminal: otherwise the
+ // initial render scrolls just enough to push the anchor row above the
+ // visible window, and the subsequent `\033[<N>A` cursor-up clamps at row 1,
+ // causing the frame to drift upward by one row on every keypress.
+ constexpr int Chrome = 5;
+ constexpr int SlackRows = 1;
+ const uint32_t TermRows = TuiConsoleRows(0);
+ int ViewportRows;
+ if (TermRows == 0)
+ {
+ ViewportRows = Count;
+ }
+ else
+ {
+ ViewportRows = std::max(1, static_cast<int>(TermRows) - Chrome - SlackRows);
+ if (ViewportRows > Count)
+ {
+ ViewportRows = Count;
+ }
+ }
+ int FrameRows = ViewportRows + Chrome;
+ int ScrollOffset = 0;
+
+ // Display budget per item line: terminal width minus the 3-column indicator.
+ // We treat byte length as an upper bound on display columns — for ASCII this
+ // is exact, and for multi-byte UTF-8 it slightly under-fills, which is fine
+ // (we'd rather under-fill than wrap). Refreshed whenever the terminal is
+ // resized so labels never re-introduce the cursor-math drift caused by wrap.
+ constexpr int kIndicatorCols = 3;
+ int LabelBudget = std::max(0, static_cast<int>(TuiConsoleColumns(120)) - kIndicatorCols);
+
+ // Walk back from a candidate UTF-8 byte cut point to a codepoint boundary so
+ // truncation never splits a multi-byte sequence.
+ auto Utf8BoundaryBefore = [](std::string_view S, int Pos) {
+ while (Pos > 0 && (static_cast<unsigned char>(S[Pos]) & 0xC0) == 0x80)
+ {
+ --Pos;
+ }
+ return Pos;
+ };
- // Renders the full entry list and hint footer.
- // On subsequent calls, moves the cursor back up first to overwrite the previous output.
bool FirstRender = true;
- auto RenderAll = [&] {
- if (!FirstRender)
- {
- printf("\033[%dA", Count + 2); // move up: entries + blank line + hint line
- }
- FirstRender = false;
-
- for (int i = 0; i < Count; ++i)
- {
- bool IsSelected = (i == SelectedIndex);
-
- printf("\r\033[K"); // erase line
-
- if (IsSelected)
- {
- printf("\033[1;7m"); // bold + reverse video
- }
- // \xe2\x96\xb6 = U+25B6 BLACK RIGHT-POINTING TRIANGLE (>)
- const char* Indicator = IsSelected ? " \xe2\x96\xb6 " : " ";
-
- printf("%s%s", Indicator, Items[i].c_str());
-
- if (IsSelected)
- {
- printf("\033[0m"); // reset attributes
- }
+ // Build each frame into a single buffer and emit it with one TuiWrite call.
+ // On Windows, individual printf calls each round-trip through the console's
+ // VT parser, so batching the whole frame into one write is dramatically
+ // faster and removes the visible per-line redraw the user observed.
+ auto RenderAll = [&] {
+ // Keep the selection inside the viewport.
+ if (SelectedIndex < ScrollOffset)
+ {
+ ScrollOffset = SelectedIndex;
+ }
+ else if (SelectedIndex >= ScrollOffset + ViewportRows)
+ {
+ ScrollOffset = SelectedIndex - ViewportRows + 1;
+ }
- printf("\n");
- }
+ ExtendableStringBuilder<4096> Frame;
- // Blank separator line
- printf("\r\033[K\n");
+ if (FirstRender)
+ {
+ // Hide the cursor on the very first frame.
+ Frame.Append("\033[?25l");
+ }
+ else
+ {
+ // Move back to the start of the previous frame.
+ fmt::format_to(StringBuilderAppender(Frame), "\033[{}A", FrameRows);
+ }
+ FirstRender = false;
+
+ // Top blank.
+ Frame.Append("\r\033[K\n");
+ // Title.
+ Frame.Append("\r\033[K");
+ Frame.Append(Title);
+ Frame.Append("\n");
+ // Blank between title and items.
+ Frame.Append("\r\033[K\n");
+
+ // Items: emit exactly ViewportRows lines so the frame height matches FrameRows.
+ for (int Row = 0; Row < ViewportRows; ++Row)
+ {
+ const int Idx = ScrollOffset + Row;
+ Frame.Append("\r\033[K");
+ if (Idx < Count)
+ {
+ const bool IsSelected = (Idx == SelectedIndex);
+ if (IsSelected)
+ {
+ Frame.Append("\033[1;7m");
+ }
+ // \xe2\x96\xb6 = U+25B6 BLACK RIGHT-POINTING TRIANGLE
+ Frame.Append(IsSelected ? " \xe2\x96\xb6 " : " ");
+
+ const std::string_view Label = Items[Idx];
+ if (static_cast<int>(Label.size()) <= LabelBudget)
+ {
+ Frame.Append(Label);
+ }
+ else if (LabelBudget <= 3)
+ {
+ // Not enough room for a meaningful ellipsis; emit whatever fits.
+ Frame.Append(std::string_view("...", static_cast<size_t>(LabelBudget)));
+ }
+ else
+ {
+ const int Cut = Utf8BoundaryBefore(Label, LabelBudget - 3);
+ Frame.Append(Label.substr(0, static_cast<size_t>(Cut)));
+ Frame.Append("...");
+ }
+ if (IsSelected)
+ {
+ Frame.Append("\033[0m");
+ }
+ }
+ Frame.Append("\n");
+ }
- // Hint footer
- // \xe2\x86\x91 = U+2191 ^ \xe2\x86\x93 = U+2193 v
- printf(
- "\r\033[K \033[2m\xe2\x86\x91/\xe2\x86\x93\033[0m navigate "
- "\033[2mEnter\033[0m confirm "
- "\033[2mEsc\033[0m cancel\n");
+ // Blank between items and hint.
+ Frame.Append("\r\033[K\n");
+
+ // Hint footer.
+ // \xe2\x86\x91 = U+2191 ^ \xe2\x86\x93 = U+2193 v
+ Frame.Append(
+ "\r\033[K \033[2m\xe2\x86\x91/\xe2\x86\x93\033[0m navigate "
+ "\033[2mPgUp/PgDn\033[0m page "
+ "\033[2mEnter\033[0m confirm "
+ "\033[2mEsc\033[0m cancel");
+ if (Count > ViewportRows)
+ {
+ fmt::format_to(StringBuilderAppender(Frame), " \033[2m[{}/{}]\033[0m", SelectedIndex + 1, Count);
+ }
+ Frame.Append("\n");
- fflush(stdout);
+ TuiWrite(Frame);
+ TuiFlush();
};
RenderAll();
@@ -356,6 +566,39 @@ TuiPickOne(std::string_view Title, std::span<const std::string> Items)
RenderAll();
break;
+ case ConsoleKey::PageUp:
+ SelectedIndex = std::max(0, SelectedIndex - ViewportRows);
+ RenderAll();
+ break;
+
+ case ConsoleKey::PageDown:
+ SelectedIndex = std::min(Count - 1, SelectedIndex + ViewportRows);
+ RenderAll();
+ break;
+
+ case ConsoleKey::Resize:
+ {
+ // Recompute viewport and label width for the new terminal size,
+ // then clear the visible screen and redraw from row 1: the
+ // previous frame's position relative to the visible viewport is
+ // no longer trackable through cursor-up math after a resize.
+ const uint32_t NewRows = TuiConsoleRows(0);
+ if (NewRows > 0)
+ {
+ ViewportRows = std::max(1, static_cast<int>(NewRows) - Chrome - SlackRows);
+ if (ViewportRows > Count)
+ {
+ ViewportRows = Count;
+ }
+ }
+ FrameRows = ViewportRows + Chrome;
+ LabelBudget = std::max(0, static_cast<int>(TuiConsoleColumns(120)) - kIndicatorCols);
+ TuiWrite("\033[H\033[2J");
+ FirstRender = true;
+ RenderAll();
+ break;
+ }
+
case ConsoleKey::Enter:
Result = SelectedIndex;
Done = true;
@@ -370,9 +613,9 @@ TuiPickOne(std::string_view Title, std::span<const std::string> Items)
}
}
- // Restore cursor and add a blank line for visual separation
- printf("\033[?25h\n");
- fflush(stdout);
+ // Restore cursor and add a blank line for visual separation.
+ TuiWrite("\033[?25h\n");
+ TuiFlush();
return Result;
}
@@ -385,9 +628,9 @@ TuiEnterAlternateScreen()
SetConsoleOutputCP(CP_UTF8);
#endif
- printf("\033[?1049h"); // Enter alternate screen buffer
- printf("\033[?25l"); // Hide cursor
- fflush(stdout);
+ // Enter alternate screen buffer + hide cursor in one write.
+ TuiWrite("\033[?1049h\033[?25l");
+ TuiFlush();
#if !ZEN_PLATFORM_WINDOWS
if (tcgetattr(STDIN_FILENO, &s_SavedAttrs) == 0)
@@ -409,9 +652,9 @@ TuiEnterAlternateScreen()
void
TuiExitAlternateScreen()
{
- printf("\033[?25h"); // Show cursor
- printf("\033[?1049l"); // Exit alternate screen buffer
- fflush(stdout);
+ // Show cursor + exit alternate screen buffer in one write.
+ TuiWrite("\033[?25h\033[?1049l");
+ TuiFlush();
#if !ZEN_PLATFORM_WINDOWS
if (s_InLiveMode)
@@ -425,7 +668,7 @@ TuiExitAlternateScreen()
void
TuiCursorHome()
{
- printf("\033[H");
+ TuiWrite("\033[H");
}
uint32_t
@@ -483,25 +726,35 @@ TuiPollQuit()
void
TuiSetScrollRegion(uint32_t Top, uint32_t Bottom)
{
- printf("\033[%u;%ur", Top, Bottom);
+ char Buf[32];
+ const int Len = std::snprintf(Buf, sizeof(Buf), "\033[%u;%ur", Top, Bottom);
+ if (Len > 0)
+ {
+ TuiWrite(std::string_view(Buf, static_cast<size_t>(Len)));
+ }
}
void
TuiResetScrollRegion()
{
- printf("\033[r");
+ TuiWrite("\033[r");
}
void
TuiMoveCursor(uint32_t Row, uint32_t Col)
{
- printf("\033[%u;%uH", Row, Col);
+ char Buf[32];
+ const int Len = std::snprintf(Buf, sizeof(Buf), "\033[%u;%uH", Row, Col);
+ if (Len > 0)
+ {
+ TuiWrite(std::string_view(Buf, static_cast<size_t>(Len)));
+ }
}
void
TuiSaveCursor()
{
- printf(
+ TuiWrite(
"\033"
"7");
}
@@ -509,7 +762,7 @@ TuiSaveCursor()
void
TuiRestoreCursor()
{
- printf(
+ TuiWrite(
"\033"
"8");
}
@@ -517,32 +770,49 @@ TuiRestoreCursor()
void
TuiEraseLine()
{
- printf("\033[2K");
+ TuiWrite("\033[2K");
}
void
TuiWrite(std::string_view Text)
{
+#if ZEN_PLATFORM_WINDOWS
+ // On Windows, stdout to a console is line-buffered (or worse, effectively
+ // per-character through the CRT's VT translation), so fwrite of a multi-line
+ // frame turns into many individual WriteFile calls. Each one round-trips
+ // through the conhost VT parser, which is visible as flicker / lag during
+ // interactive redraws. When stdout is a real console, drain the CRT buffer
+ // and emit the entire frame with one WriteConsoleW call.
+ HANDLE hStdOut = GetStdHandle(STD_OUTPUT_HANDLE);
+ DWORD dwMode = 0;
+ if (hStdOut != INVALID_HANDLE_VALUE && GetConsoleMode(hStdOut, &dwMode))
+ {
+ fflush(stdout);
+
+ ExtendableWideStringBuilder<4096> Wide;
+ Utf8ToWide(Text, Wide);
+
+ DWORD Written = 0;
+ WriteConsoleW(hStdOut, Wide.Data(), static_cast<DWORD>(Wide.Size()), &Written, nullptr);
+ return;
+ }
+#endif
fwrite(Text.data(), 1, Text.size(), stdout);
}
void
TuiFlush()
{
+ // Always fflush, even when TuiWrite bypassed the CRT — callers may have
+ // emitted printf / fwrite output earlier in the sequence and rely on this
+ // to drain the CRT buffer.
fflush(stdout);
}
void
TuiShowCursor(bool Show)
{
- if (Show)
- {
- printf("\033[?25h");
- }
- else
- {
- printf("\033[?25l");
- }
+ TuiWrite(Show ? "\033[?25h" : "\033[?25l");
}
} // namespace zen