foxygit / Torlinkc Log in
commit 305e97d688c60a8ccaca4d157720bfed947b4a28
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Sun Aug 23 21:44:07 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Sun Aug 23 21:44:07 2026 +0200

    Purple panel borders, an animated download-progress sheen, and a results marquee

    Panel borders (Search/Results/Downloads/Seeding, plus the folder/
    trackers/help modals) now use FTXUI's own border-coloring node, wrapped
    here as coloredWindow() since the installed FTXUI's public window()
    takes no color -- accent (purple) while a panel has focus, rule (grey)
    otherwise, porting Panel.tsx's focused/unfocused coloring. The splash
    search box, which had no border at all before, now gets one too
    (always purple, matching the original: SearchBar there is always
    `editing`).

    Downloads' active-item bars are ProgressBar.tsx ported: a deep/mid/
    bright ramp built from the row's status color via Color::Interpolate,
    with a moving cosine-bell highlight blended in (the fixed purple ramp
    ProgressBar.tsx's animated case uses) while status == Downloading,
    static otherwise. Fixed-width (24 cells) rather than flexed, since a
    per-cell-colored bar needs its cell count up front and FTXUI doesn't
    hand a Node its box size until after it's built.

    Both the sheen and a new results-list marquee are driven off one
    shared clock (ui/clock.hpp's MakeUiClock, ticking AppState::uiClock
    via the same OnAnimation + RequestAnimationFrame pattern as the
    spinner/logo). The results list now truncates every label to a fixed
    width -- util::truncate(), UTF-8-codepoint-aware -- so a long name can
    no longer change the list's row height and make it jump as the cursor
    moves onto it; the selected row scrolls through its full name instead
    of just sitting truncated (ui/marquee.hpp's marqueeWindow()). Rebuilt
    in place every render rather than only on search/filter changes, since
    the scroll position and selection change every frame independent of
    the result set itself.

    Also swapped the Downloads/Seeding/History name columns' ad hoc
    `.substr(0, 40)` (byte-based, could split a multi-byte UTF-8 character)
    for the same truncate().
---
 apps/tui/main.cpp                      |  70 ++++++++++++++-----
 include/torlinkc/ui/app_state.hpp      |  18 +++--
 include/torlinkc/ui/clock.hpp          |  18 +++++
 include/torlinkc/ui/colored_window.hpp |  16 +++++
 include/torlinkc/ui/marquee.hpp        |  14 ++++
 include/torlinkc/ui/progress_bar.hpp   |  30 +++++++++
 include/torlinkc/util/format.hpp       |   7 ++
 src/CMakeLists.txt                     |   4 ++
 src/ui/clock.cpp                       |  40 +++++++++++
 src/ui/colored_window.cpp              | 118 +++++++++++++++++++++++++++++++++
 src/ui/marquee.cpp                     |  55 +++++++++++++++
 src/ui/progress_bar.cpp                |  74 +++++++++++++++++++++
 src/util/format.cpp                    |  24 +++++++
 13 files changed, 468 insertions(+), 20 deletions(-)

diff --git a/apps/tui/main.cpp b/apps/tui/main.cpp
index 7846d76..19123ea 100644
--- a/apps/tui/main.cpp
+++ b/apps/tui/main.cpp
@@ -27,11 +27,15 @@
 #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/marquee.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"
@@ -130,7 +134,7 @@ Element renderHelpOverlay() {
   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)));
+  return coloredWindow(text("Keyboard"), vbox(std::move(spaced)), palette::accent);
 }

 }  // namespace
@@ -208,6 +212,24 @@ int main() {
       state.selectedResult = std::max(0, static_cast<int>(state.resultLabels.size()) - 1);
     }
   };
