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