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