diff options
| author | Stefan Boberg <[email protected]> | 2026-03-30 17:38:45 +0200 |
|---|---|---|
| committer | Stefan Boberg <[email protected]> | 2026-03-30 17:38:45 +0200 |
| commit | 3edce89333d98f95f8f5faa06dc13e88376c8133 (patch) | |
| tree | 5af717e60e00cafd16dce2aaf1ead9ac6fa4e962 /src/zenutil | |
| parent | Request validation and resilience improvements (#864) (diff) | |
| download | zen-3edce89333d98f95f8f5faa06dc13e88376c8133.tar.xz zen-3edce89333d98f95f8f5faa06dc13e88376c8133.zip | |
Add interactive help command to zen CLI
- Add `zen help` command with three modes:
- Interactive picker (TuiPickOne) to browse all commands by category
- `zen help <command>` to view a specific command's help in a pager
- `zen help --list` for non-interactive plain text listing
- Extend consoletui with new TUI primitives:
- Public ConsoleKey enum with PageUp/Down, Home/End, Backspace, Char, Resize
- TuiReadKey()/TuiReadKeyChar() for cross-platform key input
- TuiPager() for fullscreen scrollable text viewing with `/` search
- Enhance TuiPickOne with incremental type-to-filter, viewport scrolling,
line truncation, initial selection and filter persistence
- Switch TuiPickOne to alternate screen buffer for clean resize handling
- Handle console resize events (WINDOW_BUFFER_SIZE_EVENT on Windows,
SIGWINCH on POSIX)
- Move CommandInfo struct to zen.h for reuse by help command
Diffstat (limited to 'src/zenutil')
| -rw-r--r-- | src/zenutil/consoletui.cpp | 867 | ||||
| -rw-r--r-- | src/zenutil/include/zenutil/consoletui.h | 42 |
2 files changed, 764 insertions, 145 deletions
diff --git a/src/zenutil/consoletui.cpp b/src/zenutil/consoletui.cpp index 124132aed..35c38e7b9 100644 --- a/src/zenutil/consoletui.cpp +++ b/src/zenutil/consoletui.cpp @@ -8,12 +8,17 @@ # include <zencore/windows.h> #else # include <poll.h> +# include <signal.h> # include <sys/ioctl.h> # include <termios.h> # include <unistd.h> #endif +#include <algorithm> +#include <atomic> +#include <cerrno> #include <cstdio> +#include <cstring> namespace zen { @@ -53,45 +58,7 @@ private: UINT m_OldCP; }; -enum class ConsoleKey -{ - Unknown, - ArrowUp, - ArrowDown, - Enter, - Escape, -}; - -static ConsoleKey -ReadKey() -{ - HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE); - INPUT_RECORD Record{}; - DWORD dwRead = 0; - while (true) - { - if (!ReadConsoleInputA(hStdin, &Record, 1, &dwRead)) - { - return ConsoleKey::Escape; // treat read error as cancel - } - if (Record.EventType == KEY_EVENT && Record.Event.KeyEvent.bKeyDown) - { - switch (Record.Event.KeyEvent.wVirtualKeyCode) - { - case VK_UP: - return ConsoleKey::ArrowUp; - case VK_DOWN: - return ConsoleKey::ArrowDown; - case VK_RETURN: - return ConsoleKey::Enter; - case VK_ESCAPE: - return ConsoleKey::Escape; - default: - break; - } - } - } -} +static char s_LastChar = 0; #else // POSIX @@ -129,6 +96,13 @@ public: { m_Valid = true; } + + // Install SIGWINCH handler for terminal resize detection + s_GotSigWinch = 0; + struct sigaction Sa = {}; + Sa.sa_handler = SigWinchHandler; + Sa.sa_flags = SA_RESTART; + sigaction(SIGWINCH, &Sa, &m_OldSigAction); } ~RawModeGuard() @@ -137,13 +111,15 @@ public: { tcsetattr(STDIN_FILENO, TCSANOW, &m_OldAttrs); } + sigaction(SIGWINCH, &m_OldSigAction, nullptr); } bool IsValid() const { return m_Valid; } private: - struct termios m_OldAttrs = {}; - bool m_Valid = false; + struct termios m_OldAttrs = {}; + struct sigaction m_OldSigAction = {}; + bool m_Valid = false; }; static int @@ -168,48 +144,15 @@ ReadByteWithTimeout(int TimeoutMs) static struct termios s_SavedAttrs = {}; static bool s_InLiveMode = false; -enum class ConsoleKey -{ - Unknown, - ArrowUp, - ArrowDown, - Enter, - Escape, -}; +static char s_LastChar = 0; -static ConsoleKey -ReadKey() -{ - unsigned char c = 0; - if (read(STDIN_FILENO, &c, 1) != 1) - { - return ConsoleKey::Escape; // treat read error as cancel - } +// SIGWINCH (terminal resize) flag — set by signal handler, consumed by TuiReadKey() +static volatile sig_atomic_t s_GotSigWinch = 0; - if (c == 27) // ESC byte or start of an escape sequence - { - int Next = ReadByteWithTimeout(50); - if (Next == '[') - { - int Final = ReadByteWithTimeout(50); - if (Final == 'A') - { - return ConsoleKey::ArrowUp; - } - if (Final == 'B') - { - return ConsoleKey::ArrowDown; - } - } - return ConsoleKey::Escape; - } - - if (c == '\r' || c == '\n') - { - return ConsoleKey::Enter; - } - - return ConsoleKey::Unknown; +static void +SigWinchHandler(int /*Sig*/) +{ + s_GotSigWinch = 1; } #endif // ZEN_PLATFORM_WINDOWS / POSIX @@ -267,112 +210,273 @@ IsTuiAvailable() return Cached; } +// Case-insensitive substring match +static bool +ContainsCaseInsensitive(const std::string& Haystack, const std::string& Needle) +{ + if (Needle.empty()) + { + return true; + } + auto It = std::search(Haystack.begin(), Haystack.end(), Needle.begin(), Needle.end(), [](char A, char B) { + return std::tolower(static_cast<unsigned char>(A)) == std::tolower(static_cast<unsigned char>(B)); + }); + return It != Haystack.end(); +} + int -TuiPickOne(std::string_view Title, std::span<const std::string> Items) +TuiPickOne(std::string_view Title, std::span<const std::string> Items, int InitialSelection, std::string* InOutFilter) { - EnableVirtualTerminal(); + TuiEnterAlternateScreen(); -#if ZEN_PLATFORM_WINDOWS - ConsoleCodePageGuard CodePageGuard(CP_UTF8); -#else - RawModeGuard RawMode; - if (!RawMode.IsValid()) + const int TotalCount = static_cast<int>(Items.size()); + constexpr int kIndicatorLen = 3; // " ▶ " or " " — 3 display columns + + // Filter state — seed from caller if provided + std::string Filter = InOutFilter ? *InOutFilter : std::string{}; + std::vector<int> Visible; // Indices into Items that match the current filter + int CursorPos = 0; // Index into Visible + int ScrollTop = 0; // First visible index in the viewport + + auto RebuildVisible = [&] { + Visible.clear(); + for (int i = 0; i < TotalCount; ++i) + { + if (ContainsCaseInsensitive(Items[i], Filter)) + { + Visible.push_back(i); + } + } + CursorPos = 0; + ScrollTop = 0; + }; + + RebuildVisible(); + + // Apply initial selection: find it in the Visible list + if (InitialSelection > 0 && InitialSelection < TotalCount) { - return -1; + for (int i = 0; i < static_cast<int>(Visible.size()); ++i) + { + if (Visible[i] == InitialSelection) + { + CursorPos = i; + break; + } + } } -#endif - const int Count = static_cast<int>(Items.size()); - int SelectedIndex = 0; + // Layout: Row 1 = title bar, Row 2 = filter (optional), rows 3..N-1 = items, row N = hint footer + // kFixedRows = title bar (1) + hint footer (1) = 2 + constexpr int kFixedRows = 2; - printf("\n%.*s\n\n", static_cast<int>(Title.size()), Title.data()); + auto GetPageSize = [&]() -> int { + int Rows = static_cast<int>(TuiConsoleRows()); + int FilterOverhead = Filter.empty() ? 0 : 1; + int Available = Rows - kFixedRows - FilterOverhead; + return std::max(Available, 1); + }; - // Hide cursor during interaction - printf("\033[?25l"); + auto EnsureCursorVisible = [&] { + int PageSize = GetPageSize(); + int VisibleCount = static_cast<int>(Visible.size()); + if (CursorPos < ScrollTop) + { + ScrollTop = CursorPos; + } + else if (CursorPos >= ScrollTop + PageSize) + { + ScrollTop = CursorPos - PageSize + 1; + } + int MaxScroll = std::max(0, VisibleCount - PageSize); + ScrollTop = std::clamp(ScrollTop, 0, MaxScroll); + }; - // 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; + auto Render = [&] { + uint32_t Cols = TuiConsoleColumns(); + uint32_t Rows = TuiConsoleRows(); + int MaxTextLen = static_cast<int>(Cols) - kIndicatorLen; + int VisibleCount = static_cast<int>(Visible.size()); + int PageSize = GetPageSize(); - for (int i = 0; i < Count; ++i) - { - bool IsSelected = (i == SelectedIndex); + TuiCursorHome(); - printf("\r\033[K"); // erase line + // Row 1: Title bar (reverse video) + TuiMoveCursor(1, 1); + TuiEraseLine(); + printf("\033[1;7m"); // bold + reverse + printf(" %.*s", static_cast<int>(std::min(static_cast<uint32_t>(Title.size()), Cols - 1)), Title.data()); + printf("\033[0m"); - if (IsSelected) - { - printf("\033[1;7m"); // bold + reverse video - } + uint32_t CurrentRow = 2; - // \xe2\x96\xb6 = U+25B6 BLACK RIGHT-POINTING TRIANGLE (▶) - const char* Indicator = IsSelected ? " \xe2\x96\xb6 " : " "; + // Optional filter bar + if (!Filter.empty()) + { + TuiMoveCursor(CurrentRow, 1); + TuiEraseLine(); + printf(" \033[33mfilter:\033[0m %s", Filter.c_str()); + if (VisibleCount == 0) + { + printf(" \033[2m(no matches)\033[0m"); + } + ++CurrentRow; + } - printf("%s%s", Indicator, Items[i].c_str()); + // Item viewport + int ViewEnd = std::min(ScrollTop + PageSize, VisibleCount); - if (IsSelected) - { - printf("\033[0m"); // reset attributes - } + for (int Row = 0; Row < PageSize; ++Row) + { + int i = ScrollTop + Row; - printf("\n"); - } + TuiMoveCursor(CurrentRow + static_cast<uint32_t>(Row), 1); + TuiEraseLine(); - // Blank separator line - printf("\r\033[K\n"); + if (i < VisibleCount) + { + bool IsSelected = (i == CursorPos); + + if (IsSelected) + { + printf("\033[1;7m"); + } + + const char* Indicator = IsSelected ? " \xe2\x96\xb6 " : " "; + const std::string& ItemText = Items[Visible[i]]; + if (MaxTextLen > 0 && static_cast<int>(ItemText.size()) > MaxTextLen) + { + printf("%s%.*s...", Indicator, MaxTextLen - 3, ItemText.c_str()); + } + else + { + printf("%s%s", Indicator, ItemText.c_str()); + } + + if (IsSelected) + { + printf("\033[0m"); + } + } + } - // Hint footer - // \xe2\x86\x91 = U+2191 ↑ \xe2\x86\x93 = U+2193 ↓ - 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"); + // Hint footer (last row) + TuiMoveCursor(Rows, 1); + TuiEraseLine(); + printf("\033[7m"); + printf( + " \xe2\x86\x91/\xe2\x86\x93 navigate " + "Enter confirm " + "Esc %s " + "Type to filter", + Filter.empty() ? "cancel" : "clear"); + if (ScrollTop > 0 || ViewEnd < VisibleCount) + { + printf(" [%d-%d of %d]", ScrollTop + 1, ViewEnd, VisibleCount); + } + printf("\033[0m"); - fflush(stdout); + TuiFlush(); }; - RenderAll(); + EnsureCursorVisible(); + Render(); int Result = -1; bool Done = false; while (!Done) { - ConsoleKey Key = ReadKey(); + ConsoleKey Key = TuiReadKey(); + int VisibleCount = static_cast<int>(Visible.size()); + switch (Key) { case ConsoleKey::ArrowUp: - SelectedIndex = (SelectedIndex - 1 + Count) % Count; - RenderAll(); + if (VisibleCount > 0) + { + CursorPos = (CursorPos - 1 + VisibleCount) % VisibleCount; + EnsureCursorVisible(); + } break; case ConsoleKey::ArrowDown: - SelectedIndex = (SelectedIndex + 1) % Count; - RenderAll(); + if (VisibleCount > 0) + { + CursorPos = (CursorPos + 1) % VisibleCount; + EnsureCursorVisible(); + } + break; + + case ConsoleKey::PageUp: + if (VisibleCount > 0) + { + CursorPos = std::max(0, CursorPos - GetPageSize()); + EnsureCursorVisible(); + } + break; + + case ConsoleKey::PageDown: + if (VisibleCount > 0) + { + CursorPos = std::min(VisibleCount - 1, CursorPos + GetPageSize()); + EnsureCursorVisible(); + } break; case ConsoleKey::Enter: - Result = SelectedIndex; - Done = true; + if (VisibleCount > 0) + { + Result = Visible[CursorPos]; + } + Done = true; break; case ConsoleKey::Escape: - Done = true; + if (!Filter.empty()) + { + Filter.clear(); + RebuildVisible(); + EnsureCursorVisible(); + } + else + { + Done = true; + } + break; + + case ConsoleKey::Backspace: + if (!Filter.empty()) + { + Filter.pop_back(); + RebuildVisible(); + EnsureCursorVisible(); + } + break; + + case ConsoleKey::Char: + Filter += TuiReadKeyChar(); + RebuildVisible(); + EnsureCursorVisible(); + break; + + case ConsoleKey::Resize: + EnsureCursorVisible(); break; default: break; } + + Render(); } - // Restore cursor and add a blank line for visual separation - printf("\033[?25h\n"); - fflush(stdout); + // Persist filter state for the caller + if (InOutFilter) + { + *InOutFilter = std::move(Filter); + } + + TuiExitAlternateScreen(); return Result; } @@ -389,7 +493,17 @@ TuiEnterAlternateScreen() printf("\033[?25l"); // Hide cursor fflush(stdout); -#if !ZEN_PLATFORM_WINDOWS +#if ZEN_PLATFORM_WINDOWS + // Enable window-size events so ReadConsoleInput returns WINDOW_BUFFER_SIZE_EVENT + { + HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE); + DWORD dwMode = 0; + if (GetConsoleMode(hStdin, &dwMode)) + { + SetConsoleMode(hStdin, dwMode | ENABLE_WINDOW_INPUT); + } + } +#else if (tcgetattr(STDIN_FILENO, &s_SavedAttrs) == 0) { struct termios Raw = s_SavedAttrs; @@ -403,6 +517,13 @@ TuiEnterAlternateScreen() s_InLiveMode = true; } } + + // Install SIGWINCH handler for terminal resize detection + s_GotSigWinch = 0; + struct sigaction Sa = {}; + Sa.sa_handler = SigWinchHandler; + Sa.sa_flags = SA_RESTART; + sigaction(SIGWINCH, &Sa, nullptr); #endif } @@ -419,6 +540,9 @@ TuiExitAlternateScreen() tcsetattr(STDIN_FILENO, TCSANOW, &s_SavedAttrs); s_InLiveMode = false; } + + // Restore default SIGWINCH handler + signal(SIGWINCH, SIG_DFL); #endif } @@ -545,4 +669,461 @@ TuiShowCursor(bool Show) } } +////////////////////////////////////////////////////////////////////////// +// Public key reading + +ConsoleKey +TuiReadKey() +{ +#if ZEN_PLATFORM_WINDOWS + HANDLE hStdin = GetStdHandle(STD_INPUT_HANDLE); + INPUT_RECORD Record{}; + DWORD dwRead = 0; + while (true) + { + if (!ReadConsoleInputA(hStdin, &Record, 1, &dwRead)) + { + return ConsoleKey::Escape; + } + if (Record.EventType == WINDOW_BUFFER_SIZE_EVENT) + { + return ConsoleKey::Resize; + } + if (Record.EventType != KEY_EVENT || !Record.Event.KeyEvent.bKeyDown) + { + continue; + } + switch (Record.Event.KeyEvent.wVirtualKeyCode) + { + case VK_UP: + return ConsoleKey::ArrowUp; + case VK_DOWN: + return ConsoleKey::ArrowDown; + case VK_LEFT: + return ConsoleKey::ArrowLeft; + case VK_RIGHT: + return ConsoleKey::ArrowRight; + case VK_PRIOR: + return ConsoleKey::PageUp; + case VK_NEXT: + return ConsoleKey::PageDown; + case VK_HOME: + return ConsoleKey::Home; + case VK_END: + return ConsoleKey::End; + case VK_RETURN: + return ConsoleKey::Enter; + case VK_ESCAPE: + return ConsoleKey::Escape; + case VK_BACK: + return ConsoleKey::Backspace; + default: + { + char ch = Record.Event.KeyEvent.uChar.AsciiChar; + if (ch >= 32 && ch < 127) + { + s_LastChar = ch; + return ConsoleKey::Char; + } + break; + } + } + } +#else + // Check for pending SIGWINCH before blocking on read + if (s_GotSigWinch) + { + s_GotSigWinch = 0; + return ConsoleKey::Resize; + } + + unsigned char c = 0; + if (read(STDIN_FILENO, &c, 1) != 1) + { + // read() returns -1 with EINTR when interrupted by SIGWINCH + if (errno == EINTR && s_GotSigWinch) + { + s_GotSigWinch = 0; + return ConsoleKey::Resize; + } + return ConsoleKey::Escape; + } + + if (c == 27) // ESC or escape sequence + { + int Next = ReadByteWithTimeout(50); + if (Next == '[') + { + int Code = ReadByteWithTimeout(50); + switch (Code) + { + case 'A': + return ConsoleKey::ArrowUp; + case 'B': + return ConsoleKey::ArrowDown; + case 'C': + return ConsoleKey::ArrowRight; + case 'D': + return ConsoleKey::ArrowLeft; + case 'H': + return ConsoleKey::Home; + case 'F': + return ConsoleKey::End; + case '5': + if (ReadByteWithTimeout(50) == '~') + { + return ConsoleKey::PageUp; + } + break; + case '6': + if (ReadByteWithTimeout(50) == '~') + { + return ConsoleKey::PageDown; + } + break; + default: + break; + } + } + return ConsoleKey::Escape; + } + + if (c == '\r' || c == '\n') + { + return ConsoleKey::Enter; + } + if (c == 127 || c == 8) + { + return ConsoleKey::Backspace; + } + if (c >= 32 && c < 127) + { + s_LastChar = c; + return ConsoleKey::Char; + } + + return ConsoleKey::Unknown; +#endif +} + +char +TuiReadKeyChar() +{ + return s_LastChar; +} + +////////////////////////////////////////////////////////////////////////// +// TuiPager — fullscreen scrollable text viewer with search + +void +TuiPager(std::string_view Title, const std::vector<std::string>& Lines) +{ + TuiEnterAlternateScreen(); + + const uint32_t TotalLines = static_cast<uint32_t>(Lines.size()); + uint32_t TopLine = 0; // Index of the first visible line + + // Search state + std::string SearchQuery; + bool SearchActive = false; // Currently typing a search query + int32_t SearchMatchLine = -1; // Line of current match (-1 = none) + bool SearchFailed = false; // True if last search found nothing + bool SearchWrapped = false; // True if search wrapped around + + auto GetPageHeight = [&]() -> uint32_t { + uint32_t Rows = TuiConsoleRows(); + return (Rows > 2) ? (Rows - 2) : 1; // Reserve 2 lines: title bar + status bar + }; + + auto ClampTop = [&]() { + uint32_t PageH = GetPageHeight(); + if (TotalLines <= PageH) + { + TopLine = 0; + } + else if (TopLine > TotalLines - PageH) + { + TopLine = TotalLines - PageH; + } + }; + + auto Render = [&]() { + uint32_t Cols = TuiConsoleColumns(); + uint32_t Rows = TuiConsoleRows(); + uint32_t PageH = GetPageHeight(); + + TuiCursorHome(); + + // Title bar (row 1) — reverse video + TuiMoveCursor(1, 1); + printf("\033[1;7m"); // bold + reverse + TuiEraseLine(); + + // Build title string: " Title (line X-Y of Z)" + uint32_t LastVisible = std::min(TopLine + PageH, TotalLines); + printf(" %.*s (lines %u-%u of %u)", + static_cast<int>(std::min(static_cast<uint32_t>(Title.size()), Cols - 30)), + Title.data(), + TotalLines > 0 ? TopLine + 1 : 0, + LastVisible, + TotalLines); + + printf("\033[0m"); // reset + + // Content lines (rows 2 .. Rows-1) + for (uint32_t i = 0; i < PageH; ++i) + { + TuiMoveCursor(i + 2, 1); + TuiEraseLine(); + + uint32_t LineIdx = TopLine + i; + if (LineIdx < TotalLines) + { + const std::string& Line = Lines[LineIdx]; + + // Highlight search match line + if (SearchMatchLine >= 0 && LineIdx == static_cast<uint32_t>(SearchMatchLine)) + { + printf("\033[43;30m"); // yellow background, black text + } + + // Truncate to terminal width + if (Line.size() <= Cols) + { + printf("%s", Line.c_str()); + } + else + { + printf("%.*s", static_cast<int>(Cols), Line.c_str()); + } + + if (SearchMatchLine >= 0 && LineIdx == static_cast<uint32_t>(SearchMatchLine)) + { + printf("\033[0m"); + } + } + } + + // Status bar (last row) + TuiMoveCursor(Rows, 1); + TuiEraseLine(); + printf("\033[7m"); // reverse video + + if (SearchActive) + { + printf(" /%s", SearchQuery.c_str()); + TuiShowCursor(true); + } + else if (SearchFailed) + { + printf(" Pattern not found: %s", SearchQuery.c_str()); + } + else if (SearchWrapped) + { + printf(" Search wrapped: %s", SearchQuery.c_str()); + } + else + { + // Scroll percentage + if (TotalLines <= PageH) + { + printf(" (All)"); + } + else if (TopLine == 0) + { + printf(" (Top)"); + } + else if (TopLine + PageH >= TotalLines) + { + printf(" (End)"); + } + else + { + uint32_t Pct = (TopLine * 100) / (TotalLines - PageH); + printf(" (%u%%)", Pct); + } + + printf(" q:quit /:search n:next"); + } + + printf("\033[0m"); + TuiFlush(); + }; + + // Find next occurrence of SearchQuery starting from the given line + auto FindNext = [&](uint32_t StartLine, bool Wrap) -> bool { + if (SearchQuery.empty()) + { + return false; + } + + for (uint32_t i = 0; i < TotalLines; ++i) + { + uint32_t Idx = (StartLine + i) % TotalLines; + if (Idx < StartLine && !Wrap) + { + break; + } + + auto Pos = Lines[Idx].find(SearchQuery); + if (Pos != std::string::npos) + { + SearchMatchLine = static_cast<int32_t>(Idx); + SearchFailed = false; + SearchWrapped = Wrap && (Idx < StartLine); + + // Scroll so the match is visible + uint32_t PageH = GetPageHeight(); + if (static_cast<uint32_t>(SearchMatchLine) < TopLine || static_cast<uint32_t>(SearchMatchLine) >= TopLine + PageH) + { + TopLine = static_cast<uint32_t>(SearchMatchLine); + if (TopLine > 3) + { + TopLine -= 3; // Show a few lines of context above + } + else + { + TopLine = 0; + } + ClampTop(); + } + return true; + } + } + + SearchFailed = true; + SearchWrapped = false; + SearchMatchLine = -1; + return false; + }; + + ClampTop(); + Render(); + + bool Done = false; + while (!Done) + { + ConsoleKey Key = TuiReadKey(); + + if (SearchActive) + { + // In search input mode + switch (Key) + { + case ConsoleKey::Enter: + SearchActive = false; + TuiShowCursor(false); + FindNext(TopLine, true); + break; + case ConsoleKey::Escape: + SearchActive = false; + SearchFailed = false; + SearchMatchLine = -1; + TuiShowCursor(false); + break; + case ConsoleKey::Backspace: + if (!SearchQuery.empty()) + { + SearchQuery.pop_back(); + } + break; + case ConsoleKey::Char: + SearchQuery += TuiReadKeyChar(); + break; + default: + break; + } + Render(); + continue; + } + + // Normal navigation mode + SearchFailed = false; + SearchWrapped = false; + + uint32_t PageH = GetPageHeight(); + + switch (Key) + { + case ConsoleKey::ArrowUp: + if (TopLine > 0) + { + --TopLine; + } + break; + + case ConsoleKey::ArrowDown: + if (TopLine + PageH < TotalLines) + { + ++TopLine; + } + break; + + case ConsoleKey::PageUp: + if (TopLine >= PageH) + { + TopLine -= PageH; + } + else + { + TopLine = 0; + } + break; + + case ConsoleKey::PageDown: + TopLine += PageH; + ClampTop(); + break; + + case ConsoleKey::Home: + TopLine = 0; + break; + + case ConsoleKey::End: + if (TotalLines > PageH) + { + TopLine = TotalLines - PageH; + } + break; + + case ConsoleKey::Char: + { + char ch = TuiReadKeyChar(); + if (ch == 'q' || ch == 'Q') + { + Done = true; + } + else if (ch == '/') + { + SearchActive = true; + SearchQuery.clear(); + SearchMatchLine = -1; + } + else if (ch == 'n' || ch == 'N') + { + // Find next match + uint32_t Start = (SearchMatchLine >= 0) ? static_cast<uint32_t>(SearchMatchLine) + 1 : TopLine; + FindNext(Start, true); + } + break; + } + + case ConsoleKey::Escape: + Done = true; + break; + + case ConsoleKey::Resize: + ClampTop(); + break; + + default: + break; + } + + Render(); + } + + TuiExitAlternateScreen(); +} + } // namespace zen diff --git a/src/zenutil/include/zenutil/consoletui.h b/src/zenutil/include/zenutil/consoletui.h index 22737589b..b1ab0f3fb 100644 --- a/src/zenutil/include/zenutil/consoletui.h +++ b/src/zenutil/include/zenutil/consoletui.h @@ -6,9 +6,43 @@ #include <span> #include <string> #include <string_view> +#include <vector> namespace zen { +// Key identifiers returned by TuiReadKey(). +enum class ConsoleKey +{ + Unknown, + ArrowUp, + ArrowDown, + ArrowLeft, + ArrowRight, + PageUp, + PageDown, + Home, + End, + Enter, + Escape, + Backspace, + Char, // A printable character — retrieve with TuiReadKeyChar() + Resize, // Terminal was resized — callers should re-query dimensions and re-render +}; + +// Blocking read of a single key press. Returns the key identifier. +// For ConsoleKey::Char, call TuiReadKeyChar() to get the character. +// Precondition: terminal should be in raw/alternate-screen mode. +ConsoleKey TuiReadKey(); + +// Returns the character from the most recent TuiReadKey() call that returned ConsoleKey::Char. +char TuiReadKeyChar(); + +// Display text in a fullscreen pager with scrolling and search. +// Title is shown in the status bar. Lines are the pre-split text lines. +// Returns when the user presses 'q' or Escape. +// Precondition: IsTuiAvailable() must be true. +void TuiPager(std::string_view Title, const std::vector<std::string>& Lines); + // Returns the width of the console in columns, or Default if it cannot be determined. uint32_t TuiConsoleColumns(uint32_t Default = 120); @@ -29,12 +63,16 @@ bool IsTuiAvailable(); // // - Title: a short description printed once above the list // - Items: pre-formatted display labels, one per selectable entry +// - InitialSelection: index of the item to highlight initially (default 0) +// - InOutFilter: if non-null, seeded with this filter text on entry and +// updated with the current filter text on exit (for round-trip persistence) // // Arrow keys (↑/↓) navigate the selection, Enter confirms, Esc cancels. -// Returns the index of the selected item, or -1 if the user cancelled. +// Type to incrementally filter the list. Returns the index of the selected +// item in the original Items array, or -1 if the user cancelled. // // Precondition: IsTuiAvailable() must be true. -int TuiPickOne(std::string_view Title, std::span<const std::string> Items); +int TuiPickOne(std::string_view Title, std::span<const std::string> Items, int InitialSelection = 0, std::string* InOutFilter = nullptr); // Enter the alternate screen buffer for fullscreen live-update mode. // Hides the cursor. On POSIX, switches to raw/unbuffered terminal input. |