foxygit / Torlinkc Log in
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;
 }