commit 1c96919e3464103cf5e35f009186d896a4761f36
Author: MrJensK <jens.se@icloud.com>
AuthorDate: Sun Aug 23 21:12:23 2026 +0200
Commit: MrJensK <jens.se@icloud.com>
CommitDate: Sun Aug 23 21:12:23 2026 +0200
Add a swiping gradient sweep to the splash-screen logo only
renderLogo() gains an overload that blends a cosine-bell highlight
(ui/sheen.ts's math, ported from progress-bar cells to glyph columns)
into the existing static gradient at a given sweep position, plus a
MakeAnimatedLogo() component that drives it via FTXUI's native
animation system, following spinner.hpp's pattern. Only wired into the
Splash view; the Browser header and folder/trackers modals keep using
the plain static renderLogo(), so the wordmark stays still everywhere
else.
While verifying this live (diffing ANSI color codes frame-by-frame,
since capture-pane alone can't show color), found that OnAnimation
never actually fires from an ordinary redraw -- it only runs in
response to a pending RequestAnimationFrame(), which nothing was
calling. That silently affected the existing spinner too: it's been
sitting on frame 0 since Phase 4. Fixed both by calling
RequestAnimationFrame() once at each place their `isActive` condition
flips true (search start, and entering/re-entering the splash screen).
---
apps/tui/main.cpp | 12 +++++++-
include/torlinkc/ui/logo.hpp | 16 ++++++++++
src/ui/logo.cpp | 73 +++++++++++++++++++++++++++++++++++++++-----
src/ui/search_aggregator.cpp | 5 +++
4 files changed, 98 insertions(+), 8 deletions(-)
diff --git a/apps/tui/main.cpp b/apps/tui/main.cpp
index 29982d9..7846d76 100644
--- a/apps/tui/main.cpp
+++ b/apps/tui/main.cpp
@@ -273,6 +273,7 @@ int main() {
});
Component spinner = MakeSpinner([&] { return state.searching; });
+ Component animatedLogo = MakeAnimatedLogo([&] { return state.view == View::Splash; });
// --- Downloads section -----------------------------------------------
@@ -619,7 +620,7 @@ int main() {
icon::dot + " anime";
return vbox({
filler(),
- renderLogo() | center,
+ animatedLogo->Render() | center,
text("") | center,
text("A curated, terminal-native torrent downloader.") | color(palette::text) | center,
text(categories) | dim | center,
@@ -753,6 +754,9 @@ int main() {
}
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;
@@ -764,11 +768,17 @@ int main() {
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);
// 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);
diff --git a/include/torlinkc/ui/logo.hpp b/include/torlinkc/ui/logo.hpp
index 829fa34..2d9b2cb 100644
--- a/include/torlinkc/ui/logo.hpp
+++ b/include/torlinkc/ui/logo.hpp
@@ -1,5 +1,8 @@
#pragma once
+#include <functional>
+
+#include <ftxui/component/component.hpp>
#include <ftxui/dom/elements.hpp>
namespace torlinkc::ui {
@@ -13,4 +16,17 @@ inline constexpr int kLogoWidth = 39;
// gradient -- see the Phase 4 commit message).
ftxui::Element renderLogo();
+// Same wordmark, with a cosine-bell highlight (ui/sheen.ts's math, ported
+// to glyph columns instead of progress-bar cells) blended in at `sweepCenter`
+// -- one still frame of the swipe. `sweepCenter` is in glyph-column units and
+// may range outside [0, kLogoWidth) (the bell travels in from off-screen
+// left and back out to the right, per sheen.ts's SHEEN_GAP).
+ftxui::Element renderLogo(float sweepCenter);
+
+// A component that animates the sweep above via FTXUI's native animation
+// system (see spinner.hpp for the pattern this follows). Only keeps
+// requesting frames while `isActive()` is true -- intended for the splash
+// screen's logo only, so the wordmark stays static everywhere else.
+ftxui::Component MakeAnimatedLogo(std::function<bool()> isActive);
+
} // namespace torlinkc::ui
diff --git a/src/ui/logo.cpp b/src/ui/logo.cpp
index f09b46c..ce3a276 100644
--- a/src/ui/logo.cpp
+++ b/src/ui/logo.cpp
@@ -1,9 +1,13 @@
#include "torlinkc/ui/logo.hpp"
#include <cmath>
+#include <optional>
#include <string>
#include <vector>
+#include <ftxui/component/animation.hpp>
+#include <ftxui/component/component_base.hpp>
+
using namespace ftxui;
namespace torlinkc::ui {
@@ -26,6 +30,24 @@ const std::vector<std::string> kLogoLines = {
constexpr int kAccentR = 167, kAccentG = 139, kAccentB = 250;
constexpr int kBrightR = 216, kBrightG = 180, kBrightB = 254;
+// The splash-screen sweep: a cosine-bell highlight travelling across the
+// wordmark's columns. Same shape as ui/sheen.ts's progress-bar sheen, ported
+// to glyph columns instead of cells (kept separate rather than shared, since
+// the two operate in different units and sheen.ts is React/Ink-specific).
+constexpr int kPeakR = 244, kPeakG = 239, kPeakB = 255; // #f4efff
+constexpr float kSweepRadius = 4.5f; // bell half-width, in glyph columns
+constexpr float kSweepGap = 8.0f; // dark columns between sweeps
+constexpr float kSweepSpeed = 11.25f; // columns per second
+constexpr float kSweepMax = 0.9f; // peak blend toward the highlight
+
+float sweepPeriod() { return static_cast<float>(kLogoWidth) + kSweepRadius * 2.0f + kSweepGap; }
+
+float sweepIntensity(float column, float center) {
+ const float d = std::fabs(column - center);
+ if (d >= kSweepRadius) return 0.0f;
+ return 0.5f * (1.0f + std::cos(static_cast<float>(M_PI) * d / kSweepRadius)) * kSweepMax;
+}
+
std::vector<std::string> splitUtf8(const std::string& s) {
std::vector<std::string> out;
std::size_t i = 0;
@@ -42,14 +64,23 @@ std::vector<std::string> splitUtf8(const std::string& s) {
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));
-}
+int lerpInt(int a, int b, float t) { return static_cast<int>(std::lround(a + (b - a) * t)); }
-} // namespace
+Color lerpAccent(float t) { return Color::RGB(lerpInt(kAccentR, kBrightR, t), lerpInt(kAccentG, kBrightG, t), lerpInt(kAccentB, kBrightB, t)); }
+
+// Base accent->bright gradient at column fraction `t`, with the sweep
+// highlight (if any) blended in at raw column `column`.
+Color cellColor(float t, std::size_t column, std::optional<float> sweepCenter) {
+ const int baseR = lerpInt(kAccentR, kBrightR, t);
+ const int baseG = lerpInt(kAccentG, kBrightG, t);
+ const int baseB = lerpInt(kAccentB, kBrightB, t);
+ if (!sweepCenter) return Color::RGB(baseR, baseG, baseB);
+ const float intensity = sweepIntensity(static_cast<float>(column), *sweepCenter);
+ if (intensity <= 0.0f) return Color::RGB(baseR, baseG, baseB);
+ return Color::RGB(lerpInt(baseR, kPeakR, intensity), lerpInt(baseG, kPeakG, intensity), lerpInt(baseB, kPeakB, intensity));
+}
-Element renderLogo() {
+Element renderLogoImpl(std::optional<float> sweepCenter) {
Elements rows;
for (const auto& line : kLogoLines) {
const auto glyphs = splitUtf8(line);
@@ -57,11 +88,39 @@ Element renderLogo() {
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)));
+ cells.push_back(text(glyphs[i]) | color(cellColor(t, i, sweepCenter)));
}
rows.push_back(hbox(std::move(cells)));
}
return vbox(std::move(rows)) | bold;
}
+class AnimatedLogo : public ComponentBase {
+ public:
+ explicit AnimatedLogo(std::function<bool()> isActive) : isActive_(std::move(isActive)) {}
+
+ Element Render() override {
+ const float center = std::fmod(elapsed_ * kSweepSpeed, sweepPeriod()) - kSweepRadius;
+ return renderLogoImpl(center);
+ }
+
+ void OnAnimation(animation::Params& params) override {
+ if (!isActive_()) return;
+ elapsed_ = std::fmod(elapsed_ + params.duration().count(), sweepPeriod() / kSweepSpeed);
+ animation::RequestAnimationFrame();
+ }
+
+ private:
+ std::function<bool()> isActive_;
+ float elapsed_ = 0.0f;
+};
+
+} // namespace
+
+Element renderLogo() { return renderLogoImpl(std::nullopt); }
+
+Element renderLogo(float sweepCenter) { return renderLogoImpl(sweepCenter); }
+
+Component MakeAnimatedLogo(std::function<bool()> isActive) { return Make<AnimatedLogo>(std::move(isActive)); }
+
} // namespace torlinkc::ui
diff --git a/src/ui/search_aggregator.cpp b/src/ui/search_aggregator.cpp
index db8337c..6c9ee35 100644
--- a/src/ui/search_aggregator.cpp
+++ b/src/ui/search_aggregator.cpp
@@ -52,6 +52,11 @@ void SearchAggregator::search(std::string query) {
state_.query = query;
state_.searching = true;
+ // OnAnimation only fires in response to a pending animation-frame request,
+ // not on an ordinary redraw -- without this kick, the spinner component
+ // would sit on its first frame until something else happened to already
+ // have one in flight.
+ screen_.RequestAnimationFrame();
state_.doneSources = 0;
state_.totalSources = static_cast<int>(sources.size());
state_.perSource.clear();