commit 2172d3af484f0281c682d24ed37987857a5653e0
Author: MrJensK <jens.se@icloud.com>
AuthorDate: Sun Aug 23 11:44:09 2026 +0200
Commit: MrJensK <jens.se@icloud.com>
CommitDate: Sun Aug 23 11:44:09 2026 +0200
Phase 4: full navigational/functional TUI parity
Sidebar, interactive Downloads/Seeding lists, the folder and trackers
prompts, a help overlay, and a splash screen -- all 7 sections from the
original are now reachable and functional. Visual fidelity is deliberately
simplified relative to the original's exact Ink box-model layout (dynamic
row budgeting, per-cell logo sheen, animated progress-bar sheen); this phase
prioritized getting every keybinding and every screen wired correctly over
pixel-matching Ink's layout math.
- ui/app_state.hpp: View (Splash/Browser), Section (merges the original's
Category and downloads/seeding into one enum), Region (Sidebar/Content),
Download/SeedFocus for the footer. AppState grows a `history` snapshot
(from queue.getHistory(), now also published by EngineThread) and
sidebarLabels (rebuilt every render frame, same "assign in place so
Menu's binding stays valid" pattern as Phase 3's resultLabels).
- config/folder.*, config/trackers.*: ported from folder.ts/trackers.ts
(tilde expansion, tracker list parse/format/status line).
- util/open_folder.*: xdg-open/gio/open, ported from openFolder.ts (fork+
execvp+waitpid with a timeout, no Node child_process to lean on).
- ui/theme.*, ui/move.*, ui/keymap.*, ui/logo.*: ported from theme.ts,
move.ts, keymap.ts, logo.ts. The color palette lives in a `palette`
namespace, not `color` -- naming it `color` collided with ftxui::color()
once both were pulled in via `using namespace`, breaking overload
resolution on every `| color(...)` in the file.
- apps/tui/main.cpp: sidebar (Menu with live badge counts), interactive
Downloads (pause/cancel/retry/redownload/open-folder) and Seeding
(pause-resume/remove/open-folder) sections with their own cursors, the
folder/trackers prompts, and the help overlay -- all as ordinary children
of the same top-level Container::Vertical, routed to via one focusedIndex
selector kept in sync by syncFocus().
Deferred to later phases (kept out of scope here, not silently dropped):
clipboard paste ('m'), copy-magnet ('y'), export-to-.torrent ('s' in
results/'e' in downloads), the animated progress-bar sheen, and exact
Ink-equivalent layout spacing.
Found and fixed three real bugs via manual tmux-driven testing of every
section, prompt, and transition:
- AppState::focusedIndex defaulted to 0 (the sidebar) instead of the search
box Splash needs, so the very first keystrokes on launch went nowhere
(the sidebar Menu ignores character input).
- Tab-from-Splash switched view without ever triggering a search, leaving
focus on an empty, unpopulated results list -- so the next keystrokes
(e.g. 't' in a query) were swallowed as global shortcuts instead of
reaching a text field or a populated list.
- The original design routed modals (folder/trackers/help) through a
separate Container::Tab selected by a modalIndex. Its close/cancel keys
did nothing: Tab's event routing did not reliably reach a child with no
focusable descendant of its own (help's dismiss handler is a bare
Renderer+CatchEvent). Replaced with plain children of the same
Container::Vertical already proven to route events correctly by index
everywhere else in this file. That surfaced a second bug once modals used
the shared selector: EngineThread's periodic Event::Custom (posted every
~500ms to force a redraw) reached help's "any key closes this" handler
and closed it within one tick of opening, since "any event" included ones
the app posts to itself -- fixed by excluding Event::Custom specifically.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
apps/tui/main.cpp | 703 ++++++++++++++++++++++++++----
include/torlinkc/config/folder.hpp | 15 +
include/torlinkc/config/trackers.hpp | 19 +
include/torlinkc/ui/app_state.hpp | 63 ++-
include/torlinkc/ui/keymap.hpp | 31 ++
include/torlinkc/ui/logo.hpp | 14 +
include/torlinkc/ui/move.hpp | 12 +
include/torlinkc/ui/search_aggregator.hpp | 8 +-
include/torlinkc/ui/theme.hpp | 47 ++
include/torlinkc/util/open_folder.hpp | 13 +
src/CMakeLists.txt | 8 +
src/config/folder.cpp | 31 ++
src/config/trackers.cpp | 81 ++++
src/ui/app_state.cpp | 44 ++
src/ui/engine_thread.cpp | 4 +-
src/ui/keymap.cpp | 75 ++++
src/ui/logo.cpp | 61 +++
src/ui/move.cpp | 18 +
src/ui/search_aggregator.cpp | 10 +-
src/ui/theme.cpp | 24 +
src/util/open_folder.cpp | 64 +++
21 files changed, 1242 insertions(+), 103 deletions(-)
diff --git a/apps/tui/main.cpp b/apps/tui/main.cpp
index 3b0f55a..29982d9 100644
--- a/apps/tui/main.cpp
+++ b/apps/tui/main.cpp
@@ -1,12 +1,18 @@
-// Phase 3 deliverable: full search parity across all 10 sources, via a real
-// SearchAggregator (one std::jthread per source, cancellable, coalesced
-// flush), plus category tabs, the sort cycle, and dead-seeder filtering.
-// Builds on Phase 2's proven three-thread architecture (UI, engine, search)
-// -- this phase is about breadth of sources/filtering, not new concurrency
-// risk.
+// Phase 4 deliverable: full navigational/functional TUI parity with the
+// original Ink app -- a sidebar with all 7 sections (categories +
+// Downloads + Seeding), interactive Downloads/Seeding lists, the folder and
+// trackers prompts, a help overlay, and a splash screen. Visual fidelity is
+// deliberately simplified relative to the original's exact Ink box-model
+// layout math (dynamic row budgeting, per-cell logo sheen, animated
+// progress-bar sheen) -- see the Phase 4 commit message for what's
+// approximated vs. what's dropped to Phase 6.
#include <algorithm>
+#include <array>
+#include <cstdlib>
#include <csignal>
+#include <filesystem>
+#include <iostream>
#include <string>
#include <ftxui/component/component.hpp>
@@ -15,16 +21,23 @@
#include <ftxui/dom/elements.hpp>
#include "torlinkc/config/config.hpp"
+#include "torlinkc/config/folder.hpp"
+#include "torlinkc/config/trackers.hpp"
#include "torlinkc/engine/queue.hpp"
#include "torlinkc/sources/registry.hpp"
#include "torlinkc/sources/types.hpp"
#include "torlinkc/ui/app_state.hpp"
#include "torlinkc/ui/engine_thread.hpp"
#include "torlinkc/ui/filter.hpp"
+#include "torlinkc/ui/keymap.hpp"
+#include "torlinkc/ui/logo.hpp"
+#include "torlinkc/ui/move.hpp"
#include "torlinkc/ui/search_aggregator.hpp"
#include "torlinkc/ui/sort.hpp"
#include "torlinkc/ui/spinner.hpp"
+#include "torlinkc/ui/theme.hpp"
#include "torlinkc/util/format.hpp"
+#include "torlinkc/util/open_folder.hpp"
using namespace ftxui;
using namespace torlinkc;
@@ -32,32 +45,34 @@ using namespace torlinkc::ui;
namespace {
+// Container::Vertical child indices (main.cpp owns this ordering). Modals
+// are children of the same container (not a separate Container::Tab) and
+// routed to via the same focusedIndex selector -- see syncFocus() and the
+// class-less comment above mainContainer's construction for why.
+constexpr int kFocusSidebar = 0;
+constexpr int kFocusSearch = 1;
+constexpr int kFocusResults = 2;
+constexpr int kFocusDownloads = 3;
+constexpr int kFocusSeeding = 4;
+constexpr int kFocusSpinner = 5;
+constexpr int kFocusFolderPrompt = 6;
+constexpr int kFocusTrackersPrompt = 7;
+constexpr int kFocusHelp = 8;
+
+const std::array<Section, 7> kSidebarSections = {
+ Section::All, Section::Games, Section::Movies, Section::TV, Section::Anime, Section::Downloads, Section::Seeding,
+};
+
+std::string homeDir() {
+ const char* h = std::getenv("HOME");
+ 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) + ")";
}
-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);
- }
- if (state.items.empty() && state.seeds.empty()) {
- rows.push_back(text("(nothing yet -- select a result and press Enter or 'd' to download)") | dim);
- }
- return vbox(std::move(rows));
-}
-
std::string sourcesStatusLine(const AppState& state) {
int failed = 0;
std::string firstError;
@@ -77,12 +92,47 @@ std::string sourcesStatusLine(const AppState& state) {
}
std::string filterBarText(const AppState& state) {
- std::string s = "[" + categoryLabel(state.category) + "]";
+ std::string s = "[" + sectionLabel(state.section) + "]";
s += state.hideDead ? " hide-dead:on" : " hide-dead:off";
s += " sort:" + sortLabel(state.sort);
return s;
}
+Element renderFooter(const AppState& state) {
+ const auto hints = footerHints(state.region, state.section, state.downloadFocus, state.seedFocus);
+ Elements parts;
+ for (std::size_t i = 0; i < hints.size(); ++i) {
+ if (i > 0) parts.push_back(text(" ") | dim);
+ parts.push_back(text(hints[i].keys) | color(palette::alt));
+ parts.push_back(text(" " + hints[i].label) | dim);
+ }
+ return hbox(std::move(parts));
+}
+
+Element renderHelpOverlay() {
+ Elements groups;
+ for (const auto& g : helpGroups()) {
+ Elements lines;
+ lines.push_back(text(g.title) | bold | color(palette::accent));
+ for (const auto& h : g.hints) {
+ lines.push_back(hbox({
+ text(h.keys) | color(palette::alt) | size(WIDTH, EQUAL, 22),
+ text(h.label) | dim,
+ }));
+ }
+ groups.push_back(vbox(std::move(lines)));
+ }
+ Elements spaced;
+ for (std::size_t i = 0; i < groups.size(); ++i) {
+ if (i > 0) spaced.push_back(text(""));
+ spaced.push_back(groups[i]);
+ }
+ spaced.push_back(text(""));
+ spaced.push_back(text("Your downloaded files always stay on disk.") | dim);
+ spaced.push_back(text("Press any key to close") | dim);
+ return window(text("Keyboard"), vbox(std::move(spaced)));
+}
+
} // namespace
int main() {
@@ -96,11 +146,56 @@ int main() {
EngineThread engine(screen, state);
SearchAggregator aggregator(screen, state);
+ auto syncFocus = [&] {
+ if (state.showHelp) {
+ state.focusedIndex = kFocusHelp;
+ return;
+ }
+ if (state.editingFolder) {
+ state.focusedIndex = kFocusFolderPrompt;
+ return;
+ }
+ if (state.editingTrackers) {
+ state.focusedIndex = kFocusTrackersPrompt;
+ return;
+ }
+ if (state.view == View::Splash) {
+ state.focusedIndex = kFocusSearch;
+ return;
+ }
+ if (state.region == Region::Sidebar) {
+ state.focusedIndex = kFocusSidebar;
+ return;
+ }
+ switch (state.section) {
+ case Section::Downloads:
+ state.focusedIndex = kFocusDownloads;
+ break;
+ case Section::Seeding:
+ state.focusedIndex = kFocusSeeding;
+ break;
+ default:
+ state.focusedIndex = kFocusResults;
+ break;
+ }
+ };
+
+ // The sidebar Menu's own highlighted row is driven by sidebarCursor, not
+ // section -- anything that changes section from outside the sidebar itself
+ // (e.g. downloadSelected jumping to Downloads) must go through here too, or
+ // the sidebar visibly disagrees with what's on screen.
+ auto setSection = [&](Section s) {
+ state.section = s;
+ const auto it = std::find(kSidebarSections.begin(), kSidebarSections.end(), s);
+ if (it != kSidebarSections.end()) state.sidebarCursor = static_cast<int>(it - kSidebarSections.begin());
+ };
+
auto refreshVisible = [&] {
+ const Category cat = sectionToCategory(state.section).value_or(Category::All);
std::vector<TorrentResult> byCategory;
for (const auto& r : state.results) {
const auto it = sources.find(r.source);
- const bool inCategory = it == sources.end() || sourceInCategory(it->second, state.category);
+ const bool inCategory = it == sources.end() || sourceInCategory(it->second, cat);
if (inCategory) byCategory.push_back(r);
}
auto filtered = filterResults(byCategory, state.hideDead, sources);
@@ -113,13 +208,20 @@ int main() {
state.selectedResult = std::max(0, static_cast<int>(state.resultLabels.size()) - 1);
}
};
- aggregator.onResultsChanged = refreshVisible;
-
- auto cycleCategory = [&](int delta) {
- constexpr int kCategoryCount = 5; // All, Games, Movies, TV, Anime
- const int idx = static_cast<int>(state.category);
- state.category = static_cast<Category>(((idx + delta) % kCategoryCount + kCategoryCount) % kCategoryCount);
+ aggregator.onResultsChanged = [&](bool hadNoResultsBefore) {
refreshVisible();
+ if (hadNoResultsBefore && !state.results.empty() && state.view == View::Browser &&
+ state.region == Region::Content) {
+ state.focusedIndex = kFocusResults;
+ }
+ };
+
+ auto totalDownloadsRows = [&] { return static_cast<int>(state.items.size() + state.history.size()); };
+ auto seedFor = [&](const std::string& id) -> std::optional<SeedItem> {
+ for (const auto& s : state.seeds) {
+ if (s.id == id) return s;
+ }
+ return std::nullopt;
};
auto downloadSelected = [&] {
@@ -134,11 +236,19 @@ int main() {
const std::string dir = config.downloadDir;
engine.post([input, dir](DownloadQueue& q) { q.add(input, dir); });
state.notice = "queued: " + stripControl(r.name);
+ setSection(Section::Downloads);
+ syncFocus();
};
+ // --- Search box + results (shared by Splash and the browsing sections) --
+
InputOption searchOptions;
searchOptions.multiline = false;
- searchOptions.on_enter = [&] { aggregator.search(state.query); };
+ searchOptions.on_enter = [&] {
+ aggregator.search(state.query);
+ state.view = View::Browser;
+ syncFocus();
+ };
Component searchInput = Input(&state.query, "search torrents (all sources)...", searchOptions);
MenuOption menuOptions = MenuOption::Vertical();
@@ -154,81 +264,514 @@ int main() {
refreshVisible();
return true;
}
- if (event == Event::Character('h')) {
+ if (event == Event::Character('z')) {
state.hideDead = !state.hideDead;
refreshVisible();
return true;
}
- if (event == Event::ArrowLeft) {
- cycleCategory(-1);
+ return false;
+ });
+
+ Component spinner = MakeSpinner([&] { return state.searching; });
+
+ // --- Downloads section -----------------------------------------------
+
+ Component downloadsBase = Renderer([&]() -> Element {
+ const bool focused = state.region == Region::Content && state.section == Section::Downloads;
+ const int total = totalDownloadsRows();
+ if (total == 0) {
+ return text("No downloads yet. Find something and press d to grab it.") | dim;
+ }
+ Elements rows;
+ for (std::size_t i = 0; i < state.items.size(); ++i) {
+ const auto& it = state.items[i];
+ const bool here = focused && static_cast<int>(i) == state.downloadsCursor;
+ Color statusColor = palette::accent;
+ std::string statusIcon = icon::down;
+ if (it.status == DownloadStatus::Failed) {
+ statusColor = palette::bad;
+ statusIcon = icon::error;
+ } else if (it.status == DownloadStatus::Paused || it.status == DownloadStatus::Queued) {
+ statusColor = palette::paused;
+ statusIcon = it.status == DownloadStatus::Paused ? icon::pause : icon::pending;
+ }
+ std::string right;
+ if (it.status == DownloadStatus::Downloading) {
+ right = std::to_string(it.progress) + "% " + formatBytes(it.speed) + "/s peers=" + std::to_string(it.peers);
+ } else if (it.status == DownloadStatus::Paused) {
+ right = "paused " + std::to_string(it.progress) + "%";
+ } else if (it.status == DownloadStatus::Queued) {
+ right = "queued";
+ } else {
+ right = it.error.value_or("failed");
+ }
+ Element row1 = hbox({
+ text(here ? icon::pointer : " ") | color(palette::accent),
+ text(" "),
+ text(statusIcon) | color(statusColor),
+ text(" "),
+ text(stripControl(it.name).substr(0, 40)) | (here ? bold : dim) | flex,
+ text(" "),
+ text(right) | (it.status == DownloadStatus::Failed ? color(palette::bad) : dim),
+ });
+ Element row2 = hbox({text(" "), gauge(static_cast<float>(it.progress) / 100.0f) | flex});
+ rows.push_back(vbox({row1, row2}));
+ }
+ if (!state.history.empty()) {
+ rows.push_back(text("Recently downloaded (" + std::to_string(state.history.size()) + ")") | dim);
+ for (std::size_t i = 0; i < state.history.size(); ++i) {
+ const auto& h = state.history[i];
+ const bool here = focused && static_cast<int>(state.items.size() + i) == state.downloadsCursor;
+ rows.push_back(hbox({
+ text(here ? icon::pointer : " ") | color(palette::accent),
+ text(" "),
+ text(icon::done) | color(palette::good),
+ text(" "),
+ text(stripControl(h.name).substr(0, 40)) | (here ? bold : dim) | flex,
+ text(" "),
+ text(h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-") | dim,
+ }));
+ }
+ }
+ return vbox(std::move(rows));
+ });
+
+ Component downloadsComponent = CatchEvent(downloadsBase, [&](Event event) -> bool {
+ if (!(state.region == Region::Content && state.section == Section::Downloads)) return false;
+ const int total = totalDownloadsRows();
+ if (total == 0) return false;
+ const bool inActive = state.downloadsCursor < static_cast<int>(state.items.size());
+
+ if (event == Event::ArrowUp || event == Event::Character('k')) {
+ state.downloadsCursor = wrapStep(state.downloadsCursor, -1, total);
+ return true;
+ }
+ if (event == Event::ArrowDown || event == Event::Character('j')) {
+ state.downloadsCursor = wrapStep(state.downloadsCursor, 1, total);
return true;
}
- if (event == Event::ArrowRight) {
- cycleCategory(1);
+ if (event == Event::Character('f')) {
+ engine.post([](DownloadQueue& q) { q.retryFailed(); });
return true;
}
+ if (event == Event::Character('e')) {
+ const std::string dir = inActive ? state.items[static_cast<std::size_t>(state.downloadsCursor)].dir
+ : state.history[static_cast<std::size_t>(state.downloadsCursor) -
+ state.items.size()]
+ .dir;
+ if (!openFolder(dir)) state.notice = "Couldn't open folder: " + dir;
+ return true;
+ }
+ if (inActive) {
+ const std::string id = state.items[static_cast<std::size_t>(state.downloadsCursor)].id;
+ if (event == Event::Character('c')) {
+ engine.post([id](DownloadQueue& q) { q.cancel(id); });
+ return true;
+ }
+ if (event == Event::Character('p')) {
+ engine.post([id](DownloadQueue& q) { q.togglePause(id); });
+ return true;
+ }
+ } else {
+ const HistoryItem h = state.history[static_cast<std::size_t>(state.downloadsCursor) - state.items.size()];
+ if (event == Event::Character('d') || event == Event::Return) {
+ AddInput input;
+ input.id = h.id;
+ input.name = h.name;
+ input.magnet = h.magnet;
+ input.source = h.source;
+ input.sizeBytes = h.sizeBytes;
+ const std::string dir = config.downloadDir;
+ engine.post([input, dir](DownloadQueue& q) { q.add(input, dir); });
+ state.notice = "Added: " + stripControl(h.name);
+ return true;
+ }
+ if (event == Event::Character('c')) {
+ const std::string id = h.id;
+ engine.post([id](DownloadQueue& q) { q.removeHistory(id); });
+ return true;
+ }
+ }
return false;
});
- Component spinner = MakeSpinner([&] { return state.searching; });
+ // --- Seeding section ----------------------------------------------------
- Component mainContainer =
- Container::Vertical({searchInput, resultsWithKeys, spinner}, &state.focusedIndex);
-
- Component root = Renderer(mainContainer, [&] {
- // Results stream in as sources finish, so the list (once non-empty) is
- // shown *alongside* the in-progress indicator, not hidden behind it --
- // matching useConcurrentSearch.ts's incremental-results behavior rather
- // than an all-or-nothing loading state.
- Elements resultsSection;
- resultsSection.push_back(text(filterBarText(state)) | dim);
- if (state.searching) {
- resultsSection.push_back(hbox({spinner->Render(), text(" " + sourcesStatusLine(state))}));
- } else {
- resultsSection.push_back(text(sourcesStatusLine(state)) | dim);
+ Component seedingBase = Renderer([&]() -> Element {
+ const bool focused = state.region == Region::Content && state.section == Section::Seeding;
+ if (state.history.empty()) {
+ return text("Nothing here yet. Downloads start seeding automatically when they finish.") | dim;
}
- if (state.resultLabels.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);
+ Elements rows;
+ for (std::size_t i = 0; i < state.history.size(); ++i) {
+ const auto& h = state.history[i];
+ const bool here = focused && static_cast<int>(i) == state.seedingCursor;
+ const auto seed = seedFor(h.id);
+ std::string statusText = "ready";
+ Color statusColor = palette::alt;
+ bool dimIt = true;
+ if (seed) {
+ if (seed->status == SeedStatus::Seeding) {
+ statusText = std::string(icon::up) + formatBytes(seed->uploadSpeed) + "/s peers=" +
+ std::to_string(seed->peers);
+ statusColor = palette::good;
+ dimIt = false;
+ } else if (seed->status == SeedStatus::Paused) {
+ statusText = "paused";
+ } else {
+ statusText = "file gone";
+ statusColor = palette::warn;
+ dimIt = false;
+ }
+ }
+ Element statusEl = text(statusText) | color(statusColor);
+ if (dimIt) statusEl = statusEl | dim;
+ rows.push_back(hbox({
+ text(here ? icon::pointer : " ") | color(palette::accent),
+ text(" "),
+ text(stripControl(h.name).substr(0, 40)) | (here ? bold : dim) | flex,
+ text(" "),
+ text(h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-") | dim,
+ text(" "),
+ statusEl,
+ }));
+ }
+ return vbox(std::move(rows));
+ });
+
+ Component seedingComponent = CatchEvent(seedingBase, [&](Event event) -> bool {
+ if (!(state.region == Region::Content && state.section == Section::Seeding)) return false;
+ const int total = static_cast<int>(state.history.size());
+ if (total == 0) return false;
+
+ if (event == Event::ArrowUp || event == Event::Character('k')) {
+ state.seedingCursor = wrapStep(state.seedingCursor, -1, total);
+ return true;
+ }
+ if (event == Event::ArrowDown || event == Event::Character('j')) {
+ state.seedingCursor = wrapStep(state.seedingCursor, 1, total);
+ return true;
+ }
+ const HistoryItem h = state.history[static_cast<std::size_t>(state.seedingCursor)];
+ if (event == Event::Character('p')) {
+ engine.post([h](DownloadQueue& q) { q.toggleSeeding(h); });
+ return true;
+ }
+ if (event == Event::Character('c')) {
+ const std::string id = h.id;
+ engine.post([id](DownloadQueue& q) { q.removeHistory(id); });
+ return true;
+ }
+ if (event == Event::Character('e')) {
+ if (!openFolder(h.dir)) state.notice = "Couldn't open folder: " + h.dir;
+ return true;
+ }
+ return false;
+ });
+
+ // --- Modal overlays: folder prompt, trackers prompt, help --------------
+ //
+ // These become ordinary children of mainContainer (added after it's
+ // constructed, below), routed to via the same focusedIndex selector as
+ // everything else -- not a separate Container::Tab. An earlier version
+ // used Tab for this and the close/cancel keys silently did nothing: Tab's
+ // event routing did not reliably reach a child with no focusable
+ // descendant of its own (a bare Renderer+CatchEvent, as help's dismiss
+ // handler is). Container::Vertical with an explicit selector is the
+ // mechanism already proven to route events correctly by index throughout
+ // this file, so the modals reuse it instead of a second, less-well-
+ // understood primitive. Declared here (before mainRenderer) since
+ // mainRenderer's Element needs to call their ->Render() directly when a
+ // modal is showing.
+
+ InputOption folderOptions;
+ folderOptions.multiline = false;
+ folderOptions.on_enter = [&] {
+ state.editingFolder = false;
+ syncFocus();
+ const std::string dir = normalizeDownloadDir(state.folderPromptText, homeDir());
+ if (dir.empty() || dir == config.downloadDir) return;
+ std::error_code ec;
+ std::filesystem::create_directories(dir, ec);
+ if (ec) {
+ state.notice = "Couldn't use folder: " + dir;
+ return;
+ }
+ config.downloadDir = dir;
+ saveConfig(config);
+ state.notice = "Download folder: " + dir;
+ };
+ Component folderInput = Input(&state.folderPromptText, "~/Downloads/torlink", folderOptions);
+ folderInput = CatchEvent(folderInput, [&](Event event) {
+ if (event == Event::Escape) {
+ state.editingFolder = false;
+ syncFocus();
+ return true;
+ }
+ return false;
+ });
+
+ InputOption trackersOptions;
+ trackersOptions.multiline = false;
+ trackersOptions.on_enter = [&] {
+ state.editingTrackers = false;
+ syncFocus();
+ const auto list = parseTrackers(state.trackersPromptText);
+ config.trackers = list;
+ saveConfig(config);
+ engine.post([list](DownloadQueue& q) { q.setTrackers(list); });
+ state.notice = list.empty() ? "Cleared extra trackers."
+ : "Saved " + std::to_string(list.size()) + " tracker(s).";
+ };
+ Component trackersInput =
+ Input(&state.trackersPromptText, "udp://tracker.example:1337/announce, https://...", trackersOptions);
+ trackersInput = CatchEvent(trackersInput, [&](Event event) {
+ if (event == Event::Escape) {
+ state.editingTrackers = false;
+ syncFocus();
+ return true;
+ }
+ return false;
+ });
+
+ // No visible content of its own -- the help overlay is rendered inline by
+ // mainRenderer when state.showHelp is set. This just needs to be a focus
+ // target that swallows the next keypress to close it.
+ Component helpDismiss = Renderer([] { return text(""); });
+ helpDismiss = CatchEvent(helpDismiss, [&](Event event) {
+ // Event::Custom is EngineThread's periodic redraw signal (posted every
+ // engine tick, ~500ms), not a real keypress -- without excluding it,
+ // help closes itself within one tick of opening, since "any event"
+ // otherwise includes synthetic ones the app posts to itself.
+ if (event == Event::Custom) return false;
+ state.showHelp = false;
+ syncFocus();
+ return true;
+ });
+
+ // --- Sidebar --------------------------------------------------------
+
+ MenuOption sidebarOptions = MenuOption::Vertical();
+ sidebarOptions.on_change = [&] {
+ if (state.sidebarCursor < 0 || state.sidebarCursor >= static_cast<int>(kSidebarSections.size())) return;
+ state.section = kSidebarSections[static_cast<std::size_t>(state.sidebarCursor)];
+ refreshVisible();
+ };
+ sidebarOptions.on_enter = [&] {
+ state.region = Region::Content;
+ syncFocus();
+ };
+ Component sidebarMenu = Menu(&state.sidebarLabels, &state.sidebarCursor, sidebarOptions);
+
+ // --- Top-level composition -------------------------------------------
+
+ Component mainContainer = Container::Vertical(
+ {sidebarMenu, searchInput, resultsWithKeys, downloadsComponent, seedingComponent, spinner},
+ &state.focusedIndex);
+
+ Component mainRenderer = Renderer(mainContainer, [&] {
+ int activeCount = 0;
+ for (const auto& it : state.items) {
+ if (it.status == DownloadStatus::Downloading) activeCount++;
+ }
+ int seedingCount = 0;
+ for (const auto& s : state.seeds) {
+ if (s.status == SeedStatus::Seeding) seedingCount++;
+ }
+ state.sidebarLabels = {
+ "All",
+ "Games",
+ "Movies",
+ "TV",
+ "Anime",
+ activeCount > 0 ? "Downloads (" + std::to_string(activeCount) + ")" : "Downloads",
+ seedingCount > 0 ? "Seeding (" + std::to_string(seedingCount) + ")" : "Seeding",
+ };
+
+ // Sets the terminal tab title, mirroring TabTitle.tsx; a side-effect
+ // write interleaved with FTXUI's own frame output, same as the original
+ // did inside Ink's render cycle.
+ std::cout << "\x1b]0;torlinkc" << (activeCount > 0 ? " (" + std::to_string(activeCount) + ")" : "") << "\x07";
+
+ if (state.showHelp) return renderHelpOverlay();
+ if (state.editingFolder) {
+ return vbox({
+ renderLogo(),
+ separator(),
+ window(text("default download folder"), folderInput->Render()),
+ text("enter: save esc: cancel") | dim,
+ });
+ }
+ if (state.editingTrackers) {
+ return vbox({
+ renderLogo(),
+ separator(),
+ window(text("extra trackers"), vbox({
+ text(trackersStatus(config.trackers, state.trackersPromptText)) | dim,
+ trackersInput->Render(),
+ })),
+ text("enter: save esc: cancel") | dim,
+ });
+ }
+
+ if (state.view == View::Splash) {
+ const std::string categories = "games " + std::string(icon::dot) + " movies " + icon::dot + " tv " +
+ icon::dot + " anime";
+ return vbox({
+ filler(),
+ renderLogo() | center,
+ text("") | center,
+ text("A curated, terminal-native torrent downloader.") | color(palette::text) | center,
+ text(categories) | dim | center,
+ text("") | center,
+ searchInput->Render() | size(WIDTH, EQUAL, 56) | center,
+ text("") | center,
+ hbox({text("enter") | color(palette::alt), text(" search ") | dim, text("tab") | color(palette::alt),
+ text(" browse ") | dim, text("esc") | color(palette::alt), text(" quit") | dim}) |
+ center,
+ filler(),
+ }) |
+ flex;
+ }
+
+ Element content;
+ if (state.section == Section::Downloads) {
+ content = window(text("Downloads"), downloadsComponent->Render());
+ } else if (state.section == Section::Seeding) {
+ content = window(text("Seeding"), seedingComponent->Render());
} else {
- resultsSection.push_back(resultsMenu->Render() | frame | flex);
+ Elements resultsSection;
+ resultsSection.push_back(text(filterBarText(state)) | dim);
+ if (state.searching) {
+ resultsSection.push_back(hbox({spinner->Render(), text(" " + sourcesStatusLine(state))}));
+ } else {
+ resultsSection.push_back(text(sourcesStatusLine(state)) | dim);
+ }
+ if (state.resultLabels.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);
+ }
+ content = vbox({
+ window(text("Search"), searchInput->Render()),
+ window(text(sectionLabel(state.section) + " results"), vbox(std::move(resultsSection)) | flex) | flex,
+ });
}
- Elements bottom;
- if (!state.notice.empty()) bottom.push_back(text(state.notice) | dim);
+ Elements top = {renderLogo()};
+ if (!state.notice.empty()) top.push_back(filler());
+ if (!state.notice.empty()) top.push_back(text(state.notice) | color(palette::good));
return vbox({
- text("torlinkc") | bold,
+ hbox(std::move(top)),
separator(),
- window(text("Search"), searchInput->Render()),
- window(text("Results"), vbox(std::move(resultsSection)) | flex) | flex,
- window(text("Downloads"), renderDownloads(state)),
+ hbox({
+ sidebarMenu->Render() | size(WIDTH, EQUAL, 16),
+ separator(),
+ content | flex,
+ }) | flex,
separator(),
- vbox(std::move(bottom)),
- text("Enter: search/download d: download s: sort h: hide dead "
- "←/→: category Esc: quit") |
- dim,
+ renderFooter(state),
}) |
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'/'s'/'h'/'d', while the search box has
- // focus) is left alone so typing a query never accidentally triggers a
- // results-list shortcut.
- root = CatchEvent(root, [&](Event event) {
+ Component mainWithGlobalKeys = CatchEvent(mainRenderer, [&](Event event) -> bool {
+ // The prompt/help component at focusedIndex owns all input while shown
+ // (matching the original's `if (editingFolder || editingTrackers ||
+ // pendingDownload) return;` at the very top of its global handler) --
+ // otherwise, e.g., this handler's own Escape case would fire before the
+ // folder prompt's Input ever saw the key press meant to cancel it.
+ if (state.showHelp || state.editingFolder || state.editingTrackers) return false;
+
+ const bool textEditing = state.focusedIndex == kFocusSearch;
+
+ if (!textEditing) {
+ if (event == Event::Character('q')) {
+ screen.Exit();
+ return true;
+ }
+ if (event == Event::Character('?')) {
+ state.showHelp = true;
+ syncFocus();
+ return true;
+ }
+ if (event == Event::Character('o')) {
+ state.folderPromptText = config.downloadDir;
+ state.editingFolder = true;
+ syncFocus();
+ return true;
+ }
+ if (event == Event::Character('t')) {
+ state.trackersPromptText = formatTrackers(config.trackers);
+ state.editingTrackers = true;
+ syncFocus();
+ return true;
+ }
+ }
+
+ if (event == Event::Tab) {
+ if (state.view == View::Splash) {
+ // Matches the original's Splash "tab browse" hint: give up on typing
+ // a query and browse everything, the same as pressing Enter on an
+ // empty search box would. Without this, Tab would leave focus on an
+ // empty, unpopulated results list, and further keystrokes would be
+ // silently swallowed as global shortcuts instead of reaching a text
+ // field or a populated list.
+ aggregator.search(state.query);
+ state.view = View::Browser;
+ } else {
+ state.region = state.region == Region::Sidebar ? Region::Content : Region::Sidebar;
+ }
+ syncFocus();
+ return true;
+ }
+ if ((event == Event::ArrowRight || (!textEditing && event == Event::Character('l'))) &&
+ state.view == View::Browser && state.region == Region::Sidebar) {
+ state.region = Region::Content;
+ syncFocus();
+ return true;
+ }
+ if ((event == Event::ArrowLeft || (!textEditing && event == Event::Character('h'))) &&
+ state.view == View::Browser && state.region == Region::Content) {
+ state.region = Region::Sidebar;
+ syncFocus();
+ return true;
+ }
if (event == Event::Escape) {
- screen.Exit();
+ if (state.view == View::Splash) {
+ screen.Exit();
+ return true;
+ }
+ if (state.region == Region::Content) {
+ state.region = Region::Sidebar;
+ syncFocus();
+ return true;
+ }
+ state.view = View::Splash;
+ syncFocus();
return true;
}
return false;
});
+ // Modal overlays (folder prompt, trackers prompt, help) are ordinary
+ // children of mainContainer, added below -- see the comment where they're
+ // defined for why not a separate Container::Tab.
+ mainContainer->Add(folderInput);
+ mainContainer->Add(trackersInput);
+ mainContainer->Add(helpDismiss);
+
+ // AppState::focusedIndex defaults to 0 (the sidebar), not the search box
+ // Splash needs -- without this, the first keystrokes on launch go nowhere
+ // useful (the sidebar Menu ignores character input).
+ syncFocus();
+
engine.start(config);
- screen.Loop(root);
+ screen.Loop(mainWithGlobalKeys);
engine.stop();
return 0;
diff --git a/include/torlinkc/config/folder.hpp b/include/torlinkc/config/folder.hpp
new file mode 100644
index 0000000..f78d252
--- /dev/null
+++ b/include/torlinkc/config/folder.hpp
@@ -0,0 +1,15 @@
+#pragma once
+
+#include <string>
+
+namespace torlinkc {
+
+// Expands a leading ~ (and ~\, for paths pasted from Windows) against `home`.
+// ~bob isn't us, so it's left alone. Ported from config/folder.ts.
+std::string expandHome(const std::string& input, const std::string& home);
+
+// Typed input -> a path suitable for creating a directory. Blank returns ""
+// (caller: leave it be).
+std::string normalizeDownloadDir(const std::string& input, const std::string& home);
+
+} // namespace torlinkc
diff --git a/include/torlinkc/config/trackers.hpp b/include/torlinkc/config/trackers.hpp
new file mode 100644
index 0000000..de32fef
--- /dev/null
+++ b/include/torlinkc/config/trackers.hpp
@@ -0,0 +1,19 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+namespace torlinkc {
+
+// Splits on commas/whitespace, validates scheme (udp/http(s)/ws(s)), dedupes.
+// Ported from config/trackers.ts.
+std::vector<std::string> parseTrackers(const std::string& input);
+
+std::string formatTrackers(const std::vector<std::string>& trackers);
+
+// One dim status line for the trackers prompt: what is saved now, what the
+// current field text will save, and how many typed tokens get dropped (bad
+// scheme or duplicate).
+std::string trackersStatus(const std::vector<std::string>& saved, const std::string& fieldText);
+
+} // namespace torlinkc
diff --git a/include/torlinkc/ui/app_state.hpp b/include/torlinkc/ui/app_state.hpp
index 91c41b2..5e55be1 100644
--- a/include/torlinkc/ui/app_state.hpp
+++ b/include/torlinkc/ui/app_state.hpp
@@ -5,6 +5,7 @@
#include <unordered_map>
#include <vector>
+#include "torlinkc/engine/history.hpp"
#include "torlinkc/engine/types.hpp"
#include "torlinkc/sources/registry.hpp"
#include "torlinkc/sources/types.hpp"
@@ -21,6 +22,23 @@ struct SourceState {
int count = 0;
};
+// Ported from store.ts.
+enum class View { Splash, Browser };
+
+// Merges store.ts's Category ("all"/"games"/"movies"/"tv"/"anime") with its
+// Section ("downloads"/"seeding") into one enum, since the C++ port has no
+// separate synced category+section pair to keep in step.
+enum class Section { All, Games, Movies, TV, Anime, Downloads, Seeding };
+
+enum class Region { Sidebar, Content };
+
+enum class DownloadFocus { Downloading, Paused, Failed, Recent };
+enum class SeedFocus { Seeding, Paused, Missing, Idle };
+
+// nullopt = the given category doesn't filter sources (Downloads/Seeding).
+std::optional<Category> sectionToCategory(Section s);
+std::string sectionLabel(Section s);
+
// 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
@@ -30,29 +48,45 @@ struct SourceState {
// 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; SearchAggregator also nudges it to
- // 1 when results first 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.
+ // Navigation (ported from store.ts's view/section/region/*Focus).
+ View view = View::Splash;
+ Section section = Section::All;
+ Region region = Region::Content;
+ std::optional<DownloadFocus> downloadFocus;
+ std::optional<SeedFocus> seedFocus;
+
+ // Index of the currently focused child in main.cpp's top-level
+ // Container::Vertical -- see kFocus* constants in main.cpp. Kept in sync
+ // with view/section/region by main.cpp's syncFocus(), which also runs
+ // whenever SearchAggregator lands the first results of a search (so a
+ // hotkey pressed right after Enter reaches the results list, not the
+ // still-focused search box).
int focusedIndex = 0;
+ int sidebarCursor = 0;
+ int downloadsCursor = 0;
+ int seedingCursor = 0;
+ std::vector<std::string> sidebarLabels; // rebuilt each render; badges depend on live counts
+
+ bool showHelp = false;
+ bool editingFolder = false;
+ bool editingTrackers = false;
+ std::string folderPromptText;
+ std::string trackersPromptText;
// Search, across all sources (Phase 3's SearchAggregator).
std::string query;
- std::vector<TorrentResult> results; // deduped, default-ordered, unfiltered -- SearchAggregator's output
+ std::vector<TorrentResult> results; // deduped, default-ordered, unfiltered -- SearchAggregator's output
std::unordered_map<std::string, SourceState> perSource;
int doneSources = 0;
int totalSources = 0;
bool searching = false;
- // Display: category/hideDead/sort applied to `results`, recomputed into
- // these by main.cpp's refreshVisible() whenever `results` or any of the
- // three change. Kept as persistent AppState fields (not computed fresh
- // inside Render()) because FTXUI's Menu binds to resultLabels by address
- // at construction time -- see the class comment on that invariant.
- Category category = Category::All;
+ // Display: section-as-category/hideDead/sort applied to `results`,
+ // recomputed into these by main.cpp's refreshVisible() whenever `results`
+ // or any of the three change. Kept as persistent AppState fields (not
+ // computed fresh inside Render()) because FTXUI's Menu binds to
+ // resultLabels by address at construction time -- see the class comment on
+ // that invariant.
bool hideDead = false;
Sort sort;
std::vector<TorrentResult> visibleResults;
@@ -61,6 +95,7 @@ struct AppState {
// Downloads / seeds, replaced wholesale each engine-thread tick.
std::vector<QueueItem> items;
+ std::vector<HistoryItem> history;
std::vector<SeedItem> seeds;
std::string notice;
diff --git a/include/torlinkc/ui/keymap.hpp b/include/torlinkc/ui/keymap.hpp
new file mode 100644
index 0000000..0915868
--- /dev/null
+++ b/include/torlinkc/ui/keymap.hpp
@@ -0,0 +1,31 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "torlinkc/ui/app_state.hpp"
+
+namespace torlinkc::ui {
+
+struct Hint {
+ std::string keys;
+ std::string label;
+};
+
+struct HelpGroup {
+ std::string title;
+ std::vector<Hint> hints;
+};
+
+// Ported from ui/keymap.ts::HELP_GROUPS, trimmed to the keys this port
+// actually implements (no result-detail view, no clipboard/export actions --
+// see the Phase 4 commit message for what's deferred).
+const std::vector<HelpGroup>& helpGroups();
+
+// The terse contextual hint row along the bottom, ported from
+// ui/keymap.ts::footerHints.
+std::vector<Hint> footerHints(Region region, Section section, std::optional<DownloadFocus> downloadFocus,
+ std::optional<SeedFocus> seedFocus);
+
+} // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/logo.hpp b/include/torlinkc/ui/logo.hpp
new file mode 100644
index 0000000..05311c1
--- /dev/null
+++ b/include/torlinkc/ui/logo.hpp
@@ -0,0 +1,14 @@
+#pragma once
+
+#include <ftxui/dom/elements.hpp>
+
+namespace torlinkc::ui {
+
+inline constexpr int kLogoWidth = 27;
+
+// The torlinkc wordmark, rendered with a horizontal accent->bright gradient.
+// Ported from ui/logo.ts + ui/components/Logo.tsx (per-cell sheen simplified
+// to a static gradient -- see the Phase 4 commit message).
+ftxui::Element renderLogo();
+
+} // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/move.hpp b/include/torlinkc/ui/move.hpp
new file mode 100644
index 0000000..15aece2
--- /dev/null
+++ b/include/torlinkc/ui/move.hpp
@@ -0,0 +1,12 @@
+#pragma once
+
+namespace torlinkc::ui {
+
+// Modulo-wraparound stepping for cursor movement. Ported from ui/move.ts.
+int wrapStep(int current, int delta, int length);
+
+// Scrolling-viewport start index that keeps `cursor` centered, clamped to
+// bounds. Ported from ui/move.ts.
+int windowStart(int cursor, int total, int height);
+
+} // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/search_aggregator.hpp b/include/torlinkc/ui/search_aggregator.hpp
index d619a64..e92c62f 100644
--- a/include/torlinkc/ui/search_aggregator.hpp
+++ b/include/torlinkc/ui/search_aggregator.hpp
@@ -43,9 +43,11 @@ class SearchAggregator {
// 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), which SearchAggregator
- // itself has no opinion on.
- std::function<void()> onResultsChanged;
+ // 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.
+ std::function<void(bool hadNoResultsBefore)> onResultsChanged;
private:
void runSource(const Source& source, const std::string& query, std::stop_token stopToken, int generation);
diff --git a/include/torlinkc/ui/theme.hpp b/include/torlinkc/ui/theme.hpp
new file mode 100644
index 0000000..84e19b7
--- /dev/null
+++ b/include/torlinkc/ui/theme.hpp
@@ -0,0 +1,47 @@
+#pragma once
+
+#include <string>
+
+#include <ftxui/screen/color.hpp>
+
+namespace torlinkc::ui {
+
+// Ported from ui/theme.ts. FTXUI Colors instead of hex strings, but the same
+// palette.
+namespace palette {
+inline const ftxui::Color accent = ftxui::Color::RGB(167, 139, 250);
+inline const ftxui::Color text = ftxui::Color::RGB(233, 228, 245);
+inline const ftxui::Color alt = ftxui::Color::RGB(185, 167, 230);
+inline const ftxui::Color good = ftxui::Color::RGB(134, 214, 162);
+inline const ftxui::Color warn = ftxui::Color::RGB(240, 197, 96);
+inline const ftxui::Color bad = ftxui::Color::RGB(238, 125, 146);
+inline const ftxui::Color bright = ftxui::Color::RGB(216, 180, 254);
+inline const ftxui::Color rule = ftxui::Color::RGB(107, 101, 119);
+inline const ftxui::Color paused = ftxui::Color::RGB(124, 119, 133);
+} // namespace palette
+
+namespace icon {
+inline constexpr const char* done = "✓";
+inline constexpr const char* error = "✗";
+inline constexpr const char* pending = "·";
+inline constexpr const char* pointer = "❯";
+inline constexpr const char* dot = "·";
+inline constexpr const char* warn = "⚠";
+inline constexpr const char* bar = "▌";
+inline constexpr const char* down = "↓";
+inline constexpr const char* up = "↑";
+inline constexpr const char* peer = "•";
+inline constexpr const char* pause = "⏸";
+} // namespace icon
+
+struct SourceStyle {
+ std::string tag;
+ ftxui::Color color;
+};
+
+// Tolerant lookup: falls back to a neutral tag rather than crashing on an
+// absent/unknown source id (a pasted magnet, or a removed source persisted
+// in old history).
+SourceStyle sourceStyle(const std::string& id);
+
+} // namespace torlinkc::ui
diff --git a/include/torlinkc/util/open_folder.hpp b/include/torlinkc/util/open_folder.hpp
new file mode 100644
index 0000000..a08ad4a
--- /dev/null
+++ b/include/torlinkc/util/open_folder.hpp
@@ -0,0 +1,13 @@
+#pragma once
+
+#include <string>
+
+namespace torlinkc {
+
+// Opens `dir` in the platform file manager (xdg-open/gio on Linux, open on
+// macOS). Never throws; false means the caller should tell the user it
+// didn't work. Ported from util/openFolder.ts (Windows path dropped per the
+// Linux/macOS-first scope decision).
+bool openFolder(const std::string& dir);
+
+} // namespace torlinkc
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index a4098fe..e59f7de 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -1,6 +1,8 @@
add_library(torlinkc_core STATIC
config/config.cpp
+ config/folder.cpp
config/paths.cpp
+ config/trackers.cpp
engine/bootguard.cpp
engine/delete_data.cpp
engine/history.cpp
@@ -25,6 +27,7 @@ add_library(torlinkc_core STATIC
util/date_parse.cpp
util/format.cpp
util/net.cpp
+ util/open_folder.cpp
util/url_encode.cpp
)
@@ -40,12 +43,17 @@ target_link_libraries(torlinkc_core PUBLIC
# 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/app_state.cpp
ui/coalescing_notifier.cpp
ui/engine_thread.cpp
ui/filter.cpp
+ ui/keymap.cpp
+ ui/logo.cpp
+ ui/move.cpp
ui/search_aggregator.cpp
ui/sort.cpp
ui/spinner.cpp
+ ui/theme.cpp
)
target_link_libraries(torlinkc_ui PUBLIC
diff --git a/src/config/folder.cpp b/src/config/folder.cpp
new file mode 100644
index 0000000..ad84dd2
--- /dev/null
+++ b/src/config/folder.cpp
@@ -0,0 +1,31 @@
+#include "torlinkc/config/folder.hpp"
+
+#include <filesystem>
+
+namespace torlinkc {
+
+namespace {
+std::string trim(const std::string& s) {
+ auto first = s.find_first_not_of(" \t\r\n");
+ if (first == std::string::npos) return "";
+ auto last = s.find_last_not_of(" \t\r\n");
+ return s.substr(first, last - first + 1);
+}
+} // namespace
+
+std::string expandHome(const std::string& input, const std::string& home) {
+ const std::string trimmed = trim(input);
+ if (trimmed == "~") return home;
+ if (trimmed.rfind("~/", 0) == 0 || trimmed.rfind("~\\", 0) == 0) {
+ return (std::filesystem::path(home) / trimmed.substr(2)).string();
+ }
+ return trimmed;
+}
+
+std::string normalizeDownloadDir(const std::string& input, const std::string& home) {
+ const std::string expanded = expandHome(input, home);
+ if (expanded.empty()) return "";
+ return std::filesystem::path(expanded).lexically_normal().string();
+}
+
+} // namespace torlinkc
diff --git a/src/config/trackers.cpp b/src/config/trackers.cpp
new file mode 100644
index 0000000..e8bcef5
--- /dev/null
+++ b/src/config/trackers.cpp
@@ -0,0 +1,81 @@
+#include "torlinkc/config/trackers.hpp"
+
+#include <regex>
+#include <unordered_set>
+
+namespace torlinkc {
+
+namespace {
+
+std::string trim(const std::string& s) {
+ auto first = s.find_first_not_of(" \t\r\n");
+ if (first == std::string::npos) return "";
+ auto last = s.find_last_not_of(" \t\r\n");
+ return s.substr(first, last - first + 1);
+}
+
+std::vector<std::string> splitOnCommaOrWhitespace(const std::string& s) {
+ std::vector<std::string> out;
+ std::string cur;
+ for (char c : s) {
+ if (c == ',' || std::isspace(static_cast<unsigned char>(c))) {
+ if (!cur.empty()) out.push_back(cur);
+ cur.clear();
+ } else {
+ cur += c;
+ }
+ }
+ if (!cur.empty()) out.push_back(cur);
+ return out;
+}
+
+bool hasValidScheme(const std::string& url) {
+ static const std::regex kScheme(R"(^(udp|https?|wss?)://)", std::regex::icase);
+ return std::regex_search(url, kScheme);
+}
+
+} // namespace
+
+std::vector<std::string> parseTrackers(const std::string& input) {
+ std::unordered_set<std::string> seen;
+ std::vector<std::string> out;
+ for (const auto& raw : splitOnCommaOrWhitespace(input)) {
+ const std::string url = trim(raw);
+ if (url.empty() || !hasValidScheme(url)) continue;
+ if (!seen.insert(url).second) continue;
+ out.push_back(url);
+ }
+ return out;
+}
+
+std::string formatTrackers(const std::vector<std::string>& trackers) {
+ std::string out;
+ for (std::size_t i = 0; i < trackers.size(); ++i) {
+ if (i) out += ", ";
+ out += trackers[i];
+ }
+ return out;
+}
+
+std::string trackersStatus(const std::vector<std::string>& saved, const std::string& fieldText) {
+ const auto next = parseTrackers(fieldText);
+ std::size_t tokenCount = 0;
+ for (const auto& t : splitOnCommaOrWhitespace(fieldText)) {
+ if (!trim(t).empty()) tokenCount++;
+ }
+ const std::size_t ignored = tokenCount - next.size();
+ const std::string savedLabel = saved.empty() ? "none saved" : std::to_string(saved.size()) + " saved";
+
+ const bool unchanged = ignored == 0 && next.size() == saved.size() && next == saved;
+ if (unchanged) {
+ return saved.empty() ? "none saved · comma or space separated"
+ : savedLabel + " · comma or space separated · empty clears";
+ }
+ if (tokenCount == 0) return savedLabel + " → empty clears all";
+
+ std::string line = savedLabel + " → will save " + std::to_string(next.size());
+ if (ignored > 0) line += " · " + std::to_string(ignored) + " ignored";
+ return line;
+}
+
+} // namespace torlinkc
diff --git a/src/ui/app_state.cpp b/src/ui/app_state.cpp
new file mode 100644
index 0000000..b52c8d4
--- /dev/null
+++ b/src/ui/app_state.cpp
@@ -0,0 +1,44 @@
+#include "torlinkc/ui/app_state.hpp"
+
+namespace torlinkc::ui {
+
+std::optional<Category> sectionToCategory(Section s) {
+ switch (s) {
+ case Section::All:
+ return Category::All;
+ case Section::Games:
+ return Category::Games;
+ case Section::Movies:
+ return Category::Movies;
+ case Section::TV:
+ return Category::TV;
+ case Section::Anime:
+ return Category::Anime;
+ case Section::Downloads:
+ case Section::Seeding:
+ return std::nullopt;
+ }
+ return Category::All;
+}
+
+std::string sectionLabel(Section s) {
+ switch (s) {
+ case Section::All:
+ return "All";
+ case Section::Games:
+ return "Games";
+ case Section::Movies:
+ return "Movies";
+ case Section::TV:
+ return "TV";
+ case Section::Anime:
+ return "Anime";
+ case Section::Downloads:
+ return "Downloads";
+ case Section::Seeding:
+ return "Seeding";
+ }
+ return "All";
+}
+
+} // namespace torlinkc::ui
diff --git a/src/ui/engine_thread.cpp b/src/ui/engine_thread.cpp
index 83aa951..6120d57 100644
--- a/src/ui/engine_thread.cpp
+++ b/src/ui/engine_thread.cpp
@@ -77,9 +77,11 @@ void EngineThread::run(Config config) {
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 {
+ auto history = queue.getHistory();
+ screen_.Post([this, items = std::move(items), seeds = std::move(seeds), history = std::move(history)]() mutable {
state_.items = std::move(items);
state_.seeds = std::move(seeds);
+ state_.history = std::move(history);
});
// 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)
diff --git a/src/ui/keymap.cpp b/src/ui/keymap.cpp
new file mode 100644
index 0000000..cbb6f5a
--- /dev/null
+++ b/src/ui/keymap.cpp
@@ -0,0 +1,75 @@
+#include "torlinkc/ui/keymap.hpp"
+
+namespace torlinkc::ui {
+
+const std::vector<HelpGroup>& helpGroups() {
+ static const std::vector<HelpGroup> groups = {
+ {"Navigate",
+ {
+ {"up/down/left/right", "Navigate panes and lists"},
+ {"enter", "Open"},
+ {"tab", "Switch pane"},
+ {"esc", "Back"},
+ {"o", "Default download folder"},
+ {"t", "Extra trackers"},
+ {"q", "Quit"},
+ }},
+ {"Search",
+ {
+ {"enter", "Search / download selected"},
+ {"d", "Download"},
+ {"s", "Sort results"},
+ {"z", "Hide dead torrents"},
+ }},
+ {"Downloads",
+ {
+ {"p", "Pause/resume"},
+ {"c", "Cancel or remove"},
+ {"f", "Retry failed"},
+ {"d", "Download again"},
+ {"e", "Open folder"},
+ }},
+ {"Seeding",
+ {
+ {"p", "Pause/resume"},
+ {"c", "Remove"},
+ {"e", "Open folder"},
+ }},
+ };
+ return groups;
+}
+
+namespace {
+const Hint kNavigate{"up/down/left/right", "Move"};
+const Hint kAlways{"?", "Keys"};
+const Hint kSwitch{"tab", "Switch"};
+const Hint kFolder{"e", "Folder"};
+} // namespace
+
+std::vector<Hint> footerHints(Region region, Section section, std::optional<DownloadFocus> downloadFocus,
+ std::optional<SeedFocus> seedFocus) {
+ if (region == Region::Sidebar) {
+ return {kNavigate, {"enter", "Open"}, kSwitch, kAlways, {"q", "Quit"}};
+ }
+ if (section == Section::Seeding) {
+ std::string label = "Resume";
+ if (seedFocus == SeedFocus::Seeding) label = "Pause";
+ else if (seedFocus == SeedFocus::Missing) label = "Retry";
+ return {{"p", label}, {"c", "Remove from list"}, kFolder, kSwitch, kAlways};
+ }
+ if (section == Section::Downloads) {
+ if (downloadFocus == DownloadFocus::Paused) {
+ return {{"p", "Resume"}, {"c", "Cancel"}, kFolder, kSwitch, kAlways};
+ }
+ if (downloadFocus == DownloadFocus::Failed) {
+ return {{"f", "Retry"}, {"c", "Remove"}, kFolder, kSwitch, kAlways};
+ }
+ if (downloadFocus == DownloadFocus::Recent) {
+ return {{"d", "Redownload"}, {"c", "Remove from list"}, kFolder, kSwitch, kAlways};
+ }
+ return {{"p", "Pause"}, {"c", "Cancel"}, kFolder, kSwitch, kAlways};
+ }
+ return {kNavigate, {"d", "Download"}, {"s", "Sort"}, {"z", "Hide dead"}, kSwitch, kAlways};
+}
+
+} // namespace torlinkc::ui
diff --git a/src/ui/logo.cpp b/src/ui/logo.cpp
new file mode 100644
index 0000000..0fce2e5
--- /dev/null
+++ b/src/ui/logo.cpp
@@ -0,0 +1,61 @@
+#include "torlinkc/ui/logo.hpp"
+
+#include <cmath>
+#include <string>
+#include <vector>
+
+using namespace ftxui;
+
+namespace torlinkc::ui {
+
+namespace {
+
+const std::vector<std::string> kLogoLines = {
+ " 𐓏 ",
+ " ▀█▀ █▀█ █▀█ █ █ █▄ █ █▄▀",
+ " █ █▄█ █▀▄ █▄▄ █ █ ▀█ █ █",
+};
+
+// accent (#a78bfa) -> bright (#d8b4fe), matching ui/theme.ts::ACCENT_RAMP.
+constexpr int kAccentR = 167, kAccentG = 139, kAccentB = 250;
+constexpr int kBrightR = 216, kBrightG = 180, kBrightB = 254;
+
+std::vector<std::string> splitUtf8(const std::string& s) {
+ std::vector<std::string> out;
+ 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);
+ out.push_back(s.substr(i, len));
+ i += len;
+ }
+ return out;
+}
+
+Color lerpAccent(float t) {
+ auto lerp = [t](int a, int b) { return static_cast<int>(std::lround(a + (b - a) * t)); };
+ return Color::RGB(lerp(kAccentR, kBrightR), lerp(kAccentG, kBrightG), lerp(kAccentB, kBrightB));
+}
+
+} // namespace
+
+Element renderLogo() {
+ Elements rows;
+ for (const auto& line : kLogoLines) {
+ const auto glyphs = splitUtf8(line);
+ Elements cells;
+ cells.reserve(glyphs.size());
+ for (std::size_t i = 0; i < glyphs.size(); ++i) {
+ const float t = glyphs.size() <= 1 ? 0.0f : static_cast<float>(i) / static_cast<float>(glyphs.size() - 1);
+ cells.push_back(text(glyphs[i]) | color(lerpAccent(t)));
+ }
+ rows.push_back(hbox(std::move(cells)));
+ }
+ return vbox(std::move(rows)) | bold;
+}
+
+} // namespace torlinkc::ui
diff --git a/src/ui/move.cpp b/src/ui/move.cpp
new file mode 100644
index 0000000..d994a14
--- /dev/null
+++ b/src/ui/move.cpp
@@ -0,0 +1,18 @@
+#include "torlinkc/ui/move.hpp"
+
+#include <algorithm>
+
+namespace torlinkc::ui {
+
+int wrapStep(int current, int delta, int length) {
+ if (length <= 0) return 0;
+ return ((current + delta) % length + length) % length;
+}
+
+int windowStart(int cursor, int total, int height) {
+ if (total <= height) return 0;
+ const int half = height / 2;
+ return std::max(0, std::min(cursor - half, total - height));
+}
+
+} // namespace torlinkc::ui
diff --git a/src/ui/search_aggregator.cpp b/src/ui/search_aggregator.cpp
index 2a617da..db8337c 100644
--- a/src/ui/search_aggregator.cpp
+++ b/src/ui/search_aggregator.cpp
@@ -138,11 +138,11 @@ void SearchAggregator::flush() {
state_.doneSources = doneCopy;
state_.totalSources = totalCopy;
state_.searching = doneCopy < totalCopy;
- if (onResultsChanged) onResultsChanged();
- // Jump focus to the results list (index 1 in main.cpp's container) the
- // first time this search produces any results, so a hotkey pressed
- // right after Enter reaches it instead of the still-focused search box.
- if (hadNoResultsBefore && !state_.results.empty()) state_.focusedIndex = 1;
+ // main.cpp's onResultsChanged hook recomputes visibleResults/resultLabels
+ // 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);
});
screen_.PostEvent(ftxui::Event::Custom);
}
diff --git a/src/ui/theme.cpp b/src/ui/theme.cpp
new file mode 100644
index 0000000..bebf729
--- /dev/null
+++ b/src/ui/theme.cpp
@@ -0,0 +1,24 @@
+#include "torlinkc/ui/theme.hpp"
+
+#include <unordered_map>
+
+namespace torlinkc::ui {
+
+SourceStyle sourceStyle(const std::string& id) {
+ static const std::unordered_map<std::string, SourceStyle> styles = {
+ {"fitgirl", {"FG", palette::accent}},
+ {"yts", {"YTS", palette::good}},
+ {"eztv", {"EZTV", palette::warn}},
+ {"nyaa", {"NYAA", palette::bright}},
+ {"subsplease", {"SUB", palette::alt}},
+ {"tpb-movies", {"TPB", ftxui::Color::RGB(95, 208, 197)}},
+ {"tpb-tv", {"TPB", ftxui::Color::RGB(95, 208, 197)}},
+ {"x1337-movies", {"1337", ftxui::Color::RGB(246, 165, 92)}},
+ {"x1337-tv", {"1337", ftxui::Color::RGB(246, 165, 92)}},
+ {"bittorrented", {"BT", ftxui::Color::RGB(125, 184, 240)}},
+ };
+ if (auto it = styles.find(id); it != styles.end()) return it->second;
+ return SourceStyle{"•", palette::alt};
+}
+
+} // namespace torlinkc::ui
diff --git a/src/util/open_folder.cpp b/src/util/open_folder.cpp
new file mode 100644
index 0000000..5fde470
--- /dev/null
+++ b/src/util/open_folder.cpp
@@ -0,0 +1,64 @@
+#include "torlinkc/util/open_folder.hpp"
+
+#include <chrono>
+#include <filesystem>
+#include <thread>
+
+#include <signal.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+namespace torlinkc {
+
+namespace {
+
+// Forks, execs the first candidate that exists is left to the shell's PATH
+// lookup (execvp), and waits up to ~4s for a clean exit. Never throws.
+bool spawnAndWait(const std::vector<std::vector<std::string>>& candidates) {
+ for (const auto& argvStrings : candidates) {
+ pid_t pid = fork();
+ if (pid < 0) continue;
+ if (pid == 0) {
+ std::vector<char*> argv;
+ for (const auto& s : argvStrings) argv.push_back(const_cast<char*>(s.c_str()));
+ argv.push_back(nullptr);
+ execvp(argv[0], argv.data());
+ _exit(127); // execvp only returns on failure
+ }
+
+ bool exited = false;
+ int status = 0;
+ for (int i = 0; i < 40; ++i) { // ~4s in 100ms slices
+ if (waitpid(pid, &status, WNOHANG) == pid) {
+ exited = true;
+ break;
+ }
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ }
+ if (!exited) {
+ kill(pid, SIGKILL);
+ waitpid(pid, nullptr, 0);
+ continue;
+ }
+ if (WIFEXITED(status) && WEXITSTATUS(status) == 0) return true;
+ }
+ return false;
+}
+
+} // namespace
+
+bool openFolder(const std::string& dir) {
+ // Check the path ourselves first, matching the original's rationale (some
+ // launchers silently open a fallback location for a nonexistent path,
+ // which would look like success).
+ std::error_code ec;
+ if (dir.empty() || !std::filesystem::exists(dir, ec)) return false;
+
+#ifdef __APPLE__
+ return spawnAndWait({{"open", dir}});
+#else
+ return spawnAndWait({{"xdg-open", dir}, {"gio", "open", dir}});
+#endif
+}
+
+} // namespace torlinkc