commits
tags
// 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>
#include <ftxui/component/component_options.hpp>
#include <ftxui/component/screen_interactive.hpp>
#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/clock.hpp"
#include "torlinkc/ui/colored_window.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/progress_bar.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;
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 sourcesStatusLine(const AppState& state) {
int failed = 0;
std::string firstError;
for (const auto& [id, st] : state.perSource) {
if (st.error) {
failed++;
if (firstError.empty()) firstError = id + ": " + st.code.value_or("error");
}
}
std::string line = std::to_string(state.doneSources) + "/" + std::to_string(state.totalSources) + " sources";
if (failed > 0) {
line += " (" + std::to_string(failed) + " down";
if (!firstError.empty()) line += ", e.g. " + firstError;
line += ")";
}
return line;
}
// FTXUI's default InputOption::transform inverts the *entire* field while
// focused (white-on-default becomes a solid white bar) -- TextField.tsx only
// ever inverted the single cursor character. Input's own Render() already
// draws a native blinking bar cursor at the right position regardless of
// transform, so this just needs to stop painting the rest of the field.
Element plainInputTransform(InputState state) {
Element e = state.element | color(palette::text);
if (state.is_placeholder) e = e | dim;
return e;
}
std::string filterBarText(const AppState& state) {
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 coloredWindow(text("Keyboard"), vbox(std::move(spaced)), palette::accent);
}
} // namespace
int main() {
std::signal(SIGPIPE, SIG_IGN);
Config config = loadConfig();
const auto sources = sourceById(); // stateless; safe to capture by value into lambdas below
ScreenInteractive screen = ScreenInteractive::Fullscreen();
AppState state;
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, cat);
if (inCategory) byCategory.push_back(r);
}
auto filtered = filterResults(byCategory, state.hideDead, sources);
state.visibleResults = sortResults(filtered, state.sort);
if (state.selectedResult >= static_cast<int>(state.visibleResults.size())) {
state.selectedResult = std::max(0, static_cast<int>(state.visibleResults.size()) - 1);
}
};
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 = [&] {
if (state.selectedResult < 0 || state.selectedResult >= static_cast<int>(state.visibleResults.size())) return;
const TorrentResult r = state.visibleResults[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);
setSection(Section::Downloads);
syncFocus();
};
// --- Search box + results (shared by Splash and the browsing sections) --
InputOption searchOptions;
searchOptions.multiline = false;
searchOptions.transform = plainInputTransform;
searchOptions.on_enter = [&] {
aggregator.search(state.query);
state.view = View::Browser;
syncFocus();
// Kick the shared UI clock -- OnAnimation only fires in response to a
// pending animation-frame request, not on an ordinary redraw.
screen.RequestAnimationFrame();
};
Component searchInput = Input(&state.query, "search torrents (all sources)...", searchOptions);
// 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;
}
if (event == Event::Character('s')) {
state.sort = nextSort(state.sort);
refreshVisible();
return true;
}
if (event == Event::Character('z')) {
state.hideDead = !state.hideDead;
refreshVisible();
return true;
}
return false;
});
Component spinner = MakeSpinner([&] { return state.searching; });
Component animatedLogo = MakeAnimatedLogo([&] { return state.view == View::Splash; });
Component uiClock = MakeUiClock(&state.uiClock, [&] { return state.view == View::Browser; });
// --- 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(truncate(stripControl(it.name), 40)) | (here ? bold : dim) | flex,
text(" "),
text(right) | (it.status == DownloadStatus::Failed ? color(palette::bad) : dim),
});
const std::optional<float> sweep = it.status == DownloadStatus::Downloading
? std::optional(progressSheenCenter(state.uiClock, kProgressBarWidth))
: std::nullopt;
Element row2 = hbox({text(" "), renderProgressBar(it.progress, kProgressBarWidth, statusColor, sweep)});
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(truncate(stripControl(h.name), 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::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;
});
// --- Seeding section ----------------------------------------------------
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;
}
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(truncate(stripControl(h.name), 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.transform = plainInputTransform;
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.transform = plainInputTransform;
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, resultsComponent, 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() | color(palette::rule),
coloredWindow(text("default download folder"), folderInput->Render(), palette::accent),
text("enter: save esc: cancel") | dim,
});
}
if (state.editingTrackers) {
return vbox({
renderLogo(),
separator() | color(palette::rule),
coloredWindow(text("extra trackers"),
vbox({
text(trackersStatus(config.trackers, state.trackersPromptText)) | dim,
trackersInput->Render(),
}),
palette::accent),
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(),
animatedLogo->Render() | center,
text("") | center,
text("A curated, terminal-native torrent downloader.") | color(palette::text) | center,
text(categories) | dim | center,
text("") | center,
coloredWindow(text("Search"), searchInput->Render(), palette::accent) | 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) {
const Color c = state.region == Region::Content ? palette::accent : palette::rule;
content = coloredWindow(text("Downloads"), downloadsComponent->Render(), c);
} else if (state.section == Section::Seeding) {
const Color c = state.region == Region::Content ? palette::accent : palette::rule;
content = coloredWindow(text("Seeding"), seedingComponent->Render(), c);
} else {
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.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(resultsComponent->Render() | flex);
}
content = vbox({
coloredWindow(text("Search"), searchInput->Render(),
state.focusedIndex == kFocusSearch ? palette::accent : palette::rule),
coloredWindow(text(sectionLabel(state.section) + " results"), vbox(std::move(resultsSection)) | flex,
state.focusedIndex == kFocusResults ? palette::accent : palette::rule) |
flex,
});
}
Elements top = {renderLogo()};
if (!state.notice.empty()) top.push_back(filler());
if (!state.notice.empty()) top.push_back(text(state.notice) | color(palette::good));
// Only one divider, below the logo/notice bar -- matches the original
// (App.tsx's single `showTopRule` Rule); nothing separates the sidebar
// from the content or sits above the footer there, just spacing.
return vbox({
hbox(std::move(top)),
separator() | color(palette::rule),
hbox({
sidebarMenu->Render() | size(WIDTH, EQUAL, 16),
text(" "),
content | flex,
}) | flex,
renderFooter(state),
}) |
flex;
});
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;
screen.RequestAnimationFrame();
} 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) {
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();
// Kick off the logo sweep -- OnAnimation only fires in response to a
// pending animation-frame request, not on an ordinary redraw.
screen.RequestAnimationFrame();
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);
// Never a focus target (like `spinner` above) -- just needs to be in the
// tree so it receives OnAnimation ticks while the splash screen is up.
mainContainer->Add(animatedLogo);
mainContainer->Add(uiClock);
// 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();
// Kick off the splash logo's sweep -- OnAnimation only fires in response
// to a pending animation-frame request, not on an ordinary redraw.
screen.RequestAnimationFrame();
engine.start(config);
screen.Loop(mainWithGlobalKeys);
engine.stop();
return 0;
}