aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorStefan Boberg <[email protected]>2026-03-30 20:41:35 +0200
committerStefan Boberg <[email protected]>2026-03-30 20:41:35 +0200
commite79d8e75df209d6ee8c6548de614ec18f1f3ca5c (patch)
tree154dca80d132546c2d1c77064e5a9de7756602e6 /src
parentHighlight command and subcommand names in help TUI (diff)
downloadzen-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.cpp425
-rw-r--r--src/zen/cmds/help_cmd.h17
-rw-r--r--src/zenutil/consoletui.cpp299
-rw-r--r--src/zenutil/include/zenutil/consoletui.h15
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.