diff options
| author | Stefan Boberg <[email protected]> | 2026-03-30 20:41:35 +0200 |
|---|---|---|
| committer | Stefan Boberg <[email protected]> | 2026-03-30 20:41:35 +0200 |
| commit | e79d8e75df209d6ee8c6548de614ec18f1f3ca5c (patch) | |
| tree | 154dca80d132546c2d1c77064e5a9de7756602e6 /src | |
| parent | Highlight command and subcommand names in help TUI (diff) | |
| download | zen-e79d8e75df209d6ee8c6548de614ec18f1f3ca5c.tar.xz zen-e79d8e75df209d6ee8c6548de614ec18f1f3ca5c.zip | |
Add Markdown reference generation and interactive browser with word wrapping
- Add --md flag for interactive filtered document browser: displays all
command/subcommand help as a scrollable document, typing filters to
show only sections whose content matches
- Add --md-file <path> to write a Markdown reference file with definition
lists and option category headings
- Add TuiWrapLines() for ANSI-aware word wrapping with indent-preserving
continuation lines; used by TuiPager and the markdown browser
- TuiPager now word-wraps instead of truncating, re-wraps on resize
- Extend TuiPickOne with optional SearchTexts span for filtering against
auxiliary content instead of display labels
- Replace std::fstream/sstream with fmt string building and IoBuffer/WriteFile
Diffstat (limited to 'src')
| -rw-r--r-- | src/zen/cmds/help_cmd.cpp | 425 | ||||
| -rw-r--r-- | src/zen/cmds/help_cmd.h | 17 | ||||
| -rw-r--r-- | src/zenutil/consoletui.cpp | 299 | ||||
| -rw-r--r-- | src/zenutil/include/zenutil/consoletui.h | 15 |
4 files changed, 696 insertions, 60 deletions
diff --git a/src/zen/cmds/help_cmd.cpp b/src/zen/cmds/help_cmd.cpp index 6dfe7bed5..8c1b47489 100644 --- a/src/zen/cmds/help_cmd.cpp +++ b/src/zen/cmds/help_cmd.cpp @@ -2,10 +2,14 @@ #include "help_cmd.h" +#include <zencore/except_fmt.h> #include <zencore/string.h> #include <zenutil/consoletui.h> #include <zenutil/logging.h> +#include <zencore/filesystem.h> +#include <zencore/iobuffer.h> + #include <algorithm> #include <map> @@ -15,6 +19,9 @@ HelpCommand::HelpCommand() { m_Options.add_options()("h,help", "Print help"); m_Options.add_options()("l,list", "List all commands (non-interactive)", cxxopts::value(m_List)->default_value("false")); + m_Options.add_options()("md", "Browse Markdown reference interactively", cxxopts::value(m_Markdown)->default_value("false")); + m_Options + .add_option("", "", "md-file", "Write Markdown reference to file", cxxopts::value(m_MarkdownPath)->default_value(""), "<file>"); m_Options.add_option("__hidden__", "", "command", "Command to show help for", cxxopts::value(m_CommandName)->default_value(""), ""); m_Options.parse_positional({"command"}); } @@ -150,6 +157,410 @@ HelpCommand::BuildFullHelpText(const CommandInfo& Cmd, bool UseAnsi) const return Text; } +// Format a single option entry as a Markdown string: "term\n: definition\n\n" +static std::string +FormatOptionMarkdown(const cxxopts::HelpOptionDetails& Opt, std::string_view DefinitionPrefix) +{ + std::string Term; + if (!Opt.s.empty()) + { + Term += fmt::format("`-{}`", Opt.s); + } + for (const std::string& LongName : Opt.l) + { + if (!Term.empty()) + { + Term += ", "; + } + Term += fmt::format("`--{}`", LongName); + } + if (!Opt.arg_help.empty()) + { + Term += fmt::format(" {}", Opt.arg_help); + } + + std::string Def = std::string(Opt.desc); + if (Opt.has_default && !Opt.default_value.empty() && !Opt.is_boolean) + { + Def += fmt::format(" (default: `{}`)", Opt.default_value); + } + + return fmt::format("{}\n{}{} \n\n", Term, DefinitionPrefix, Def); +} + +// Render options from a cxxopts::Options as a Markdown definition list. +// Skips the __hidden__ group. +static std::string +FormatOptionsAsMarkdown(cxxopts::Options& Opts, std::string_view HeadingPrefix = "### ", std::string_view DefinitionPrefix = ": ") +{ + std::string Result; + std::vector<std::string> Groups = Opts.groups(); + Groups.erase(std::remove(Groups.begin(), Groups.end(), std::string("__hidden__")), Groups.end()); + + for (const std::string& GroupName : Groups) + { + const cxxopts::HelpGroupDetails& Group = Opts.group_help(GroupName); + + if (!GroupName.empty()) + { + Result += fmt::format("{}{}\n\n", HeadingPrefix, GroupName); + } + + for (const cxxopts::HelpOptionDetails& Opt : Group.options) + { + Result += FormatOptionMarkdown(Opt, DefinitionPrefix); + } + } + + return Result; +} + +void +HelpCommand::GenerateMarkdown(const std::string& OutputPath) const +{ + std::string Content = "# Zen CLI Reference\n\n"; + + for (const CommandInfo& Cmd : m_Commands) + { + Content += fmt::format("# {}\n\n{}\n\n", Cmd.CmdName, Cmd.CmdSummary); + Content += FormatOptionsAsMarkdown(Cmd.Cmd->Options()); + + // Subcommands + ZenCmdWithSubCommands* CmdWithSubs = dynamic_cast<ZenCmdWithSubCommands*>(Cmd.Cmd); + if (CmdWithSubs != nullptr) + { + for (ZenSubCmdBase* Sub : CmdWithSubs->SubCommands()) + { + Content += fmt::format("## {} {}\n\n{}\n\n", Cmd.CmdName, Sub->SubOptions().program(), Sub->Description()); + Content += FormatOptionsAsMarkdown(Sub->SubOptions()); + } + } + } + + if (m_GlobalOptions != nullptr) + { + Content += "# Global Options\n\n"; + Content += FormatOptionsAsMarkdown(*m_GlobalOptions); + } + + IoBuffer Buffer(IoBuffer::Clone, Content.data(), Content.size()); + WriteFile(OutputPath, std::move(Buffer)); + + ZEN_CONSOLE("Markdown reference written to '{}'", OutputPath); +} + +std::vector<HelpCommand::MarkdownSection> +HelpCommand::BuildMarkdownSections() const +{ + std::vector<MarkdownSection> Sections; + + for (const CommandInfo& Cmd : m_Commands) + { + std::string Body = fmt::format("{}\n\n{}", Cmd.CmdSummary, FormatOptionsAsMarkdown(Cmd.Cmd->Options(), "**", " ")); + + Sections.push_back({ + .Heading = Cmd.CmdName, + .Body = Body, + .SearchText = std::string(Cmd.CmdName) + " " + Cmd.CmdSummary + " " + Body, + }); + + // Subcommands + ZenCmdWithSubCommands* CmdWithSubs = dynamic_cast<ZenCmdWithSubCommands*>(Cmd.Cmd); + if (CmdWithSubs != nullptr) + { + for (ZenSubCmdBase* Sub : CmdWithSubs->SubCommands()) + { + std::string SubName = fmt::format("{} {}", Cmd.CmdName, Sub->SubOptions().program()); + std::string SubBody = fmt::format("{}\n\n{}", Sub->Description(), FormatOptionsAsMarkdown(Sub->SubOptions(), "**", " ")); + + Sections.push_back({ + .Heading = SubName, + .Body = SubBody, + .SearchText = SubName + " " + std::string(Sub->Description()) + " " + SubBody, + }); + } + } + } + + // Global options + if (m_GlobalOptions != nullptr) + { + std::string Body = FormatOptionsAsMarkdown(*m_GlobalOptions, "**", " "); + Sections.push_back({ + .Heading = "Global Options", + .Body = Body, + .SearchText = "Global Options " + Body, + }); + } + + return Sections; +} + +// Case-insensitive substring match for filtering sections +static bool +MatchesFilter(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(); +} + +void +HelpCommand::BrowseMarkdownInteractively() const +{ + if (!IsTuiAvailable()) + { + // Non-interactive: print sections as plain text + std::vector<MarkdownSection> Sections = BuildMarkdownSections(); + for (const MarkdownSection& Sec : Sections) + { + ZEN_CONSOLE("=== {} ===\n{}", Sec.Heading, Sec.Body); + } + return; + } + + std::vector<MarkdownSection> Sections = BuildMarkdownSections(); + + // Pre-split each section body into lines and prepend the heading + struct SectionLines + { + std::vector<std::string> Lines; // Heading line(s) + body lines + }; + std::vector<SectionLines> AllSectionLines; + AllSectionLines.reserve(Sections.size()); + + for (const MarkdownSection& Sec : Sections) + { + SectionLines SL; + // Heading rendered as bright white, with a blank line after + SL.Lines.push_back(fmt::format("\033[1;37m# {}\033[0m", Sec.Heading)); + SL.Lines.push_back(""); + + std::vector<std::string> BodyLines = SplitLines(Sec.Body); + SL.Lines.insert(SL.Lines.end(), BodyLines.begin(), BodyLines.end()); + + // Blank line between sections + SL.Lines.push_back(""); + AllSectionLines.push_back(std::move(SL)); + } + + // Build the flattened, word-wrapped line list from sections that match the current filter + std::string Filter; + std::vector<std::string> VisibleLines; + uint32_t TopLine = 0; + uint32_t WrapWidth = 0; + int MatchCount = 0; + + auto RebuildLines = [&] { + uint32_t Cols = TuiConsoleColumns(); + WrapWidth = Cols; + VisibleLines.clear(); + MatchCount = 0; + for (size_t i = 0; i < Sections.size(); ++i) + { + if (MatchesFilter(Sections[i].SearchText, Filter)) + { + std::vector<std::string> Wrapped = TuiWrapLines(AllSectionLines[i].Lines, Cols); + VisibleLines.insert(VisibleLines.end(), Wrapped.begin(), Wrapped.end()); + ++MatchCount; + } + } + }; + + RebuildLines(); + + TuiEnterAlternateScreen(); + + auto GetPageHeight = [&]() -> uint32_t { + uint32_t Rows = TuiConsoleRows(); + return (Rows > 2) ? (Rows - 2) : 1; + }; + + auto ClampTop = [&]() { + uint32_t PageH = GetPageHeight(); + uint32_t LineCount = static_cast<uint32_t>(VisibleLines.size()); + if (LineCount <= PageH) + { + TopLine = 0; + } + else if (TopLine > LineCount - PageH) + { + TopLine = LineCount - PageH; + } + }; + + auto Render = [&]() { + uint32_t Rows = TuiConsoleRows(); + uint32_t PageH = GetPageHeight(); + uint32_t LineCount = static_cast<uint32_t>(VisibleLines.size()); + + TuiCursorHome(); + + // Title bar + TuiMoveCursor(1, 1); + TuiEraseLine(); + printf("\033[1;7m"); + printf(" Zen CLI Reference"); + if (!Filter.empty()) + { + printf(" | filter: %s (%d section%s)", Filter.c_str(), MatchCount, MatchCount == 1 ? "" : "s"); + } + else + { + printf(" | %d sections — type to filter", static_cast<int>(Sections.size())); + } + printf("\033[0m"); + + // Content lines + for (uint32_t i = 0; i < PageH; ++i) + { + TuiMoveCursor(i + 2, 1); + TuiEraseLine(); + + uint32_t LineIdx = TopLine + i; + if (LineIdx < LineCount) + { + const std::string& Line = VisibleLines[LineIdx]; + printf("%s", Line.c_str()); + } + } + + // Status bar + TuiMoveCursor(Rows, 1); + TuiEraseLine(); + printf("\033[7m"); + + if (LineCount <= PageH) + { + printf(" (All)"); + } + else if (TopLine == 0) + { + printf(" (Top)"); + } + else if (TopLine + PageH >= LineCount) + { + printf(" (End)"); + } + else + { + uint32_t Pct = (TopLine * 100) / (LineCount - PageH); + printf(" (%u%%)", Pct); + } + + printf(" Esc:%s Backspace:edit filter", Filter.empty() ? "quit" : "clear"); + printf("\033[0m"); + TuiFlush(); + }; + + ClampTop(); + Render(); + + bool Done = false; + while (!Done) + { + ConsoleKey Key = TuiReadKey(); + + uint32_t PageH = GetPageHeight(); + uint32_t LineCount = static_cast<uint32_t>(VisibleLines.size()); + + switch (Key) + { + case ConsoleKey::ArrowUp: + if (TopLine > 0) + { + --TopLine; + } + break; + + case ConsoleKey::ArrowDown: + if (TopLine + PageH < LineCount) + { + ++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 (LineCount > PageH) + { + TopLine = LineCount - PageH; + } + break; + + case ConsoleKey::Char: + Filter += TuiReadKeyChar(); + TopLine = 0; + RebuildLines(); + ClampTop(); + break; + + case ConsoleKey::Backspace: + if (!Filter.empty()) + { + Filter.pop_back(); + TopLine = 0; + RebuildLines(); + ClampTop(); + } + break; + + case ConsoleKey::Escape: + if (!Filter.empty()) + { + Filter.clear(); + TopLine = 0; + RebuildLines(); + ClampTop(); + } + else + { + Done = true; + } + break; + + case ConsoleKey::Resize: + if (TuiConsoleColumns() != WrapWidth) + { + RebuildLines(); + } + ClampTop(); + break; + + default: + break; + } + + Render(); + } + + TuiExitAlternateScreen(); +} + void HelpCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv) { @@ -160,6 +571,20 @@ HelpCommand::Run(const ZenCliOptions& GlobalOptions, int argc, char** argv) return; } + // zen help --md-file <file>: generate Markdown reference to file + if (!m_MarkdownPath.empty()) + { + GenerateMarkdown(m_MarkdownPath); + return; + } + + // zen help --md: browse Markdown reference interactively + if (m_Markdown) + { + BrowseMarkdownInteractively(); + return; + } + // zen help --list: print plain listing if (m_List) { diff --git a/src/zen/cmds/help_cmd.h b/src/zen/cmds/help_cmd.h index 289abdece..a31cbd5dd 100644 --- a/src/zen/cmds/help_cmd.h +++ b/src/zen/cmds/help_cmd.h @@ -5,6 +5,7 @@ #include "../zen.h" #include <span> +#include <vector> namespace zen { @@ -25,13 +26,25 @@ public: virtual ZenCmdCategory& CommandCategory() const override { return g_UtilitiesCategory; } private: - std::string BuildFullHelpText(const CommandInfo& Cmd, bool UseAnsi = false) const; + struct MarkdownSection + { + std::string Heading; // Display heading (e.g. "hub up") + std::string Body; // Full section body (Markdown text) + std::string SearchText; // Concatenated searchable text (heading + body) + }; + + std::string BuildFullHelpText(const CommandInfo& Cmd, bool UseAnsi = false) const; + std::vector<MarkdownSection> BuildMarkdownSections() const; + void GenerateMarkdown(const std::string& OutputPath) const; + void BrowseMarkdownInteractively() const; cxxopts::Options m_Options{Name, Description}; std::span<const CommandInfo> m_Commands; cxxopts::Options* m_GlobalOptions = nullptr; std::string m_CommandName; - bool m_List = false; + std::string m_MarkdownPath; + bool m_Markdown = false; + bool m_List = false; }; } // namespace zen diff --git a/src/zenutil/consoletui.cpp b/src/zenutil/consoletui.cpp index d3d73814d..84af1d372 100644 --- a/src/zenutil/consoletui.cpp +++ b/src/zenutil/consoletui.cpp @@ -275,6 +275,181 @@ TruncateAnsi(const std::string& Text, int MaxVisible) return Text; } +// Word-wrap a single line to fit within Width visible columns. +// ANSI escape codes pass through without counting toward width. +// Returns one or more lines. +static std::vector<std::string> +WrapLine(const std::string& Line, uint32_t Width) +{ + if (Width == 0) + { + return {Line}; + } + + // Fast path: line already fits + if (VisibleWidth(Line) <= static_cast<int>(Width)) + { + return {Line}; + } + + // Measure leading whitespace for continuation indent so wrapped lines + // align with the original text. + int IndentChars = 0; + for (char c : Line) + { + if (c == ' ') + { + ++IndentChars; + } + else if (c == '\t') + { + IndentChars += 4; + } + else + { + break; + } + } + // Cap indent to half the width to avoid degenerate cases + int ContinuationIndent = std::min(IndentChars, static_cast<int>(Width) / 2); + + std::vector<std::string> Result; + std::string CurrentLine; + int CurrentVisible = 0; + int EffectiveWidth = static_cast<int>(Width); + bool InEscape = false; + bool HasWords = false; // True once a word has been appended to CurrentLine + + // Track the current "word" being accumulated + std::string Word; + int WordVisible = 0; + + auto FlushWord = [&]() { + if (Word.empty()) + { + return; + } + + // Would this word overflow? + int SpaceNeeded = WordVisible; + if (HasWords) + { + SpaceNeeded += 1; // space before word + } + + if (HasWords && CurrentVisible + SpaceNeeded > EffectiveWidth) + { + // Wrap: emit current line, start new continuation line + Result.push_back(CurrentLine); + CurrentLine = std::string(ContinuationIndent, ' '); + CurrentVisible = ContinuationIndent; + EffectiveWidth = static_cast<int>(Width); + HasWords = false; + } + + // Force-break words wider than the available space + if (WordVisible > EffectiveWidth - CurrentVisible) + { + // Append character by character + bool WordEscape = false; + for (char c : Word) + { + if (WordEscape) + { + CurrentLine += c; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) + { + WordEscape = false; + } + } + else if (c == '\033') + { + CurrentLine += c; + WordEscape = true; + } + else + { + if (CurrentVisible >= EffectiveWidth) + { + Result.push_back(CurrentLine); + CurrentLine = std::string(ContinuationIndent, ' '); + CurrentVisible = ContinuationIndent; + HasWords = false; + } + CurrentLine += c; + ++CurrentVisible; + } + } + HasWords = true; + Word.clear(); + WordVisible = 0; + return; + } + + // Append with space separator + if (HasWords) + { + CurrentLine += ' '; + ++CurrentVisible; + } + CurrentLine += Word; + CurrentVisible += WordVisible; + HasWords = true; + + Word.clear(); + WordVisible = 0; + }; + + for (size_t i = 0; i < Line.size(); ++i) + { + char c = Line[i]; + + if (InEscape) + { + Word += c; + if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')) + { + InEscape = false; + } + } + else if (c == '\033') + { + Word += c; + InEscape = true; + } + else if (c == ' ' || c == '\t') + { + FlushWord(); + } + else + { + Word += c; + ++WordVisible; + } + } + + FlushWord(); + + if (!CurrentLine.empty() || Result.empty()) + { + Result.push_back(CurrentLine); + } + + return Result; +} + +std::vector<std::string> +TuiWrapLines(const std::vector<std::string>& Lines, uint32_t Width) +{ + std::vector<std::string> Result; + for (const std::string& Line : Lines) + { + std::vector<std::string> Wrapped = WrapLine(Line, Width); + Result.insert(Result.end(), Wrapped.begin(), Wrapped.end()); + } + return Result; +} + // Case-insensitive substring match static bool ContainsCaseInsensitive(const std::string& Haystack, const std::string& Needle) @@ -290,13 +465,20 @@ ContainsCaseInsensitive(const std::string& Haystack, const std::string& Needle) } int -TuiPickOne(std::string_view Title, std::span<const std::string> Items, int InitialSelection, std::string* InOutFilter) +TuiPickOne(std::string_view Title, + std::span<const std::string> Items, + int InitialSelection, + std::string* InOutFilter, + std::span<const std::string> SearchTexts) { TuiEnterAlternateScreen(); const int TotalCount = static_cast<int>(Items.size()); constexpr int kIndicatorLen = 3; // " ▶ " or " " — 3 display columns + // When SearchTexts is provided, filter against it instead of display labels + bool UseSearchTexts = !SearchTexts.empty(); + // 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 @@ -307,7 +489,8 @@ TuiPickOne(std::string_view Title, std::span<const std::string> Items, int Initi Visible.clear(); for (int i = 0; i < TotalCount; ++i) { - if (ContainsCaseInsensitive(Items[i], Filter)) + const std::string& Haystack = (UseSearchTexts && i < static_cast<int>(SearchTexts.size())) ? SearchTexts[i] : Items[i]; + if (ContainsCaseInsensitive(Haystack, Filter)) { Visible.push_back(i); } @@ -879,88 +1062,92 @@ TuiReadKeyChar() } ////////////////////////////////////////////////////////////////////////// -// TuiPager — fullscreen scrollable text viewer with search +// TuiPager — fullscreen scrollable text viewer with search and word wrapping 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 + // Word-wrapped lines and the last width they were wrapped to + std::vector<std::string> Wrapped; + uint32_t WrapWidth = 0; + + auto Rewrap = [&]() { + uint32_t Cols = TuiConsoleColumns(); + if (Cols != WrapWidth) + { + Wrapped = TuiWrapLines(Lines, Cols); + WrapWidth = Cols; + } + }; + + Rewrap(); + + uint32_t TopLine = 0; // Search state std::string SearchQuery; - 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 + int32_t SearchMatchLine = -1; + bool SearchFailed = false; + bool SearchWrapped = false; auto GetPageHeight = [&]() -> uint32_t { uint32_t Rows = TuiConsoleRows(); - return (Rows > 2) ? (Rows - 2) : 1; // Reserve 2 lines: title bar + status bar + return (Rows > 2) ? (Rows - 2) : 1; }; auto ClampTop = [&]() { - uint32_t PageH = GetPageHeight(); - if (TotalLines <= PageH) + uint32_t PageH = GetPageHeight(); + uint32_t LineCount = static_cast<uint32_t>(Wrapped.size()); + if (LineCount <= PageH) { TopLine = 0; } - else if (TopLine > TotalLines - PageH) + else if (TopLine > LineCount - PageH) { - TopLine = TotalLines - PageH; + TopLine = LineCount - PageH; } }; auto Render = [&]() { - uint32_t Cols = TuiConsoleColumns(); - uint32_t Rows = TuiConsoleRows(); - uint32_t PageH = GetPageHeight(); + uint32_t Cols = TuiConsoleColumns(); + uint32_t Rows = TuiConsoleRows(); + uint32_t PageH = GetPageHeight(); + uint32_t LineCount = static_cast<uint32_t>(Wrapped.size()); TuiCursorHome(); - // Title bar (row 1) — reverse video + // Title bar TuiMoveCursor(1, 1); - printf("\033[1;7m"); // bold + reverse + printf("\033[1;7m"); TuiEraseLine(); - - // Build title string: " Title (line X-Y of Z)" - uint32_t LastVisible = std::min(TopLine + PageH, TotalLines); + uint32_t LastVisible = std::min(TopLine + PageH, LineCount); 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, + LineCount > 0 ? TopLine + 1 : 0, LastVisible, - TotalLines); - - printf("\033[0m"); // reset + LineCount); + printf("\033[0m"); - // Content lines (rows 2 .. Rows-1) + // Content lines for (uint32_t i = 0; i < PageH; ++i) { TuiMoveCursor(i + 2, 1); TuiEraseLine(); uint32_t LineIdx = TopLine + i; - if (LineIdx < TotalLines) + if (LineIdx < LineCount) { - const std::string& Line = Lines[LineIdx]; + const std::string& Line = Wrapped[LineIdx]; - // Highlight search match line if (SearchMatchLine >= 0 && LineIdx == static_cast<uint32_t>(SearchMatchLine)) { - printf("\033[43;30m"); // yellow background, black text + printf("\033[43;30m"); } - // Truncate to terminal width (ANSI-aware) - if (VisibleWidth(Line) <= static_cast<int>(Cols)) - { - printf("%s", Line.c_str()); - } - else - { - printf("%s", TruncateAnsi(Line, static_cast<int>(Cols)).c_str()); - } + printf("%s", Line.c_str()); if (SearchMatchLine >= 0 && LineIdx == static_cast<uint32_t>(SearchMatchLine)) { @@ -969,13 +1156,12 @@ TuiPager(std::string_view Title, const std::vector<std::string>& Lines) } } - // Status bar (last row) + // Status bar TuiMoveCursor(Rows, 1); TuiEraseLine(); - printf("\033[7m"); // reverse video + printf("\033[7m"); - // Scroll percentage - if (TotalLines <= PageH) + if (LineCount <= PageH) { printf(" (All)"); } @@ -983,13 +1169,13 @@ TuiPager(std::string_view Title, const std::vector<std::string>& Lines) { printf(" (Top)"); } - else if (TopLine + PageH >= TotalLines) + else if (TopLine + PageH >= LineCount) { printf(" (End)"); } else { - uint32_t Pct = (TopLine * 100) / (TotalLines - PageH); + uint32_t Pct = (TopLine * 100) / (LineCount - PageH); printf(" (%u%%)", Pct); } @@ -1017,36 +1203,34 @@ TuiPager(std::string_view Title, const std::vector<std::string>& Lines) 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 LineCount = static_cast<uint32_t>(Wrapped.size()); + for (uint32_t i = 0; i < LineCount; ++i) { - uint32_t Idx = (StartLine + i) % TotalLines; + uint32_t Idx = (StartLine + i) % LineCount; if (Idx < StartLine && !Wrap) { break; } - auto Pos = Lines[Idx].find(SearchQuery); - if (Pos != std::string::npos) + if (ContainsCaseInsensitive(Wrapped[Idx], SearchQuery)) { 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 + TopLine -= 3; } else { @@ -1075,7 +1259,8 @@ TuiPager(std::string_view Title, const std::vector<std::string>& Lines) SearchFailed = false; SearchWrapped = false; - uint32_t PageH = GetPageHeight(); + uint32_t PageH = GetPageHeight(); + uint32_t LineCount = static_cast<uint32_t>(Wrapped.size()); switch (Key) { @@ -1087,7 +1272,7 @@ TuiPager(std::string_view Title, const std::vector<std::string>& Lines) break; case ConsoleKey::ArrowDown: - if (TopLine + PageH < TotalLines) + if (TopLine + PageH < LineCount) { ++TopLine; } @@ -1114,9 +1299,9 @@ TuiPager(std::string_view Title, const std::vector<std::string>& Lines) break; case ConsoleKey::End: - if (TotalLines > PageH) + if (LineCount > PageH) { - TopLine = TotalLines - PageH; + TopLine = LineCount - PageH; } break; @@ -1142,7 +1327,6 @@ TuiPager(std::string_view Title, const std::vector<std::string>& Lines) case ConsoleKey::Enter: { - // Jump to next match uint32_t Start = (SearchMatchLine >= 0) ? static_cast<uint32_t>(SearchMatchLine) + 1 : TopLine; FindNext(Start, true); break; @@ -1161,6 +1345,7 @@ TuiPager(std::string_view Title, const std::vector<std::string>& Lines) break; case ConsoleKey::Resize: + Rewrap(); ClampTop(); break; diff --git a/src/zenutil/include/zenutil/consoletui.h b/src/zenutil/include/zenutil/consoletui.h index b1ab0f3fb..921d33ce2 100644 --- a/src/zenutil/include/zenutil/consoletui.h +++ b/src/zenutil/include/zenutil/consoletui.h @@ -37,8 +37,14 @@ ConsoleKey TuiReadKey(); // Returns the character from the most recent TuiReadKey() call that returned ConsoleKey::Char. char TuiReadKeyChar(); +// Word-wrap a list of lines to fit within the given visible width. +// ANSI escape sequences are preserved and do not count toward the width. +// Long words that exceed the width are force-broken. +std::vector<std::string> TuiWrapLines(const std::vector<std::string>& Lines, uint32_t Width); + // Display text in a fullscreen pager with scrolling and search. // Title is shown in the status bar. Lines are the pre-split text lines. +// Lines are word-wrapped to fit the terminal width. // 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); @@ -66,13 +72,20 @@ bool IsTuiAvailable(); // - 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) +// - SearchTexts: if non-empty, filter matching uses these strings instead of +// Items (must be same length as Items). Useful when the display label differs +// from the searchable content. // // Arrow keys (↑/↓) navigate the selection, Enter confirms, Esc cancels. // 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 InitialSelection = 0, std::string* InOutFilter = nullptr); +int TuiPickOne(std::string_view Title, + std::span<const std::string> Items, + int InitialSelection = 0, + std::string* InOutFilter = nullptr, + std::span<const std::string> SearchTexts = {}); // Enter the alternate screen buffer for fullscreen live-update mode. // Hides the cursor. On POSIX, switches to raw/unbuffered terminal input. |