From db1a2de641414dcc6a24d6d10155b89f99923a84 Mon Sep 17 00:00:00 2001 From: FarnaHerry Date: Wed, 26 Aug 2026 22:58:10 +0800 Subject: [PATCH] feat(components): unify button and slider interaction states --- components/button.h | 15 +++ components/slider.h | 22 ++++- components/theme.h | 88 ++++++++++++++++- "docs/\347\273\204\344\273\266.md" | 2 +- examples/control_states_demo.cpp | 148 +++++++++++++++++++++++++++++ tests/unit/ui_state.cpp | 48 +++++++--- 6 files changed, 307 insertions(+), 16 deletions(-) create mode 100644 examples/control_states_demo.cpp diff --git a/components/button.h b/components/button.h index 88733f46..564ab2ee 100644 --- a/components/button.h +++ b/components/button.h @@ -22,6 +22,8 @@ struct ButtonStyle { icon = text; border = theme::buttonBorder(tokens, primary); shadow = theme::buttonShadow(tokens); + focusRing = theme::focusRing(tokens); + disabled = tokens.interaction.disabledFill; radius = tokens.metrics.radius.overlay; } @@ -32,6 +34,8 @@ struct ButtonStyle { core::Color icon; core::Border border; core::Shadow shadow; + core::Border focusRing; + core::Color disabled; float radius = 16.0f; float opacity = 1.0f; float pressScale = 0.965f; @@ -105,7 +109,13 @@ class ButtonBuilder { const float labelWidth = hasIcon && hasText ? std::max(0.0f, w - iconWidth - gap - metrics_.spacing.section * 2.0f * scale_) : w; + const bool focused = ui_.isFocused(id_ + ".bg"); core::Border border = style_.border; + if (disabled_) { + border = {0.0f, style_.disabled}; + } else if (focused) { + border = style_.focusRing; + } border.width *= scale_; core::Shadow shadow = style_.shadow; @@ -115,6 +125,10 @@ class ButtonBuilder { shadow.spread *= scale_; core::Color textColor = style_.text; core::Color iconColor = style_.icon; + if (disabled_) { + textColor = style_.disabled; + iconColor = style_.disabled; + } textColor.a *= style_.opacity; iconColor.a *= style_.opacity; const std::function onPress = onPress_; @@ -140,6 +154,7 @@ class ButtonBuilder { .translate(translateX_, translateY_) .transition(transition_) .disabled(disabled_) + .focusable(!disabled_) .preserveFocusOnPress(preserveFocusOnPress_) .onClick(onClick_) .onContextMenu(onContextMenu_); diff --git a/components/slider.h b/components/slider.h index 63e8f1f8..3aab2d4e 100644 --- a/components/slider.h +++ b/components/slider.h @@ -18,11 +18,17 @@ struct SliderStyle { track = core::mixColor(tokens.surfaceHover, tokens.surfaceActive, tokens.dark ? 0.24f : 0.18f); fill = tokens.primary; knob = tokens.text; + hoverKnob = theme::buttonHover(tokens, knob); + focusRing = theme::focusRing(tokens); + disabledKnob = tokens.interaction.disabledContent; } core::Color track; core::Color fill; core::Color knob; + core::Color hoverKnob; + core::Border focusRing; + core::Color disabledKnob; }; class SliderBuilder { @@ -49,6 +55,7 @@ class SliderBuilder { return *this; } SliderBuilder& onChange(std::function callback) { onChange_ = std::move(callback); return *this; } + SliderBuilder& disabled(bool value = true) { disabled_ = value; return *this; } void build() { const float trackHeight = std::max(metrics_.spacing.tiny - metrics_.spacing.hairline, @@ -56,6 +63,13 @@ class SliderBuilder { const float trackY = (height_ - trackHeight) * 0.5f; const float knobSize = std::max(metrics_.typography.label, height_ * 0.72f); const std::function onChange = onChange_; + const bool focused = ui_.isFocused(id_ + ".hit"); + const auto state = theme::controlState(disabled_, false, false, focused); + const core::Color knobColor = state == theme::ControlState::Disabled + ? style_.disabledKnob + : (state == theme::ControlState::Focused ? style_.hoverKnob : style_.knob); + const core::Border knobBorder = state == theme::ControlState::Focused + ? style_.focusRing : core::Border{}; ui_.stack(id_) .size(width_, height_) @@ -81,8 +95,9 @@ class SliderBuilder { ui_.rect(id_ + ".knob") .y((height_ - knobSize) * 0.5f) .size(knobSize, knobSize) - .color(style_.knob) + .color(knobColor) .radius(knobSize * 0.5f) + .border(knobBorder) .shadow(12.0f, 0.0f, 4.0f, theme::withAlpha(style_.fill, 0.20f)) .transition(transition_) .animate(core::AnimProperty::Color | core::AnimProperty::Shadow) @@ -95,8 +110,10 @@ class SliderBuilder { theme::color(0.0f, 0.0f, 0.0f, 0.0f), theme::color(0.0f, 0.0f, 0.0f, 0.0f)) .zIndex(10) - .interactive() .sliderInputFrom(id_) + .interactive(!disabled_) + .disabled(disabled_) + .focusable(!disabled_) .build(); }) .build(); @@ -112,6 +129,7 @@ class SliderBuilder { float width_ = 300.0f; float height_ = 32.0f; float value_ = 0.0f; + bool disabled_ = false; }; inline SliderBuilder slider(core::dsl::Ui& ui, const std::string& id) { diff --git a/components/theme.h b/components/theme.h index 18d1f697..2b2a8ab3 100644 --- a/components/theme.h +++ b/components/theme.h @@ -85,6 +85,28 @@ struct ThemeMetricTokens { ControlSizeTokens control; }; +enum class ControlState { + Normal, + Hovered, + Pressed, + Focused, + Disabled +}; + +// Visual tokens shared by interactive controls. Checked, selected, expanded, +// loading, and error remain orthogonal component states and are not folded into +// this mutually exclusive base state. +struct InteractionTokens { + core::Color hoverBorder; + core::Color pressedOverlay; + core::Color focusRing; + core::Color disabledFill; + core::Color disabledContent; + float focusRingWidth = 2.0f; + float focusRingOffset = 2.0f; + float disabledOpacity = 0.50f; +}; + struct ThemeColorTokens { core::Color background; core::Color primary; @@ -95,6 +117,7 @@ struct ThemeColorTokens { core::Color border; bool dark = false; ThemeMetricTokens metrics; + InteractionTokens interaction; }; struct PageVisualTokens { @@ -153,7 +176,7 @@ inline core::Color withOpacity(core::Color value, float opacity) { } inline ThemeColorTokens light() { - return { + ThemeColorTokens result{ color(0.95f, 0.95f, 0.97f), defaultPrimary(), color(1.00f, 1.00f, 1.00f), @@ -163,10 +186,21 @@ inline ThemeColorTokens light() { color(0.80f, 0.80f, 0.80f), false }; + result.interaction = { + withAlpha(result.primary, 0.85f), + withAlpha(result.surfaceActive, 0.35f), + withAlpha(result.primary, 1.0f), + withAlpha(result.surfaceActive, 0.55f), + withAlpha(result.text, 0.45f), + 2.0f, + 2.0f, + 0.50f + }; + return result; } inline ThemeColorTokens dark() { - return { + ThemeColorTokens result{ color(0.10f, 0.10f, 0.12f), defaultPrimary(), color(0.15f, 0.15f, 0.18f), @@ -176,6 +210,56 @@ inline ThemeColorTokens dark() { color(0.30f, 0.30f, 0.30f), true }; + result.interaction = { + withAlpha(result.primary, 0.95f), + color(0.0f, 0.0f, 0.0f, 0.34f), + color(0.40f, 0.70f, 1.0f, 1.0f), + color(0.0f, 0.0f, 0.0f, 0.30f), + withAlpha(result.text, 0.45f), + 2.0f, + 2.0f, + 0.50f + }; + return result; +} + +inline ControlState controlState(bool disabled, bool pressed, bool hovered, bool focused) { + if (disabled) { + return ControlState::Disabled; + } + if (pressed) { + return ControlState::Pressed; + } + if (focused) { + return ControlState::Focused; + } + if (hovered) { + return ControlState::Hovered; + } + return ControlState::Normal; +} + +inline core::Color controlFill(const ThemeColorTokens& tokens, + core::Color normal, + core::Color hover, + core::Color pressed, + ControlState state) { + switch (state) { + case ControlState::Disabled: + return core::mixColor(normal, tokens.interaction.disabledFill, 0.65f); + case ControlState::Pressed: + return pressed; + case ControlState::Focused: + case ControlState::Hovered: + return hover; + case ControlState::Normal: + default: + return normal; + } +} + +inline core::Border focusRing(const ThemeColorTokens& tokens) { + return {tokens.interaction.focusRingWidth, tokens.interaction.focusRing}; } inline PageVisualTokens pageVisuals(const ThemeColorTokens& tokens) { diff --git "a/docs/\347\273\204\344\273\266.md" "b/docs/\347\273\204\344\273\266.md" index a39e1833..537aa233 100644 --- "a/docs/\347\273\204\344\273\266.md" +++ "b/docs/\347\273\204\344\273\266.md" @@ -979,7 +979,7 @@ components::pieChart(ui, "usage.pie") - `dialog`、`toast`、`contextMenu`、`dropdown`、`datePicker`、`timePicker`、`colorPicker` 需要页面传入 open/visible 状态,组件不会自己持久化开关状态。 - 容器 transform 会继承到组件内部子树渲染,包含 2D transform 和 2.5D 透视投影;默认 hit-test 仍按布局矩形计算,组件确实需要跟随视觉变换时可在内部元素上开启 `.transformedHitTest()`。 - Runtime 按绘制顺序从最上层往下命中 interactive 元素,同一位置只会触发最上层交互层;focus 命中会被上层 interactive blocker 阻断。 -- 复杂嵌套滚动、圆角 clip、事件冒泡等能力仍属于 Runtime 后续增强范围。 +- 交互状态基础层区分 `normal`、`hovered`、`pressed`、`focused`、`disabled`;鼠标悬停表示指针位置,焦点表示键盘/程序操作目标,两者可以同时存在。`checked`、`selected`、`expanded`、`loading`、`error` 是可叠加的控件状态,不应覆盖基础交互状态。当前版本先提供统一状态优先级和主题 token,具体控件的 focus ring、键盘导航和无障碍语义会分阶段接入。 ## 写新组件时的底线 diff --git a/examples/control_states_demo.cpp b/examples/control_states_demo.cpp new file mode 100644 index 00000000..9c5b99b3 --- /dev/null +++ b/examples/control_states_demo.cpp @@ -0,0 +1,148 @@ +#include "eui_neo.h" + +namespace app { + +const DslAppConfig& dslAppConfig() { + static const DslAppConfig config = DslAppConfig{} + .title("Control States Demo") + .windowSize(920, 680) + .fps(60.0) + .showDebugStatsInTitle(false); + return config; +} + +void compose(eui::Ui& ui, const eui::Screen& screen) { + const auto tokens = components::theme::dark(); + const auto page = components::theme::pageVisuals(tokens); + float* sliderValue = &ui.state("slider.value"); + if (*sliderValue <= 0.0f) { + *sliderValue = 0.62f; + } + + ui.column("root") + .size(screen.width, screen.height) + .padding(32.0f) + .gap(18.0f) + .content([&] { + ui.text("title") + .text("控件交互状态") + .fontSize(page.headerTitleSize) + .color(page.titleColor) + .build(); + ui.text("subtitle") + .text("把鼠标移到控件上,按住鼠标查看 pressed 状态;输入框点击后显示 focused 状态。") + .fontSize(page.headerSubtitleSize) + .color(page.subtitleColor) + .wrap(true) + .build(); + + ui.text("button.label") + .text("Button · normal / hover / pressed / disabled") + .fontSize(tokens.metrics.typography.label) + .color(tokens.text) + .build(); + ui.row("buttons") + .size(856.0f, 58.0f) + .gap(14.0f) + .content([&] { + components::button(ui, "button.normal") + .size(190.0f, 48.0f) + .text("Normal / Hover") + .theme(tokens, true) + .build(); + components::button(ui, "button.secondary") + .size(190.0f, 48.0f) + .text("Secondary") + .theme(tokens, false) + .build(); + components::button(ui, "button.disabled") + .size(190.0f, 48.0f) + .text("Disabled") + .theme(tokens, true) + .disabled() + .build(); + }) + .build(); + + ui.text("selection.label") + .text("Selection · unchecked / checked / hover") + .fontSize(tokens.metrics.typography.label) + .color(tokens.text) + .build(); + ui.row("selection") + .size(856.0f, 52.0f) + .gap(20.0f) + .content([&] { + components::checkbox(ui, "checkbox.off") + .text("Unchecked") + .checked(false) + .theme(tokens) + .build(); + components::checkbox(ui, "checkbox.on") + .text("Checked") + .checked(true) + .theme(tokens) + .build(); + components::toggleSwitch(ui, "switch") + .text("Switch") + .checked(true) + .theme(tokens) + .build(); + }) + .build(); + + ui.text("input.label") + .text("Input · normal / hover / focused") + .fontSize(tokens.metrics.typography.label) + .color(tokens.text) + .build(); + components::input(ui, "input") + .size(520.0f, 48.0f) + .placeholder("点击这里获得焦点,然后输入文字") + .theme(tokens) + .build(); + + ui.text("slider.label") + .text("Slider · hover / focused / disabled") + .fontSize(tokens.metrics.typography.label) + .color(tokens.text) + .build(); + components::slider(ui, "slider") + .size(520.0f, 38.0f) + .value(*sliderValue) + .theme(tokens) + .onChange([sliderValue](float value) { *sliderValue = value; }) + .build(); + + components::slider(ui, "slider.disabled") + .size(520.0f, 38.0f) + .value(0.62f) + .theme(tokens) + .disabled() + .build(); + + ui.stack("state.note") + .size(856.0f, 84.0f) + .content([&] { + ui.rect("state.note.background") + .size(856.0f, 84.0f) + .color(tokens.surface) + .radius(tokens.metrics.radius.card) + .build(); + ui.text("state.note.text") + .position(18.0f, 14.0f) + .size(820.0f, 56.0f) + .text("Hover 是鼠标位置;focus 是键盘/程序操作目标。\n" + "本页面用于观察现有控件反馈:Slider 是一个主要依赖鼠标拖动的控件,后续可接入统一状态。") + .fontSize(tokens.metrics.typography.body) + .lineHeight(24.0f) + .color(page.bodyColor) + .wrap(true) + .build(); + }) + .build(); + }) + .build(); +} + +} // namespace app diff --git a/tests/unit/ui_state.cpp b/tests/unit/ui_state.cpp index 9748cfa4..0147e81c 100644 --- a/tests/unit/ui_state.cpp +++ b/tests/unit/ui_state.cpp @@ -138,6 +138,37 @@ bool textSizeMeasurementMatchesLineLayout() { return true; } +bool controlStatePriorityIsStable() { + using components::theme::ControlState; + using components::theme::controlState; + if (controlState(false, false, false, false) != ControlState::Normal || + controlState(false, false, true, false) != ControlState::Hovered || + controlState(false, false, false, true) != ControlState::Focused || + controlState(false, true, true, true) != ControlState::Pressed || + controlState(true, true, true, true) != ControlState::Disabled) { + std::cerr << "control state priority is inconsistent\n"; + return false; + } + return true; +} + +bool interactionTokensAreComplete() { + const auto light = components::theme::light(); + const auto dark = components::theme::dark(); + const auto valid = [](const components::theme::ThemeColorTokens& tokens) { + return tokens.interaction.focusRingWidth > 0.0f && + tokens.interaction.focusRingOffset >= 0.0f && + tokens.interaction.disabledOpacity >= 0.0f && + tokens.interaction.disabledOpacity <= 1.0f && + tokens.interaction.focusRing.a > 0.0f; + }; + if (!valid(light) || !valid(dark)) { + std::cerr << "theme interaction tokens are incomplete\n"; + return false; + } + return true; +} + bool componentDefaultsMatchGallery() { core::dsl::Ui ui; ui.begin("component.defaults"); @@ -156,17 +187,10 @@ bool componentDefaultsMatchGallery() { ui.layout(1000.0f, 800.0f); const std::pair expected[] = { - {"button", 54.0f}, - {"input", 44.0f}, - {"dropdown.field", 44.0f}, - {"checkbox", 30.0f}, - {"radio", 30.0f}, - {"switch", 32.0f}, - {"progress", 14.0f}, - {"slider", 32.0f}, - {"segmented", 38.0f}, - {"tabs", 42.0f}, - {"stepper", 40.0f}, + {"button", 54.0f}, {"input", 44.0f}, {"dropdown.field", 44.0f}, + {"checkbox", 30.0f}, {"radio", 30.0f}, {"switch", 32.0f}, + {"progress", 14.0f}, {"slider", 32.0f}, {"segmented", 38.0f}, + {"tabs", 42.0f}, {"stepper", 40.0f}, }; for (const auto& entry : expected) { const core::dsl::Element* element = ui.find(entry.first); @@ -210,5 +234,7 @@ int main() { ok = textWrapContentUsesIntrinsicSize() && ok; ok = textSizeMeasurementMatchesLineLayout() && ok; ok = componentDefaultsMatchGallery() && ok; + ok = controlStatePriorityIsStable() && ok; + ok = interactionTokensAreComplete() && ok; return ok ? 0 : 1; }