跳转至

ui/widget/material/widget/radiobutton/radiobutton.cpp

Material Design 3 RadioButton Implementation. More...

Namespaces

Name
cf
cf::ui
cf::ui::widget
cf::ui::widget::material

Detailed Description

Material Design 3 RadioButton Implementation.

Author: CFDesktop Team

Version: 0.1

Since: 0.1

Date: 2026-03-01

Implements a Material Design 3 radio button with circular selection area, inner circle scale animation, ripple effects, and focus indicators.

Source code

#include "radiobutton.h"
#include "application_support/application.h"
#include "base/device_pixel.h"
#include "base/easing.h"
#include "cfmaterial_animation_factory.h"
#include "core/token/material_scheme/cfmaterial_token_literals.h"
#include "widget/material/base/focus_ring.h"
#include "widget/material/base/ripple_helper.h"
#include "widget/material/base/state_machine.h"

#include <QApplication>
#include <QFontMetrics>
#include <QMouseEvent>
#include <QPainter>
#include <QPainterPath>

namespace cf::ui::widget::material {

using namespace cf::ui::base;
using namespace cf::ui::base::device;
using namespace cf::ui::components;
using namespace cf::ui::components::material;
using namespace cf::ui::core;
using namespace cf::ui::core::token::literals;
using namespace cf::ui::widget::material::base;
using namespace cf::ui::widget::application_support;

// Material Design 3 specifications for RadioButton
namespace {
// Radio button size specifications (in dp)
constexpr float RADIO_SIZE_DP = 20.0f;        // Outer ring diameter
constexpr float INNER_CIRCLE_SIZE_DP = 10.0f; // Inner circle diameter (50% of outer)
constexpr float TOUCH_TARGET_DP = 48.0f;      // Minimum touch target size
constexpr float TEXT_SPACING_DP = 8.0f;       // Spacing between radio and text
constexpr float STROKE_WIDTH_DP = 2.0f;       // Outer ring stroke width

// Inner circle animation scale values
constexpr float INNER_CIRCLE_SCALE_UNCHECKED = 0.0f;
constexpr float INNER_CIRCLE_SCALE_CHECKED = 1.0f;
} // namespace

// Fallback colors when theme is not available
namespace {
inline CFColor fallbackPrimary() {
    return CFColor(103, 80, 164);
} // Purple 700
inline CFColor fallbackOnPrimary() {
    return CFColor(255, 255, 255);
} // White
inline CFColor fallbackOutline() {
    return CFColor(120, 124, 132);
} // Outline
inline CFColor fallbackOnSurface() {
    return CFColor(27, 27, 31);
} // On Surface
inline CFColor fallbackError() {
    return CFColor(186, 26, 26);
} // Error red
} // namespace

// ============================================================================
// Constructor / Destructor
// ============================================================================

RadioButton::RadioButton(QWidget* parent) : QRadioButton(parent) {
    // Get animation factory from Application
    m_animationFactory =
        cf::WeakPtr<CFMaterialAnimationFactory>::DynamicCast(Application::animationFactory());

    // Initialize behavior components
    m_stateMachine = new StateMachine(m_animationFactory, this);
    m_ripple = new RippleHelper(m_animationFactory, this);
    m_focusIndicator = new MdFocusIndicator(m_animationFactory, this);

    // Set ripple mode to bounded (clipped by widget bounds)
    m_ripple->setMode(RippleHelper::Mode::Bounded);

    // Connect repaint signals
    connect(m_ripple, &RippleHelper::repaintNeeded, this, QOverload<>::of(&RadioButton::update));
    connect(m_stateMachine, &StateMachine::stateLayerOpacityChanged, this,
            QOverload<>::of(&RadioButton::update));

    // Set default font
    setFont(labelFont());

    // Initialize inner circle scale based on checked state
    m_innerCircleScale = isChecked() ? INNER_CIRCLE_SCALE_CHECKED : INNER_CIRCLE_SCALE_UNCHECKED;
}

RadioButton::RadioButton(const QString& text, QWidget* parent) : RadioButton(parent) {
    setText(text);
}

RadioButton::~RadioButton() {
    // Components are parented to this, Qt will delete them automatically
}

// ============================================================================
// Event Handlers
// ============================================================================

void RadioButton::enterEvent(QEnterEvent* event) {
    QRadioButton::enterEvent(event);
    if (m_stateMachine) {
        m_stateMachine->onHoverEnter();
    }
    update();
}

void RadioButton::leaveEvent(QEvent* event) {
    QRadioButton::leaveEvent(event);
    if (m_stateMachine) {
        m_stateMachine->onHoverLeave();
    }
    if (m_ripple) {
        m_ripple->onCancel();
    }
    update();
}

void RadioButton::mousePressEvent(QMouseEvent* event) {
    QRadioButton::mousePressEvent(event);
    if (m_stateMachine) {
        m_stateMachine->onPress(event->pos());
    }
    if (m_ripple && m_pressEffectEnabled) {
        // Calculate radio rect for ripple clipping
        QRectF radioRect = calculateRadioRect();
        m_ripple->onPress(event->pos(), radioRect.united(calculateTextRect(radioRect)));
    }
    update();
}

void RadioButton::mouseReleaseEvent(QMouseEvent* event) {
    QRadioButton::mouseReleaseEvent(event);
    if (m_stateMachine) {
        m_stateMachine->onRelease();
    }
    if (m_ripple && m_pressEffectEnabled) {
        m_ripple->onRelease();
    }
    update();
}

void RadioButton::focusInEvent(QFocusEvent* event) {
    QRadioButton::focusInEvent(event);
    if (m_stateMachine) {
        m_stateMachine->onFocusIn();
    }
    if (m_focusIndicator) {
        m_focusIndicator->onFocusIn();
    }
    update();
}

void RadioButton::focusOutEvent(QFocusEvent* event) {
    QRadioButton::focusOutEvent(event);
    if (m_stateMachine) {
        m_stateMachine->onFocusOut();
    }
    if (m_focusIndicator) {
        m_focusIndicator->onFocusOut();
    }
    update();
}

void RadioButton::changeEvent(QEvent* event) {
    QRadioButton::changeEvent(event);
    if (event->type() == QEvent::EnabledChange) {
        if (m_stateMachine) {
            if (isEnabled()) {
                m_stateMachine->onEnable();
            } else {
                m_stateMachine->onDisable();
            }
        }
        update();
    }
}

void RadioButton::nextCheckState() {
    bool wasChecked = isChecked();
    QRadioButton::nextCheckState();
    bool isCheckedNow = isChecked();

    // Update inner circle animation when checked state changes
    if (wasChecked != isCheckedNow) {
        if (m_stateMachine) {
            m_stateMachine->onCheckedChanged(isCheckedNow);
        }
        startInnerCircleAnimation(isCheckedNow);
    }
}

void RadioButton::setChecked(bool checked) {
    bool wasChecked = isChecked();
    if (wasChecked == checked) {
        return; // No change
    }

    QRadioButton::setChecked(checked);
    bool isCheckedNow = isChecked();

    // Sync inner circle scale with checked state
    // This handles programmatic setChecked() calls which don't trigger nextCheckState
    if (wasChecked != isCheckedNow) {
        if (m_stateMachine) {
            m_stateMachine->onCheckedChanged(isCheckedNow);
        }
        // For programmatic checked change, set scale immediately without animation
        m_innerCircleScale = isCheckedNow ? INNER_CIRCLE_SCALE_CHECKED : INNER_CIRCLE_SCALE_UNCHECKED;
        update();
    }
}

// ============================================================================
// Property Getters/Setters
// ============================================================================

bool RadioButton::hasError() const {
    return m_hasError;
}

void RadioButton::setError(bool error) {
    if (m_hasError != error) {
        m_hasError = error;
        update();
    }
}

bool RadioButton::pressEffectEnabled() const {
    return m_pressEffectEnabled;
}

void RadioButton::setPressEffectEnabled(bool enabled) {
    if (m_pressEffectEnabled != enabled) {
        m_pressEffectEnabled = enabled;
        update();
    }
}

bool RadioButton::hitButton(const QPoint& pos) const {
    // For custom-drawn radio button, entire widget area is clickable
    // This ensures proper click handling even when text is empty
    return rect().contains(pos);
}

// ============================================================================
// Size Hints
// ============================================================================

QSize RadioButton::sizeHint() const {
    CanvasUnitHelper helper(qApp->devicePixelRatio());

    // Material Design 3 specifications:
    // - Radio size: 20dp
    // - Spacing between radio and text: 8dp
    // - Left padding (for stroke clipping): 12dp
    // - Touch target: 48dp minimum

    float leftPadding = helper.dpToPx(12.0f);
    float radioSize = helper.dpToPx(RADIO_SIZE_DP);
    float textSpacing = helper.dpToPx(TEXT_SPACING_DP);

    // Text width
    float textWidth = text().isEmpty() ? 0.0f : fontMetrics().horizontalAdvance(text());

    // Total width
    float totalWidth = leftPadding + radioSize + textSpacing + textWidth;

    // Height is at least the touch target size
    float height = helper.dpToPx(TOUCH_TARGET_DP);

    return QSize(int(std::ceil(totalWidth)), int(std::ceil(height)));
}

QSize RadioButton::minimumSizeHint() const {
    CanvasUnitHelper helper(qApp->devicePixelRatio());

    float leftPadding = helper.dpToPx(12.0f);
    float radioSize = helper.dpToPx(RADIO_SIZE_DP);
    float textSpacing = helper.dpToPx(TEXT_SPACING_DP);
    float minTextWidth = text().isEmpty() ? 0 : fontMetrics().horizontalAdvance("M");

    float totalWidth = leftPadding + radioSize + textSpacing + minTextWidth;
    float height = helper.dpToPx(TOUCH_TARGET_DP);

    return QSize(int(std::ceil(totalWidth)), int(std::ceil(height)));
}

// ============================================================================
// Color Access Methods
// ============================================================================

CFColor RadioButton::radioColor() const {
    // Error state takes priority
    if (m_hasError) {
        return errorColor();
    }

    // Checked state uses primary, unchecked uses outline
    if (isChecked()) {
        auto* app = Application::instance();
        if (!app) {
            return fallbackPrimary();
        }

        try {
            const auto& theme = app->currentTheme();
            auto& colorScheme = theme.color_scheme();
            return CFColor(colorScheme.queryColor(PRIMARY));
        } catch (...) {
            return fallbackPrimary();
        }
    } else {
        auto* app = Application::instance();
        if (!app) {
            return fallbackOutline();
        }

        try {
            const auto& theme = app->currentTheme();
            auto& colorScheme = theme.color_scheme();
            return CFColor(colorScheme.queryColor(OUTLINE));
        } catch (...) {
            return fallbackOutline();
        }
    }
}

CFColor RadioButton::onRadioColor() const {
    auto* app = Application::instance();
    if (!app) {
        return fallbackOnPrimary();
    }

    try {
        const auto& theme = app->currentTheme();
        auto& colorScheme = theme.color_scheme();
        return CFColor(colorScheme.queryColor(ON_PRIMARY));
    } catch (...) {
        return fallbackOnPrimary();
    }
}

CFColor RadioButton::stateLayerColor() const {
    // State layer uses the same color as the radio (primary when checked, onSurface when unchecked)
    if (isChecked()) {
        return radioColor();
    } else {
        auto* app = Application::instance();
        if (!app) {
            return fallbackOnSurface();
        }

        try {
            const auto& theme = app->currentTheme();
            auto& colorScheme = theme.color_scheme();
            return CFColor(colorScheme.queryColor(ON_SURFACE));
        } catch (...) {
            return fallbackOnSurface();
        }
    }
}

CFColor RadioButton::errorColor() const {
    auto* app = Application::instance();
    if (!app) {
        return fallbackError();
    }

    try {
        const auto& theme = app->currentTheme();
        auto& colorScheme = theme.color_scheme();
        return CFColor(colorScheme.queryColor(ERROR));
    } catch (...) {
        return fallbackError();
    }
}

QFont RadioButton::labelFont() const {
    auto* app = Application::instance();
    if (!app) {
        // Fallback to system font with reasonable size
        QFont font = QRadioButton::font();
        font.setPixelSize(14);
        font.setWeight(QFont::Normal);
        return font;
    }

    try {
        const auto& theme = app->currentTheme();
        auto& fontType = theme.font_type();
        return fontType.queryTargetFont("bodyLarge");
    } catch (...) {
        QFont font = QRadioButton::font();
        font.setPixelSize(14);
        font.setWeight(QFont::Normal);
        return font;
    }
}

// ============================================================================
// Layout Calculations
// ============================================================================

QRectF RadioButton::calculateRadioRect() const {
    CanvasUnitHelper helper(qApp->devicePixelRatio());
    float radioSize = helper.dpToPx(RADIO_SIZE_DP);
    float y = (height() - radioSize) / 2.0f;

    // Add left padding (for focus indicator and to prevent stroke clipping)
    // This ensures the outer ring stroke is not clipped at the left edge
    float x = helper.dpToPx(12.0f);

    return QRectF(x, y, radioSize, radioSize);
}

QRectF RadioButton::calculateTextRect(const QRectF& radioRect) const {
    CanvasUnitHelper helper(qApp->devicePixelRatio());
    float textSpacing = helper.dpToPx(TEXT_SPACING_DP);

    float x = radioRect.right() + textSpacing;
    return QRectF(x, 0.0f, width() - x, height());
}

// ============================================================================
// Animation Helpers
// ============================================================================

void RadioButton::startInnerCircleAnimation(bool checked) {
    float targetScale = checked ? INNER_CIRCLE_SCALE_CHECKED : INNER_CIRCLE_SCALE_UNCHECKED;
    float fromScale = m_innerCircleScale;

    // For checking, immediately show a small visible dot (20% size) so user sees feedback right away
    // For unchecking, keep current scale and let animation shrink it
    if (checked) {
        fromScale = 0.2f;
        m_innerCircleScale = fromScale;
    }

    if (!m_animationFactory) {
        // No factory, set directly
        m_innerCircleScale = targetScale;
        update();
        return;
    }

    // Create property animation
    auto anim = m_animationFactory->createPropertyAnimation(
        &m_innerCircleScale, fromScale, targetScale, 200,
        cf::ui::base::Easing::Type::EmphasizedDecelerate, this);

    if (anim) {
        anim->start();
    } else {
        m_innerCircleScale = targetScale;
        update();
    }
}

// =============================================================================
// Paint Event
// =============================================================================

void RadioButton::paintEvent(QPaintEvent* event) {
    Q_UNUSED(event)

    QPainter p(this);
    p.setRenderHint(QPainter::Antialiasing);

    // Calculate layout
    QRectF radioRect = calculateRadioRect();
    QRectF textRect = calculateTextRect(radioRect);

    // Step 1: Draw state layer (behind everything)
    drawStateLayer(p, radioRect);

    // Step 2: Draw ripple
    drawRipple(p, radioRect);

    // Step 3: Draw outer ring
    drawOuterRing(p, radioRect);

    // Step 4: Draw inner circle (when checked)
    drawInnerCircle(p, radioRect);

    // Step 5: Draw text label
    drawText(p, textRect);

    // Step 6: Draw focus indicator
    drawFocusIndicator(p, radioRect);
}

// ============================================================================
// Drawing Helpers
// ============================================================================

void RadioButton::drawStateLayer(QPainter& p, const QRectF& radioRect) {
    if (!isEnabled() || !m_stateMachine) {
        return;
    }

    float opacity = m_stateMachine->stateLayerOpacity();
    if (opacity <= 0.0f) {
        return;
    }

    // Create circular state layer path
    QPainterPath circlePath;
    circlePath.addEllipse(radioRect);

    CFColor stateColor = stateLayerColor();
    QColor color = stateColor.native_color();
    color.setAlphaF(color.alphaF() * opacity);

    p.fillPath(circlePath, color);
}

void RadioButton::drawRipple(QPainter& p, const QRectF& radioRect) {
    if (!m_ripple || !m_pressEffectEnabled) {
        return;
    }

    // Set ripple color based on state
    m_ripple->setColor(stateLayerColor());

    // Create clipping path for the radio button circle
    QPainterPath clipPath;
    clipPath.addEllipse(radioRect);

    m_ripple->paint(&p, clipPath);
}

void RadioButton::drawOuterRing(QPainter& p, const QRectF& radioRect) {
    CanvasUnitHelper helper(qApp->devicePixelRatio());
    float strokeWidth = helper.dpToPx(STROKE_WIDTH_DP);

    CFColor ringColor = radioColor();
    QColor color = ringColor.native_color();

    // Handle disabled state
    if (!isEnabled()) {
        color.setAlphaF(0.38f); // 38% opacity for disabled
    }

    p.save();

    // Inset the rect by half the stroke width to ensure the stroke stays within bounds
    // This prevents the left edge from being clipped when x=0
    float inset = strokeWidth / 2.0f;
    QRectF insetRect = radioRect.adjusted(inset, inset, -inset, -inset);

    // Create the outer ring path from the inset rect
    QPainterPath ringPath;
    ringPath.addEllipse(insetRect);

    // Set pen for stroking the ring
    QPen pen(color, strokeWidth);
    pen.setCosmetic(false); // Use device pixels for consistent stroke width
    p.setPen(pen);
    p.setBrush(Qt::NoBrush);

    p.drawPath(ringPath);

    p.restore();
}

void RadioButton::drawInnerCircle(QPainter& p, const QRectF& radioRect) {
    if (!isChecked() && m_innerCircleScale <= 0.01f) {
        return;
    }

    // Calculate inner circle dimensions based on scale
    float outerRadius = radioRect.width() / 2.0f;
    float innerRadius = outerRadius * 0.5f; // Inner circle is 50% of outer
    float scaledRadius = innerRadius * m_innerCircleScale;

    QPointF center = radioRect.center();

    p.save();

    // Create inner circle path
    QPainterPath innerCirclePath;
    innerCirclePath.addEllipse(center, scaledRadius, scaledRadius);

    CFColor fillColor = onRadioColor();
    QColor color = fillColor.native_color();

    // Handle disabled state
    if (!isEnabled()) {
        color.setAlphaF(0.38f);
    }

    p.fillPath(innerCirclePath, color);

    p.restore();
}

void RadioButton::drawText(QPainter& p, const QRectF& textRect) {
    if (text().isEmpty()) {
        return;
    }

    CFColor textColor;
    if (m_hasError) {
        textColor = errorColor();
    } else {
        auto* app = Application::instance();
        if (!app) {
            textColor = fallbackOnSurface();
        } else {
            try {
                const auto& theme = app->currentTheme();
                auto& colorScheme = theme.color_scheme();
                textColor = CFColor(colorScheme.queryColor(ON_SURFACE));
            } catch (...) {
                textColor = fallbackOnSurface();
            }
        }
    }

    if (!isEnabled()) {
        QColor color = textColor.native_color();
        color.setAlphaF(0.38f);
        p.setPen(color);
    } else {
        p.setPen(textColor.native_color());
    }

    // Use label font
    QFont font = labelFont();
    p.setFont(font);

    // Draw text with vertical center alignment and left alignment
    p.drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, text());
}

void RadioButton::drawFocusIndicator(QPainter& p, const QRectF& radioRect) {
    if (!m_focusIndicator) {
        return;
    }

    // Create focus indicator path (slightly larger than the radio button)
    QPainterPath focusPath;
    CanvasUnitHelper helper(qApp->devicePixelRatio());
    float focusPadding = helper.dpToPx(4.0f); // 4dp padding for focus ring
    focusPath.addEllipse(
        radioRect.adjusted(-focusPadding, -focusPadding, focusPadding, focusPadding));

    // Use the radio color for the focus indicator
    CFColor indicatorColor = radioColor();
    m_focusIndicator->paint(&p, focusPath, indicatorColor);
}

} // namespace cf::ui::widget::material

Updated on 2026-03-09 at 10:14:01 +0000