commit f29f4e963558d477eed6c7a1e22b589001522e7a
Author: MrJensK <jens.se@icloud.com>
AuthorDate: Sun Aug 23 10:24:14 2026 +0200
Commit: MrJensK <jens.se@icloud.com>
CommitDate: Sun Aug 23 10:24:14 2026 +0200
Phase 2: minimal interactive FTXUI shell (search, download, live progress)
The riskiest piece of the whole rewrite per the phased plan: three threads
(UI, the libtorrent-backed engine, and search) meeting through one AppState,
with FTXUI having no automatic reactivity to lean on the way React did in
the original.
- ui/app_state.hpp: AppState, a plain struct replacing store.ts + App.tsx's
useState bundle. Mutated only on the UI thread, so it needs no locking of
its own -- see the class comment for the invariant this depends on.
- ui/engine_thread.*: owns the DownloadQueue (and therefore the libtorrent
session) on a dedicated thread. The UI thread only ever reaches it via
post() (a thread-safe command queue); it only ever reaches back via
ScreenInteractive::Post()'d state snapshots. Boots through the same
restore/bootguard/reconcile sequence as Phase 1's core_cli.
- ui/search_runner.*: runs one apibay search at a time on its own thread.
Phase 3 generalizes this to N concurrent per-source threads with real
cancellation (std::stop_token); Phase 2 just needs the single-source case
to prove the Post()-based handoff works.
- ui/spinner.*: a small animation::RequestAnimationFrame/OnAnimation-driven
spinner, proving that mechanism works alongside the Post()-based updates
rather than porting the original's full sheen effect early.
- util/format.{hpp,cpp}: formatBytes and stripControl, factored out of
core_cli's local copy and now shared with the TUI. stripControl matters
here specifically: torrent names are untrusted, scraped network data
rendered directly into the results list, so they're sanitized against
terminal escape injection before display.
Found and fixed two real bugs via manual tmux-driven testing (search real
apibay results, download a real Sintel torrent, verify live progress):
focus never moved to the results list after a search landed, so a 'd'
keypress typed into the still-focused search box instead of downloading
anything; and a Post()'d state-mutating closure doesn't itself wake FTXUI's
render loop -- it needs a paired PostEvent(Event::Custom) to actually
redraw, which isn't obvious from the API alone.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
CMakeLists.txt | 1 +
apps/core_cli/main.cpp | 13 +--
apps/tui/CMakeLists.txt | 2 +
apps/tui/main.cpp | 159 ++++++++++++++++++++++++++++++++++
include/torlinkc/ui/app_state.hpp | 44 ++++++++++
include/torlinkc/ui/engine_thread.hpp | 61 +++++++++++++
include/torlinkc/ui/search_runner.hpp | 38 ++++++++
include/torlinkc/ui/spinner.hpp | 19 ++++
include/torlinkc/util/format.hpp | 23 +++++
src/CMakeLists.txt | 17 ++++
src/ui/engine_thread.cpp | 92 ++++++++++++++++++++
src/ui/search_runner.cpp | 61 +++++++++++++
src/ui/spinner.cpp | 44 ++++++++++
src/util/format.cpp | 31 +++++++
14 files changed, 593 insertions(+), 12 deletions(-)
diff --git a/CMakeLists.txt b/CMakeLists.txt
index c6171f8..e1f32de 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -18,4 +18,5 @@ enable_testing()
add_subdirectory(phase0)
add_subdirectory(src)
add_subdirectory(apps/core_cli)
+add_subdirectory(apps/tui)
add_subdirectory(tests)
diff --git a/apps/core_cli/main.cpp b/apps/core_cli/main.cpp
index b4e7d5a..7712210 100644
--- a/apps/core_cli/main.cpp
+++ b/apps/core_cli/main.cpp
@@ -22,6 +22,7 @@
#include "torlinkc/sources/cache.hpp"
#include "torlinkc/sources/magnet.hpp"
#include "torlinkc/sources/piratebay.hpp"
+#include "torlinkc/util/format.hpp"
using namespace torlinkc;
@@ -30,18 +31,6 @@ namespace {
std::atomic<bool> gStop{false};
void handleSignal(int) { gStop = true; }
-std::string formatBytes(double bytes) {
- static const char* units[] = {"B", "KB", "MB", "GB", "TB"};
- int u = 0;
- while (bytes >= 1024.0 && u < 4) {
- bytes /= 1024.0;
- u++;
- }
- char buf[64];
- std::snprintf(buf, sizeof(buf), "%.1f %s", bytes, units[u]);
- return buf;
-}
-
void printUsage() {
std::cout << "usage: torlinkc_core_cli search <query>\n"
" torlinkc_core_cli run [magnet|infohash] [download-dir]\n";
diff --git a/apps/tui/CMakeLists.txt b/apps/tui/CMakeLists.txt
new file mode 100644
index 0000000..d04ef5d
--- /dev/null
+++ b/apps/tui/CMakeLists.txt
@@ -0,0 +1,2 @@
+add_executable(torlinkc_tui main.cpp)
+target_link_libraries(torlinkc_tui PRIVATE torlinkc_ui)
diff --git a/apps/tui/main.cpp b/apps/tui/main.cpp
new file mode 100644
index 0000000..42b9c57
--- /dev/null
+++ b/apps/tui/main.cpp
@@ -0,0 +1,159 @@
+// Phase 2 deliverable: the smallest usable interactive FTXUI shell -- a
+// search box, a results list, and a downloads list with a live progress
+// bar, against a single source (apibay/tpb-movies), no tabs/sidebar yet.
+// This is where the plan's highest-risk piece gets proven: three threads
+// (UI, the libtorrent-backed engine thread, and a search thread) meeting
+// through one AppState, entirely through ScreenInteractive::Post -- see
+// ui/engine_thread.* and ui/search_runner.*.
+
+#include <csignal>
+#include <string>
+
+#include <ftxui/component/component.hpp>
+#include <ftxui/component/component_options.hpp>
+#include <ftxui/component/screen_interactive.hpp>
+#include <ftxui/dom/elements.hpp>
+
+#include "torlinkc/config/config.hpp"
+#include "torlinkc/engine/queue.hpp"
+#include "torlinkc/sources/types.hpp"
+#include "torlinkc/ui/app_state.hpp"
+#include "torlinkc/ui/engine_thread.hpp"
+#include "torlinkc/ui/search_runner.hpp"
+#include "torlinkc/ui/spinner.hpp"
+#include "torlinkc/util/format.hpp"
+
+using namespace ftxui;
+using namespace torlinkc;
+using namespace torlinkc::ui;
+
+namespace {
+
+// SIGINT/SIGTERM ignored deliberately: ScreenInteractive::Fullscreen()
+// already restores the terminal on a normal return from Loop(), and the
+// only exit path here is the in-app Escape/Ctrl+C-to-quit binding below, not
+// a signal. Phase 1's core_cli handled real signals because it had no UI
+// event loop to bind a quit key to.
+
+std::string downloadsAndSeedsText(const AppState& state) {
+ if (state.items.empty() && state.seeds.empty()) {
+ return "(nothing yet -- select a result and press Enter or 'd' to download)";
+ }
+ return "";
+}
+
+Element renderDownloads(const AppState& state) {
+ Elements rows;
+ for (const auto& it : state.items) {
+ rows.push_back(hbox({
+ text(stripControl(it.name).substr(0, 32)) | size(WIDTH, EQUAL, 34),
+ gauge(static_cast<float>(it.progress) / 100.0f) | flex,
+ text(" " + std::to_string(it.progress) + "% " + formatBytes(it.speed) + "/s peers=" +
+ std::to_string(it.peers)),
+ }));
+ }
+ for (const auto& s : state.seeds) {
+ rows.push_back(text("[seeding] " + stripControl(s.name).substr(0, 40) + " " + formatBytes(s.uploadSpeed) +
+ "/s up") |
+ dim);
+ }
+ const std::string placeholder = downloadsAndSeedsText(state);
+ if (!placeholder.empty()) rows.push_back(text(placeholder) | dim);
+ return vbox(std::move(rows));
+}
+
+} // namespace
+
+int main(int argc, char** argv) {
+ std::signal(SIGPIPE, SIG_IGN);
+
+ Config config = loadConfig();
+
+ ScreenInteractive screen = ScreenInteractive::Fullscreen();
+ AppState state;
+ EngineThread engine(screen, state);
+ SearchRunner searchRunner(screen, state);
+
+ auto downloadSelected = [&] {
+ if (state.selectedResult < 0 || state.selectedResult >= static_cast<int>(state.results.size())) return;
+ const TorrentResult r = state.results[static_cast<std::size_t>(state.selectedResult)];
+ AddInput input;
+ input.id = r.infoHash;
+ input.name = r.name;
+ input.magnet = r.magnet;
+ input.source = r.source;
+ input.sizeBytes = r.sizeBytes;
+ const std::string dir = config.downloadDir;
+ engine.post([input, dir](DownloadQueue& q) { q.add(input, dir); });
+ state.notice = "queued: " + stripControl(r.name);
+ };
+
+ InputOption searchOptions;
+ searchOptions.multiline = false;
+ searchOptions.on_enter = [&] { searchRunner.search(state.query); };
+ Component searchInput = Input(&state.query, "search torrents (Movies, via apibay)...", searchOptions);
+
+ MenuOption menuOptions = MenuOption::Vertical();
+ menuOptions.on_enter = downloadSelected;
+ Component resultsMenu = Menu(&state.resultLabels, &state.selectedResult, menuOptions);
+ Component resultsWithDownloadKey = CatchEvent(resultsMenu, [&](Event event) {
+ if (event == Event::Character('d')) {
+ downloadSelected();
+ return true;
+ }
+ return false;
+ });
+
+ Component spinner = MakeSpinner([&] { return state.searching; });
+
+ Component mainContainer =
+ Container::Vertical({searchInput, resultsWithDownloadKey, spinner}, &state.focusedIndex);
+
+ Component root = Renderer(mainContainer, [&] {
+ Elements resultsSection;
+ if (state.searching) {
+ resultsSection.push_back(hbox({spinner->Render(), text(" searching apibay...")}));
+ } else if (!state.searchError.empty()) {
+ resultsSection.push_back(text("search failed: " + state.searchError) | color(Color::Red));
+ } else if (state.resultLabels.empty()) {
+ resultsSection.push_back(text("no results yet -- type a query and press Enter") | dim);
+ } else {
+ resultsSection.push_back(resultsMenu->Render() | frame | flex);
+ }
+
+ Elements bottom;
+ if (!state.notice.empty()) bottom.push_back(text(state.notice) | dim);
+
+ return vbox({
+ text("torlinkc") | bold,
+ separator(),
+ window(text("Search"), searchInput->Render()),
+ window(text("Results"), vbox(std::move(resultsSection)) | flex) | flex,
+ window(text("Downloads"), renderDownloads(state)),
+ separator(),
+ vbox(std::move(bottom)),
+ text("Enter: search / download selected d: download selected Esc: quit") | dim,
+ }) |
+ flex;
+ });
+
+ // Escape quits from anywhere (checked before the focused child sees the
+ // event), matching the original App.tsx's global-keybinding-first model.
+ // Regular text input (including 'q') is left alone so typing a search
+ // query never accidentally quits the app.
+ root = CatchEvent(root, [&](Event event) {
+ if (event == Event::Escape) {
+ screen.Exit();
+ return true;
+ }
+ return false;
+ });
+
+ engine.start(config);
+ screen.Loop(root);
+ engine.stop();
+
+ (void)argc;
+ (void)argv;
+ return 0;
+}
diff --git a/include/torlinkc/ui/app_state.hpp b/include/torlinkc/ui/app_state.hpp
new file mode 100644
index 0000000..5242f2c
--- /dev/null
+++ b/include/torlinkc/ui/app_state.hpp
@@ -0,0 +1,44 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include "torlinkc/engine/types.hpp"
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc::ui {
+
+// The single source of truth for the TUI, mutated only on the UI thread --
+// either directly inside an FTXUI event handler, or via a closure a
+// background thread posts through ftxui::ScreenInteractive::Post(). Ported
+// conceptually from ui/store.ts, but as a plain struct rather than a
+// React-context-plus-useState bundle, since FTXUI has no equivalent
+// reactivity to hang hooks off of (see the Phase 2 section of the plan).
+// No internal locking: that invariant (UI-thread-only mutation) is what
+// makes locking unnecessary, not something this struct enforces itself.
+struct AppState {
+ // Which child of the top-level Container::Vertical has focus: 0 = search
+ // box, 1 = results list (see main.cpp, which owns that ordering). Tab/
+ // Shift-Tab update this the normal way; SearchRunner also nudges it to 1
+ // when results arrive, so a completed search is immediately navigable
+ // without the user having to Tab there themselves -- otherwise a hotkey
+ // like 'd' typed right after Enter lands in the still-focused search box
+ // as a literal character instead of reaching the results list.
+ int focusedIndex = 0;
+
+ // Search. Phase 2 is one source with no category tabs (see Phase 3).
+ std::string query;
+ std::vector<TorrentResult> results;
+ std::vector<std::string> resultLabels; // parallel to results; what the results Menu renders
+ int selectedResult = 0;
+ bool searching = false;
+ std::string searchError;
+
+ // Downloads / seeds, replaced wholesale each engine-thread tick.
+ std::vector<QueueItem> items;
+ std::vector<SeedItem> seeds;
+
+ std::string notice;
+};
+
+} // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/engine_thread.hpp b/include/torlinkc/ui/engine_thread.hpp
new file mode 100644
index 0000000..f6a4f14
--- /dev/null
+++ b/include/torlinkc/ui/engine_thread.hpp
@@ -0,0 +1,61 @@
+#pragma once
+
+#include <atomic>
+#include <deque>
+#include <functional>
+#include <mutex>
+#include <thread>
+
+#include <ftxui/component/screen_interactive.hpp>
+
+#include "torlinkc/config/config.hpp"
+#include "torlinkc/engine/queue.hpp"
+#include "torlinkc/ui/app_state.hpp"
+
+namespace torlinkc::ui {
+
+// Owns the DownloadQueue -- and therefore the libtorrent session -- on a
+// dedicated background thread, since neither is safe to touch concurrently
+// from the UI thread. The two threads only ever talk in one direction each:
+// the UI thread enqueues a command via post(), and the engine thread replies
+// (if at all) by posting a state snapshot onto FTXUI's own thread-safe queue
+// (ftxui::ScreenInteractive::Post), which runs it on the UI thread. Neither
+// side ever reaches across and touches the other's data directly -- see the
+// "Concurrency / state model" section of the phased plan.
+class EngineThread {
+ public:
+ EngineThread(ftxui::ScreenInteractive& screen, AppState& state);
+ ~EngineThread();
+
+ EngineThread(const EngineThread&) = delete;
+ EngineThread& operator=(const EngineThread&) = delete;
+
+ // Boots the queue (restore, safe-mode/bootguard handling) and starts
+ // ticking. Call once, before screen.Loop().
+ void start(Config config);
+
+ // Signals the loop to stop, waits for it to flush state and exit. Safe to
+ // call from the UI thread after screen.Loop() returns.
+ void stop();
+
+ // Thread-safe: queues `fn` to run against the live DownloadQueue on the
+ // engine thread's next loop iteration. Use this for anything a keypress
+ // wants the queue to do (add/pause/resume/...) -- never call DownloadQueue
+ // methods directly from the UI thread.
+ void post(std::function<void(DownloadQueue&)> fn);
+
+ private:
+ void run(Config config);
+ void publishSnapshot(DownloadQueue& queue);
+
+ ftxui::ScreenInteractive& screen_;
+ AppState& state_;
+
+ std::thread thread_;
+ std::atomic<bool> running_{false};
+
+ std::mutex commandsMutex_;
+ std::deque<std::function<void(DownloadQueue&)>> commands_;
+};
+
+} // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/search_runner.hpp b/include/torlinkc/ui/search_runner.hpp
new file mode 100644
index 0000000..4e49af9
--- /dev/null
+++ b/include/torlinkc/ui/search_runner.hpp
@@ -0,0 +1,38 @@
+#pragma once
+
+#include <string>
+#include <thread>
+
+#include <ftxui/component/screen_interactive.hpp>
+
+#include "torlinkc/sources/cache.hpp"
+#include "torlinkc/ui/app_state.hpp"
+
+namespace torlinkc::ui {
+
+// Runs one search at a time on its own background thread. Phase 2 has a
+// single source, so there is nothing to fan out or coalesce across yet --
+// Phase 3's SearchAggregator generalizes this to N concurrent per-source
+// threads with a coalesced-flush notifier (see the plan). A search already
+// in flight is dropped rather than superseded: with only one caller
+// (pressing Enter) and no cancellation primitive yet (that's Phase 3's
+// std::stop_token work), this is the simplest thing that can't race.
+class SearchRunner {
+ public:
+ SearchRunner(ftxui::ScreenInteractive& screen, AppState& state);
+ ~SearchRunner();
+
+ SearchRunner(const SearchRunner&) = delete;
+ SearchRunner& operator=(const SearchRunner&) = delete;
+
+ // Must be called from the UI thread (reads/writes AppState directly).
+ void search(std::string query);
+
+ private:
+ ftxui::ScreenInteractive& screen_;
+ AppState& state_;
+ SearchCache cache_;
+ std::thread thread_;
+};
+
+} // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/spinner.hpp b/include/torlinkc/ui/spinner.hpp
new file mode 100644
index 0000000..cf4a217
--- /dev/null
+++ b/include/torlinkc/ui/spinner.hpp
@@ -0,0 +1,19 @@
+#pragma once
+
+#include <functional>
+
+#include <ftxui/component/component.hpp>
+
+namespace torlinkc::ui {
+
+// A small braille spinner driven by FTXUI's native animation system
+// (animation::RequestAnimationFrame / ComponentBase::OnAnimation) rather
+// than a manual interval timer -- see the plan's "UI animation ticks"
+// section. It only keeps requesting frames while `isActive()` is true, so it
+// costs nothing once a search finishes. The caller must still call
+// ScreenInteractive::RequestAnimationFrame() once to kick off the first
+// frame when isActive() flips true; after that this component re-requests
+// its own next frame for as long as it's active.
+ftxui::Component MakeSpinner(std::function<bool()> isActive);
+
+} // namespace torlinkc::ui
diff --git a/include/torlinkc/util/format.hpp b/include/torlinkc/util/format.hpp
new file mode 100644
index 0000000..7fb7716
--- /dev/null
+++ b/include/torlinkc/util/format.hpp
@@ -0,0 +1,23 @@
+#pragma once
+
+#include <string>
+
+namespace torlinkc {
+
+// "12.3 MB" / "0 B" style formatting. Ported from util/format.ts::formatBytes
+// (reused here for both byte counts and byte/sec rates, same as the console
+// harness already did).
+std::string formatBytes(double bytes);
+
+// 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
+// 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
+// through them.
+std::string stripControl(const std::string& s);
+
+} // namespace torlinkc
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index fcc67f4..83376eb 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -13,6 +13,7 @@ add_library(torlinkc_core STATIC
sources/magnet.cpp
sources/piratebay.cpp
util/atomic_write.cpp
+ util/format.cpp
util/net.cpp
)
@@ -23,3 +24,19 @@ target_link_libraries(torlinkc_core PUBLIC
CURL::libcurl
nlohmann_json::nlohmann_json
)
+
+# Kept separate from torlinkc_core: the engine/sources/config layer stays
+# UI-agnostic (Phase 1's console harness and Phase 5's daemon modes link
+# torlinkc_core without ever pulling in FTXUI).
+add_library(torlinkc_ui STATIC
+ ui/engine_thread.cpp
+ ui/search_runner.cpp
+ ui/spinner.cpp
+)
+
+target_link_libraries(torlinkc_ui PUBLIC
+ torlinkc_core
+ ftxui::component
+ ftxui::dom
+ ftxui::screen
+)
diff --git a/src/ui/engine_thread.cpp b/src/ui/engine_thread.cpp
new file mode 100644
index 0000000..83aa951
--- /dev/null
+++ b/src/ui/engine_thread.cpp
@@ -0,0 +1,92 @@
+#include "torlinkc/ui/engine_thread.hpp"
+
+#include <chrono>
+
+#include <ftxui/component/event.hpp>
+
+#include "torlinkc/engine/bootguard.hpp"
+#include "torlinkc/engine/persist.hpp"
+#include "torlinkc/engine/reconcile.hpp"
+
+namespace torlinkc::ui {
+
+namespace {
+constexpr auto kTickInterval = std::chrono::milliseconds(500);
+}
+
+EngineThread::EngineThread(ftxui::ScreenInteractive& screen, AppState& state) : screen_(screen), state_(state) {}
+
+EngineThread::~EngineThread() { stop(); }
+
+void EngineThread::start(Config config) {
+ running_ = true;
+ thread_ = std::thread([this, config = std::move(config)]() mutable { run(std::move(config)); });
+}
+
+void EngineThread::stop() {
+ running_ = false;
+ if (thread_.joinable()) thread_.join();
+}
+
+void EngineThread::post(std::function<void(DownloadQueue&)> fn) {
+ std::lock_guard<std::mutex> lock(commandsMutex_);
+ commands_.push_back(std::move(fn));
+}
+
+void EngineThread::run(Config config) {
+ DownloadQueue queue;
+ queue.setTrackers(config.trackers);
+
+ const bool safe = wasBootInterrupted();
+ armBootMarker();
+ queue.restoreHistory(loadHistory());
+ queue.restore(reconcileQueue(loadQueue()), RestoreOptions{safe});
+ queue.restoreSeeds(loadSeeds(), RestoreOptions{safe});
+
+ if (safe) {
+ screen_.Post([this] { state_.notice = "previous run did not shut down cleanly -- restored paused (safe mode)"; });
+ screen_.PostEvent(ftxui::Event::Custom);
+ }
+
+ const auto bootAt = std::chrono::steady_clock::now();
+ bool settled = false;
+
+ while (running_) {
+ std::deque<std::function<void(DownloadQueue&)>> pending;
+ {
+ std::lock_guard<std::mutex> lock(commandsMutex_);
+ std::swap(pending, commands_);
+ }
+ for (auto& fn : pending) fn(queue);
+
+ queue.tick();
+
+ if (!settled && std::chrono::steady_clock::now() - bootAt > std::chrono::milliseconds(kBootSettleMs)) {
+ // The boot survived long enough to be worth trusting.
+ disarmBootMarker();
+ settled = true;
+ }
+
+ publishSnapshot(queue);
+ std::this_thread::sleep_for(kTickInterval);
+ }
+
+ queue.suspend();
+}
+
+void EngineThread::publishSnapshot(DownloadQueue& queue) {
+ auto items = queue.getItems();
+ auto seeds = queue.getSeeds();
+ screen_.Post([this, items = std::move(items), seeds = std::move(seeds)]() mutable {
+ state_.items = std::move(items);
+ state_.seeds = std::move(seeds);
+ });
+ // A bare Post()'d closure updates state but doesn't itself wake FTXUI's
+ // main loop to redraw -- only a real Event does. PostEvent(Event::Custom)
+ // is FTXUI's documented no-op event for exactly this: force a redraw after
+ // a background thread mutates state, without it needing to mean anything
+ // to any component's OnEvent.
+ screen_.PostEvent(ftxui::Event::Custom);
+}
+
+} // namespace torlinkc::ui
diff --git a/src/ui/search_runner.cpp b/src/ui/search_runner.cpp
new file mode 100644
index 0000000..e272efc
--- /dev/null
+++ b/src/ui/search_runner.cpp
@@ -0,0 +1,61 @@
+#include "torlinkc/ui/search_runner.hpp"
+
+#include <ftxui/component/event.hpp>
+
+#include "torlinkc/sources/piratebay.hpp"
+#include "torlinkc/util/format.hpp"
+
+namespace torlinkc::ui {
+
+namespace {
+
+std::string formatResultLabel(const TorrentResult& r) {
+ return stripControl(r.name) + " [" + formatBytes(static_cast<double>(r.sizeBytes)) +
+ ", seeders=" + std::to_string(r.seeders) + "]";
+}
+
+} // namespace
+
+SearchRunner::SearchRunner(ftxui::ScreenInteractive& screen, AppState& state) : screen_(screen), state_(state) {}
+
+SearchRunner::~SearchRunner() {
+ if (thread_.joinable()) thread_.join();
+}
+
+void SearchRunner::search(std::string query) {
+ if (state_.searching) return; // one at a time in Phase 2, see the class comment
+ if (thread_.joinable()) thread_.join(); // the previous search has already finished by now
+
+ state_.searching = true;
+ state_.searchError.clear();
+ state_.query = query;
+ screen_.RequestAnimationFrame(); // kicks off the spinner's OnAnimation loop
+
+ thread_ = std::thread([this, query = std::move(query)]() {
+ std::vector<TorrentResult> results;
+ std::string error;
+ try {
+ results = cache_.cachedSearch(tpbMoviesSource(), query);
+ } catch (const std::exception& e) {
+ error = e.what();
+ }
+
+ screen_.Post([this, results = std::move(results), error = std::move(error)]() mutable {
+ state_.searching = false;
+ state_.searchError = std::move(error);
+ state_.results = std::move(results);
+ state_.resultLabels.clear();
+ state_.resultLabels.reserve(state_.results.size());
+ for (const auto& r : state_.results) state_.resultLabels.push_back(formatResultLabel(r));
+ state_.selectedResult = 0;
+ // Jump focus to the results list (index 1 in main.cpp's container) so
+ // a hotkey pressed right after Enter reaches it, not the search box.
+ if (!state_.results.empty()) state_.focusedIndex = 1;
+ });
+ // See EngineThread::publishSnapshot: a bare Post()'d closure doesn't
+ // itself wake FTXUI's main loop to redraw, only a real Event does.
+ screen_.PostEvent(ftxui::Event::Custom);
+ });
+}
+
+} // namespace torlinkc::ui
diff --git a/src/ui/spinner.cpp b/src/ui/spinner.cpp
new file mode 100644
index 0000000..42d80f3
--- /dev/null
+++ b/src/ui/spinner.cpp
@@ -0,0 +1,44 @@
+#include "torlinkc/ui/spinner.hpp"
+
+#include <ftxui/component/animation.hpp>
+#include <ftxui/component/component_base.hpp>
+#include <ftxui/dom/elements.hpp>
+
+namespace torlinkc::ui {
+
+namespace {
+
+constexpr const char* kFrames[] = {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"};
+constexpr int kFrameCount = 10;
+constexpr float kFrameSeconds = 0.08f;
+
+class SpinnerComponent : public ftxui::ComponentBase {
+ public:
+ explicit SpinnerComponent(std::function<bool()> isActive) : isActive_(std::move(isActive)) {}
+
+ ftxui::Element Render() override {
+ if (!isActive_()) return ftxui::text("");
+ return ftxui::text(kFrames[frame_ % kFrameCount]);
+ }
+
+ void OnAnimation(ftxui::animation::Params& params) override {
+ if (!isActive_()) return;
+ elapsed_ += params.duration().count();
+ if (elapsed_ >= kFrameSeconds) {
+ elapsed_ = 0.0f;
+ frame_ = (frame_ + 1) % kFrameCount;
+ }
+ ftxui::animation::RequestAnimationFrame();
+ }
+
+ private:
+ std::function<bool()> isActive_;
+ int frame_ = 0;
+ float elapsed_ = 0.0f;
+};
+
+} // namespace
+
+ftxui::Component MakeSpinner(std::function<bool()> isActive) { return ftxui::Make<SpinnerComponent>(std::move(isActive)); }
+
+} // namespace torlinkc::ui
diff --git a/src/util/format.cpp b/src/util/format.cpp
new file mode 100644
index 0000000..2cb02a4
--- /dev/null
+++ b/src/util/format.cpp
@@ -0,0 +1,31 @@
+#include "torlinkc/util/format.hpp"
+
+#include <cmath>
+#include <cstdio>
+
+namespace torlinkc {
+
+std::string formatBytes(double bytes) {
+ if (!std::isfinite(bytes) || bytes <= 0) return "0 B";
+ static const char* units[] = {"B", "KB", "MB", "GB", "TB"};
+ int i = 0;
+ while (bytes >= 1024.0 && i < 4) {
+ bytes /= 1024.0;
+ i++;
+ }
+ char buf[64];
+ std::snprintf(buf, sizeof(buf), i == 0 ? "%.0f %s" : "%.2f %s", bytes, units[i]);
+ return buf;
+}
+
+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);
+ }
+ return out;
+}
+
+} // namespace torlinkc