mirror of
https://github.com/noctalia-dev/noctalia-shell.git
synced 2026-05-11 17:08:27 +08:00
feat(controls): add stepper control
This commit is contained in:
@@ -549,6 +549,7 @@ _noctalia_sources = files(
|
||||
'src/ui/controls/slider.cpp',
|
||||
'src/ui/controls/spacer.cpp',
|
||||
'src/ui/controls/spinner.cpp',
|
||||
'src/ui/controls/stepper.cpp',
|
||||
'src/ui/controls/toggle.cpp',
|
||||
'src/ui/palette.cpp',
|
||||
'src/util/fuzzy_match.cpp',
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
#include "ui/controls/select.h"
|
||||
#include "ui/controls/slider.h"
|
||||
#include "ui/controls/spinner.h"
|
||||
#include "ui/controls/stepper.h"
|
||||
#include "ui/controls/toggle.h"
|
||||
#include "ui/dialogs/color_picker_dialog.h"
|
||||
#include "ui/dialogs/file_dialog.h"
|
||||
@@ -420,6 +421,31 @@ void TestPanel::create() {
|
||||
colB->addChild(std::move(section));
|
||||
}
|
||||
|
||||
{
|
||||
auto stepper = std::make_unique<Stepper>();
|
||||
stepper->setScale(scale);
|
||||
stepper->setRange(0, 99);
|
||||
stepper->setStep(1);
|
||||
stepper->setValue(42);
|
||||
stepper->setOnValueChanged([this](int v) {
|
||||
if (m_stepperValueLabel != nullptr) {
|
||||
m_stepperValueLabel->setText("onChange: " + std::to_string(v));
|
||||
}
|
||||
});
|
||||
m_stepper = stepper.get();
|
||||
|
||||
auto valueLabel = std::make_unique<Label>();
|
||||
valueLabel->setText("onChange: 42");
|
||||
valueLabel->setCaptionStyle();
|
||||
valueLabel->setFontSize(Style::fontSizeCaption * scale);
|
||||
m_stepperValueLabel = valueLabel.get();
|
||||
|
||||
auto section = makeSection("Stepper");
|
||||
section->addChild(std::move(stepper));
|
||||
section->addChild(std::move(valueLabel));
|
||||
colB->addChild(std::move(section));
|
||||
}
|
||||
|
||||
// ── Column C: File dialog, Color picker, Grid view, Transforms ──────────
|
||||
{
|
||||
auto resultLabel = std::make_unique<Label>();
|
||||
@@ -687,6 +713,8 @@ void TestPanel::onClose() {
|
||||
m_radioA = nullptr;
|
||||
m_radioB = nullptr;
|
||||
m_spinner = nullptr;
|
||||
m_stepper = nullptr;
|
||||
m_stepperValueLabel = nullptr;
|
||||
m_input = nullptr;
|
||||
m_inputValueLabel = nullptr;
|
||||
m_openFileDialogButton = nullptr;
|
||||
|
||||
@@ -14,6 +14,7 @@ class Select;
|
||||
class Label;
|
||||
class Slider;
|
||||
class Spinner;
|
||||
class Stepper;
|
||||
class Toggle;
|
||||
|
||||
class TestPanel : public Panel {
|
||||
@@ -51,6 +52,8 @@ private:
|
||||
RadioButton* m_radioA = nullptr;
|
||||
RadioButton* m_radioB = nullptr;
|
||||
Spinner* m_spinner = nullptr;
|
||||
Stepper* m_stepper = nullptr;
|
||||
Label* m_stepperValueLabel = nullptr;
|
||||
Input* m_input = nullptr;
|
||||
Label* m_inputValueLabel = nullptr;
|
||||
Button* m_openFileDialogButton = nullptr;
|
||||
|
||||
@@ -19,6 +19,8 @@ const std::unordered_map<std::string, char32_t> kIcons = {
|
||||
{"settings", 0xEB20},
|
||||
{"refresh", 0xEB13}, // refresh
|
||||
{"add", 0xEB0B}, // plus
|
||||
{"plus", 0xEB0B},
|
||||
{"minus", 0xEAF2},
|
||||
{"trash", 0xEB41},
|
||||
{"menu", 0xEC42}, // menu-2
|
||||
{"more-vertical", 0xEA94}, // dots-vertical
|
||||
|
||||
+104
-30
@@ -154,11 +154,14 @@ Input::Input() {
|
||||
stopCursorBlink();
|
||||
updateCursorVisibility();
|
||||
applyVisualState();
|
||||
if (m_onFocusLoss) {
|
||||
m_onFocusLoss();
|
||||
}
|
||||
});
|
||||
area->setOnPress([this](const InputArea::PointerData& data) {
|
||||
if (data.pressed) {
|
||||
const float textStartX = m_horizontalPadding + kTextInnerInset;
|
||||
const std::size_t offset = xToByteOffset(data.localX - textStartX + m_scrollOffset);
|
||||
const std::size_t offset = xToByteOffset(data.localX - textStartX + m_scrollOffset - m_contentLeadSlack);
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const bool isDoubleClick = data.button == BTN_LEFT && m_hasLastPrimaryPress &&
|
||||
now - m_lastPrimaryPressTime <= kDoubleClickThreshold &&
|
||||
@@ -204,7 +207,7 @@ Input::Input() {
|
||||
clampScrollOffset();
|
||||
if (!handledByEdgeScroll) {
|
||||
const float textStartX = m_horizontalPadding + kTextInnerInset;
|
||||
m_cursorPos = xToByteOffset(data.localX - textStartX + m_scrollOffset);
|
||||
m_cursorPos = xToByteOffset(data.localX - textStartX + m_scrollOffset - m_contentLeadSlack);
|
||||
}
|
||||
updateInteractiveGeometry();
|
||||
revealCursor();
|
||||
@@ -324,6 +327,39 @@ void Input::setInvalid(bool invalid) {
|
||||
markPaintDirty();
|
||||
}
|
||||
|
||||
void Input::setFrameVisible(bool visible) {
|
||||
if (m_frameVisible == visible) {
|
||||
return;
|
||||
}
|
||||
m_frameVisible = visible;
|
||||
applyVisualState();
|
||||
markPaintDirty();
|
||||
}
|
||||
|
||||
void Input::setBold(bool bold) {
|
||||
if (m_label != nullptr) {
|
||||
m_label->setBold(bold);
|
||||
}
|
||||
markLayoutDirty();
|
||||
}
|
||||
|
||||
void Input::setMinLayoutWidth(float width) {
|
||||
const float next = std::max(0.0f, width);
|
||||
if (m_minLayoutWidth == next) {
|
||||
return;
|
||||
}
|
||||
m_minLayoutWidth = next;
|
||||
markLayoutDirty();
|
||||
}
|
||||
|
||||
void Input::setTextAlign(TextAlign align) {
|
||||
if (m_textAlign == align) {
|
||||
return;
|
||||
}
|
||||
m_textAlign = align;
|
||||
markLayoutDirty();
|
||||
}
|
||||
|
||||
void Input::setOnChange(std::function<void(const std::string&)> callback) { m_onChange = std::move(callback); }
|
||||
|
||||
void Input::setOnSubmit(std::function<void(const std::string&)> callback) { m_onSubmit = std::move(callback); }
|
||||
@@ -332,6 +368,8 @@ void Input::setOnKeyEvent(std::function<bool(std::uint32_t, std::uint32_t)> call
|
||||
m_onKeyEvent = std::move(callback);
|
||||
}
|
||||
|
||||
void Input::setOnFocusLoss(std::function<void()> callback) { m_onFocusLoss = std::move(callback); }
|
||||
|
||||
void Input::setClipboardService(ClipboardService* clipboard) noexcept { g_clipboard = clipboard; }
|
||||
|
||||
void Input::setValidateKeyMatcher(std::function<bool(std::uint32_t, std::uint32_t)> matcher) noexcept {
|
||||
@@ -384,7 +422,9 @@ void Input::clearSelection() {
|
||||
}
|
||||
|
||||
void Input::doLayout(Renderer& renderer) {
|
||||
const float w = width() > 0.0f ? width() : kDefaultWidth;
|
||||
const float minFromHint = m_minLayoutWidth > 0.0f ? m_minLayoutWidth : 0.0f;
|
||||
const float wBase = width() > 0.0f ? width() : (minFromHint > 0.0f ? minFromHint : kDefaultWidth);
|
||||
const float w = std::max(wBase, minFromHint);
|
||||
const float h = m_controlHeight;
|
||||
setSize(w, h);
|
||||
const bool showClearButton = clearButtonVisible();
|
||||
@@ -429,17 +469,32 @@ void Input::doLayout(Renderer& renderer) {
|
||||
}
|
||||
}
|
||||
|
||||
m_contentLeadSlack = 0.0f;
|
||||
if (!showPasswordGlyphs && m_textAlign == TextAlign::Center) {
|
||||
const float textInset = m_horizontalPadding + kTextInnerInset;
|
||||
const float rightInset = showClearButton ? clearButtonTextReserveWidth() : textInset;
|
||||
const float vw = std::max(0.0f, w - textInset - rightInset);
|
||||
float textExtent = 0.0f;
|
||||
if (!m_value.empty() && m_stopX.size() > 1U) {
|
||||
textExtent = m_stopX.back();
|
||||
} else if (m_value.empty() && !m_placeholder.empty()) {
|
||||
textExtent = renderer.measureText(m_placeholder, m_fontSize, m_label->bold()).width;
|
||||
}
|
||||
if (vw > 0.0f && textExtent > 0.0f && textExtent + 0.5f < vw) {
|
||||
m_contentLeadSlack = std::round((vw - textExtent) * 0.5f);
|
||||
}
|
||||
}
|
||||
|
||||
if (m_inputArea != nullptr && m_inputArea->focused()) {
|
||||
ensureCursorVisible();
|
||||
} else {
|
||||
// Keep unfocused inputs anchored to the beginning of the text.
|
||||
m_scrollOffset = 0.0f;
|
||||
}
|
||||
updateInteractiveGeometry();
|
||||
|
||||
if (showPasswordGlyphs) {
|
||||
syncPasswordGlyphNodes(charCount);
|
||||
float maskX = 0.0f;
|
||||
float gx = m_contentLeadSlack - m_scrollOffset;
|
||||
for (std::size_t i = 0; i < m_passwordGlyphs.size(); ++i) {
|
||||
auto* glyph = m_passwordGlyphs[i];
|
||||
const char32_t codepoint = passwordMaskCodepointForIndex(i);
|
||||
@@ -447,14 +502,14 @@ void Input::doLayout(Renderer& renderer) {
|
||||
glyph->setCodepoint(codepoint);
|
||||
glyph->setFontSize(passwordGlyphSize);
|
||||
glyph->setColor(colorForRole(ColorRole::OnSurface));
|
||||
glyph->setPosition(maskX - m_scrollOffset, maskGlyphY);
|
||||
glyph->setPosition(gx, maskGlyphY);
|
||||
glyph->setVisible(true);
|
||||
maskX += metrics.width;
|
||||
gx += metrics.width;
|
||||
}
|
||||
} else {
|
||||
syncPasswordGlyphNodes(0);
|
||||
const float labelY = std::round((h - m_label->height()) * 0.5f);
|
||||
m_label->setPosition(-m_scrollOffset, labelY);
|
||||
m_label->setPosition(-m_scrollOffset + m_contentLeadSlack, labelY);
|
||||
}
|
||||
|
||||
m_background->setPosition(0.0f, 0.0f);
|
||||
@@ -485,6 +540,7 @@ void Input::doLayout(Renderer& renderer) {
|
||||
m_clearButtonGlyph->setPosition(buttonSize * 0.5f - glyphCenterX, h * 0.5f - glyphInkCenter);
|
||||
}
|
||||
|
||||
updateInteractiveGeometry();
|
||||
applyVisualState();
|
||||
updateCursorVisibility();
|
||||
}
|
||||
@@ -632,20 +688,25 @@ void Input::applyVisualState() {
|
||||
const bool inputHovered = (m_inputArea != nullptr && m_inputArea->hovered()) || clearButtonHovered;
|
||||
const bool readOnly = isReadOnlyVisual();
|
||||
|
||||
const Color fill = focused ? resolved(ColorRole::Surface) : resolved(ColorRole::SurfaceVariant);
|
||||
const Color border = m_invalid
|
||||
? resolved(ColorRole::Error)
|
||||
: (focused ? resolved(ColorRole::Primary)
|
||||
: (inputHovered ? resolved(ColorRole::Hover) : resolved(ColorRole::Outline)));
|
||||
if (m_frameVisible) {
|
||||
m_background->setVisible(true);
|
||||
const Color fill = focused ? resolved(ColorRole::Surface) : resolved(ColorRole::SurfaceVariant);
|
||||
const Color border = m_invalid
|
||||
? resolved(ColorRole::Error)
|
||||
: (focused ? resolved(ColorRole::Primary)
|
||||
: (inputHovered ? resolved(ColorRole::Hover) : resolved(ColorRole::Outline)));
|
||||
|
||||
m_background->setStyle(RoundedRectStyle{
|
||||
.fill = fill,
|
||||
.border = border,
|
||||
.fillMode = FillMode::Solid,
|
||||
.radius = Style::radiusMd,
|
||||
.softness = 1.0f,
|
||||
.borderWidth = Style::borderWidth,
|
||||
});
|
||||
m_background->setStyle(RoundedRectStyle{
|
||||
.fill = fill,
|
||||
.border = border,
|
||||
.fillMode = FillMode::Solid,
|
||||
.radius = Style::radiusMd,
|
||||
.softness = 1.0f,
|
||||
.borderWidth = Style::borderWidth,
|
||||
});
|
||||
} else if (m_background != nullptr) {
|
||||
m_background->setVisible(false);
|
||||
}
|
||||
|
||||
auto selectionStyle = m_selectionRect->style();
|
||||
selectionStyle.fill = resolved(ColorRole::Primary);
|
||||
@@ -730,13 +791,13 @@ void Input::updateInteractiveGeometry() {
|
||||
const float maxCursorHeight = std::max(0.0f, controlHeight - kCursorPadV * 2.0f);
|
||||
const float cursorHeight = std::clamp(controlHeight * kCursorHeightRatio, kCursorMinHeight, maxCursorHeight);
|
||||
const float cursorY = std::round((controlHeight - cursorHeight) * 0.5f);
|
||||
const float cursorX = stopXForByte(m_cursorPos) - m_scrollOffset;
|
||||
const float cursorX = stopXForByte(m_cursorPos) - m_scrollOffset + m_contentLeadSlack;
|
||||
m_cursor->setPosition(cursorX, cursorY);
|
||||
m_cursor->setFrameSize(kCursorWidth, cursorHeight);
|
||||
|
||||
if (hasSelection()) {
|
||||
const float selX0 = stopXForByte(selectionStart()) - m_scrollOffset;
|
||||
const float selX1 = stopXForByte(selectionEnd()) - m_scrollOffset;
|
||||
const float selX0 = stopXForByte(selectionStart()) - m_scrollOffset + m_contentLeadSlack;
|
||||
const float selX1 = stopXForByte(selectionEnd()) - m_scrollOffset + m_contentLeadSlack;
|
||||
m_selectionRect->setPosition(selX0, cursorY);
|
||||
m_selectionRect->setFrameSize(std::max(0.0f, selX1 - selX0), cursorHeight);
|
||||
m_selectionRect->setVisible(true);
|
||||
@@ -759,13 +820,15 @@ void Input::ensureCursorVisible() {
|
||||
|
||||
const float cursorContentX = stopXForByte(m_cursorPos);
|
||||
const float revealPad = std::max(kCursorRevealPadding, kTextInnerInset);
|
||||
const float leftEdge = m_scrollOffset + revealPad;
|
||||
const float rightEdge = m_scrollOffset + viewportWidth - revealPad - kCursorWidth;
|
||||
const float slack = m_contentLeadSlack;
|
||||
const float cursorVx = cursorContentX - m_scrollOffset + slack;
|
||||
const float leftEdge = revealPad;
|
||||
const float rightEdge = viewportWidth - revealPad - kCursorWidth;
|
||||
|
||||
if (cursorContentX < leftEdge) {
|
||||
m_scrollOffset = cursorContentX - revealPad;
|
||||
} else if (cursorContentX > rightEdge) {
|
||||
m_scrollOffset = cursorContentX - viewportWidth + revealPad + kCursorWidth;
|
||||
if (cursorVx < leftEdge) {
|
||||
m_scrollOffset = cursorContentX + slack - leftEdge;
|
||||
} else if (cursorVx > rightEdge) {
|
||||
m_scrollOffset = cursorContentX + slack - rightEdge;
|
||||
}
|
||||
|
||||
clampScrollOffset();
|
||||
@@ -780,6 +843,17 @@ void Input::clampScrollOffset() {
|
||||
m_scrollOffset = std::clamp(m_scrollOffset, 0.0f, maxOffset);
|
||||
}
|
||||
|
||||
LayoutSize Input::doMeasure(Renderer& renderer, const LayoutConstraints& constraints) {
|
||||
const float minFromHint = m_minLayoutWidth > 0.0f ? m_minLayoutWidth : 0.0f;
|
||||
if (constraints.hasExactWidth()) {
|
||||
const float assignW = std::max(constraints.maxWidth, minFromHint);
|
||||
setSize(assignW, m_controlHeight);
|
||||
}
|
||||
doLayout(renderer);
|
||||
const float w = std::max(width(), minFromHint);
|
||||
return constraints.constrain(LayoutSize{.width = w, .height = height()});
|
||||
}
|
||||
|
||||
void Input::updateCursorVisibility() {
|
||||
const bool focused = m_inputArea != nullptr && m_inputArea->focused();
|
||||
m_cursor->setVisible(focused && m_cursorBlinkVisible);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "core/timer_manager.h"
|
||||
#include "render/core/renderer.h"
|
||||
#include "render/scene/node.h"
|
||||
#include "ui/signal.h"
|
||||
#include "ui/style.h"
|
||||
@@ -37,9 +38,14 @@ public:
|
||||
void setClearButtonEnabled(bool enabled);
|
||||
void setPasswordMode(bool enabled);
|
||||
void setInvalid(bool invalid);
|
||||
void setFrameVisible(bool visible);
|
||||
void setBold(bool bold);
|
||||
void setMinLayoutWidth(float width);
|
||||
void setTextAlign(TextAlign align);
|
||||
void setOnChange(std::function<void(const std::string&)> callback);
|
||||
void setOnSubmit(std::function<void(const std::string&)> callback);
|
||||
void setOnKeyEvent(std::function<bool(std::uint32_t sym, std::uint32_t modifiers)> callback);
|
||||
void setOnFocusLoss(std::function<void()> callback);
|
||||
void selectAll();
|
||||
void moveCaretLeft(bool shift = false);
|
||||
void moveCaretRight(bool shift = false);
|
||||
@@ -57,6 +63,7 @@ public:
|
||||
|
||||
private:
|
||||
void doLayout(Renderer& renderer) override;
|
||||
LayoutSize doMeasure(Renderer& renderer, const LayoutConstraints& constraints) override;
|
||||
void handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modifiers, bool preedit = false);
|
||||
void applyVisualState();
|
||||
void updateDisplayText();
|
||||
@@ -114,12 +121,17 @@ private:
|
||||
std::function<void(const std::string&)> m_onChange;
|
||||
std::function<void(const std::string&)> m_onSubmit;
|
||||
std::function<bool(std::uint32_t, std::uint32_t)> m_onKeyEvent;
|
||||
std::function<void()> m_onFocusLoss;
|
||||
float m_fontSize = Style::fontSizeBody;
|
||||
float m_controlHeight = Style::controlHeight;
|
||||
float m_horizontalPadding = Style::spaceMd;
|
||||
bool m_clearButtonEnabled = false;
|
||||
bool m_passwordMode = false;
|
||||
bool m_invalid = false;
|
||||
bool m_frameVisible = true;
|
||||
float m_minLayoutWidth = 0.0f;
|
||||
float m_contentLeadSlack = 0.0f;
|
||||
TextAlign m_textAlign = TextAlign::Start;
|
||||
std::chrono::steady_clock::time_point m_lastPrimaryPressTime{};
|
||||
float m_lastPrimaryPressX = 0.0f;
|
||||
float m_lastPrimaryPressY = 0.0f;
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
#include "ui/controls/stepper.h"
|
||||
|
||||
#include "render/core/renderer.h"
|
||||
#include "render/scene/input_area.h"
|
||||
#include "ui/controls/button.h"
|
||||
#include "ui/controls/input.h"
|
||||
#include "ui/palette.h"
|
||||
#include "ui/style.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <xkbcommon/xkbcommon-keysyms.h>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr float kDefaultMinWidth = 140.0f;
|
||||
constexpr float kValueFieldHPadding = 2.0f;
|
||||
|
||||
std::string trimAscii(std::string_view s) {
|
||||
std::size_t a = 0;
|
||||
std::size_t b = s.size();
|
||||
while (a < b && std::isspace(static_cast<unsigned char>(s[a])) != 0) {
|
||||
++a;
|
||||
}
|
||||
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1])) != 0) {
|
||||
--b;
|
||||
}
|
||||
return std::string(s.substr(a, b - a));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Stepper::Stepper() {
|
||||
setDirection(FlexDirection::Horizontal);
|
||||
setAlign(FlexAlign::Center);
|
||||
setJustify(FlexJustify::SpaceBetween);
|
||||
setGap(0.0f);
|
||||
setPadding(Style::spaceXs, Style::spaceXs);
|
||||
setMinWidth(kDefaultMinWidth);
|
||||
setFill(colorSpecFromRole(ColorRole::SurfaceVariant));
|
||||
setBorder(colorSpecFromRole(ColorRole::Outline), Style::borderWidth);
|
||||
setRadius(Style::radiusMd);
|
||||
|
||||
auto makeStepButton = [this](bool increment) -> std::unique_ptr<Button> {
|
||||
auto btn = std::make_unique<Button>();
|
||||
btn->setVariant(ButtonVariant::Ghost);
|
||||
btn->setGlyph(increment ? "plus" : "minus");
|
||||
btn->setGlyphSize(Style::fontSizeBody);
|
||||
btn->setMinWidth(Style::controlHeightSm);
|
||||
btn->setMinHeight(Style::controlHeightSm);
|
||||
btn->setPadding(0.0f);
|
||||
btn->setContentAlign(ButtonContentAlign::Center);
|
||||
btn->setOnClick([this, increment]() { stepBy(increment ? 1 : -1); });
|
||||
return btn;
|
||||
};
|
||||
|
||||
{
|
||||
auto dec = makeStepButton(false);
|
||||
m_decrement = dec.get();
|
||||
addChild(std::move(dec));
|
||||
}
|
||||
|
||||
{
|
||||
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->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));
|
||||
}
|
||||
|
||||
{
|
||||
auto inc = makeStepButton(true);
|
||||
m_increment = inc.get();
|
||||
addChild(std::move(inc));
|
||||
}
|
||||
|
||||
syncValueField();
|
||||
refreshButtons();
|
||||
}
|
||||
|
||||
void Stepper::setRange(int minValue, int maxValue) {
|
||||
if (maxValue < minValue) {
|
||||
std::swap(minValue, maxValue);
|
||||
}
|
||||
if (m_min == minValue && m_max == maxValue) {
|
||||
return;
|
||||
}
|
||||
m_min = minValue;
|
||||
m_max = maxValue;
|
||||
setValue(m_value);
|
||||
}
|
||||
|
||||
void Stepper::setStep(int step) {
|
||||
const int next = std::max(1, step);
|
||||
if (m_step == next) {
|
||||
return;
|
||||
}
|
||||
m_step = next;
|
||||
setValue(m_value);
|
||||
}
|
||||
|
||||
void Stepper::setValue(int value) {
|
||||
const int next = std::clamp(value, m_min, m_max);
|
||||
if (next == m_value) {
|
||||
syncValueField();
|
||||
refreshButtons();
|
||||
return;
|
||||
}
|
||||
m_value = next;
|
||||
syncValueField();
|
||||
refreshButtons();
|
||||
if (m_onValueChanged) {
|
||||
m_onValueChanged(m_value);
|
||||
}
|
||||
markLayoutDirty();
|
||||
}
|
||||
|
||||
void Stepper::setEnabled(bool enabled) {
|
||||
if (m_enabled == enabled) {
|
||||
return;
|
||||
}
|
||||
m_enabled = enabled;
|
||||
refreshButtons();
|
||||
if (m_valueInput != nullptr) {
|
||||
m_valueInput->inputArea()->setEnabled(enabled);
|
||||
m_valueInput->inputArea()->setFocusable(enabled);
|
||||
}
|
||||
markPaintDirty();
|
||||
}
|
||||
|
||||
void Stepper::setOnValueChanged(std::function<void(int)> callback) { m_onValueChanged = std::move(callback); }
|
||||
|
||||
void Stepper::setScale(float scale) {
|
||||
m_scale = std::max(0.1f, scale);
|
||||
setGap(0.0f);
|
||||
setPadding(Style::spaceXs * m_scale, Style::spaceXs * m_scale);
|
||||
setMinWidth(kDefaultMinWidth * m_scale);
|
||||
setRadius(Style::radiusMd * m_scale);
|
||||
setBorder(colorSpecFromRole(ColorRole::Outline), Style::borderWidth * 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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
markLayoutDirty();
|
||||
}
|
||||
|
||||
void Stepper::syncValueFieldMinWidth(Renderer& renderer) {
|
||||
if (m_valueInput == nullptr) {
|
||||
return;
|
||||
}
|
||||
const float fs = Style::fontSizeBody * m_scale;
|
||||
const float wMin = renderer.measureText(std::to_string(m_min), fs, true).width;
|
||||
const float wMax = renderer.measureText(std::to_string(m_max), fs, true).width;
|
||||
const float digitFloor = fs * 2.5f;
|
||||
m_valueInput->setMinLayoutWidth(std::max({wMin, wMax, digitFloor}));
|
||||
}
|
||||
|
||||
LayoutSize Stepper::doMeasure(Renderer& renderer, const LayoutConstraints& constraints) {
|
||||
syncValueFieldMinWidth(renderer);
|
||||
return Flex::doMeasure(renderer, constraints);
|
||||
}
|
||||
|
||||
void Stepper::doLayout(Renderer& renderer) {
|
||||
syncValueFieldMinWidth(renderer);
|
||||
Flex::doLayout(renderer);
|
||||
if (m_decrement != nullptr) {
|
||||
m_decrement->updateInputArea();
|
||||
}
|
||||
if (m_increment != nullptr) {
|
||||
m_increment->updateInputArea();
|
||||
}
|
||||
}
|
||||
|
||||
void Stepper::stepBy(int directionSign) {
|
||||
if (!m_enabled || directionSign == 0) {
|
||||
return;
|
||||
}
|
||||
const long delta = static_cast<long>(m_step) * static_cast<long>(directionSign);
|
||||
const long nextLong = static_cast<long>(m_value) + delta;
|
||||
const int next = static_cast<int>(std::clamp(nextLong, static_cast<long>(m_min), static_cast<long>(m_max)));
|
||||
setValue(next);
|
||||
}
|
||||
|
||||
void Stepper::syncValueField() {
|
||||
if (m_valueInput != nullptr) {
|
||||
m_valueInput->setValue(std::to_string(m_value));
|
||||
}
|
||||
}
|
||||
|
||||
void Stepper::commitValueField() {
|
||||
if (m_valueInput == nullptr || !m_enabled) {
|
||||
return;
|
||||
}
|
||||
const std::string t = trimAscii(m_valueInput->value());
|
||||
if (t.empty()) {
|
||||
syncValueField();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
std::size_t idx = 0;
|
||||
const long v = std::stol(t, &idx, 10);
|
||||
if (idx != t.size()) {
|
||||
syncValueField();
|
||||
return;
|
||||
}
|
||||
setValue(static_cast<int>(v));
|
||||
} catch (const std::logic_error&) {
|
||||
syncValueField();
|
||||
}
|
||||
}
|
||||
|
||||
bool Stepper::swallowNonNumericKey(std::uint32_t sym, std::uint32_t modifiers) {
|
||||
(void)modifiers;
|
||||
if (sym >= XKB_KEY_0 && sym <= XKB_KEY_9) {
|
||||
return false;
|
||||
}
|
||||
if (sym >= XKB_KEY_KP_0 && sym <= XKB_KEY_KP_9) {
|
||||
return false;
|
||||
}
|
||||
if (m_min < 0) {
|
||||
if (sym == XKB_KEY_minus || sym == XKB_KEY_KP_Subtract) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (sym == XKB_KEY_plus || sym == XKB_KEY_KP_Add) {
|
||||
return true;
|
||||
}
|
||||
if ((sym >= XKB_KEY_a && sym <= XKB_KEY_z) || (sym >= XKB_KEY_A && sym <= XKB_KEY_Z)) {
|
||||
return true;
|
||||
}
|
||||
if (sym == XKB_KEY_space) {
|
||||
return true;
|
||||
}
|
||||
if (sym == XKB_KEY_period || sym == XKB_KEY_comma) {
|
||||
return true;
|
||||
}
|
||||
if (sym == XKB_KEY_KP_Decimal || sym == XKB_KEY_KP_Separator) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void Stepper::refreshButtons() {
|
||||
if (m_decrement != nullptr) {
|
||||
m_decrement->setEnabled(m_enabled && m_value > m_min);
|
||||
}
|
||||
if (m_increment != nullptr) {
|
||||
m_increment->setEnabled(m_enabled && m_value < m_max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/controls/flex.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
class Button;
|
||||
class Input;
|
||||
class Renderer;
|
||||
|
||||
// Horizontal numeric stepper: [ − ] editable value [ + ]
|
||||
class Stepper : public Flex {
|
||||
public:
|
||||
Stepper();
|
||||
|
||||
void setRange(int minValue, int maxValue);
|
||||
void setStep(int step);
|
||||
void setValue(int value);
|
||||
void setEnabled(bool enabled);
|
||||
void setOnValueChanged(std::function<void(int)> callback);
|
||||
void setScale(float scale);
|
||||
|
||||
[[nodiscard]] int value() const noexcept { return m_value; }
|
||||
[[nodiscard]] int minValue() const noexcept { return m_min; }
|
||||
[[nodiscard]] int maxValue() const noexcept { return m_max; }
|
||||
[[nodiscard]] int step() const noexcept { return m_step; }
|
||||
[[nodiscard]] bool enabled() const noexcept { return m_enabled; }
|
||||
|
||||
[[nodiscard]] Input* valueField() const noexcept { return m_valueInput; }
|
||||
[[nodiscard]] Button* decrementButton() const noexcept { return m_decrement; }
|
||||
[[nodiscard]] Button* incrementButton() const noexcept { return m_increment; }
|
||||
|
||||
private:
|
||||
LayoutSize doMeasure(Renderer& renderer, const LayoutConstraints& constraints) override;
|
||||
void doLayout(Renderer& renderer) override;
|
||||
void syncValueFieldMinWidth(Renderer& renderer);
|
||||
void stepBy(int directionSign);
|
||||
void syncValueField();
|
||||
void commitValueField();
|
||||
bool swallowNonNumericKey(std::uint32_t sym, std::uint32_t modifiers);
|
||||
void refreshButtons();
|
||||
|
||||
Button* m_decrement = nullptr;
|
||||
Button* m_increment = nullptr;
|
||||
Input* m_valueInput = nullptr;
|
||||
|
||||
std::function<void(int)> m_onValueChanged;
|
||||
|
||||
int m_min = 0;
|
||||
int m_max = 100;
|
||||
int m_step = 1;
|
||||
int m_value = 0;
|
||||
bool m_enabled = true;
|
||||
float m_scale = 1.0f;
|
||||
};
|
||||
Reference in New Issue
Block a user