commit c1ce85f8ce4e7ed1f5e3f31221b8ba2a5c15a613
Author: MrJensK <jens.se@icloud.com>
AuthorDate: Sun Aug 23 22:23:28 2026 +0200
Commit: MrJensK <jens.se@icloud.com>
CommitDate: Sun Aug 23 22:23:28 2026 +0200
Rework the results list into a column table (#/Name/Size/Seed:Lch/Src)
Ported Results.tsx's actual column layout -- a header row plus fixed-
width Size/Seed:Lch/Src columns and a flexed Name column -- replacing
the flat "[source] name (size, seeders=n)" string the results Menu
rendered. A fixed-width column can only clip, never reflow, so this
was the real fix for the list jumping on a long name; the marquee
added earlier for the same symptom is now redundant and removed
(ui/marquee.hpp/cpp, AppState::resultLabels, MakeUiClock's mention of
it) per request -- cells make it unnecessary.
Necessarily drops the stock ftxui::Menu for a hand-built Renderer +
CatchEvent component (same shape as Downloads/Seeding already use):
independent per-column alignment/coloring needs one Element per cell,
which a Menu bound to one string per row can't give. Scroll-to-keep-
selected-visible, previously free from Menu+frame, is now select() on
the current row + yframe() on the list -- the same mechanism, applied
by hand.
Added util::formatCount() (ported from format.ts, "9981"/"12k"/"1.4m")
for the Seed:Lch column.
Also fixed a real, now user-visible bug in stripControl(): it checked
every byte for C0/C1 control ranges independently, but a multi-byte
UTF-8 character's continuation bytes (0x80-0xBF) commonly land in
0x80-0x9F and would get deleted as a "C1 control", corrupting the
character -- a Cyrillic name in the results table was breaking every
column after it. Fixed to only ever inspect a byte that starts its own
one-byte sequence; a multi-byte sequence is now always kept whole.
---
apps/tui/main.cpp | 114 ++++++++++++++++++++----------
include/torlinkc/ui/app_state.hpp | 14 ++--
include/torlinkc/ui/clock.hpp | 8 +--
include/torlinkc/ui/marquee.hpp | 14 ----
include/torlinkc/ui/search_aggregator.hpp | 10 +--
include/torlinkc/util/format.hpp | 14 ++--
src/CMakeLists.txt | 1 -
src/ui/marquee.cpp | 55 --------------
src/ui/search_aggregator.cpp | 4 +-
src/util/format.cpp | 40 ++++++++++-
10 files changed, 139 insertions(+), 135 deletions(-)
diff --git a/apps/tui/main.cpp b/apps/tui/main.cpp
index 2b6d024..ead1962 100644
--- a/apps/tui/main.cpp
+++ b/apps/tui/main.cpp
@@ -33,7 +33,6 @@
#include "torlinkc/ui/filter.hpp"
#include "torlinkc/ui/keymap.hpp"
#include "torlinkc/ui/logo.hpp"
-#include "torlinkc/ui/marquee.hpp"
#include "torlinkc/ui/move.hpp"
#include "torlinkc/ui/progress_bar.hpp"
#include "torlinkc/ui/search_aggregator.hpp"
@@ -72,10 +71,6 @@ std::string homeDir() {
return h ? h : "/tmp";
}
-std::string formatResultLabel(const TorrentResult& r) {
- return "[" + r.source + "] " + stripControl(r.name) + " (" + formatBytes(static_cast<double>(r.sizeBytes)) +
- ", seeders=" + std::to_string(r.seeders) + ")";
-}
std::string sourcesStatusLine(const AppState& state) {
int failed = 0;
@@ -215,32 +210,11 @@ int main() {
}
auto filtered = filterResults(byCategory, state.hideDead, sources);
state.visibleResults = sortResults(filtered, state.sort);
-
- state.resultLabels.clear();
- state.resultLabels.reserve(state.visibleResults.size());
- for (const auto& r : state.visibleResults) state.resultLabels.push_back(formatResultLabel(r));
- if (state.selectedResult >= static_cast<int>(state.resultLabels.size())) {
- state.selectedResult = std::max(0, static_cast<int>(state.resultLabels.size()) - 1);
+ if (state.selectedResult >= static_cast<int>(state.visibleResults.size())) {
+ state.selectedResult = std::max(0, static_cast<int>(state.visibleResults.size()) - 1);
}
};
- // Rewrites state.resultLabels' *contents* in place every render (called
- // from mainRenderer, below): a fixed max width means a long name can never
- // change the list's row height/width (what was causing it to visibly jump
- // as the cursor moved onto a long entry), and the selected row scrolls
- // through its full name instead of just sitting truncated. Kept as a
- // full rebuild each frame rather than patching only the selected index, so
- // there's no stale marquee text left behind once the cursor moves off a
- // row -- see the class comment on AppState::resultLabels.
- constexpr int kResultLabelWidth = 76;
- auto updateResultLabelsForFrame = [&] {
- for (std::size_t i = 0; i < state.visibleResults.size(); ++i) {
- const std::string full = formatResultLabel(state.visibleResults[i]);
- state.resultLabels[i] = static_cast<int>(i) == state.selectedResult
- ? marqueeWindow(full, kResultLabelWidth, state.uiClock)
- : truncate(full, kResultLabelWidth);
- }
- };
aggregator.onResultsChanged = [&](bool hadNoResultsBefore) {
refreshVisible();
if (hadNoResultsBefore && !state.results.empty() && state.view == View::Browser &&
@@ -288,11 +262,78 @@ int main() {
};
Component searchInput = Input(&state.query, "search torrents (all sources)...", searchOptions);
- MenuOption menuOptions = MenuOption::Vertical();
- menuOptions.on_enter = downloadSelected;
- Component resultsMenu = Menu(&state.resultLabels, &state.selectedResult, menuOptions);
- Component resultsWithKeys = CatchEvent(resultsMenu, [&](Event event) {
- if (event == Event::Character('d')) {
+ // A hand-built table (ported from Results.tsx's column layout) rather than
+ // a stock Menu bound to one flat string per row: independent per-column
+ // alignment/coloring needs one Element per cell, and a fixed column width
+ // means a long name can never change the list's row height/width (what
+ // was making it visibly jump as the cursor moved onto a long entry) --
+ // FTXUI just clips a too-narrow box, no truncate()/ellipsis needed.
+ constexpr int kResultsGutterWidth = 2;
+ constexpr int kResultsSizeWidth = 10;
+ constexpr int kResultsSeedWidth = 9;
+ constexpr int kResultsSrcWidth = 4;
+
+ Component resultsBase = Renderer([&]() -> Element {
+ const bool focused = state.focusedIndex == kFocusResults;
+ const int numW = std::max(2, static_cast<int>(std::to_string(state.visibleResults.size()).size()));
+
+ auto headerCell = [](const std::string& s, int w) { return text(s) | bold | dim | size(WIDTH, EQUAL, w) | align_right; };
+ Elements rows;
+ rows.push_back(hbox({
+ text("") | size(WIDTH, EQUAL, kResultsGutterWidth),
+ headerCell("#", numW),
+ text(" "),
+ text("Name") | bold | dim | flex,
+ text(" "),
+ headerCell("Size", kResultsSizeWidth),
+ text(" "),
+ headerCell("Seed:Lch", kResultsSeedWidth),
+ text(" "),
+ headerCell("Src", kResultsSrcWidth),
+ }));
+
+ for (std::size_t i = 0; i < state.visibleResults.size(); ++i) {
+ const auto& r = state.visibleResults[i];
+ const bool here = focused && static_cast<int>(i) == state.selectedResult;
+
+ Element pointer = text(here ? icon::pointer : "") | color(palette::accent) | size(WIDTH, EQUAL, kResultsGutterWidth);
+ Element num = text(std::to_string(i + 1)) | dim | size(WIDTH, EQUAL, numW) | align_right;
+
+ Element name = text(stripControl(r.name)) | flex;
+ name = here ? (name | bold | color(palette::accent)) : (name | dim);
+
+ Element sizeEl = text(r.sizeBytes > 0 ? formatBytes(static_cast<double>(r.sizeBytes)) : "-") |
+ size(WIDTH, EQUAL, kResultsSizeWidth) | align_right;
+ sizeEl = here ? (sizeEl | bold) : (sizeEl | dim);
+
+ const std::string seedLch =
+ r.seeders > 0 || r.leechers > 0 ? formatCount(r.seeders) + ":" + formatCount(r.leechers) : "-";
+ Element seedEl = text(seedLch) | size(WIDTH, EQUAL, kResultsSeedWidth) | align_right;
+ if (r.seeders > 0) seedEl = seedEl | color(palette::good);
+ seedEl = here ? (seedEl | bold) : (seedEl | dim);
+
+ const auto ss = sourceStyle(r.source);
+ Element srcEl =
+ text(ss.tag) | color(ss.color) | size(WIDTH, EQUAL, kResultsSrcWidth) | align_right | (here ? bold : dim);
+
+ Element row = hbox({pointer, num, text(" "), name, text(" "), sizeEl, text(" "), seedEl, text(" "), srcEl});
+ rows.push_back(here ? select(row) : row);
+ }
+ return vbox(std::move(rows)) | yframe;
+ });
+
+ Component resultsComponent = CatchEvent(resultsBase, [&](Event event) -> bool {
+ if (state.visibleResults.empty()) return false;
+ const int total = static_cast<int>(state.visibleResults.size());
+ if (event == Event::ArrowUp || event == Event::Character('k')) {
+ state.selectedResult = wrapStep(state.selectedResult, -1, total);
+ return true;
+ }
+ if (event == Event::ArrowDown || event == Event::Character('j')) {
+ state.selectedResult = wrapStep(state.selectedResult, 1, total);
+ return true;
+ }
+ if (event == Event::Return || event == Event::Character('d')) {
downloadSelected();
return true;
}
@@ -610,7 +651,7 @@ int main() {
// --- Top-level composition -------------------------------------------
Component mainContainer = Container::Vertical(
- {sidebarMenu, searchInput, resultsWithKeys, downloadsComponent, seedingComponent, spinner},
+ {sidebarMenu, searchInput, resultsComponent, downloadsComponent, seedingComponent, spinner},
&state.focusedIndex);
Component mainRenderer = Renderer(mainContainer, [&] {
@@ -689,7 +730,6 @@ int main() {
const Color c = state.region == Region::Content ? palette::accent : palette::rule;
content = coloredWindow(text("Seeding"), seedingComponent->Render(), c);
} else {
- updateResultLabelsForFrame();
Elements resultsSection;
resultsSection.push_back(text(filterBarText(state)) | dim);
if (state.searching) {
@@ -697,13 +737,13 @@ int main() {
} else {
resultsSection.push_back(text(sourcesStatusLine(state)) | dim);
}
- if (state.resultLabels.empty()) {
+ if (state.visibleResults.empty()) {
std::string hint = "no results yet -- type a query and press Enter";
if (state.searching) hint = "searching...";
else if (!state.results.empty()) hint = "no results in this category/filter";
resultsSection.push_back(text(hint) | dim);
} else {
- resultsSection.push_back(resultsMenu->Render() | frame | flex);
+ resultsSection.push_back(resultsComponent->Render() | flex);
}
content = vbox({
coloredWindow(text("Search"), searchInput->Render(),
diff --git a/include/torlinkc/ui/app_state.hpp b/include/torlinkc/ui/app_state.hpp
index ca74244..51e7651 100644
--- a/include/torlinkc/ui/app_state.hpp
+++ b/include/torlinkc/ui/app_state.hpp
@@ -83,22 +83,16 @@ struct AppState {
// Display: section-as-category/hideDead/sort applied to `results`,
// recomputed into `visibleResults` by main.cpp's refreshVisible() whenever
- // `results` or any of the three change. `resultLabels` is kept as a
- // persistent AppState field (not computed fresh inside Render()) because
- // FTXUI's Menu binds to it by address at construction time -- but its
- // *contents* are rewritten every render (truncated/marquee'd from
- // visibleResults, see updateResultLabelsForFrame() in main.cpp), since the
- // selected row's marquee position changes every frame even when the
- // result set itself hasn't.
+ // `results` or any of the three change. main.cpp's results table renders
+ // this directly (one row per entry, in a fixed-width column layout), so
+ // there's no separate label cache to keep in sync.
bool hideDead = false;
Sort sort;
std::vector<TorrentResult> visibleResults;
- std::vector<std::string> resultLabels; // parallel to visibleResults; what the results Menu renders
int selectedResult = 0;
// Elapsed seconds while View::Browser is showing (see ui/clock.hpp) --
- // drives the results marquee and the downloads progress-bar sheen off one
- // shared time base.
+ // drives the downloads progress-bar sheen.
float uiClock = 0.0f;
// Downloads / seeds, replaced wholesale each engine-thread tick.
diff --git a/include/torlinkc/ui/clock.hpp b/include/torlinkc/ui/clock.hpp
index 0b910b7..41e6ce4 100644
--- a/include/torlinkc/ui/clock.hpp
+++ b/include/torlinkc/ui/clock.hpp
@@ -8,10 +8,10 @@ namespace torlinkc::ui {
// Advances *clock by elapsed seconds every animation frame while isActive()
// is true (wrapped to stay bounded over a long-running session), and costs
-// nothing while inactive -- same pattern as spinner.hpp. Drives both the
-// results marquee and the downloads progress-bar sheen off one shared time
-// base, so a page of active downloads sweeps in sync. The caller must still
-// call ScreenInteractive::RequestAnimationFrame() once to kick off the first
+// nothing while inactive -- same pattern as spinner.hpp. Drives the
+// downloads progress-bar sheen, so a page of active downloads sweeps in
+// sync. The caller must still call
+// ScreenInteractive::RequestAnimationFrame() once to kick off the first
// frame when isActive() flips true (see spinner.hpp).
ftxui::Component MakeUiClock(float* clock, std::function<bool()> isActive);
diff --git a/include/torlinkc/ui/marquee.hpp b/include/torlinkc/ui/marquee.hpp
deleted file mode 100644
index 2c5a022..0000000
--- a/include/torlinkc/ui/marquee.hpp
+++ /dev/null
@@ -1,14 +0,0 @@
-#pragma once
-
-#include <string>
-
-namespace torlinkc::ui {
-
-// Returns a `maxGlyphs`-wide UTF-8 window into `text`: the text as-is if it
-// already fits, otherwise a window that scrolls through it (plus a blank
-// gap) as `clockSeconds` advances. Pairs with a fixed max width elsewhere
-// (util::truncate for every other row) so a long name can never change a
-// list's row height/width -- this is how the rest of it still gets seen.
-std::string marqueeWindow(const std::string& text, int maxGlyphs, float clockSeconds);
-
-} // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/search_aggregator.hpp b/include/torlinkc/ui/search_aggregator.hpp
index e92c62f..ce68b98 100644
--- a/include/torlinkc/ui/search_aggregator.hpp
+++ b/include/torlinkc/ui/search_aggregator.hpp
@@ -42,11 +42,11 @@ class SearchAggregator {
void search(std::string query);
// Called on the UI thread at the end of every flush, after state_.results
- // is updated -- main.cpp hooks this to recompute visibleResults/
- // resultLabels (category/hideDead/sort applied) and to decide whether to
- // jump focus to the results list, neither of which SearchAggregator has an
- // opinion on. The argument is whether state_.results was empty just before
- // this flush.
+ // is updated -- main.cpp hooks this to recompute visibleResults
+ // (category/hideDead/sort applied) and to decide whether to jump focus to
+ // the results list, neither of which SearchAggregator has an opinion on.
+ // The argument is whether state_.results was empty just before this
+ // flush.
std::function<void(bool hadNoResultsBefore)> onResultsChanged;
private:
diff --git a/include/torlinkc/util/format.hpp b/include/torlinkc/util/format.hpp
index 76821e1..deb4a52 100644
--- a/include/torlinkc/util/format.hpp
+++ b/include/torlinkc/util/format.hpp
@@ -10,16 +10,22 @@ namespace torlinkc {
// harness already did).
std::string formatBytes(double bytes);
+// "9981" / "12k" / "1.4m" style abbreviation for seeder/leecher counts.
+// Ported from util/format.ts::formatCount.
+std::string formatCount(double n);
+
// Parses a human-readable size like "1.2 GiB" or "700 MB" (as scraped from
// x1337/nyaa's HTML/RSS) back into bytes. Ported from
// util/format.ts::parseSize.
std::int64_t parseSize(const std::string& s);
// Strips C0/DEL/C1 control and escape-capable code points from a UTF-8
-// string, byte-for-byte (ASCII range only -- multi-byte UTF-8 continuation
-// bytes are all >= 0x80 and < 0xa0 only overlaps the C1 range for a handful
-// of invalid/overlong encodings, so this is safe to apply to valid UTF-8).
-// Ported from util/format.ts::stripControl: torrent names and other fields
+// string. UTF-8-sequence-aware: a multi-byte character's continuation bytes
+// (0x80-0xBF) are never inspected on their own, only whichever single byte
+// starts a sequence -- otherwise a continuation byte landing in 0x80-0x9F
+// (common; that's most of the range) gets deleted as a "C1 control",
+// corrupting the character. Ported from util/format.ts::stripControl:
+// torrent names and other fields
// pulled from scraped, untrusted network responses get rendered verbatim in
// the TUI, so a hijacked or malicious source must not be able to smuggle an
// OSC/CSI escape sequence (e.g. an OSC 52 clipboard write) to the terminal
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 38784f9..cfd27a0 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -51,7 +51,6 @@ add_library(torlinkc_ui STATIC
ui/filter.cpp
ui/keymap.cpp
ui/logo.cpp
- ui/marquee.cpp
ui/move.cpp
ui/progress_bar.cpp
ui/search_aggregator.cpp
diff --git a/src/ui/marquee.cpp b/src/ui/marquee.cpp
deleted file mode 100644
index 56da8e2..0000000
--- a/src/ui/marquee.cpp
+++ /dev/null
@@ -1,55 +0,0 @@
-#include "torlinkc/ui/marquee.hpp"
-
-#include <algorithm>
-#include <cmath>
-#include <vector>
-
-namespace torlinkc::ui {
-
-namespace {
-
-constexpr float kSpeed = 6.0f; // glyphs per second
-constexpr float kGap = 6.0f; // blank glyphs between loops
-
-std::vector<std::size_t> glyphStarts(const std::string& s) {
- std::vector<std::size_t> starts;
- std::size_t i = 0;
- while (i < s.size()) {
- starts.push_back(i);
- const unsigned char c = static_cast<unsigned char>(s[i]);
- std::size_t len = 1;
- if ((c & 0xE0) == 0xC0) len = 2;
- else if ((c & 0xF0) == 0xE0) len = 3;
- else if ((c & 0xF8) == 0xF0) len = 4;
- i += std::min(len, s.size() - i);
- }
- starts.push_back(s.size());
- return starts;
-}
-
-} // namespace
-
-std::string marqueeWindow(const std::string& text, int maxGlyphs, float clockSeconds) {
- const auto starts = glyphStarts(text);
- const int total = static_cast<int>(starts.size()) - 1;
- if (total <= maxGlyphs) return text;
-
- const int period = total + static_cast<int>(kGap);
- const int offset = static_cast<int>(std::fmod(clockSeconds * kSpeed, static_cast<float>(period)));
-
- std::string out;
- out.reserve(static_cast<std::size_t>(maxGlyphs) * 4);
- for (int col = 0; col < maxGlyphs; ++col) {
- const int idx = (offset + col) % period;
- if (idx < total) {
- const auto start = starts[static_cast<std::size_t>(idx)];
- const auto end = starts[static_cast<std::size_t>(idx) + 1];
- out.append(text, start, end - start);
- } else {
- out += ' ';
- }
- }
- return out;
-}
-
-} // namespace torlinkc::ui
diff --git a/src/ui/search_aggregator.cpp b/src/ui/search_aggregator.cpp
index 6c9ee35..bb82266 100644
--- a/src/ui/search_aggregator.cpp
+++ b/src/ui/search_aggregator.cpp
@@ -143,8 +143,8 @@ void SearchAggregator::flush() {
state_.doneSources = doneCopy;
state_.totalSources = totalCopy;
state_.searching = doneCopy < totalCopy;
- // main.cpp's onResultsChanged hook recomputes visibleResults/resultLabels
- // and owns the "jump focus to the results list the first time this
+ // main.cpp's onResultsChanged hook recomputes visibleResults and owns
+ // the "jump focus to the results list the first time this
// search produces any results" decision -- it knows the container's
// focus-index scheme, which SearchAggregator deliberately doesn't.
if (onResultsChanged) onResultsChanged(hadNoResultsBefore);
diff --git a/src/util/format.cpp b/src/util/format.cpp
index a52b143..c3107f7 100644
--- a/src/util/format.cpp
+++ b/src/util/format.cpp
@@ -22,6 +22,23 @@ std::string formatBytes(double bytes) {
return buf;
}
+std::string formatCount(double n) {
+ if (!std::isfinite(n) || n <= 0) return "0";
+ if (n < 10000) return std::to_string(static_cast<long long>(std::lround(n)));
+ const double k = std::round(n / 1000.0);
+ if (k < 1000) return std::to_string(static_cast<long long>(k)) + "k";
+ const double m = n / 1'000'000.0;
+ char buf[32];
+ if (m < 10) {
+ std::snprintf(buf, sizeof(buf), "%.1f", m);
+ std::string s = buf;
+ if (s.size() >= 2 && s[s.size() - 2] == '.' && s.back() == '0') s.resize(s.size() - 2);
+ return s + "m";
+ }
+ std::snprintf(buf, sizeof(buf), "%.0fm", std::round(m));
+ return buf;
+}
+
std::int64_t parseSize(const std::string& s) {
static const std::map<std::string, double> kUnits = {
{"B", 1}, {"KIB", 1024}, {"MIB", 1024.0 * 1024}, {"GIB", 1024.0 * 1024 * 1024},
@@ -46,9 +63,26 @@ std::int64_t parseSize(const std::string& s) {
std::string stripControl(const std::string& s) {
std::string out;
out.reserve(s.size());
- for (unsigned char c : s) {
- const bool isControl = c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f);
- if (!isControl) out += static_cast<char>(c);
+ std::size_t i = 0;
+ while (i < s.size()) {
+ const unsigned char c = static_cast<unsigned char>(s[i]);
+ std::size_t len = 1;
+ if ((c & 0xE0) == 0xC0) len = 2;
+ else if ((c & 0xF0) == 0xE0) len = 3;
+ else if ((c & 0xF8) == 0xF0) len = 4;
+ len = std::min(len, s.size() - i);
+ if (len > 1) {
+ // A valid multi-byte lead byte is always >= 0xC2, outside every
+ // control range below -- only a lone byte can be one, so a whole
+ // sequence is always kept together, never inspected byte-by-byte
+ // (which would treat a continuation byte in 0x80-0x9F as a C1
+ // control and corrupt the encoding -- the bug this used to have).
+ out.append(s, i, len);
+ } else {
+ const bool isControl = c <= 0x1f || c == 0x7f || (c >= 0x80 && c <= 0x9f);
+ if (!isControl) out += static_cast<char>(c);
+ }
+ i += len;
}
return out;
}