ui(controls): visual overhaul for stepper & segmented

This commit is contained in:
Ly-sec
2026-05-03 17:43:13 +02:00
parent c410c645f3
commit f52c93325c
8 changed files with 241 additions and 49 deletions
+42
View File
@@ -336,6 +336,15 @@ void Input::setFrameVisible(bool visible) {
markPaintDirty();
}
void Input::setEmbeddedOnSolidPrimary(bool embedded) {
if (m_embeddedOnSolidPrimary == embedded) {
return;
}
m_embeddedOnSolidPrimary = embedded;
applyVisualState();
markPaintDirty();
}
void Input::setBold(bool bold) {
if (m_label != nullptr) {
m_label->setBold(bold);
@@ -708,6 +717,39 @@ void Input::applyVisualState() {
m_background->setVisible(false);
}
if (m_embeddedOnSolidPrimary && !m_frameVisible) {
auto selectionStyleEmb = m_selectionRect->style();
selectionStyleEmb.fill = resolved(ColorRole::Surface, 0.4f);
selectionStyleEmb.fillMode = FillMode::Solid;
selectionStyleEmb.radius = 2.0f;
m_selectionRect->setStyle(selectionStyleEmb);
auto cursorStyleEmb = m_cursor->style();
cursorStyleEmb.fill = resolved(ColorRole::Surface);
cursorStyleEmb.fillMode = FillMode::Solid;
cursorStyleEmb.radius = 1.0f;
m_cursor->setStyle(cursorStyleEmb);
if (m_invalid) {
m_label->setColor(colorSpecFromRole(ColorRole::Error));
} else if ((m_value.empty() && !m_placeholder.empty()) || readOnly) {
m_label->setColor(colorSpecFromRole(ColorRole::OnPrimary, 0.65f));
} else {
m_label->setColor(colorSpecFromRole(ColorRole::OnPrimary));
}
const Color passwordGlyphEmb =
m_invalid ? resolved(ColorRole::Error)
: (((m_value.empty() && !m_placeholder.empty()) || readOnly) ? resolved(ColorRole::OnPrimary, 0.65f)
: resolved(ColorRole::OnPrimary));
for (auto* glyph : m_passwordGlyphs) {
glyph->setColor(passwordGlyphEmb);
}
if (m_clearButtonGlyph != nullptr) {
m_clearButtonGlyph->setColor(resolved(ColorRole::OnPrimary, clearButtonHovered ? 1.0f : 0.72f));
}
return;
}
auto selectionStyle = m_selectionRect->style();
selectionStyle.fill = resolved(ColorRole::Primary);
selectionStyle.fillMode = FillMode::Solid;
+3
View File
@@ -39,6 +39,8 @@ public:
void setPasswordMode(bool enabled);
void setInvalid(bool invalid);
void setFrameVisible(bool visible);
/// When the frame is hidden, treat the field as sitting on a solid Primary fill (e.g. segmented control center).
void setEmbeddedOnSolidPrimary(bool embedded);
void setBold(bool bold);
void setMinLayoutWidth(float width);
void setTextAlign(TextAlign align);
@@ -129,6 +131,7 @@ private:
bool m_passwordMode = false;
bool m_invalid = false;
bool m_frameVisible = true;
bool m_embeddedOnSolidPrimary = false;
float m_minLayoutWidth = 0.0f;
float m_contentLeadSlack = 0.0f;
TextAlign m_textAlign = TextAlign::Start;
+31 -8
View File
@@ -2,6 +2,8 @@
#include "render/core/render_styles.h"
#include "ui/controls/button.h"
#include "ui/controls/flex.h"
#include "ui/controls/separator.h"
#include "ui/palette.h"
#include "ui/style.h"
@@ -20,8 +22,15 @@ std::size_t Segmented::addOption(std::string_view label) { return addOption(labe
std::size_t Segmented::addOption(std::string_view label, std::string_view glyph) {
const std::size_t index = m_buttons.size();
Button* btn = makeSegmentButton(label, glyph, index);
m_buttons.push_back(btn);
if (index > 0) {
auto sep = makeSegmentSeparator();
m_separators.push_back(sep.get());
addChild(std::move(sep));
}
auto btn = makeSegmentButton(label, glyph, index);
Button* raw = btn.get();
m_buttons.push_back(raw);
addChild(std::move(btn));
refreshVariants();
return index;
}
@@ -60,13 +69,29 @@ void Segmented::setScale(float scale) {
btn->setGlyphSize(fs);
}
}
const float ruleW = std::max(1.0f, Style::borderWidth * m_scale);
for (Separator* sep : m_separators) {
if (sep != nullptr) {
sep->setThickness(ruleW);
}
}
refreshVariants();
markLayoutDirty();
}
void Segmented::setOnChange(std::function<void(std::size_t)> callback) { m_onChange = std::move(callback); }
Button* Segmented::makeSegmentButton(std::string_view label, std::string_view glyph, std::size_t index) {
std::unique_ptr<Separator> Segmented::makeSegmentSeparator() {
auto sep = std::make_unique<Separator>();
sep->setOrientation(SeparatorOrientation::VerticalRule);
sep->setThickness(std::max(1.0f, Style::borderWidth * m_scale));
sep->setColor(colorSpecFromRole(ColorRole::Outline, 0.5f));
sep->setFlexGrow(0.0f);
return sep;
}
std::unique_ptr<Button> Segmented::makeSegmentButton(std::string_view label, std::string_view glyph,
std::size_t index) {
auto btn = std::make_unique<Button>();
if (!glyph.empty()) {
btn->setGlyph(glyph);
@@ -77,11 +102,9 @@ Button* Segmented::makeSegmentButton(std::string_view label, std::string_view gl
btn->setMinHeight(Style::controlHeight * m_scale);
btn->setPadding(Style::spaceXs * m_scale, Style::spaceMd * m_scale);
btn->setOnClick([this, index]() { setSelectedIndex(index); });
Button* raw = btn.get();
raw->setFlexGrow(m_equalSegmentWidths ? 1.0f : 0.0f);
raw->setContentAlign(ButtonContentAlign::Center);
addChild(std::move(btn));
return raw;
btn->setFlexGrow(m_equalSegmentWidths ? 1.0f : 0.0f);
btn->setContentAlign(ButtonContentAlign::Center);
return btn;
}
void Segmented::setEqualSegmentWidths(bool equalWidths) {
+6 -1
View File
@@ -4,10 +4,12 @@
#include <cstddef>
#include <functional>
#include <memory>
#include <string_view>
#include <vector>
class Button;
class Separator;
class Segmented : public Flex {
public:
@@ -28,11 +30,14 @@ public:
void setEqualSegmentWidths(bool equalWidths);
private:
Button* makeSegmentButton(std::string_view label, std::string_view glyph, std::size_t index);
[[nodiscard]] std::unique_ptr<Separator> makeSegmentSeparator();
[[nodiscard]] std::unique_ptr<Button> makeSegmentButton(std::string_view label, std::string_view glyph,
std::size_t index);
void refreshVariants();
void applyOuterStyle();
[[nodiscard]] float effectiveFontSize() const noexcept;
std::vector<Separator*> m_separators;
std::vector<Button*> m_buttons;
std::size_t m_selected = 0;
std::function<void(std::size_t)> m_onChange;
+52 -14
View File
@@ -23,13 +23,54 @@ void Separator::setThickness(float thickness) {
markLayoutDirty();
}
void Separator::doLayout(Renderer& /*renderer*/) {
bool horizontal = true;
if (auto* flex = dynamic_cast<Flex*>(parent()); flex != nullptr) {
horizontal = flex->direction() == FlexDirection::Vertical;
void Separator::setOrientation(SeparatorOrientation orientation) {
if (m_orientation == orientation) {
return;
}
m_orientation = orientation;
markLayoutDirty();
}
if (horizontal) {
bool Separator::ruleIsHorizontal() const {
if (m_orientation == SeparatorOrientation::HorizontalRule) {
return true;
}
if (m_orientation == SeparatorOrientation::VerticalRule) {
return false;
}
if (const auto* flex = dynamic_cast<const Flex*>(parent()); flex != nullptr) {
return flex->direction() == FlexDirection::Vertical;
}
return true;
}
LayoutSize Separator::doMeasure(Renderer& renderer, const LayoutConstraints& constraints) {
const bool horiz = ruleIsHorizontal();
float w = 0.0f;
float h = 0.0f;
if (horiz) {
h = m_thickness;
if (constraints.hasExactWidth()) {
w = constraints.maxWidth;
} else {
w = width() > 0.0f ? width() : m_thickness;
}
} else {
w = m_thickness;
if (constraints.hasExactHeight()) {
h = constraints.maxHeight;
} else {
h = height() > 0.0f ? height() : m_thickness;
}
}
setSize(w, h);
doLayout(renderer);
return constraints.constrain(LayoutSize{w, h});
}
void Separator::doLayout(Renderer& /*renderer*/) {
const bool horiz = ruleIsHorizontal();
if (horiz) {
const float w = width() > 0.0f ? width() : (parent() != nullptr ? parent()->width() : 0.0f);
setSize(w, m_thickness);
const float halfW = w * 0.5f;
@@ -38,28 +79,25 @@ void Separator::doLayout(Renderer& /*renderer*/) {
m_rectEnd->setPosition(halfW, 0.0f);
m_rectEnd->setFrameSize(w - halfW, m_thickness);
} else {
const float h = height() > 0.0f ? height() : (parent() != nullptr ? parent()->height() : 0.0f);
setSize(m_thickness, h);
const float halfH = h * 0.5f;
const float lineH = height() > 0.0f ? height() : (parent() != nullptr ? parent()->height() : 0.0f);
setSize(m_thickness, lineH);
const float halfH = lineH * 0.5f;
m_rectStart->setPosition(0.0f, 0.0f);
m_rectStart->setFrameSize(m_thickness, halfH);
m_rectEnd->setPosition(0.0f, halfH);
m_rectEnd->setFrameSize(m_thickness, h - halfH);
m_rectEnd->setFrameSize(m_thickness, lineH - halfH);
}
applyPalette();
}
void Separator::applyPalette() {
bool horizontal = true;
if (auto* flex = dynamic_cast<Flex*>(parent()); flex != nullptr) {
horizontal = flex->direction() == FlexDirection::Vertical;
}
const bool horiz = ruleIsHorizontal();
const Color opaque = resolveColorSpec(m_color);
Color transparent = opaque;
transparent.a = 0.0f;
const GradientDirection dir = horizontal ? GradientDirection::Horizontal : GradientDirection::Vertical;
const GradientDirection dir = horiz ? GradientDirection::Horizontal : GradientDirection::Vertical;
m_rectStart->setStyle(RoundedRectStyle{
.fill = transparent,
+13
View File
@@ -4,25 +4,38 @@
#include "ui/palette.h"
#include "ui/signal.h"
#include <cstdint>
class RectNode;
class Renderer;
enum class SeparatorOrientation : std::uint8_t {
// Infer from parent: horizontal rule inside a vertical Flex, vertical rule inside a horizontal Flex.
Auto,
HorizontalRule,
VerticalRule,
};
class Separator : public Node {
public:
Separator();
void setColor(const ColorSpec& color);
void setThickness(float thickness);
void setOrientation(SeparatorOrientation orientation);
protected:
LayoutSize doMeasure(Renderer& renderer, const LayoutConstraints& constraints) override;
void doLayout(Renderer& renderer) override;
private:
[[nodiscard]] bool ruleIsHorizontal() const;
void applyPalette();
RectNode* m_rectStart = nullptr;
RectNode* m_rectEnd = nullptr;
ColorSpec m_color = colorSpecFromRole(ColorRole::Outline);
float m_thickness = 1.0f;
SeparatorOrientation m_orientation = SeparatorOrientation::Auto;
Signal<>::ScopedConnection m_paletteConn;
};
+88 -25
View File
@@ -1,9 +1,12 @@
#include "ui/controls/stepper.h"
#include "render/core/render_styles.h"
#include "render/core/renderer.h"
#include "render/scene/input_area.h"
#include "ui/controls/button.h"
#include "ui/controls/flex.h"
#include "ui/controls/input.h"
#include "ui/controls/separator.h"
#include "ui/palette.h"
#include "ui/style.h"
@@ -17,7 +20,15 @@
namespace {
constexpr float kDefaultMinWidth = 140.0f;
constexpr float kValueFieldHPadding = 2.0f;
std::unique_ptr<Separator> makeStepperSeparator(float scale) {
auto sep = std::make_unique<Separator>();
sep->setOrientation(SeparatorOrientation::VerticalRule);
sep->setThickness(std::max(1.0f, Style::borderWidth * scale));
sep->setColor(colorSpecFromRole(ColorRole::Outline, 0.5f));
sep->setFlexGrow(0.0f);
return sep;
}
std::string trimAscii(std::string_view s) {
std::size_t a = 0;
@@ -35,24 +46,24 @@ namespace {
Stepper::Stepper() {
setDirection(FlexDirection::Horizontal);
setAlign(FlexAlign::Center);
setJustify(FlexJustify::SpaceBetween);
setAlign(FlexAlign::Stretch);
setJustify(FlexJustify::Start);
setGap(0.0f);
setPadding(Style::spaceXs, Style::spaceXs);
setPadding(0.0f);
setMinWidth(kDefaultMinWidth);
setFill(colorSpecFromRole(ColorRole::SurfaceVariant));
setBorder(colorSpecFromRole(ColorRole::Outline), Style::borderWidth);
clearBorder();
setRadius(Style::radiusMd);
auto makeStepButton = [this](bool increment) -> std::unique_ptr<Button> {
auto btn = std::make_unique<Button>();
btn->setVariant(ButtonVariant::Ghost);
btn->setVariant(ButtonVariant::Tab);
btn->setGlyph(increment ? "plus" : "minus");
btn->setGlyphSize(Style::fontSizeBody);
btn->setMinWidth(Style::controlHeightSm);
btn->setMinHeight(Style::controlHeightSm);
btn->setPadding(0.0f);
btn->setMinHeight(Style::controlHeight);
btn->setPadding(Style::spaceXs, Style::spaceMd);
btn->setContentAlign(ButtonContentAlign::Center);
btn->setFlexGrow(0.0f);
btn->setOnClick([this, increment]() { stepBy(increment ? 1 : -1); });
return btn;
};
@@ -64,19 +75,45 @@ Stepper::Stepper() {
}
{
auto sep = makeStepperSeparator(m_scale);
m_separatorBeforeValue = sep.get();
addChild(std::move(sep));
}
{
auto track = std::make_unique<Flex>();
track->setDirection(FlexDirection::Horizontal);
track->setAlign(FlexAlign::Stretch);
track->setJustify(FlexJustify::Center);
track->setGap(0.0f);
track->setPadding(0.0f);
track->setFlexGrow(1.0f);
track->setMinHeight(Style::controlHeight);
track->clearFill();
track->clearBorder();
track->setRadii(Radii{});
m_valueTrack = track.get();
auto field = std::make_unique<Input>();
field->setFrameVisible(false);
field->setBold(true);
field->setTextAlign(TextAlign::Center);
field->setFontSize(Style::fontSizeBody);
field->setControlHeight(Style::controlHeightSm);
field->setHorizontalPadding(kValueFieldHPadding);
field->setControlHeight(Style::controlHeight);
field->setHorizontalPadding(Style::spaceMd);
field->setFlexGrow(1.0f);
field->setOnSubmit([this](const std::string& /*text*/) { commitValueField(); });
field->setOnFocusLoss([this]() { commitValueField(); });
field->setOnKeyEvent([this](std::uint32_t sym, std::uint32_t mod) { return swallowNonNumericKey(sym, mod); });
m_valueInput = field.get();
addChild(std::move(field));
track->addChild(std::move(field));
addChild(std::move(track));
}
{
auto sep = makeStepperSeparator(m_scale);
m_separatorAfterValue = sep.get();
addChild(std::move(sep));
}
{
@@ -87,6 +124,7 @@ Stepper::Stepper() {
syncValueField();
refreshButtons();
refreshSegmentStyle();
}
void Stepper::setRange(int minValue, int maxValue) {
@@ -144,29 +182,28 @@ void Stepper::setOnValueChanged(std::function<void(int)> callback) { m_onValueCh
void Stepper::setScale(float scale) {
m_scale = std::max(0.1f, scale);
setGap(0.0f);
setPadding(Style::spaceXs * m_scale, Style::spaceXs * m_scale);
setPadding(0.0f);
setMinWidth(kDefaultMinWidth * m_scale);
setRadius(Style::radiusMd * m_scale);
setBorder(colorSpecFromRole(ColorRole::Outline), Style::borderWidth * m_scale);
clearBorder();
if (m_valueTrack != nullptr) {
m_valueTrack->setMinHeight(Style::controlHeight * m_scale);
}
if (m_valueInput != nullptr) {
m_valueInput->setFontSize(Style::fontSizeBody * m_scale);
m_valueInput->setControlHeight(Style::controlHeightSm * m_scale);
m_valueInput->setHorizontalPadding(kValueFieldHPadding * m_scale);
m_valueInput->setControlHeight(Style::controlHeight * m_scale);
m_valueInput->setHorizontalPadding(Style::spaceMd * m_scale);
}
if (m_decrement != nullptr) {
m_decrement->setGlyphSize(Style::fontSizeBody * m_scale);
m_decrement->setMinWidth(Style::controlHeightSm * m_scale);
m_decrement->setMinHeight(Style::controlHeightSm * m_scale);
m_decrement->setPadding(0.0f);
m_decrement->setRadius(Style::radiusMd * m_scale);
m_decrement->setMinHeight(Style::controlHeight * m_scale);
m_decrement->setPadding(Style::spaceXs * m_scale, Style::spaceMd * m_scale);
}
if (m_increment != nullptr) {
m_increment->setGlyphSize(Style::fontSizeBody * m_scale);
m_increment->setMinWidth(Style::controlHeightSm * m_scale);
m_increment->setMinHeight(Style::controlHeightSm * m_scale);
m_increment->setPadding(0.0f);
m_increment->setRadius(Style::radiusMd * m_scale);
m_increment->setMinHeight(Style::controlHeight * m_scale);
m_increment->setPadding(Style::spaceXs * m_scale, Style::spaceMd * m_scale);
}
refreshSegmentStyle();
markLayoutDirty();
}
@@ -274,3 +311,29 @@ void Stepper::refreshButtons() {
m_increment->setEnabled(m_enabled && m_value < m_max);
}
}
void Stepper::refreshSegmentStyle() {
const float r = Style::radiusMd * m_scale;
setFill(colorSpecFromRole(ColorRole::SurfaceVariant));
clearBorder();
setRadius(r);
if (m_decrement != nullptr) {
m_decrement->setVariant(ButtonVariant::Tab);
m_decrement->setRadii({r, 0.0f, 0.0f, r});
}
if (m_increment != nullptr) {
m_increment->setVariant(ButtonVariant::Tab);
m_increment->setRadii({0.0f, r, r, 0.0f});
}
if (m_valueTrack != nullptr) {
m_valueTrack->setRadii(Radii{});
m_valueTrack->clearFill();
}
const float ruleW = std::max(1.0f, Style::borderWidth * m_scale);
if (m_separatorBeforeValue != nullptr) {
m_separatorBeforeValue->setThickness(ruleW);
}
if (m_separatorAfterValue != nullptr) {
m_separatorAfterValue->setThickness(ruleW);
}
}
+6 -1
View File
@@ -7,9 +7,10 @@
class Button;
class Input;
class Separator;
class Renderer;
// Horizontal numeric stepper: [ ] editable value [ + ]
// Horizontal numeric stepper styled like Segmented (shared SurfaceVariant track, Tab-style ends + center).
class Stepper : public Flex {
public:
Stepper();
@@ -40,8 +41,12 @@ private:
void commitValueField();
bool swallowNonNumericKey(std::uint32_t sym, std::uint32_t modifiers);
void refreshButtons();
void refreshSegmentStyle();
Button* m_decrement = nullptr;
Separator* m_separatorBeforeValue = nullptr;
Flex* m_valueTrack = nullptr;
Separator* m_separatorAfterValue = nullptr;
Button* m_increment = nullptr;
Input* m_valueInput = nullptr;