diff options
| author | Stefan Boberg <[email protected]> | 2026-05-04 15:04:54 +0200 |
|---|---|---|
| committer | GitHub Enterprise <[email protected]> | 2026-05-04 15:04:54 +0200 |
| commit | 03bac7c302eaa5a335b2162a5a0d59c30d3b6f49 (patch) | |
| tree | 8cefbf680fe85f839bbd69a9ea37cdbe20063f34 | |
| parent | zen CLI: project-* commands → 'project <sub>' subcommands (#1026) (diff) | |
| download | archived-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.cpp | 25 | ||||
| -rw-r--r-- | src/zen/cmds/ui_cmd.cpp | 23 | ||||
| -rw-r--r-- | src/zenutil/consoletui.cpp | 412 |
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 |