feat(controls): add optional scrolling to label

This commit is contained in:
Ly-sec
2026-05-03 13:29:20 +02:00
parent f3e2102513
commit 8508c269b2
3 changed files with 356 additions and 47 deletions
+23
View File
@@ -218,6 +218,29 @@ void TestPanel::create() {
colA->addChild(std::move(section));
}
// Label (auto-scroll)
{
auto marquee = std::make_unique<Label>();
marquee->setText("This label scrolls automatically when the line is longer than its layout width :p");
marquee->setFontSize(Style::fontSizeBody * scale);
marquee->setMaxWidth(240.0f * scale);
marquee->setAutoScroll(true);
marquee->setAutoScrollSpeed(42.0f * scale);
auto marqueeHover = std::make_unique<Label>();
marqueeHover->setText("Hover this row to scroll - the marquee pauses when the pointer leaves the label.");
marqueeHover->setFontSize(Style::fontSizeBody * scale);
marqueeHover->setMaxWidth(240.0f * scale);
marqueeHover->setAutoScroll(true);
marqueeHover->setAutoScrollSpeed(42.0f * scale);
marqueeHover->setAutoScrollOnlyWhenHovered(true);
auto section = makeSection("Label (auto-scroll)");
section->addChild(std::move(marquee));
section->addChild(std::move(marqueeHover));
colA->addChild(std::move(section));
}
// Slider
{
auto slider = std::make_unique<Slider>();
+295 -45
View File
@@ -1,5 +1,7 @@
#include "ui/controls/label.h"
#include "render/animation/animation.h"
#include "render/animation/animation_manager.h"
#include "render/core/renderer.h"
#include "ui/palette.h"
#include "ui/style.h"
@@ -8,7 +10,13 @@
#include <cmath>
#include <memory>
Label::Label() {
namespace {
constexpr const char* kMarqueeGap = " ";
} // namespace
Label::Label() : InputArea() {
auto textNode = std::make_unique<TextNode>();
m_textNode = static_cast<TextNode*>(addChild(std::move(textNode)));
m_textNode->setFontSize(Style::fontSizeBody);
@@ -17,9 +25,11 @@ Label::Label() {
}
bool Label::setText(std::string_view text) {
if (m_textNode->text() == text)
if (m_plainText == text) {
return false;
m_textNode->setText(std::string(text));
}
m_plainText = std::string(text);
m_textNode->setText(m_plainText);
m_measureCached = false;
return true;
}
@@ -50,18 +60,24 @@ void Label::setMinWidth(float minWidth) {
}
void Label::setMaxWidth(float maxWidth) {
if (m_textNode->maxWidth() == maxWidth) {
if (m_userMaxWidth == maxWidth) {
return;
}
m_textNode->setMaxWidth(maxWidth);
m_userMaxWidth = maxWidth;
if (!m_autoScroll) {
m_textNode->setMaxWidth(maxWidth);
}
m_measureCached = false;
}
void Label::setMaxLines(int maxLines) {
if (m_textNode->maxLines() == maxLines) {
if (m_userMaxLines == maxLines) {
return;
}
m_textNode->setMaxLines(maxLines);
m_userMaxLines = maxLines;
if (!m_autoScroll) {
m_textNode->setMaxLines(maxLines);
}
m_measureCached = false;
}
@@ -73,13 +89,13 @@ void Label::setBold(bool bold) {
m_measureCached = false;
}
const std::string& Label::text() const noexcept { return m_textNode->text(); }
const std::string& Label::text() const noexcept { return m_plainText; }
float Label::fontSize() const noexcept { return m_textNode->fontSize(); }
const Color& Label::color() const noexcept { return m_textNode->color(); }
float Label::maxWidth() const noexcept { return m_textNode->maxWidth(); }
float Label::maxWidth() const noexcept { return m_userMaxWidth; }
bool Label::bold() const noexcept { return m_textNode->bold(); }
@@ -107,6 +123,195 @@ void Label::setShadow(const Color& color, float offsetX, float offsetY) {
void Label::clearShadow() { m_textNode->clearShadow(); }
void Label::setAutoScroll(bool enabled) {
if (m_autoScroll == enabled) {
return;
}
m_autoScroll = enabled;
stopScrollAnimations();
m_scrollOffset = 0.0f;
syncTextNodeConstraints();
m_measureCached = false;
syncHoverInteraction();
}
void Label::setAutoScrollOnlyWhenHovered(bool enabled) {
if (m_autoScrollHoverOnly == enabled) {
return;
}
m_autoScrollHoverOnly = enabled;
syncHoverInteraction();
restartScrollIfNeeded();
}
void Label::syncHoverInteraction() {
if (!m_autoScroll || !m_autoScrollHoverOnly) {
setOnEnter(nullptr);
setOnLeave(nullptr);
return;
}
setOnEnter([this](const PointerData&) { restartScrollIfNeeded(); });
setOnLeave([this]() { restartScrollIfNeeded(); });
}
void Label::setAutoScrollSpeed(float pixelsPerSecond) {
const float next = std::max(pixelsPerSecond, 1.0f);
if (m_scrollSpeedPxPerSec == next) {
return;
}
m_scrollSpeedPxPerSec = next;
if (!m_autoScroll) {
return;
}
stopScrollAnimations();
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
startMarqueeLoop();
}
void Label::syncTextNodeConstraints() {
if (m_autoScroll) {
m_textNode->setMaxWidth(0.0f);
m_textNode->setMaxLines(1);
} else {
m_textNode->setMaxWidth(m_userMaxWidth);
m_textNode->setMaxLines(m_userMaxLines);
}
}
void Label::applyScrollPosition() { m_textNode->setPosition(m_textBaseX - m_scrollOffset, m_baselineOffset); }
void Label::stopMarqueeAnimation() {
if (animationManager() != nullptr && m_marqueeAnimId != 0) {
animationManager()->cancel(m_marqueeAnimId);
}
m_marqueeAnimId = 0;
}
void Label::stopSnapAnimation() {
if (animationManager() != nullptr && m_snapAnimId != 0) {
animationManager()->cancel(m_snapAnimId);
}
m_snapAnimId = 0;
}
void Label::stopScrollAnimations() {
stopMarqueeAnimation();
stopSnapAnimation();
}
void Label::startSnapToZero() {
stopMarqueeAnimation();
if (m_scrollOffset <= 0.5f) {
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
return;
}
if (animationManager() == nullptr) {
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
return;
}
if (m_snapAnimId != 0) {
return;
}
const float from = m_scrollOffset;
const float rewindSpeed = m_scrollSpeedPxPerSec * 8.0f;
float durationMs = std::max(36.0f, (from / rewindSpeed) * 1000.0f);
durationMs = std::min(durationMs, 180.0f);
m_snapAnimId = animationManager()->animate(
from, 0.0f, durationMs, Easing::EaseOutCubic,
[this](float v) {
m_scrollOffset = v;
applyScrollPosition();
markPaintDirty();
},
[this]() {
m_snapAnimId = 0;
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
},
this);
}
void Label::startMarqueeLoop() {
if (!m_autoScroll || animationManager() == nullptr) {
return;
}
if (m_autoScrollHoverOnly && !hovered()) {
return;
}
const float viewportW = width();
if (viewportW <= 0.0f || m_fullTextWidth <= viewportW + 0.5f) {
return;
}
if (m_marqueeLoopPeriod <= 0.0f) {
return;
}
if (m_marqueeAnimId != 0) {
return;
}
stopSnapAnimation();
const float period = m_marqueeLoopPeriod;
const float durationMs = (period / m_scrollSpeedPxPerSec) * 1000.0f;
m_marqueeAnimId = animationManager()->animate(
0.0f, period, durationMs, Easing::Linear,
[this](float v) {
m_scrollOffset = v;
applyScrollPosition();
markPaintDirty();
},
[this]() {
m_marqueeAnimId = 0;
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
startMarqueeLoop();
},
this);
}
void Label::restartScrollIfNeeded() {
stopMarqueeAnimation();
const bool overflow = m_autoScroll && width() > 0.0f && m_fullTextWidth > width() + 0.5f;
if (!overflow) {
stopSnapAnimation();
m_scrollOffset = 0.0f;
setClipChildren(false);
m_textNode->setText(m_plainText);
applyScrollPosition();
markPaintDirty();
return;
}
setClipChildren(true);
const bool runMarquee = !m_autoScrollHoverOnly || hovered();
if (!runMarquee) {
if (m_scrollOffset > 0.5f) {
startSnapToZero();
} else {
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
}
return;
}
stopSnapAnimation();
m_scrollOffset = 0.0f;
applyScrollPosition();
markPaintDirty();
startMarqueeLoop();
}
void Label::doLayout(Renderer& renderer) { measure(renderer); }
LayoutSize Label::doMeasure(Renderer& renderer, const LayoutConstraints& constraints) {
@@ -132,42 +337,42 @@ void Label::measure(Renderer& renderer) {
}
LayoutSize Label::measureWithConstraints(Renderer& renderer, const LayoutConstraints& constraints) {
const float configuredMaxWidth = m_textNode->maxWidth();
const float configuredMaxWidth = m_userMaxWidth;
float measureMaxWidth = configuredMaxWidth;
if (constraints.hasMaxWidth) {
measureMaxWidth =
configuredMaxWidth > 0.0f ? std::min(configuredMaxWidth, constraints.maxWidth) : constraints.maxWidth;
}
const int maxLines = m_textNode->maxLines();
const bool singleLine = (maxLines == 1) || (maxLines == 0 && configuredMaxWidth <= 0.0f &&
m_textNode->text().find('\n') == std::string::npos);
if (m_autoScroll) {
measureMaxWidth = 0.0f;
}
const int effectiveMaxLines = m_autoScroll ? 1 : m_userMaxLines;
const bool singleLine =
m_autoScroll || (effectiveMaxLines == 1) ||
(effectiveMaxLines == 0 && configuredMaxWidth <= 0.0f && m_plainText.find('\n') == std::string::npos);
const TextAlign align = m_textNode->textAlign();
if (m_measureCached && m_cachedText == m_textNode->text() && m_cachedFontSize == m_textNode->fontSize() &&
m_cachedBold == m_textNode->bold() && m_cachedMaxWidth == configuredMaxWidth && m_cachedMaxLines == maxLines &&
if (m_measureCached && m_cachedText == m_plainText && m_cachedFontSize == m_textNode->fontSize() &&
m_cachedBold == m_textNode->bold() && m_cachedMaxWidth == m_userMaxWidth && m_cachedMaxLines == m_userMaxLines &&
m_cachedMinWidth == m_minWidth && m_cachedConstraintMinWidth == constraints.minWidth &&
m_cachedConstraintMaxWidth == constraints.maxWidth && m_cachedHasConstraintMaxWidth == constraints.hasMaxWidth &&
m_cachedTextAlign == align && m_cachedStableBaseline == m_stableBaseline) {
m_cachedTextAlign == align && m_cachedStableBaseline == m_stableBaseline && m_cachedAutoScroll == m_autoScroll) {
return LayoutSize{.width = width(), .height = height()};
}
auto metrics = renderer.measureText(m_textNode->text(), m_textNode->fontSize(), m_textNode->bold(), measureMaxWidth,
maxLines, align);
syncTextNodeConstraints();
auto metrics = renderer.measureText(m_plainText, m_textNode->fontSize(), m_textNode->bold(), measureMaxWidth,
effectiveMaxLines, align);
auto refMetrics = renderer.measureText("A", m_textNode->fontSize(), m_textNode->bold());
const float measuredWidth = measureMaxWidth > 0.0f ? std::min(metrics.width, measureMaxWidth) : metrics.width;
m_fullTextWidth = m_autoScroll ? measuredWidth : 0.0f;
const bool hasAssignedWidth = constraints.hasExactWidth();
const float assignedWidth = constraints.maxWidth;
const float refHeight = refMetrics.bottom - refMetrics.top;
const float actualHeight = metrics.bottom - metrics.top;
const float inkHeight = std::max(0.0f, metrics.inkBottom - metrics.inkTop);
// Keep single-line labels on the same reference height as glyphs, but center
// the visible text ink within that height so digits and symbols do not read
// optically low beside icons.
if (singleLine && inkHeight > 0.0f) {
// Stable-baseline labels center on the caps reference ("A") instead of the
// current text's ink. That keeps caps at a fixed y across text changes
// (e.g. a clock cycling "Mar" → "Apr") AND matches the y-position used by
// sibling dynamic-mode labels whose text happens to be caps-only (e.g. a
// weather capsule reading "15°C"), so they align horizontally.
float inkTopForCentering = metrics.inkTop;
float inkHeightForCentering = inkHeight;
if (m_stableBaseline) {
@@ -177,48 +382,93 @@ LayoutSize Label::measureWithConstraints(Renderer& renderer, const LayoutConstra
inkHeightForCentering = capInkHeight;
}
}
// Round height BEFORE computing the ink-centering offset so the ink center
// lands at the geometric center of the rounded (visible) box, not the
// unrounded refHeight — otherwise callers that center the label box inside
// a parent see the ink offset by up to 0.5px.
const float height = std::round(std::max(refHeight, inkHeight));
m_baselineOffset = -inkTopForCentering + (height - inkHeightForCentering) * 0.5f;
const float finalWidth =
hasAssignedWidth ? std::max(assignedWidth, m_minWidth) : std::max(measuredWidth, m_minWidth);
float finalWidth = 0.0f;
if (m_autoScroll) {
float boxW = m_fullTextWidth;
if (hasAssignedWidth) {
boxW = assignedWidth;
} else {
if (constraints.hasMaxWidth) {
boxW = std::min(boxW, constraints.maxWidth);
}
if (m_userMaxWidth > 0.0f) {
boxW = std::min(boxW, m_userMaxWidth);
}
}
finalWidth = std::max(boxW, m_minWidth);
} else {
finalWidth = hasAssignedWidth ? std::max(assignedWidth, m_minWidth) : std::max(measuredWidth, m_minWidth);
}
setSize(std::round(finalWidth), height);
} else {
m_baselineOffset = -std::min(refMetrics.top, metrics.top);
const float inkBottom = m_baselineOffset + metrics.bottom;
const float height = std::max({refHeight, actualHeight, inkBottom});
const float finalWidth =
hasAssignedWidth ? std::max(assignedWidth, m_minWidth) : std::max(measuredWidth, m_minWidth);
float finalWidth = 0.0f;
if (m_autoScroll) {
float boxW = m_fullTextWidth;
if (hasAssignedWidth) {
boxW = assignedWidth;
} else {
if (constraints.hasMaxWidth) {
boxW = std::min(boxW, constraints.maxWidth);
}
if (m_userMaxWidth > 0.0f) {
boxW = std::min(boxW, m_userMaxWidth);
}
}
finalWidth = std::max(boxW, m_minWidth);
} else {
finalWidth = hasAssignedWidth ? std::max(assignedWidth, m_minWidth) : std::max(measuredWidth, m_minWidth);
}
setSize(std::round(finalWidth), std::round(height));
}
if (width() < m_minWidth) {
setSize(std::round(m_minWidth), height());
}
const float layoutWidth = width();
const bool overflow = m_autoScroll && m_fullTextWidth > layoutWidth + 0.5f;
const float alignWidth = m_autoScroll ? m_fullTextWidth : measuredWidth;
float textX = 0.0f;
const float finalWidth = width();
if (align == TextAlign::Center) {
textX = (finalWidth - measuredWidth) * 0.5f;
} else if (align == TextAlign::End) {
textX = finalWidth - measuredWidth;
if (!overflow) {
if (align == TextAlign::Center) {
textX = (layoutWidth - alignWidth) * 0.5f;
} else if (align == TextAlign::End) {
textX = layoutWidth - alignWidth;
}
}
m_textBaseX = overflow ? 0.0f : textX;
if (!overflow) {
m_scrollOffset = 0.0f;
}
// Keep subpixel baseline/text offsets here; cairo text rendering performs
// a single final snap in device-pixel space after full world transform.
m_textNode->setPosition(textX, m_baselineOffset);
m_cachedText = m_textNode->text();
if (overflow && m_autoScroll) {
auto gapMetrics = renderer.measureText(kMarqueeGap, m_textNode->fontSize(), m_textNode->bold(), 0.0f, 1, align);
m_marqueeLoopPeriod = m_fullTextWidth + gapMetrics.width;
m_textNode->setText(m_plainText + kMarqueeGap + m_plainText);
} else {
m_marqueeLoopPeriod = 0.0f;
m_textNode->setText(m_plainText);
}
applyScrollPosition();
m_cachedText = m_plainText;
m_cachedFontSize = m_textNode->fontSize();
m_cachedBold = m_textNode->bold();
m_cachedMaxWidth = configuredMaxWidth;
m_cachedMaxLines = maxLines;
m_cachedMaxWidth = m_userMaxWidth;
m_cachedMaxLines = m_userMaxLines;
m_cachedMinWidth = m_minWidth;
m_cachedConstraintMinWidth = constraints.minWidth;
m_cachedConstraintMaxWidth = constraints.maxWidth;
m_cachedHasConstraintMaxWidth = constraints.hasMaxWidth;
m_cachedTextAlign = align;
m_cachedStableBaseline = m_stableBaseline;
m_cachedAutoScroll = m_autoScroll;
m_measureCached = true;
restartScrollIfNeeded();
return LayoutSize{.width = width(), .height = height()};
}
+38 -2
View File
@@ -1,17 +1,18 @@
#pragma once
#include "render/core/color.h"
#include "render/scene/node.h"
#include "render/scene/input_area.h"
#include "render/scene/text_node.h"
#include "ui/palette.h"
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
class Renderer;
class Label : public Node {
class Label : public InputArea {
public:
Label();
@@ -33,6 +34,16 @@ public:
void setStableBaseline(bool stable);
void setShadow(const Color& color, float offsetX, float offsetY);
void clearShadow();
// Single-line horizontal marquee when the line is wider than the laid-out width.
// Constrain width with parent layout and/or setMaxWidth() — Flex ignores preset setSize().
// Requires an AnimationManager on the scene (via setAnimationManager).
void setAutoScroll(bool enabled);
void setAutoScrollSpeed(float pixelsPerSecond);
// When true (with auto-scroll), marquee runs only while the pointer is over the label.
void setAutoScrollOnlyWhenHovered(bool enabled);
[[nodiscard]] bool autoScroll() const noexcept { return m_autoScroll; }
[[nodiscard]] float autoScrollSpeed() const noexcept { return m_scrollSpeedPxPerSec; }
[[nodiscard]] bool autoScrollOnlyWhenHovered() const noexcept { return m_autoScrollHoverOnly; }
[[nodiscard]] const std::string& text() const noexcept;
[[nodiscard]] float fontSize() const noexcept;
@@ -52,6 +63,15 @@ private:
void doArrange(Renderer& renderer, const LayoutRect& rect) override;
void applyPalette();
LayoutSize measureWithConstraints(Renderer& renderer, const LayoutConstraints& constraints);
void syncTextNodeConstraints();
void restartScrollIfNeeded();
void stopMarqueeAnimation();
void stopSnapAnimation();
void stopScrollAnimations();
void startMarqueeLoop();
void startSnapToZero();
void applyScrollPosition();
void syncHoverInteraction();
TextNode* m_textNode = nullptr;
float m_minWidth = 0.0f;
@@ -59,6 +79,9 @@ private:
ColorSpec m_color = colorSpecFromRole(ColorRole::OnSurface);
Signal<>::ScopedConnection m_paletteConn;
// User-visible text (wire text may duplicate for seamless marquee).
std::string m_plainText;
// Memoized measure() inputs — lets repeated layout passes with identical
// text skip the Pango/fontconfig path entirely.
std::string m_cachedText;
@@ -71,7 +94,20 @@ private:
TextAlign m_cachedTextAlign = TextAlign::Start;
bool m_cachedBold = false;
bool m_cachedStableBaseline = false;
bool m_cachedAutoScroll = false;
bool m_cachedHasConstraintMaxWidth = false;
bool m_measureCached = false;
bool m_stableBaseline = false;
float m_userMaxWidth = 0.0f;
int m_userMaxLines = 0;
bool m_autoScroll = false;
bool m_autoScrollHoverOnly = false;
float m_scrollSpeedPxPerSec = 48.0f;
float m_scrollOffset = 0.0f;
float m_fullTextWidth = 0.0f;
float m_marqueeLoopPeriod = 0.0f;
float m_textBaseX = 0.0f;
std::uint32_t m_marqueeAnimId = 0;
std::uint32_t m_snapAnimId = 0;
};