+
+  // Rewrites state.resultLabels' *contents* in place every render (called
+  // from mainRenderer, below): a fixed max width means a long name can never
+  // change the list's row height/width (what was causing it to visibly jump
+  // as the cursor moved onto a long entry), and the selected row scrolls
+  // through its full name instead of just sitting truncated. Kept as a
+  // full rebuild each frame rather than patching only the selected index, so
+  // there's no stale marquee text left behind once the cursor moves off a
+  // row -- see the class comment on AppState::resultLabels.
+  constexpr int kResultLabelWidth = 76;
+  auto updateResultLabelsForFrame = [&] {
+    for (std::size_t i = 0; i < state.visibleResults.size(); ++i) {
+      const std::string full = formatResultLabel(state.visibleResults[i]);
+      state.resultLabels[i] = static_cast<int>(i) == state.selectedResult
+                                   ? marqueeWindow(full, kResultLabelWidth, state.uiClock)
+                                   : truncate(full, kResultLabelWidth);
+    }
+  };
   aggregator.onResultsChanged = [&](bool hadNoResultsBefore) {
     refreshVisible();
     if (hadNoResultsBefore && !state.results.empty() && state.view == View::Browser &&
@@ -248,6 +270,9 @@ int main() {
     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);

@@ -274,6 +299,7 @@ int main() {

   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 -----------------------------------------------

@@ -311,11 +337,14 @@ int main() {
           text(" "),
           text(statusIcon) | color(statusColor),
           text(" "),
-          text(stripControl(it.name).substr(0, 40)) | (here ? bold : dim) | flex,
+          text(truncate(stripControl(it.name), 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});
+      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()) {
@@ -328,7 +357,7 @@ int main() {
             text(" "),
             text(icon::done) | color(palette::good),
             text(" "),
-            text(stripControl(h.name).substr(0, 40)) | (here ? bold : dim) | flex,
+            text(truncate(stripControl(h.name), 40)) | (here ? bold : dim) | flex,
             text("  "),
             text(h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-") | dim,
         }));
@@ -430,7 +459,7 @@ int main() {
       rows.push_back(hbox({
           text(here ? icon::pointer : " ") | color(palette::accent),
           text(" "),
-          text(stripControl(h.name).substr(0, 40)) | (here ? bold : dim) | flex,
+          text(truncate(stripControl(h.name), 40)) | (here ? bold : dim) | flex,
           text("  "),
           text(h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-") | dim,
           text("  "),
@@ -599,7 +628,7 @@ int main() {
       return vbox({
           renderLogo(),
           separator(),
-          window(text("default download folder"), folderInput->Render()),
+          coloredWindow(text("default download folder"), folderInput->Render(), palette::accent),
           text("enter: save   esc: cancel") | dim,
       });
     }
@@ -607,10 +636,12 @@ int main() {
       return vbox({
           renderLogo(),
           separator(),
-          window(text("extra trackers"), vbox({
-                                              text(trackersStatus(config.trackers, state.trackersPromptText)) | dim,
-                                              trackersInput->Render(),
-                                          })),
+          coloredWindow(text("extra trackers"),
+                        vbox({
+                            text(trackersStatus(config.trackers, state.trackersPromptText)) | dim,
+                            trackersInput->Render(),
+                        }),
+                        palette::accent),
           text("enter: save   esc: cancel") | dim,
       });
     }
@@ -625,7 +656,8 @@ int main() {
                  text("A curated, terminal-native torrent downloader.") | color(palette::text) | center,
                  text(categories) | dim | center,
                  text("") | center,
-                 searchInput->Render() | size(WIDTH, EQUAL, 56) | 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}) |
@@ -637,10 +669,13 @@ int main() {

     Element content;
     if (state.section == Section::Downloads) {
-      content = window(text("Downloads"), downloadsComponent->Render());
+      const Color c = state.region == Region::Content ? palette::accent : palette::rule;
+      content = coloredWindow(text("Downloads"), downloadsComponent->Render(), c);
     } else if (state.section == Section::Seeding) {
-      content = window(text("Seeding"), seedingComponent->Render());
+      const Color c = state.region == Region::Content ? palette::accent : palette::rule;
+      content = coloredWindow(text("Seeding"), seedingComponent->Render(), c);
     } else {
+      updateResultLabelsForFrame();
       Elements resultsSection;
       resultsSection.push_back(text(filterBarText(state)) | dim);
       if (state.searching) {
@@ -657,8 +692,11 @@ int main() {
         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,
+          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,
       });
     }

@@ -724,6 +762,7 @@ int main() {
         // 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;
       }
@@ -771,6 +810,7 @@ int main() {
   // 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
diff --git a/include/torlinkc/ui/app_state.hpp b/include/torlinkc/ui/app_state.hpp
index 5e55be1..ca74244 100644
--- a/include/torlinkc/ui/app_state.hpp
+++ b/include/torlinkc/ui/app_state.hpp
@@ -82,17 +82,25 @@ struct AppState {
   bool searching = false;

   // 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.
+  // recomputed into `visibleResults` by main.cpp's refreshVisible() whenever
+  // `results` or any of the three change. `resultLabels` is kept as a
+  // persistent AppState field (not computed fresh inside Render()) because
+  // FTXUI's Menu binds to it by address at construction time -- but its
+  // *contents* are rewritten every render (truncated/marquee'd from
+  // visibleResults, see updateResultLabelsForFrame() in main.cpp), since the
+  // selected row's marquee position changes every frame even when the
+  // result set itself hasn't.
   bool hideDead = false;
   Sort sort;
   std::vector<TorrentResult> visibleResults;
   std::vector<std::string> resultLabels;  // parallel to visibleResults; what the results Menu renders
   int selectedResult = 0;

+  // Elapsed seconds while View::Browser is showing (see ui/clock.hpp) --
+  // drives the results marquee and the downloads progress-bar sheen off one
+  // shared time base.
+  float uiClock = 0.0f;
+
   // Downloads / seeds, replaced wholesale each engine-thread tick.
   std::vector<QueueItem> items;
   std::vector<HistoryItem> history;
diff --git a/include/torlinkc/ui/clock.hpp b/include/torlinkc/ui/clock.hpp
new file mode 100644
index 0000000..0b910b7
--- /dev/null
+++ b/include/torlinkc/ui/clock.hpp
@@ -0,0 +1,18 @@
+#pragma once
+
+#include <functional>
+
+#include <ftxui/component/component.hpp>
+
+namespace torlinkc::ui {
+
+// Advances *clock by elapsed seconds every animation frame while isActive()
+// is true (wrapped to stay bounded over a long-running session), and costs
+// nothing while inactive -- same pattern as spinner.hpp. Drives both the
+// results marquee and the downloads progress-bar sheen off one shared time
+// base, so a page of active downloads sweeps in sync. The caller must still
+// call ScreenInteractive::RequestAnimationFrame() once to kick off the first
+// frame when isActive() flips true (see spinner.hpp).
+ftxui::Component MakeUiClock(float* clock, std::function<bool()> isActive);
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/colored_window.hpp b/include/torlinkc/ui/colored_window.hpp
new file mode 100644
index 0000000..64f3883
--- /dev/null
+++ b/include/torlinkc/ui/colored_window.hpp
@@ -0,0 +1,16 @@
+#pragma once
+
+#include <ftxui/dom/elements.hpp>
+#include <ftxui/screen/color.hpp>
+
+namespace torlinkc::ui {
+
+// Same as ftxui::window(title, content), but with the border (including the
+// title text embedded in it) tinted `color` -- interior `content` keeps
+// whatever colors it set for itself, untouched. The installed FTXUI's public
+// window() has no color parameter (its color-capable Border node is an
+// internal, unexported class), so this is a small adaptation of that node
+// (FTXUI is MIT-licensed) exposed as a free function instead.
+ftxui::Element coloredWindow(ftxui::Element title, ftxui::Element content, ftxui::Color color);
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/marquee.hpp b/include/torlinkc/ui/marquee.hpp
new file mode 100644
index 0000000..2c5a022
--- /dev/null
+++ b/include/torlinkc/ui/marquee.hpp
@@ -0,0 +1,14 @@
+#pragma once
+
+#include <string>
+
+namespace torlinkc::ui {
+
+// Returns a `maxGlyphs`-wide UTF-8 window into `text`: the text as-is if it
+// already fits, otherwise a window that scrolls through it (plus a blank
+// gap) as `clockSeconds` advances. Pairs with a fixed max width elsewhere
+// (util::truncate for every other row) so a long name can never change a
+// list's row height/width -- this is how the rest of it still gets seen.
+std::string marqueeWindow(const std::string& text, int maxGlyphs, float clockSeconds);
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/progress_bar.hpp b/include/torlinkc/ui/progress_bar.hpp
new file mode 100644
index 0000000..f9cec41
--- /dev/null
+++ b/include/torlinkc/ui/progress_bar.hpp
@@ -0,0 +1,30 @@
+#pragma once
+
+#include <optional>
+
+#include <ftxui/dom/elements.hpp>
+#include <ftxui/screen/color.hpp>
+
+namespace torlinkc::ui {
+
+// Fixed rather than flexed: FTXUI doesn't hand a Node its final box size
+// until after it's built, so a per-cell-colored bar (like the logo) needs a
+// width decided up front. 24 sits in the original's dynamic 8-28 range.
+inline constexpr int kProgressBarWidth = 24;
+
+// A `width`-cell bar at `pct` (0-100), ported from ProgressBar.tsx: a
+// deep->mid->bright ramp built from `base` (the row's status color), with
+// the empty portion drawn in palette::rule. When `sweepCenter` is given
+// (only while status == Downloading), blends in a moving cosine-bell
+// highlight -- same trick as the splash logo's sweep -- and switches to the
+// fixed purple ramp ProgressBar.tsx's animated case uses, ignoring `base`,
+// matching the original 1:1 (every animated row is a downloading row, which
+// is already accent-colored anyway).
+ftxui::Element renderProgressBar(int pct, int width, ftxui::Color base, std::optional<float> sweepCenter);
+
+// The sheen's position (in bar-cells, possibly outside [0, width) while it
+// travels in from/out to off-screen) at a given point on the shared UI
+// clock. Mirrors logo.cpp's sweep math but in the progress bar's own units.
+float progressSheenCenter(float clockSeconds, int width);
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/util/format.hpp b/include/torlinkc/util/format.hpp
index 19fdfcc..76821e1 100644
--- a/include/torlinkc/util/format.hpp
+++ b/include/torlinkc/util/format.hpp
@@ -26,4 +26,11 @@ std::int64_t parseSize(const std::string& s);
 // through them.
 std::string stripControl(const std::string& s);

+// Truncates to at most `maxWidth` UTF-8 codepoints ("glyphs" -- never splits
+// a multi-byte character), replacing the last one with an ellipsis when the
+// string is longer. Ported from util/format.ts::truncate, counting UTF-8
+// codepoints instead of UTF-16 code units (torrent names routinely contain
+// non-ASCII characters).
+std::string truncate(const std::string& s, int maxWidth);
+
 }  // namespace torlinkc
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index e59f7de..38784f9 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -44,12 +44,16 @@ target_link_libraries(torlinkc_core PUBLIC
 # torlinkc_core without ever pulling in FTXUI).
 add_library(torlinkc_ui STATIC
   ui/app_state.cpp
+  ui/clock.cpp
   ui/coalescing_notifier.cpp
+  ui/colored_window.cpp
   ui/engine_thread.cpp
   ui/filter.cpp
   ui/keymap.cpp
   ui/logo.cpp
+  ui/marquee.cpp
   ui/move.cpp
+  ui/progress_bar.cpp
   ui/search_aggregator.cpp
   ui/sort.cpp
   ui/spinner.cpp
diff --git a/src/ui/clock.cpp b/src/ui/clock.cpp
new file mode 100644
index 0000000..381d63b
--- /dev/null
+++ b/src/ui/clock.cpp
@@ -0,0 +1,40 @@
+#include "torlinkc/ui/clock.hpp"
+
+#include <cmath>
+
+#include <ftxui/component/animation.hpp>
+#include <ftxui/component/component_base.hpp>
+#include <ftxui/dom/elements.hpp>
+
+namespace torlinkc::ui {
+
+namespace {
+
+// Large relative to any consumer's period, so wrapping here never produces
+// a visible seam in their own fmod()-based math.
+constexpr float kWrap = 100000.0f;
+
+class UiClockComponent : public ftxui::ComponentBase {
+ public:
+  UiClockComponent(float* clock, std::function<bool()> isActive) : clock_(clock), isActive_(std::move(isActive)) {}
+
+  ftxui::Element Render() override { return ftxui::text(""); }
+
+  void OnAnimation(ftxui::animation::Params& params) override {
+    if (!isActive_()) return;
+    *clock_ = std::fmod(*clock_ + params.duration().count(), kWrap);
+    ftxui::animation::RequestAnimationFrame();
+  }
+
+ private:
+  float* clock_;
+  std::function<bool()> isActive_;
+};
+
+}  // namespace
+
+ftxui::Component MakeUiClock(float* clock, std::function<bool()> isActive) {
+  return ftxui::Make<UiClockComponent>(clock, std::move(isActive));
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/ui/colored_window.cpp b/src/ui/colored_window.cpp
new file mode 100644
index 0000000..0297524
--- /dev/null
+++ b/src/ui/colored_window.cpp
@@ -0,0 +1,118 @@
+#include "torlinkc/ui/colored_window.hpp"
+
+#include <algorithm>
+#include <array>
+#include <memory>
+#include <string>
+#include <utility>
+
+#include <ftxui/dom/node.hpp>
+#include <ftxui/dom/requirement.hpp>
+#include <ftxui/dom/take_any_args.hpp>
+#include <ftxui/screen/box.hpp>
+#include <ftxui/screen/screen.hpp>
+
+// Adapted from FTXUI v5.0.0's internal (unexported) `Border` node
+// (src/ftxui/dom/border.cpp, MIT-licensed) with the ROUNDED charset and a
+// mandatory foreground color baked in, since the public `window()` in this
+// FTXUI version takes neither.
+
+namespace torlinkc::ui {
+
+namespace {
+
+using ftxui::Box;
+using ftxui::Color;
+using ftxui::Element;
+using ftxui::Elements;
+using ftxui::Node;
+using ftxui::Pixel;
+using ftxui::Screen;
+
+constexpr std::array<const char*, 6> kRoundedCharset = {"╭", "╮", "╰", "╯", "─", "│"};
+
+class ColoredWindowNode : public Node {
+ public:
+  ColoredWindowNode(Elements children, Color color) : Node(std::move(children)), color_(color) {}
+
+  void ComputeRequirement() override {
+    Node::ComputeRequirement();
+    requirement_ = children_[0]->requirement();
+    requirement_.min_x += 2;
+    requirement_.min_y += 2;
+    requirement_.min_x = std::max(requirement_.min_x, children_[1]->requirement().min_x + 2);
+    requirement_.selected_box.x_min++;
+    requirement_.selected_box.x_max++;
+    requirement_.selected_box.y_min++;
+    requirement_.selected_box.y_max++;
+  }
+
+  void SetBox(Box box) override {
+    Node::SetBox(box);
+    Box title_box;
+    title_box.x_min = box.x_min + 1;
+    title_box.x_max = box.x_max - 1;
+    title_box.y_min = box.y_min;
+    title_box.y_max = box.y_min;
+    children_[1]->SetBox(title_box);
+
+    box.x_min++;
+    box.x_max--;
+    box.y_min++;
+    box.y_max--;
+    children_[0]->SetBox(box);
+  }
+
+  void Render(Screen& screen) override {
+    children_[0]->Render(screen);
+
+    if (box_.x_min >= box_.x_max || box_.y_min >= box_.y_max) return;
+
+    screen.at(box_.x_min, box_.y_min) = kRoundedCharset[0];
+    screen.at(box_.x_max, box_.y_min) = kRoundedCharset[1];
+    screen.at(box_.x_min, box_.y_max) = kRoundedCharset[2];
+    screen.at(box_.x_max, box_.y_max) = kRoundedCharset[3];
+
+    for (int x = box_.x_min + 1; x < box_.x_max; ++x) {
+      Pixel& p1 = screen.PixelAt(x, box_.y_min);
+      Pixel& p2 = screen.PixelAt(x, box_.y_max);
+      p1.character = kRoundedCharset[4];
+      p2.character = kRoundedCharset[4];
+      p1.automerge = true;
+      p2.automerge = true;
+    }
+    for (int y = box_.y_min + 1; y < box_.y_max; ++y) {
+      Pixel& p3 = screen.PixelAt(box_.x_min, y);
+      Pixel& p4 = screen.PixelAt(box_.x_max, y);
+      p3.character = kRoundedCharset[5];
+      p4.character = kRoundedCharset[5];
+      p3.automerge = true;
+      p4.automerge = true;
+    }
+
+    children_[1]->Render(screen);
+
+    // Overwriting the perimeter's foreground_color after the title renders
+    // is what tints the title text too (it sits on the top edge), while
+    // children_[0] (the interior) was already drawn above and is untouched.
+    for (int x = box_.x_min; x <= box_.x_max; ++x) {
+      screen.PixelAt(x, box_.y_min).foreground_color = color_;
+      screen.PixelAt(x, box_.y_max).foreground_color = color_;
+    }
+    for (int y = box_.y_min; y <= box_.y_max; ++y) {
+      screen.PixelAt(box_.x_min, y).foreground_color = color_;
+      screen.PixelAt(box_.x_max, y).foreground_color = color_;
+    }
+  }
+
+ private:
+  Color color_;
+};
+
+}  // namespace
+
+Element coloredWindow(Element title, Element content, Color color) {
+  return std::make_shared<ColoredWindowNode>(ftxui::unpack(std::move(content), std::move(title)), color);
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/ui/marquee.cpp b/src/ui/marquee.cpp
new file mode 100644
index 0000000..56da8e2
--- /dev/null
+++ b/src/ui/marquee.cpp
@@ -0,0 +1,55 @@
+#include "torlinkc/ui/marquee.hpp"
+
+#include <algorithm>
+#include <cmath>
+#include <vector>
+
+namespace torlinkc::ui {
+
+namespace {
+
+constexpr float kSpeed = 6.0f;  // glyphs per second
+constexpr float kGap = 6.0f;    // blank glyphs between loops
+
+std::vector<std::size_t> glyphStarts(const std::string& s) {
+  std::vector<std::size_t> starts;
+  std::size_t i = 0;
+  while (i < s.size()) {
+    starts.push_back(i);
+    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;
+    i += std::min(len, s.size() - i);
+  }
+  starts.push_back(s.size());
+  return starts;
+}
+
+}  // namespace
+
+std::string marqueeWindow(const std::string& text, int maxGlyphs, float clockSeconds) {
+  const auto starts = glyphStarts(text);
+  const int total = static_cast<int>(starts.size()) - 1;
+  if (total <= maxGlyphs) return text;
+
+  const int period = total + static_cast<int>(kGap);
+  const int offset = static_cast<int>(std::fmod(clockSeconds * kSpeed, static_cast<float>(period)));
+
+  std::string out;
+  out.reserve(static_cast<std::size_t>(maxGlyphs) * 4);
+  for (int col = 0; col < maxGlyphs; ++col) {
+    const int idx = (offset + col) % period;
+    if (idx < total) {
+      const auto start = starts[static_cast<std::size_t>(idx)];
+      const auto end = starts[static_cast<std::size_t>(idx) + 1];
+      out.append(text, start, end - start);
+    } else {
+      out += ' ';
+    }
+  }
+  return out;
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/ui/progress_bar.cpp b/src/ui/progress_bar.cpp
new file mode 100644
index 0000000..796aae9
--- /dev/null
+++ b/src/ui/progress_bar.cpp
@@ -0,0 +1,74 @@
+#include "torlinkc/ui/progress_bar.hpp"
+
+#include <algorithm>
+#include <cmath>
+
+#include "torlinkc/ui/theme.hpp"
+
+using namespace ftxui;
+
+namespace torlinkc::ui {
+
+namespace {
+
+constexpr float kSheenRadius = 4.5f;   // bell half-width, in bar cells
+constexpr float kSheenGap = 8.0f;      // dark cells between sweeps
+constexpr float kSheenSpeed = 11.25f;  // cells per second
+constexpr float kSheenMax = 0.9f;      // peak blend toward the highlight
+
+const Color kSheenPeak = Color::RGB(244, 239, 255);   // #f4efff
+const Color kAnimatedDeep = Color::RGB(124, 92, 214);  // #7c5cd6, ProgressBar.tsx's DEEP
+
+float sheenPeriod(int width) { return static_cast<float>(width) + kSheenRadius * 2.0f + kSheenGap; }
+
+float sheenIntensity(float cell, float center) {
+  const float d = std::fabs(cell - center);
+  if (d >= kSheenRadius) return 0.0f;
+  return 0.5f * (1.0f + std::cos(static_cast<float>(M_PI) * d / kSheenRadius)) * kSheenMax;
+}
+
+Color ramp(float t, Color deep, Color mid, Color bright) {
+  return t <= 0.5f ? Color::Interpolate(t / 0.5f, deep, mid) : Color::Interpolate((t - 0.5f) / 0.5f, mid, bright);
+}
+
+}  // namespace
+
+float progressSheenCenter(float clockSeconds, int width) {
+  return std::fmod(clockSeconds * kSheenSpeed, sheenPeriod(width)) - kSheenRadius;
+}
+
+Element renderProgressBar(int pct, int width, Color base, std::optional<float> sweepCenter) {
+  const int clamped = std::clamp(pct, 0, 100);
+  const int filled = static_cast<int>(std::lround((clamped / 100.0) * width));
+  const int empty = std::max(0, width - filled);
+  const int denom = std::max(1, width - 1);
+
+  Color deep;
+  Color mid;
+  Color bright;
+  if (sweepCenter) {
+    deep = kAnimatedDeep;
+    mid = palette::accent;
+    bright = palette::bright;
+  } else {
+    deep = Color::Interpolate(0.3f, base, Color::RGB(0, 0, 0));
+    mid = base;
+    bright = Color::Interpolate(0.35f, base, palette::text);
+  }
+
+  Elements cells;
+  cells.reserve(static_cast<std::size_t>(width));
+  for (int i = 0; i < filled; ++i) {
+    const float t = static_cast<float>(i) / static_cast<float>(denom);
+    Color c = ramp(t, deep, mid, bright);
+    if (sweepCenter) {
+      const float intensity = sheenIntensity(static_cast<float>(i), *sweepCenter);
+      if (intensity > 0.0f) c = Color::Interpolate(intensity, c, kSheenPeak);
+    }
+    cells.push_back(text("█") | color(c));
+  }
+  for (int i = 0; i < empty; ++i) cells.push_back(text("░") | color(palette::rule));
+  return hbox(std::move(cells));
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/util/format.cpp b/src/util/format.cpp
index 4df1976..a52b143 100644
--- a/src/util/format.cpp
+++ b/src/util/format.cpp
@@ -5,6 +5,7 @@
 #include <cstdio>
 #include <map>
 #include <regex>
+#include <vector>

 namespace torlinkc {

@@ -52,4 +53,27 @@ std::string stripControl(const std::string& s) {
   return out;
 }

+std::string truncate(const std::string& s, int maxWidth) {
+  std::vector<std::size_t> starts;
+  std::size_t i = 0;
+  while (i < s.size()) {
+    starts.push_back(i);
+    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;
+    i += std::min(len, s.size() - i);
+  }
+  starts.push_back(s.size());
+  const int total = static_cast<int>(starts.size()) - 1;
+
+  if (maxWidth <= 1) {
+    const int keep = std::max(0, maxWidth);
+    return keep >= total ? s : s.substr(0, starts[static_cast<std::size_t>(keep)]);
+  }
+  if (total <= maxWidth) return s;
+  return s.substr(0, starts[static_cast<std::size_t>(maxWidth - 1)]) + "\xE2\x80\xA6";
+}
+
 }  // namespace torlinkc