feat(qml): MD3 rework batch 1 — 20 components rewritten

Core: CButton (pill variants), CFab (radius 16 tonal), CIconButton (circle state layers)
Cards: CCard (filled/outlined/elevated, radius 12), CAccordionItem (smooth expand), CPaper
Chips: CChip (8px radius, filter/assist/input), CBadge (6px dot/16px pill), CStatBadge, CStatusBadge
Forms: CTextField (floating label, outlined), CSelect (styled popup), CTextarea (scrollable)
Toggles: CCheckbox (Canvas checkmark), CSwitch (52x32 pill track), CRadio (scale dot), CRating (hover stars)
Feedback: CProgress (4px track, indeterminate slide), CSpinner (Canvas arc sweep), CErrorState (tonal container)

All components: theme-aware, dark/light mode, hover/press state layers, preserved public API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 04:03:18 +00:00
parent 6fa7e700cb
commit eecaac8634
20 changed files with 1586 additions and 667 deletions

View File

@@ -4,115 +4,149 @@ import QtQuick.Layouts
import QmlComponents 1.0
/**
* CButton.qml - Styled button component (mirrors _button.scss)
* Uses StyleVariables for consistent sizing and spacing
* CButton.qml - Material Design 3 button component
*
* Variants:
* primary - Filled button (pill, Theme.primary bg, white text)
* default - Tonal button (pill, 12% primary tint bg)
* secondary - Tonal button (pill, 12% primary tint bg)
* ghost - Outlined button (pill, border, transparent bg)
* outlined - Outlined button (alias for ghost)
* text - Text button (no border, no bg, colored text)
* danger - Filled danger button (pill, Theme.error bg)
*/
Button {
id: control
property string variant: "default" // default, primary, secondary, ghost, danger, text
property string variant: "default" // default, primary, secondary, ghost, outlined, danger, text
property string size: "md" // sm, md, lg
property string iconSource: ""
property string iconText: "" // Alias for simpler icon usage (emoji/text icons)
property string iconText: ""
property bool loading: false
// Effective icon: prefer iconText over iconSource
readonly property string _effectiveIcon: iconText || iconSource
// Use StyleVariables for sizing (mirrors _button.scss)
// MD3 sizing: sm=32, md=40, lg=48
implicitHeight: {
switch (size) {
case "sm": return StyleVariables.buttonSizes.sm.height
case "lg": return StyleVariables.buttonSizes.lg.height
default: return StyleVariables.buttonSizes.md.height
case "sm": return 32
case "lg": return 48
default: return 40
}
}
implicitWidth: Math.max(implicitHeight, contentRow.implicitWidth + _paddingH * 2)
readonly property int _paddingH: {
switch (size) {
case "sm": return StyleVariables.buttonSizes.sm.paddingH
case "lg": return StyleVariables.buttonSizes.lg.paddingH
default: return StyleVariables.buttonSizes.md.paddingH
case "sm": return 16
case "lg": return 28
default: return 24
}
}
font.pixelSize: {
switch (size) {
case "sm": return StyleVariables.buttonSizes.sm.fontSize
case "lg": return StyleVariables.buttonSizes.lg.fontSize
default: return StyleVariables.buttonSizes.md.fontSize
}
font.pixelSize: 14
font.weight: Font.DemiBold
// Resolve whether this variant is filled, outlined, tonal, or text
readonly property bool _isFilled: variant === "primary" || variant === "danger"
readonly property bool _isOutlined: variant === "ghost" || variant === "outlined"
readonly property bool _isText: variant === "text"
readonly property bool _isTonal: variant === "default" || variant === "secondary"
// Base fill color (before state layers)
readonly property color _baseFill: {
if (variant === "primary") return Theme.primary
if (variant === "danger") return Theme.error
if (_isTonal) return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
return "transparent" // ghost, outlined, text
}
font.weight: Font.Medium
// Foreground / text color
readonly property color _foreground: {
if (!enabled) return Theme.textDisabled
if (variant === "primary") return "#ffffff"
if (variant === "danger") return "#ffffff"
return Theme.primary
}
opacity: enabled ? 1.0 : 0.38
Behavior on opacity { NumberAnimation { duration: Theme.transitionShortest } }
background: Rectangle {
radius: StyleVariables.radiusSm
radius: control.height / 2
// State layer: hover = 8% overlay, pressed = 12% overlay
readonly property color _stateLayer: {
if (control._isFilled) return "#ffffff" // white overlay on filled
return Theme.primary // primary overlay on others
}
color: {
if (!control.enabled) return Theme.surface
if (!control.enabled) {
if (control._isFilled) return Theme.surface
return "transparent"
}
if (control.down) {
switch(control.variant) {
case "primary": return Qt.darker(Theme.primary, 1.3)
case "secondary": return Qt.darker(Theme.success, 1.3)
case "danger": return Qt.darker(Theme.error, 1.3)
case "ghost":
case "text": return StyleMixins.activeBg(Theme.mode === "dark")
default: return Qt.darker(Theme.surface, 1.2)
}
if (control._isFilled)
return Qt.rgba(_stateLayer.r, _stateLayer.g, _stateLayer.b, 0.12)
// blend: we layer 12% on top of base
// For filled, darken slightly instead
if (control._isTonal)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.22)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
}
if (control.hovered) {
switch(control.variant) {
case "primary": return Qt.darker(Theme.primary, 1.1)
case "secondary": return Qt.darker(Theme.success, 1.1)
case "danger": return Qt.darker(Theme.error, 1.1)
case "ghost":
case "text": return StyleMixins.hoverBg(Theme.mode === "dark")
default: return Qt.lighter(Theme.surface, 1.1)
}
}
switch(control.variant) {
case "primary": return Theme.primary
case "secondary": return Theme.success
case "danger": return Theme.error
case "ghost": return "transparent"
case "text": return "transparent"
default: return Theme.surface
if (control._isFilled)
return control._baseFill // overlay handled by stateOverlay
if (control._isTonal)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.18)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
}
return control._baseFill
}
border.width: control._isOutlined ? 1 : 0
border.color: control.enabled ? Theme.border : Theme.actionDisabled
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
// State layer overlay for filled buttons (hover/press)
Rectangle {
anchors.fill: parent
radius: parent.radius
visible: control._isFilled && control.enabled && (control.hovered || control.down)
color: control.variant === "danger" ? "#000000" : "#ffffff"
opacity: control.down ? 0.12 : (control.hovered ? 0.08 : 0)
Behavior on opacity { NumberAnimation { duration: Theme.transitionShortest } }
}
border.width: control.variant === "ghost" ? 1 : 0
border.color: Theme.border
Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } }
}
contentItem: RowLayout {
id: contentRow
spacing: StyleVariables.spacingSm
spacing: 8
BusyIndicator {
Layout.preferredWidth: 16
Layout.preferredHeight: 16
running: control.loading
visible: control.loading
}
Text {
visible: control._effectiveIcon && !control.loading
visible: control._effectiveIcon !== "" && !control.loading
text: control._effectiveIcon
font.pixelSize: control.font.pixelSize
color: control.enabled ? Theme.text : Theme.textDisabled
color: control._foreground
}
Text {
text: control.text
font: control.font
color: control.enabled ? Theme.text : Theme.textDisabled
color: control._foreground
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Behavior on opacity { NumberAnimation { duration: StyleVariables.transitionFast } }
opacity: enabled ? 1.0 : 0.5
}

View File

@@ -5,48 +5,75 @@ import QtQuick.Effects
import QmlComponents 1.0
/**
* CCard.qml - Card container component (mirrors _card.scss)
* Uses StyleVariables for consistent styling
* CCard.qml - Material Design 3 Card component
*
* Variants:
* "filled" (default) - surfaceContainerHigh fill, subtle border
* "outlined" - transparent fill, Theme.border stroke
* "elevated" - surface color + drop shadow
*/
Rectangle {
id: card
// ── Public API (preserved) ──────────────────────────────────────
property string title: ""
property string subtitle: ""
property bool elevated: false
property bool hoverable: false
property bool clickable: false
property string variant: "default" // default, outlined, elevated
property string variant: "filled" // filled, outlined, elevated
signal clicked()
default property alias cardContent: contentColumn.data
color: Theme.paper
radius: StyleVariables.radiusMd
border.width: 1
border.color: {
if (hoverable && mouseArea.containsMouse) return Theme.primary
return variant === "outlined" ? Theme.border : Theme.border
}
implicitHeight: contentColumn.implicitHeight
// ── MD3 surface tints ───────────────────────────────────────────
readonly property bool isDark: Theme.mode === "dark"
readonly property color surfaceContainer:
isDark ? Qt.rgba(1, 1, 1, 0.05) : Qt.rgba(0.31, 0.31, 0.44, 0.06)
readonly property color surfaceContainerHigh:
isDark ? Qt.rgba(1, 1, 1, 0.08) : Qt.rgba(0.31, 0.31, 0.44, 0.10)
// ── Geometry ────────────────────────────────────────────────────
radius: 12
implicitWidth: 300
implicitHeight: contentColumn.implicitHeight
// ── Fill colour per variant ─────────────────────────────────────
color: {
switch (variant) {
case "outlined": return "transparent"
case "elevated": return Theme.surface
default: return surfaceContainerHigh // filled
}
}
// ── Border ──────────────────────────────────────────────────────
border.width: variant === "elevated" ? 0 : 1
border.color: {
if ((hoverable || clickable) && mouseArea.containsMouse)
return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.5)
if (variant === "outlined") return Theme.border
// filled: subtle border
return isDark ? Qt.rgba(1, 1, 1, 0.06) : Qt.rgba(0, 0, 0, 0.08)
}
Behavior on border.color { ColorAnimation { duration: StyleVariables.transitionFast } }
Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } }
// Hover effect for clickable cards
Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } }
// ── Hover overlay ───────────────────────────────────────────────
Rectangle {
anchors.fill: parent
radius: parent.radius
color: (card.hoverable || card.clickable) && mouseArea.containsMouse
? StyleMixins.hoverBg(Theme.mode === "dark")
color: (card.hoverable || card.clickable) && mouseArea.containsMouse
? StyleMixins.hoverBg(card.isDark)
: "transparent"
Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } }
}
// ── Elevation (shadow) ──────────────────────────────────────────
layer.enabled: elevated || variant === "elevated"
layer.effect: MultiEffect {
shadowEnabled: true
@@ -54,7 +81,8 @@ Rectangle {
shadowBlur: StyleVariables.shadowMd.blur
shadowVerticalOffset: StyleVariables.shadowMd.offset
}
// ── Interaction ─────────────────────────────────────────────────
MouseArea {
id: mouseArea
anchors.fill: parent
@@ -62,11 +90,14 @@ Rectangle {
cursorShape: card.clickable ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (card.clickable) card.clicked()
}
// Simple content column - children are placed here
// ── Content column with MD3 16 px padding ──────────────────────
ColumnLayout {
id: contentColumn
anchors.fill: parent
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 16
spacing: 0
}
}

View File

@@ -4,92 +4,168 @@ import QtQuick.Layouts
import QmlComponents 1.0
/**
* CChip.qml - Chip/tag component (mirrors _chip.scss)
* Uses StyleVariables and StyleMixins for consistent styling
* CChip.qml - Material Design 3 Chip component
*
* Variants:
* assist - Outlined, transparent fill (default)
* filter - Tonal fill when selected, outlined when not
* input - Outlined with optional close icon
* suggestion - Outlined, similar to assist
*
* Status variants (legacy compat): success, warning, error, info, primary
*/
Rectangle {
id: chip
property string text: ""
property string icon: ""
property string variant: "default" // default, success, warning, error, info, primary, outlined
property string size: "sm" // sm, md
property string variant: "assist" // assist, filter, input, suggestion, success, warning, error, info, primary, outlined
property string size: "md" // sm, md
property bool clickable: false
property bool closable: false
property bool checked: false
property bool selected: false
property color color: Theme.primary
signal clicked()
signal closeClicked()
// Use StyleVariables for sizing
implicitHeight: size === "sm" ? StyleVariables.chipSizes.sm.height : StyleVariables.chipSizes.md.height
implicitWidth: chipRow.implicitWidth + (size === "sm" ? StyleVariables.chipSizes.sm.paddingH : StyleVariables.chipSizes.md.paddingH) * 2
radius: StyleVariables.radiusFull
// Use StyleMixins for status colors
color: {
if (variant === "outlined") return "transparent"
switch(variant) {
case "success": return StyleMixins.statusBgColor("success")
case "warning": return StyleMixins.statusBgColor("warning")
case "error": return StyleMixins.statusBgColor("error")
case "info": return StyleMixins.statusBgColor("info")
case "primary": return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.15)
default: return Theme.surface
}
}
border.width: variant === "outlined" ? 1 : 0
border.color: _chipColor
readonly property color _chipColor: {
switch(variant) {
case "success": return StyleMixins.statusColor("success")
case "warning": return StyleMixins.statusColor("warning")
case "error": return StyleMixins.statusColor("error")
case "info": return StyleMixins.statusColor("info")
// MD3: 32px height
implicitHeight: 32
implicitWidth: chipRow.implicitWidth + _paddingH * 2
readonly property real _paddingH: size === "sm" ? 12 : 16
// MD3: 8px radius (not full pill)
radius: 8
// Resolve the effective color for status variants
readonly property color _resolvedColor: {
switch (variant) {
case "success": return Theme.success
case "warning": return Theme.warning
case "error": return Theme.error
case "info": return Theme.info
case "primary": return Theme.primary
default: return Theme.textSecondary
default: return color
}
}
readonly property bool _isStatusVariant: {
return variant === "success" || variant === "warning" ||
variant === "error" || variant === "info" || variant === "primary"
}
// MD3 fill logic
color: {
// Status variants: tonal fill
if (_isStatusVariant) {
return Qt.rgba(_resolvedColor.r, _resolvedColor.g, _resolvedColor.b, 0.12)
}
switch (variant) {
case "filter":
// Selected filter chips get tonal fill
if (selected || checked) {
return Qt.rgba(_resolvedColor.r, _resolvedColor.g, _resolvedColor.b, 0.12)
}
return "transparent"
case "assist":
case "input":
case "suggestion":
case "outlined":
default:
return "transparent"
}
}
// MD3 border: 1px outline for non-selected, no border for tonal/selected
border.width: {
if (_isStatusVariant) return 0
if (variant === "filter" && (selected || checked)) return 0
return 1
}
border.color: {
if (_isStatusVariant) return _resolvedColor
return Theme.border
}
// MD3 text/icon color
readonly property color _contentColor: {
if (_isStatusVariant) return _resolvedColor
if (variant === "filter" && (selected || checked)) return _resolvedColor
return Theme.text
}
Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } }
Behavior on border.width { NumberAnimation { duration: StyleVariables.transitionFast } }
// Hover/press overlay
Rectangle {
anchors.fill: parent
radius: parent.radius
color: chip._contentColor
opacity: mouseArea.containsPress ? 0.12 : mouseArea.containsMouse ? 0.08 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: chip.clickable
cursorShape: chip.clickable ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (chip.clickable) chip.clicked()
}
RowLayout {
id: chipRow
anchors.centerIn: parent
spacing: StyleVariables.spacingXs
// Leading icon
Text {
text: chip.icon
font.pixelSize: size === "sm" ? StyleVariables.chipSizes.sm.fontSize : StyleVariables.chipSizes.md.fontSize
color: chip._chipColor
visible: chip.icon
font.pixelSize: 18
color: chip._contentColor
visible: chip.icon !== ""
}
// Check icon for selected filter chips
Text {
text: "\u2713"
font.pixelSize: 14
font.weight: Font.Bold
color: chip._contentColor
visible: chip.variant === "filter" && (chip.selected || chip.checked)
}
// Label
Text {
text: chip.text
font.pixelSize: size === "sm" ? StyleVariables.chipSizes.sm.fontSize : StyleVariables.chipSizes.md.fontSize
font.pixelSize: 13
font.weight: Font.Medium
color: chip._chipColor
color: chip._contentColor
}
Text {
text: "✕"
font.pixelSize: size === "sm" ? StyleVariables.fontSizeXs : StyleVariables.fontSizeSm
color: Qt.darker(chip._chipColor, 1.2)
visible: chip.closable
// Close/remove icon for input chips or closable chips
Rectangle {
width: 18
height: 18
radius: 9
color: closeMouseArea.containsMouse ? Qt.rgba(chip._contentColor.r, chip._contentColor.g, chip._contentColor.b, 0.12) : "transparent"
visible: chip.closable || chip.variant === "input"
Text {
anchors.centerIn: parent
text: "\u2715"
font.pixelSize: 11
font.weight: Font.Medium
color: chip._contentColor
}
MouseArea {
id: closeMouseArea
anchors.fill: parent
anchors.margins: -4
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: chip.closeClicked()
}

View File

@@ -2,27 +2,55 @@ import QtQuick
import QmlComponents 1.0
/**
* CFab.qml - Floating action button
* CFab.qml - Material Design 3 Floating Action Button
*
* MD3 FAB: 56px, rounded corner (radius 16), tonal surface with primary icon.
* Hover adds 8% state layer, press adds 12% state layer.
*/
Rectangle {
id: root
property alias icon: iconLabel.text
property int size: 56
signal clicked()
width: size
height: size
radius: size/2
color: Theme.primary
radius: 16
anchors.margins: StyleVariables.spacingMd
// MD3 tonal surface: primary at 12% opacity over surface
color: Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
// State layer overlay for hover/press
Rectangle {
id: stateLayer
anchors.fill: parent
radius: parent.radius
color: Theme.primary
opacity: mouseArea.pressed ? 0.12 : (mouseArea.containsMouse ? 0.08 : 0)
visible: opacity > 0
Behavior on opacity { NumberAnimation { duration: Theme.transitionShortest } }
}
Text {
id: iconLabel
anchors.centerIn: parent
text: "+"
color: Theme.onPrimary
font.pixelSize: size * 0.5
color: Theme.primary
font.pixelSize: root.size * 0.43
font.weight: Font.DemiBold
}
MouseArea { anchors.fill: parent; onClicked: root.clicked() }
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
}

View File

@@ -2,48 +2,58 @@ import QtQuick
import QtQuick.Controls
import QmlComponents 1.0
/**
* CIconButton.qml - Material Design 3 Icon Button
*
* MD3 icon button: 40px circle, transparent background, hover/press state layers.
* Variants:
* default - transparent bg, icon in textSecondary, hover state layer
* primary - transparent bg, icon in primary, hover state layer in primary tint
* ghost - same as default (backward compat)
*/
Item {
id: control
property string icon: ""
property string size: "md" // sm, md, lg
property string variant: "default" // default, primary, ghost
property bool loading: false
property string tooltip: ""
signal clicked()
width: size === "sm" ? 32 : size === "lg" ? 48 : 40
height: width
opacity: enabled ? 1.0 : 0.38
Behavior on opacity { NumberAnimation { duration: Theme.transitionShortest } }
Rectangle {
id: bg
anchors.fill: parent
radius: width / 2
color: {
if (!control.enabled) return "transparent"
if (mouseArea.pressed) {
switch(control.variant) {
case "primary": return Qt.darker(Theme.primary, 1.2)
default: return "#404040"
}
}
if (mouseArea.containsMouse) {
switch(control.variant) {
case "primary": return Theme.primary
default: return "#3d3d3d"
}
}
switch(control.variant) {
case "primary": return Theme.primary
case "ghost": return "transparent"
default: return Theme.surface
color: "transparent"
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
// State layer overlay
Rectangle {
id: stateLayer
anchors.fill: parent
radius: parent.radius
color: control.variant === "primary" ? Theme.primary : (Theme.mode === "dark" ? "#ffffff" : "#000000")
opacity: {
if (!control.enabled) return 0
if (mouseArea.pressed) return 0.12
if (mouseArea.containsMouse) return 0.08
return 0
}
visible: opacity > 0
Behavior on opacity { NumberAnimation { duration: Theme.transitionShortest } }
}
Behavior on color { ColorAnimation { duration: 150 } }
}
BusyIndicator {
anchors.centerIn: parent
width: parent.width * 0.5
@@ -51,15 +61,20 @@ Item {
running: control.loading
visible: control.loading
}
Text {
anchors.centerIn: parent
text: control.icon
font.pixelSize: control.size === "sm" ? 14 : control.size === "lg" ? 22 : 18
color: control.enabled ? (control.variant === "primary" ? "#ffffff" : Theme.textSecondary) : Theme.textDisabled
font.pixelSize: control.size === "sm" ? 16 : control.size === "lg" ? 24 : 20
font.weight: Font.DemiBold
color: {
if (!control.enabled) return Theme.textDisabled
if (control.variant === "primary") return Theme.primary
return Theme.textSecondary
}
visible: !control.loading
}
MouseArea {
id: mouseArea
anchors.fill: parent
@@ -67,11 +82,8 @@ Item {
cursorShape: Qt.PointingHandCursor
onClicked: control.clicked()
}
ToolTip.visible: tooltip && mouseArea.containsMouse
ToolTip.text: tooltip
ToolTip.delay: 500
opacity: enabled ? 1.0 : 0.5
Behavior on opacity { NumberAnimation { duration: 150 } }
}

View File

@@ -2,54 +2,60 @@ import QtQuick
import QmlComponents 1.0
/**
* CBadge.qml - Notification badge (mirrors _badge.scss)
* Small indicator for counts or status
* CBadge.qml - Material Design 3 Badge
*
* Small: 6px dot indicator, no text
* Standard: 16px height pill with count/text, radius 8
* Large: 20px for bigger counts
*
* MD3 default color: Theme.error (notification badge)
*/
Rectangle {
id: root
property string size: "md" // sm, md, lg
property string variant: "primary" // primary, success, warning, error
property int count: 0 // Number to display (0 = dot only)
property bool dot: false // Show as dot without number
property string text: "" // Direct text label (overrides count)
// Size mapping
readonly property var _sizes: ({
sm: { minWidth: 14, height: 14, fontSize: 9, padding: 3 },
md: { minWidth: 16, height: 16, fontSize: 10, padding: 4 },
lg: { minWidth: 20, height: 20, fontSize: 11, padding: 5 }
})
readonly property var _sizeConfig: _sizes[size] || _sizes.md
// Color mapping
property string text: ""
property bool accent: false
property color color: accent ? Theme.primary : Theme.error
property int count: 0
property bool dot: false
property string variant: "primary" // primary, success, warning, error (legacy compat)
// Resolve badge color from variant or explicit color
readonly property color _bgColor: {
if (accent) return Theme.primary
switch (variant) {
case "success": return Theme.success
case "warning": return Theme.warning
case "error": return Theme.error
default: return Theme.primary
default: return color
}
}
// MD3: white text on badge, dark text on warning
readonly property color _textColor: variant === "warning" ? "#000000" : "#ffffff"
// Sizing
readonly property bool _hasText: text !== ""
width: dot ? _sizeConfig.height : Math.max(_sizeConfig.minWidth, label.implicitWidth + _sizeConfig.padding * 2)
height: _sizeConfig.height
radius: height / 2
// Display string
readonly property string _displayText: {
if (text !== "") return text
if (count > 99) return "99+"
return count.toString()
}
readonly property bool _showText: !dot && (text !== "" || count > 0)
// MD3 small badge: 6px dot, standard badge: 16px pill
width: dot ? 6 : Math.max(16, label.implicitWidth + 8)
height: dot ? 6 : 16
radius: dot ? 3 : 8
color: _bgColor
// Badge text
Text {
id: label
anchors.centerIn: parent
text: root._hasText ? root.text : (root.count > 99 ? "99+" : root.count.toString())
text: root._displayText
color: root._textColor
font.pixelSize: root._sizeConfig.fontSize
font.weight: Font.DemiBold
visible: !root.dot && (root._hasText || root.count > 0)
font.pixelSize: 11
font.weight: Font.Bold
visible: root._showText
}
}

View File

@@ -3,82 +3,96 @@ import QtQuick.Layouts
import QmlComponents 1.0
/**
* CStatBadge.qml - Statistic badge (mirrors _stat-badge.scss)
* Display a statistic with label
* CStatBadge.qml - Material Design 3 Statistic Badge
*
* Large stat display with label, value, and optional icon.
* Uses tonal surface container following MD3 color system.
*/
Rectangle {
id: root
property string label: ""
property string value: ""
property string icon: ""
property string variant: "default" // default, primary, success, warning, error
property string size: "md" // sm, md, lg
// Color mapping
property string variant: "default" // default, primary, success, warning, error
property string size: "md" // sm, md, lg
// MD3 tonal surface container colors
readonly property color _bgColor: {
switch (variant) {
case "primary": return Theme.primaryContainer
case "success": return Theme.successContainer
case "warning": return Theme.warningContainer
case "error": return Theme.errorContainer
default: return Theme.surfaceVariant
case "primary": return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
case "success": return Qt.rgba(Theme.success.r, Theme.success.g, Theme.success.b, 0.12)
case "warning": return Qt.rgba(Theme.warning.r, Theme.warning.g, Theme.warning.b, 0.12)
case "error": return Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12)
default: return Theme.surfaceVariant
}
}
readonly property color _textColor: {
// MD3 on-container text color
readonly property color _accentColor: {
switch (variant) {
case "primary": return Theme.primary
case "success": return Theme.success
case "warning": return Theme.warning
case "error": return Theme.error
default: return Theme.onSurface
case "error": return Theme.error
default: return Theme.text
}
}
readonly property color _labelColor: {
switch (variant) {
case "default": return Theme.textSecondary
default: return Qt.darker(_accentColor, 1.1)
}
}
// Size config
readonly property var _sizes: ({
sm: { padding: StyleVariables.spacingSm, valueSize: StyleVariables.fontSizeLg, labelSize: StyleVariables.fontSizeXs },
md: { padding: StyleVariables.spacingMd, valueSize: StyleVariables.fontSize2xl, labelSize: StyleVariables.fontSizeSm },
lg: { padding: StyleVariables.spacingLg, valueSize: 32, labelSize: StyleVariables.fontSizeMd }
sm: { padding: 12, valueSize: 20, labelSize: 11, iconSize: 20, spacing: 2 },
md: { padding: 16, valueSize: 28, labelSize: 13, iconSize: 28, spacing: 4 },
lg: { padding: 20, valueSize: 36, labelSize: 15, iconSize: 36, spacing: 6 }
})
readonly property var _config: _sizes[size] || _sizes.md
color: _bgColor
radius: StyleVariables.radiusMd
radius: 12 // MD3 medium container radius
implicitWidth: contentRow.implicitWidth + _config.padding * 2
implicitHeight: contentRow.implicitHeight + _config.padding * 2
RowLayout {
id: contentRow
anchors.centerIn: parent
spacing: StyleVariables.spacingSm
// Icon
Text {
text: root.icon
font.pixelSize: root._config.valueSize
font.pixelSize: root._config.iconSize
color: root._accentColor
visible: root.icon !== ""
Layout.alignment: Qt.AlignVCenter
}
ColumnLayout {
spacing: 2
// Value
spacing: root._config.spacing
// Value - large prominent text
Text {
text: root.value
color: root._textColor
color: root._accentColor
font.pixelSize: root._config.valueSize
font.weight: Font.Bold
font.letterSpacing: -0.5
}
// Label
// Label - smaller secondary text
Text {
text: root.label
color: Theme.onSurfaceVariant
color: root._labelColor
font.pixelSize: root._config.labelSize
font.weight: Font.Medium
visible: root.label !== ""
}
}

View File

@@ -1,63 +1,87 @@
import QtQuick
import QmlComponents 1.0
/**
* CStatusBadge.qml - Material Design 3 Status Pill
*
* Status indicator pill with tonal background matching the status color.
* Statuses: completed/success, running/info, queued/warning, failed/error, unknown/neutral
*/
Rectangle {
id: badge
property string status: "unknown" // completed, running, queued, failed, unknown
property string status: "unknown" // completed, running, queued, failed, unknown, success, warning, error, info
property string text: status
property bool showDot: true
property var themeColors: ({})
// Internal colors with fallbacks
readonly property var colors: ({
success: themeColors.success || "#22c55e",
info: themeColors.info || "#3b82f6",
warning: themeColors.warning || "#f59e0b",
error: themeColors.error || "#ef4444",
neutral: themeColors.mid || "#333333"
})
implicitHeight: 22
implicitWidth: badgeRow.implicitWidth + 12
radius: 4
color: {
switch(status) {
case "completed": return colors.success
case "running": return colors.info
case "queued": return colors.warning
case "failed": return colors.error
default: return colors.neutral
// Resolve status to a semantic category
readonly property string _semantic: {
switch (status) {
case "completed":
case "success": return "success"
case "running":
case "info": return "info"
case "queued":
case "warning": return "warning"
case "failed":
case "error": return "error"
default: return "neutral"
}
}
// MD3 status colors with theme fallbacks
readonly property color _statusColor: {
switch (_semantic) {
case "success": return themeColors.success || Theme.success
case "info": return themeColors.info || Theme.info
case "warning": return themeColors.warning || Theme.warning
case "error": return themeColors.error || Theme.error
default: return themeColors.mid || Theme.textSecondary
}
}
// MD3 tonal background: 12% opacity of status color
readonly property color _bgColor: Qt.rgba(_statusColor.r, _statusColor.g, _statusColor.b, 0.12)
// MD3 on-container text: full status color
readonly property color _textColor: _statusColor
implicitHeight: 24
implicitWidth: badgeRow.implicitWidth + 20
radius: 12 // Full pill for status badges
color: _bgColor
Row {
id: badgeRow
anchors.centerIn: parent
spacing: 6
// Animated dot for running status
// Status dot indicator
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 6
height: 6
radius: 3
color: "#ffffff"
visible: badge.showDot && badge.status === "running"
color: badge._textColor
visible: badge.showDot
// Pulse animation for running/active status
SequentialAnimation on opacity {
running: badge.status === "running"
running: badge._semantic === "info" && badge.showDot
loops: Animation.Infinite
NumberAnimation { to: 0.3; duration: 500 }
NumberAnimation { to: 1.0; duration: 500 }
NumberAnimation { to: 0.3; duration: 600; easing.type: Easing.InOutSine }
NumberAnimation { to: 1.0; duration: 600; easing.type: Easing.InOutSine }
}
}
Text {
text: badge.text
font.pixelSize: 11
font.pixelSize: 12
font.weight: Font.Medium
color: "#ffffff"
font.letterSpacing: 0.2
color: badge._textColor
textFormat: Text.PlainText
}
}

View File

@@ -3,70 +3,127 @@ import QtQuick.Layouts
import QmlComponents 1.0
/**
* CErrorState.qml - Error state display (mirrors _error-state.scss)
* Shows error message with optional retry action
* CErrorState.qml - Material Design 3 Error State
*
* MD3 spec: Centered layout with tonal surface container, large icon,
* headline title, body description, and filled action button.
*
* Usage:
* CErrorState {
* title: "Connection failed"
* message: "Check your internet and try again."
* onRetry: reloadData()
* }
*/
Rectangle {
id: root
// Public properties
property string title: "Something went wrong"
property string message: ""
property string icon: "⚠️"
property string icon: "\u26A0" // Warning symbol (Unicode)
property string size: "md" // sm, md, lg
property bool showRetry: true
property string retryText: "Try Again"
signal retry()
color: Theme.errorContainer
radius: StyleVariables.radiusMd
// MD3 tonal surface container
color: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, 0.7)
radius: 16
border.width: 1
border.color: Qt.rgba(Theme.border.r, Theme.border.g, Theme.border.b, 0.3)
implicitWidth: parent ? parent.width : 400
implicitHeight: contentCol.implicitHeight + StyleVariables.spacingLg * 2
implicitHeight: contentCol.implicitHeight + Theme.spacingXl * 2
ColumnLayout {
id: contentCol
anchors.centerIn: parent
width: parent.width - StyleVariables.spacingLg * 2
spacing: StyleVariables.spacingMd
// Icon
Text {
width: Math.min(parent.width - Theme.spacingXl * 2, 360)
spacing: Theme.spacingMd
// MD3 large icon (48px)
Rectangle {
Layout.alignment: Qt.AlignHCenter
text: root.icon
font.pixelSize: 48
width: 72
height: 72
radius: 36
color: Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.12)
Text {
anchors.centerIn: parent
text: root.icon
font.pixelSize: 48
color: Theme.error
}
}
// Title
// MD3 headline: h5 weight
Text {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
text: root.title
color: Theme.error
font.pixelSize: StyleVariables.fontSizeLg
font.weight: Font.DemiBold
color: Theme.text
font.pixelSize: Theme.fontSizeH5
font.weight: Font.Medium
font.family: Theme.fontFamily
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
}
// Message
// MD3 body: body2, textSecondary
Text {
Layout.alignment: Qt.AlignHCenter
Layout.fillWidth: true
text: root.message
color: Theme.onErrorContainer
font.pixelSize: StyleVariables.fontSizeSm
color: Theme.textSecondary
font.pixelSize: Theme.fontSizeSm
font.family: Theme.fontFamily
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
lineHeight: 1.5
visible: root.message !== ""
}
// Retry button
CButton {
Layout.alignment: Qt.AlignHCenter
text: root.retryText
variant: "outlined"
// Spacer before button
Item {
Layout.fillWidth: true
height: Theme.spacingXs
visible: root.showRetry
onClicked: root.retry()
}
// MD3 filled button
Rectangle {
Layout.alignment: Qt.AlignHCenter
width: buttonLabel.implicitWidth + Theme.spacingLg * 2
height: 40
radius: 20
color: buttonArea.containsMouse ? Qt.lighter(Theme.primary, 1.15) : Theme.primary
visible: root.showRetry
Text {
id: buttonLabel
anchors.centerIn: parent
text: root.retryText
color: Theme.primaryContrastText
font.pixelSize: Theme.fontSizeSm
font.weight: Font.Medium
font.family: Theme.fontFamily
font.letterSpacing: 0.5
}
MouseArea {
id: buttonArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.retry()
}
Behavior on color {
ColorAnimation { duration: Theme.transitionShortest }
}
}
}
}

View File

@@ -2,183 +2,108 @@ import QtQuick
import QmlComponents 1.0
/**
* CProgress.qml - Progress indicator (mirrors _progress.scss)
* Linear or circular progress indicator
*
* CProgress.qml - Material Design 3 Linear Progress Indicator
*
* MD3 spec: 4px track with rounded ends, primary indicator, indeterminate sliding animation
*
* Usage:
* CProgress { value: 0.5 } // 50% linear
* CProgress { variant: "circular"; value: 0.75 }
* CProgress { indeterminate: true } // Animated indeterminate
* CProgress { value: 0.5 } // 50% determinate
* CProgress { indeterminate: true } // Animated indeterminate
* CProgress { value: 0.75; label: "75%" } // With label
*/
Item {
id: root
// Public properties
property real value: 0 // 0.0 to 1.0
property string variant: "linear" // linear, circular
property real value: 0 // 0.0 to 1.0
property bool indeterminate: false
property string color: "" // Custom color, uses primary if empty
property string trackColor: "" // Custom track color
property int thickness: 4 // Line thickness
property string size: "md" // sm, md, lg (for circular)
property string label: "" // Optional label (for linear)
// Computed colors
readonly property color _progressColor: color || Theme.primary
readonly property color _trackColor: trackColor || Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.12)
// Size based on variant
implicitWidth: variant === "circular" ? _circularSize : 200
implicitHeight: variant === "circular" ? _circularSize : (label ? thickness + StyleVariables.fontSizeSm + StyleVariables.spacingXs : thickness)
readonly property int _circularSize: {
property string color: "" // Custom indicator color, uses Theme.primary if empty
property string trackColor: "" // Custom track color
property string size: "md" // sm, md, lg (track thickness)
property string label: "" // Optional label below track
// Computed colors (MD3: primary for indicator, surfaceContainerHighest for track)
readonly property color _indicatorColor: color || Theme.primary
readonly property color _trackColor: trackColor || Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, 0.38)
// MD3 track height: 4px standard
readonly property int _trackHeight: {
switch (size) {
case "sm": return 24
case "lg": return 56
default: return 40
case "sm": return 2
case "lg": return 6
default: return 4
}
}
// Linear progress
Item {
visible: root.variant === "linear"
anchors.fill: parent
// Track
implicitWidth: 200
implicitHeight: label ? _trackHeight + Theme.fontSizeXs + Theme.spacingXs : _trackHeight
// Track background (MD3: rounded, surfaceContainerHighest)
Rectangle {
id: track
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: root._trackHeight
radius: root._trackHeight / 2
color: root._trackColor
clip: true
// Determinate indicator
Rectangle {
id: track
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
height: root.thickness
radius: root.thickness / 2
color: root._trackColor
}
// Progress bar
Rectangle {
id: progressBar
id: determinateBar
visible: !root.indeterminate
anchors.left: parent.left
anchors.top: parent.top
height: root.thickness
width: root.indeterminate ? parent.width * 0.3 : parent.width * root.value
radius: root.thickness / 2
color: root._progressColor
height: parent.height
width: parent.width * Math.max(0, Math.min(1, root.value))
radius: root._trackHeight / 2
color: root._indicatorColor
Behavior on width {
enabled: !root.indeterminate
NumberAnimation { duration: StyleVariables.transitionNormal }
NumberAnimation {
duration: Theme.transitionStandard
easing.type: Easing.OutCubic
}
}
}
// Indeterminate animation
SequentialAnimation on progressBar.x {
running: root.indeterminate && root.variant === "linear"
loops: Animation.Infinite
NumberAnimation {
from: -track.width * 0.3
to: track.width
duration: 1500
easing.type: Easing.InOutQuad
// Indeterminate indicator (MD3: sliding bar that traverses the track)
Rectangle {
id: indeterminateBar
visible: root.indeterminate
anchors.top: parent.top
height: parent.height
width: parent.width * 0.4
radius: root._trackHeight / 2
color: root._indicatorColor
SequentialAnimation {
running: root.indeterminate && root.visible
loops: Animation.Infinite
// Slide right, accelerating
NumberAnimation {
target: indeterminateBar
property: "x"
from: -indeterminateBar.width
to: track.width
duration: 1500
easing.type: Easing.InOutQuad
}
}
}
// Label
Text {
visible: root.label
anchors.top: track.bottom
anchors.topMargin: StyleVariables.spacingXs
anchors.horizontalCenter: parent.horizontalCenter
text: root.label
font.pixelSize: StyleVariables.fontSizeXs
color: Theme.textSecondary
}
}
// Circular progress
Item {
visible: root.variant === "circular"
anchors.fill: parent
// Track circle
Canvas {
id: circularTrack
anchors.fill: parent
onPaint: {
var ctx = getContext("2d")
ctx.reset()
ctx.strokeStyle = root._trackColor
ctx.lineWidth = root.thickness
ctx.lineCap = "round"
var centerX = width / 2
var centerY = height / 2
var radius = (Math.min(width, height) - root.thickness) / 2
ctx.beginPath()
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI)
ctx.stroke()
}
}
// Progress arc
Canvas {
id: circularProgress
anchors.fill: parent
property real animatedValue: root.indeterminate ? 0.25 : root.value
property real rotation: 0
Behavior on animatedValue {
enabled: !root.indeterminate
NumberAnimation { duration: StyleVariables.transitionNormal }
}
// Indeterminate rotation
RotationAnimation on rotation {
running: root.indeterminate
from: 0
to: 360
duration: 1400
loops: Animation.Infinite
}
transform: Rotation {
origin.x: circularProgress.width / 2
origin.y: circularProgress.height / 2
angle: circularProgress.rotation - 90
}
onPaint: {
var ctx = getContext("2d")
ctx.reset()
ctx.strokeStyle = root._progressColor
ctx.lineWidth = root.thickness
ctx.lineCap = "round"
var centerX = width / 2
var centerY = height / 2
var radius = (Math.min(width, height) - root.thickness) / 2
ctx.beginPath()
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI * animatedValue)
ctx.stroke()
}
onAnimatedValueChanged: requestPaint()
onRotationChanged: requestPaint()
}
// Center label (percentage)
Text {
visible: !root.indeterminate && root.size !== "sm"
anchors.centerIn: parent
text: Math.round(root.value * 100) + "%"
font.pixelSize: root._circularSize / 4
font.weight: Font.Medium
color: Theme.text
}
// Optional label
Text {
visible: root.label !== ""
anchors.top: track.bottom
anchors.topMargin: Theme.spacingXs
anchors.horizontalCenter: parent.horizontalCenter
text: root.label
font.pixelSize: Theme.fontSizeXs
font.family: Theme.fontFamily
color: Theme.textSecondary
}
}

View File

@@ -2,68 +2,101 @@ import QtQuick
import QmlComponents 1.0
/**
* CSpinner.qml - Loading spinner (mirrors _spinner.scss)
* Simple rotating loading indicator
*
* CSpinner.qml - Material Design 3 Circular Progress Indicator
*
* MD3 spec: Canvas-based arc with rotation + arc sweep animation.
* Arc sweeps between 10-300 degrees while the whole spinner rotates at 1.5s/rev.
*
* Usage:
* CSpinner {}
* CSpinner { size: "lg"; color: Theme.success }
* CSpinner { size: "lg" }
* CSpinner { size: "sm"; color: Theme.success }
*/
Item {
id: root
// Public properties
property string size: "md" // sm, md, lg
property string size: "md" // sm (24px), md (40px), lg (56px)
property color color: Theme.primary
property int strokeWidth: size === "sm" ? 2 : (size === "lg" ? 4 : 3)
// Size
implicitWidth: _size
implicitHeight: _size
readonly property int _size: {
property int strokeWidth: 4
// Size mapping (MD3 defaults)
readonly property int _diameter: {
switch (size) {
case "sm": return 20
case "lg": return 48
default: return 32
case "sm": return 24
case "lg": return 56
default: return 40
}
}
// Spinning animation
implicitWidth: _diameter
implicitHeight: _diameter
// Rotation animation: 1.5s per full revolution
NumberAnimation on rotation {
from: 0
to: 360
duration: 1500
loops: Animation.Infinite
running: root.visible
}
Canvas {
id: canvas
anchors.fill: parent
property real angle: 0
RotationAnimation on angle {
// Arc sweep: oscillates between 10 and 300 degrees
property real sweepAngle: 10
property real sweepOffset: 0
SequentialAnimation on sweepAngle {
running: root.visible
loops: Animation.Infinite
NumberAnimation {
from: 10
to: 300
duration: 750
easing.type: Easing.InOutCubic
}
NumberAnimation {
from: 300
to: 10
duration: 750
easing.type: Easing.InOutCubic
}
}
// Offset rotates so the shrinking arc doesn't snap back
NumberAnimation on sweepOffset {
from: 0
to: 360
duration: 1000
to: 720
duration: 3000
loops: Animation.Infinite
running: root.visible
}
onAngleChanged: requestPaint()
onSweepAngleChanged: requestPaint()
onSweepOffsetChanged: requestPaint()
onPaint: {
var ctx = getContext("2d")
ctx.reset()
var centerX = width / 2
var centerY = height / 2
var radius = (Math.min(width, height) - root.strokeWidth) / 2
// Draw arc
ctx.strokeStyle = root.color
ctx.lineWidth = root.strokeWidth
ctx.lineCap = "round"
var startAngle = (angle - 90) * Math.PI / 180
var endAngle = startAngle + 1.5 * Math.PI
// Convert degrees to radians
var startRad = (sweepOffset - 90) * Math.PI / 180
var endRad = startRad + sweepAngle * Math.PI / 180
ctx.beginPath()
ctx.arc(centerX, centerY, radius, startAngle, endAngle)
ctx.arc(centerX, centerY, radius, startRad, endRad)
ctx.stroke()
}
}

View File

@@ -3,48 +3,122 @@ import QtQuick.Controls
import QmlComponents 1.0
/**
* CCheckbox.qml - styled checkbox wrapper
* CCheckbox.qml - Material Design 3 styled checkbox
*
* MD3 spec: 18px box, 2px border, slight rounding (radius 2),
* animated fill on check, white checkmark via Canvas.
*/
Rectangle {
id: root
property bool checked: false
property bool indeterminate: false
property alias text: label.text
property bool enabled: true
signal toggled(bool checked)
width: 120
height: 28
width: row.implicitWidth
height: 40
color: "transparent"
Row {
anchors.fill: parent
id: row
spacing: StyleVariables.spacingSm
anchors.verticalCenter: parent.verticalCenter
Rectangle {
id: box
width: 18
height: 18
radius: 3
color: checked ? Theme.primary : Theme.surfaceVariant
border.color: Theme.divider
border.width: 1
// Ripple touch target (40x40 centered on 18px box)
Item {
width: 40
height: 40
Text {
// Hover/press ripple circle
Rectangle {
id: ripple
anchors.centerIn: parent
text: indeterminate ? "-" : (checked ? "✓" : "")
color: Theme.onPrimary
font.pixelSize: 12
width: 40; height: 40
radius: 20
color: root.checked ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.08)
opacity: mouseArea.containsMouse || mouseArea.pressed ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
MouseArea { anchors.fill: parent; onClicked: { root.checked = !root.checked; root.toggled(root.checked) } }
// The checkbox box
Rectangle {
id: box
anchors.centerIn: parent
width: 18; height: 18
radius: 2
color: root.checked || root.indeterminate ? Theme.primary : "transparent"
border.color: root.checked || root.indeterminate ? Theme.primary : Theme.border
border.width: 2
opacity: root.enabled ? 1.0 : 0.38
Behavior on color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } }
Behavior on border.color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } }
// Checkmark drawn via Canvas
Canvas {
id: checkCanvas
anchors.centerIn: parent
width: 12; height: 12
visible: root.checked || root.indeterminate
opacity: visible ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 150 } }
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = "#ffffff";
ctx.lineWidth = 2;
ctx.lineCap = "round";
ctx.lineJoin = "round";
if (root.indeterminate) {
// Horizontal dash for indeterminate
ctx.beginPath();
ctx.moveTo(2, height / 2);
ctx.lineTo(width - 2, height / 2);
ctx.stroke();
} else {
// Checkmark path
ctx.beginPath();
ctx.moveTo(1, height * 0.5);
ctx.lineTo(width * 0.38, height - 2);
ctx.lineTo(width - 1, 2);
ctx.stroke();
}
}
Connections {
target: root
function onCheckedChanged() { checkCanvas.requestPaint() }
function onIndeterminateChanged() { checkCanvas.requestPaint() }
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
enabled: root.enabled
cursorShape: Qt.PointingHandCursor
onClicked: {
root.checked = !root.checked;
root.toggled(root.checked);
}
}
}
Text {
id: label
text: "Checkbox"
color: Theme.onSurface
color: root.enabled ? Theme.text : Theme.textDisabled
font.pixelSize: StyleVariables.fontSizeSm
font.family: Theme.fontFamily
anchors.verticalCenter: parent.verticalCenter
}
}

View File

@@ -2,41 +2,98 @@ import QtQuick
import QmlComponents 1.0
/**
* CRadio.qml - radio control
* CRadio.qml - Material Design 3 styled radio button
*
* MD3 spec: 20px outer circle, 2px border, 10px inner dot
* with scale animation on selection.
*/
Item {
Rectangle {
id: root
property bool checked: false
property alias text: label.text
property bool enabled: true
signal toggled(bool checked)
width: 160
height: 28
width: row.implicitWidth
height: 40
color: "transparent"
Row {
anchors.fill: parent
id: row
spacing: StyleVariables.spacingSm
anchors.verticalCenter: parent.verticalCenter
Canvas {
id: circle
width: 18; height: 18
onPaint: {
var ctx = getContext("2d");
ctx.clearRect(0,0,width,height);
ctx.beginPath();
ctx.arc(width/2, height/2, 8, 0, 2*Math.PI);
ctx.fillStyle = root.checked ? Theme.primary : Theme.surfaceVariant;
ctx.fill();
ctx.strokeStyle = Theme.divider;
ctx.stroke();
if (root.checked) {
ctx.beginPath(); ctx.arc(width/2, height/2, 4, 0, 2*Math.PI); ctx.fillStyle = Theme.onPrimary; ctx.fill();
// Ripple touch target
Item {
width: 40; height: 40
// Hover/press ripple
Rectangle {
id: ripple
anchors.centerIn: parent
width: 40; height: 40
radius: 20
color: root.checked ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.08)
opacity: mouseArea.containsMouse || mouseArea.pressed ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 150 } }
}
// Outer circle
Rectangle {
id: outerCircle
anchors.centerIn: parent
width: 20; height: 20
radius: 10
color: "transparent"
border.width: 2
border.color: root.checked ? Theme.primary : Theme.border
opacity: root.enabled ? 1.0 : 0.38
Behavior on border.color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } }
// Inner filled dot
Rectangle {
id: innerDot
anchors.centerIn: parent
width: 10; height: 10
radius: 5
color: Theme.primary
opacity: root.enabled ? 1.0 : 0.38
scale: root.checked ? 1.0 : 0.0
visible: scale > 0
Behavior on scale {
NumberAnimation {
duration: 200
easing.type: Easing.OutCubic
}
}
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
enabled: root.enabled
cursorShape: Qt.PointingHandCursor
onClicked: {
root.checked = true;
root.toggled(true);
}
}
MouseArea { anchors.fill: parent; onClicked: { root.checked = true; root.toggled(true) } }
}
Text { id: label; text: "Option"; color: Theme.onSurface; font.pixelSize: StyleVariables.fontSizeSm }
Text {
id: label
text: "Option"
color: root.enabled ? Theme.text : Theme.textDisabled
font.pixelSize: StyleVariables.fontSizeSm
font.family: Theme.fontFamily
anchors.verticalCenter: parent.verticalCenter
}
}
}

View File

@@ -2,22 +2,70 @@ import QtQuick
import QmlComponents 1.0
/**
* CRating.qml - simple star rating control
* CRating.qml - Material Design 3 styled star rating
*
* Interactive star rating with hover preview, unicode stars,
* and themed colors (primary for filled, border for empty).
*/
Row {
id: root
property int value: 0
property int max: 5
property bool readOnly: false
property int hoverValue: -1
property color filledColor: "#ffc107" // MD3 amber / override with Theme.primary if desired
property color emptyColor: Theme.border
signal valueChanged(int newValue)
spacing: 4
Repeater {
model: root.max
delegate: Text {
text: index < root.value ? "★" : "☆"
font.pixelSize: 18
color: index < root.value ? Theme.primary : Theme.onSurfaceVariant
MouseArea { anchors.fill: parent; enabled: !root.readOnly; onClicked: root.value = index+1 }
delegate: Item {
id: starItem
width: 32; height: 32
property bool isFilled: {
if (root.hoverValue >= 0)
return index < root.hoverValue;
return index < root.value;
}
Text {
id: starText
anchors.centerIn: parent
text: starItem.isFilled ? "\u2605" : "\u2606"
font.pixelSize: 24
color: starItem.isFilled ? root.filledColor : root.emptyColor
Behavior on color { ColorAnimation { duration: 150 } }
// Scale bump on hover
scale: starMouseArea.containsMouse ? 1.2 : 1.0
Behavior on scale { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } }
}
MouseArea {
id: starMouseArea
anchors.fill: parent
hoverEnabled: !root.readOnly
enabled: !root.readOnly
cursorShape: root.readOnly ? Qt.ArrowCursor : Qt.PointingHandCursor
onClicked: {
root.value = index + 1;
root.valueChanged(root.value);
}
onEntered: {
root.hoverValue = index + 1;
}
onExited: {
root.hoverValue = -1;
}
}
}
}
}

View File

@@ -3,10 +3,193 @@ import QtQuick.Controls
import QtQuick.Layouts
/**
* CSelect.qml - simple select (ComboBox wrapper)
* CSelect.qml - Material Design 3 outlined select (ComboBox wrapper)
*
* Outlined variant matching CTextField styling with dropdown popup,
* surface background, elevation shadow, and arrow indicator.
*/
ComboBox {
id: root
property string label: ""
property string helper: ""
property string errorText: ""
property bool hasError: errorText.length > 0
property string size: "md" // "sm", "md", "lg"
model: []
Layout.preferredWidth: 200
implicitHeight: size === "sm" ? 40 : (size === "lg" ? 56 : 48)
font.pixelSize: 14
font.family: Theme.fontFamily
// Content item (selected value display)
contentItem: Text {
leftPadding: 16
rightPadding: 40
verticalAlignment: Text.AlignVCenter
text: root.displayText
font: root.font
color: root.enabled ? Theme.text : Theme.textDisabled
elide: Text.ElideRight
}
// Arrow indicator
indicator: Item {
width: 40
height: root.height
anchors.right: parent.right
Text {
anchors.centerIn: parent
text: root.popup.visible ? "\u25B2" : "\u25BC"
font.pixelSize: 10
color: {
if (!root.enabled) return Theme.textDisabled
if (root.activeFocus) return Theme.primary
return Theme.textSecondary
}
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
}
}
// Outlined background matching CTextField
background: Rectangle {
id: bg
radius: 8
color: root.activeFocus ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.04) : "transparent"
border.width: root.activeFocus ? 2 : 1
border.color: {
if (!root.enabled) return Theme.actionDisabled
if (root.hasError) return Theme.error
if (root.activeFocus) return Theme.primary
if (bgHover.containsMouse) return Theme.text
return Theme.border
}
Behavior on border.color { ColorAnimation { duration: Theme.transitionShortest } }
Behavior on border.width { NumberAnimation { duration: Theme.transitionShortest } }
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
MouseArea {
id: bgHover
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: Qt.PointingHandCursor
}
// Floating label
Text {
id: floatingLabel
text: root.label
visible: root.label.length > 0
font.pixelSize: (root.activeFocus || root.currentIndex >= 0) ? 11 : 14
font.family: Theme.fontFamily
font.weight: Font.Medium
color: {
if (!root.enabled) return Theme.textDisabled
if (root.hasError) return Theme.error
if (root.activeFocus) return Theme.primary
return Theme.textSecondary
}
x: 16
y: (root.activeFocus || root.currentIndex >= 0) ? 6 : (parent.height - height) / 2
Behavior on y { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } }
Behavior on font.pixelSize { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
}
}
// Dropdown popup
popup: Popup {
y: root.height + 4
width: root.width
implicitHeight: contentItem.implicitHeight + 16
padding: 8
contentItem: ListView {
clip: true
implicitHeight: contentHeight
model: root.popup.visible ? root.delegateModel : null
currentIndex: root.highlightedIndex
ScrollIndicator.vertical: ScrollIndicator {}
}
background: Rectangle {
radius: 8
color: Theme.surface
border.width: 1
border.color: Theme.border
// Elevation shadow
layer.enabled: true
layer.effect: Item {
Rectangle {
anchors.fill: parent
anchors.margins: -2
radius: 10
color: "transparent"
border.width: 0
Rectangle {
anchors.fill: parent
anchors.topMargin: 2
radius: 10
color: Qt.rgba(0, 0, 0, 0.12)
z: -1
}
}
}
}
}
// Delegate for each item in the dropdown
delegate: ItemDelegate {
id: itemDelegate
width: root.width - 16
height: 40
leftPadding: 16
contentItem: Text {
text: modelData !== undefined ? modelData : (model.display !== undefined ? model.display : "")
font.pixelSize: 14
font.family: Theme.fontFamily
color: itemDelegate.highlighted ? Theme.primary : Theme.text
verticalAlignment: Text.AlignVCenter
elide: Text.ElideRight
}
background: Rectangle {
radius: 4
color: {
if (itemDelegate.highlighted) return Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.12)
if (itemDelegate.hovered) return Theme.actionHover
return "transparent"
}
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
}
highlighted: root.highlightedIndex === index
}
// Helper / error text below the field
Text {
id: helperText
parent: root.parent
anchors.top: root.bottom
anchors.topMargin: 4
anchors.left: root.left
anchors.leftMargin: 16
text: root.hasError ? root.errorText : root.helper
font.pixelSize: 11
font.family: Theme.fontFamily
color: root.hasError ? Theme.error : Theme.textSecondary
visible: text.length > 0
}
}

View File

@@ -1,6 +1,107 @@
import QtQuick
import QtQuick.Controls
import QmlComponents 1.0
Switch {
id: sw
/**
* CSwitch.qml - Material Design 3 styled toggle switch
*
* MD3 spec: 52x32 pill track, 24px thumb (28px when pressed),
* animated slide and color transitions.
*/
Rectangle {
id: root
property bool checked: false
property alias text: label.text
property bool enabled: true
signal toggled(bool checked)
width: row.implicitWidth
height: 40
color: "transparent"
Row {
id: row
spacing: StyleVariables.spacingSm
anchors.verticalCenter: parent.verticalCenter
// Track + thumb container
Item {
width: 52; height: 32
anchors.verticalCenter: parent.verticalCenter
// Track
Rectangle {
id: track
anchors.fill: parent
radius: 16
color: root.checked ? Theme.primary : Theme.surface
border.color: root.checked ? "transparent" : Theme.border
border.width: root.checked ? 0 : 2
opacity: root.enabled ? 1.0 : 0.38
Behavior on color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } }
Behavior on border.color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } }
}
// Thumb
Rectangle {
id: thumb
property int thumbSize: mouseArea.pressed ? 28 : 24
width: thumbSize; height: thumbSize
radius: thumbSize / 2
color: root.checked ? "#ffffff" : Theme.border
y: (parent.height - height) / 2
x: root.checked ? (parent.width - width - 4) : 4
opacity: root.enabled ? 1.0 : 0.38
// Elevation shadow for thumb
layer.enabled: true
layer.effect: null
Behavior on x { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
Behavior on width { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } }
Behavior on height { NumberAnimation { duration: 100; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 200; easing.type: Easing.OutCubic } }
}
// Hover state circle around thumb
Rectangle {
id: hoverIndicator
width: 40; height: 40
radius: 20
color: root.checked ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08)
: Qt.rgba(Theme.text.r, Theme.text.g, Theme.text.b, 0.08)
opacity: mouseArea.containsMouse || mouseArea.pressed ? 1 : 0
x: thumb.x + (thumb.width / 2) - (width / 2)
y: thumb.y + (thumb.height / 2) - (height / 2)
Behavior on opacity { NumberAnimation { duration: 150 } }
Behavior on x { NumberAnimation { duration: 200; easing.type: Easing.OutCubic } }
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
enabled: root.enabled
cursorShape: Qt.PointingHandCursor
onClicked: {
root.checked = !root.checked;
root.toggled(root.checked);
}
}
}
Text {
id: label
text: ""
color: root.enabled ? Theme.text : Theme.textDisabled
font.pixelSize: StyleVariables.fontSizeSm
font.family: Theme.fontFamily
anchors.verticalCenter: parent.verticalCenter
visible: text.length > 0
}
}
}

View File

@@ -2,9 +2,15 @@ import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
/**
* CTextField.qml - Material Design 3 outlined text field
*
* Outlined variant with floating label, prefix/suffix icons,
* error state, and clearable support.
*/
TextField {
id: control
property string label: ""
property string helper: ""
property string errorText: ""
@@ -12,53 +18,104 @@ TextField {
property string prefixIcon: ""
property string suffixIcon: ""
property bool clearable: false
property string size: "md" // "sm", "md", "lg"
signal suffixClicked()
implicitHeight: 40
leftPadding: prefixIcon ? 36 : 12
rightPadding: (clearable && text.length > 0) || suffixIcon ? 36 : 12
implicitHeight: size === "sm" ? 40 : (size === "lg" ? 56 : 48)
leftPadding: prefixIcon ? 40 : 16
rightPadding: (clearable && text.length > 0) || suffixIcon ? 40 : 16
topPadding: label && (activeFocus || text.length > 0) ? 18 : 0
verticalAlignment: TextInput.AlignVCenter
font.pixelSize: 14
color: "#ffffff"
placeholderTextColor: "#666666"
selectionColor: "#4dabf7"
selectedTextColor: "#ffffff"
font.family: Theme.fontFamily
color: enabled ? Theme.text : Theme.textDisabled
placeholderTextColor: Theme.textSecondary
selectionColor: Theme.primary
selectedTextColor: Theme.primaryContrastText
background: Rectangle {
id: bg
radius: 8
color: "#1e1e1e"
color: control.activeFocus ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.04) : "transparent"
border.width: control.activeFocus ? 2 : 1
border.color: {
if (control.hasError) return "#f44336"
if (control.activeFocus) return "#4dabf7"
return "#3d3d3d"
if (!control.enabled) return Theme.actionDisabled
if (control.hasError) return Theme.error
if (control.activeFocus) return Theme.primary
if (bgMouseArea.containsMouse) return Theme.text
return Theme.border
}
Behavior on border.color { ColorAnimation { duration: 150 } }
Behavior on border.width { NumberAnimation { duration: 150 } }
Behavior on border.color { ColorAnimation { duration: Theme.transitionShortest } }
Behavior on border.width { NumberAnimation { duration: Theme.transitionShortest } }
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
// Hover detection for the background area
MouseArea {
id: bgMouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: Qt.IBeamCursor
}
// Floating label
Text {
id: floatingLabel
text: control.label
visible: control.label.length > 0
font.pixelSize: (control.activeFocus || control.text.length > 0) ? 11 : 14
font.family: Theme.fontFamily
font.weight: Font.Medium
color: {
if (!control.enabled) return Theme.textDisabled
if (control.hasError) return Theme.error
if (control.activeFocus) return Theme.primary
return Theme.textSecondary
}
x: control.prefixIcon ? 40 : 16
y: (control.activeFocus || control.text.length > 0) ? 6 : (parent.height - height) / 2
Behavior on y { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } }
Behavior on font.pixelSize { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
}
// Prefix icon
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: control.prefixIcon
font.pixelSize: 16
color: "#888888"
visible: control.prefixIcon
font.pixelSize: 18
color: {
if (!control.enabled) return Theme.textDisabled
if (control.activeFocus) return Theme.primary
return Theme.textSecondary
}
visible: control.prefixIcon.length > 0
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
}
// Suffix/clear button
// Suffix / clear button
Text {
id: suffixText
anchors.right: parent.right
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: control.clearable && control.text.length > 0 ? "" : control.suffixIcon
font.pixelSize: 14
color: clearMouseArea.containsMouse ? "#ffffff" : "#888888"
visible: (control.clearable && control.text.length > 0) || control.suffixIcon
text: control.clearable && control.text.length > 0 ? "\u2715" : control.suffixIcon
font.pixelSize: 16
color: {
if (!control.enabled) return Theme.textDisabled
if (clearMouseArea.containsMouse) return Theme.text
return Theme.textSecondary
}
visible: (control.clearable && control.text.length > 0) || control.suffixIcon.length > 0
MouseArea {
id: clearMouseArea
anchors.fill: parent
@@ -75,4 +132,19 @@ TextField {
}
}
}
// Helper / error text below the field
Text {
id: helperText
parent: control.parent
anchors.top: control.bottom
anchors.topMargin: 4
anchors.left: control.left
anchors.leftMargin: 16
text: control.hasError ? control.errorText : control.helper
font.pixelSize: 11
font.family: Theme.fontFamily
color: control.hasError ? Theme.error : Theme.textSecondary
visible: text.length > 0
}
}

View File

@@ -1,6 +1,121 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
TextArea {
wrapMode: Text.WordWrap
/**
* CTextarea.qml - Material Design 3 outlined multi-line text area
*
* Same outline treatment as CTextField but supports multiple lines.
* Min height 100px, grows with content.
*/
ScrollView {
id: root
property alias text: textArea.text
property alias placeholderText: textArea.placeholderText
property alias readOnly: textArea.readOnly
property alias wrapMode: textArea.wrapMode
property alias font: textArea.font
property alias textDocument: textArea.textDocument
property string label: ""
property string helper: ""
property string errorText: ""
property bool hasError: errorText.length > 0
property string size: "md" // "sm", "md", "lg"
property alias activeFocus: textArea.activeFocus
property bool enabled: true
property int minHeight: 100
property int maxHeight: 400
signal textChanged()
implicitWidth: 200
implicitHeight: Math.max(minHeight, Math.min(textArea.implicitHeight + 32, maxHeight))
clip: true
ScrollBar.vertical.policy: textArea.implicitHeight + 32 > root.maxHeight ? ScrollBar.AsNeeded : ScrollBar.AlwaysOff
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
TextArea {
id: textArea
wrapMode: Text.WordWrap
font.pixelSize: 14
font.family: Theme.fontFamily
color: root.enabled ? Theme.text : Theme.textDisabled
placeholderTextColor: Theme.textSecondary
selectionColor: Theme.primary
selectedTextColor: Theme.primaryContrastText
enabled: root.enabled
leftPadding: 16
rightPadding: 16
topPadding: root.label.length > 0 ? 24 : 14
bottomPadding: 14
onTextChanged: root.textChanged()
background: Rectangle {
id: bg
radius: 8
color: textArea.activeFocus ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.04) : "transparent"
border.width: textArea.activeFocus ? 2 : 1
border.color: {
if (!root.enabled) return Theme.actionDisabled
if (root.hasError) return Theme.error
if (textArea.activeFocus) return Theme.primary
if (bgHover.containsMouse) return Theme.text
return Theme.border
}
Behavior on border.color { ColorAnimation { duration: Theme.transitionShortest } }
Behavior on border.width { NumberAnimation { duration: Theme.transitionShortest } }
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
MouseArea {
id: bgHover
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
cursorShape: Qt.IBeamCursor
}
// Floating label
Text {
id: floatingLabel
text: root.label
visible: root.label.length > 0
font.pixelSize: (textArea.activeFocus || textArea.text.length > 0) ? 11 : 14
font.family: Theme.fontFamily
font.weight: Font.Medium
color: {
if (!root.enabled) return Theme.textDisabled
if (root.hasError) return Theme.error
if (textArea.activeFocus) return Theme.primary
return Theme.textSecondary
}
x: 16
y: (textArea.activeFocus || textArea.text.length > 0) ? 6 : 14
Behavior on y { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } }
Behavior on font.pixelSize { NumberAnimation { duration: Theme.transitionShortest; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: Theme.transitionShortest } }
}
}
}
// Helper / error text below the field
Text {
id: helperText
parent: root.parent
anchors.top: root.bottom
anchors.topMargin: 4
anchors.left: root.left
anchors.leftMargin: 16
text: root.hasError ? root.errorText : root.helper
font.pixelSize: 11
font.family: Theme.fontFamily
color: root.hasError ? Theme.error : Theme.textSecondary
visible: text.length > 0
}
}

View File

@@ -3,54 +3,71 @@ import QtQuick.Layouts
import QmlComponents 1.0
/**
* CAccordionItem.qml - Single accordion item
* Used inside CAccordion
* CAccordionItem.qml - Material Design 3 expandable accordion item
*
* Rounded 12 px container with smooth height animation.
* Used inside CAccordion; pairs with CCard visual language.
*/
Rectangle {
id: root
objectName: "CAccordionItem"
// Public properties
// ── Public properties ───────────────────────────────────────────
property string title: ""
property string icon: ""
property string subtitle: ""
property bool expanded: false
property bool disabled: false
// Internal - set by parent accordion
property int index: 0
property var accordion: null
// Content slot
default property alias content: contentColumn.data
// Size
// ── MD3 surface tints ───────────────────────────────────────────
readonly property bool isDark: Theme.mode === "dark"
readonly property color surfaceContainer:
isDark ? Qt.rgba(1, 1, 1, 0.05) : Qt.rgba(0.31, 0.31, 0.44, 0.06)
// ── Layout ──────────────────────────────────────────────────────
Layout.fillWidth: true
implicitHeight: headerRect.height + (expanded ? contentLoader.height : 0)
// Appearance
color: "transparent"
// Animation
radius: 12
clip: true
implicitHeight: headerRect.height + (expanded ? contentWrapper.height : 0)
implicitWidth: 300
color: surfaceContainer
Behavior on implicitHeight {
NumberAnimation { duration: StyleVariables.transitionNormal; easing.type: Easing.OutCubic }
NumberAnimation {
duration: StyleVariables.transitionNormal
easing.type: Easing.OutCubic
}
}
// Header
Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } }
// ── Header ──────────────────────────────────────────────────────
Rectangle {
id: headerRect
width: parent.width
height: 48
color: headerMouse.containsMouse && !root.disabled ? StyleMixins.hoverBg(Theme.mode === "dark") : "transparent"
height: 52
radius: root.radius
color: headerMouse.containsMouse && !root.disabled
? StyleMixins.hoverBg(root.isDark)
: "transparent"
Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } }
MouseArea {
id: headerMouse
anchors.fill: parent
hoverEnabled: !root.disabled
cursorShape: root.disabled ? Qt.ForbiddenCursor : Qt.PointingHandCursor
onClicked: {
if (!root.disabled) {
root.expanded = !root.expanded
@@ -60,26 +77,26 @@ Rectangle {
}
}
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: StyleVariables.spacingMd
anchors.rightMargin: StyleVariables.spacingMd
anchors.leftMargin: 16
anchors.rightMargin: 16
spacing: StyleVariables.spacingSm
// Icon
Text {
visible: root.icon
visible: root.icon !== ""
text: root.icon
font.pixelSize: StyleVariables.fontSizeLg
color: root.disabled ? Theme.textDisabled : Theme.text
}
// Title and subtitle
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: root.title
font.pixelSize: StyleVariables.fontSizeSm
@@ -88,9 +105,9 @@ Rectangle {
elide: Text.ElideRight
Layout.fillWidth: true
}
Text {
visible: root.subtitle
visible: root.subtitle !== ""
text: root.subtitle
font.pixelSize: StyleVariables.fontSizeXs
color: Theme.textSecondary
@@ -98,58 +115,56 @@ Rectangle {
Layout.fillWidth: true
}
}
// Expand indicator
// Expand chevron
Text {
text: ""
text: "\u25BC"
font.pixelSize: 10
color: root.disabled ? Theme.textDisabled : Theme.textSecondary
rotation: root.expanded ? 180 : 0
Behavior on rotation {
NumberAnimation { duration: StyleVariables.transitionNormal }
NumberAnimation {
duration: StyleVariables.transitionNormal
easing.type: Easing.OutCubic
}
}
}
}
// Bottom border
// Divider between header and content
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 1
anchors.leftMargin: 16
anchors.rightMargin: 16
height: root.expanded ? 1 : 0
color: Theme.divider
opacity: root.expanded ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: StyleVariables.transitionFast }
}
}
}
// Content
Loader {
id: contentLoader
// ── Content area ────────────────────────────────────────────────
Item {
id: contentWrapper
anchors.top: headerRect.bottom
width: parent.width
active: root.expanded
height: root.expanded ? contentColumn.implicitHeight + 32 : 0
visible: root.expanded
sourceComponent: Rectangle {
width: contentLoader.width
height: contentColumn.implicitHeight + StyleVariables.spacingMd * 2
color: Qt.rgba(Theme.surface.r, Theme.surface.g, Theme.surface.b, 0.3)
ColumnLayout {
id: contentColumn
anchors.fill: parent
anchors.margins: StyleVariables.spacingMd
spacing: StyleVariables.spacingSm
}
// Bottom border
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: 1
color: Theme.divider
}
clip: true
ColumnLayout {
id: contentColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 16
spacing: StyleVariables.spacingSm
}
}
}

View File

@@ -1,8 +1,22 @@
import QtQuick
import QtQuick.Controls
import QmlComponents 1.0
/**
* CPaper.qml - Material Design 3 surface container
*
* A simple themed rectangle that provides a paper-like background.
* Serves as the base surface for dialogs, drawers, side-panels, etc.
*/
Rectangle {
color: "white"
radius: 4
border.color: "#ddd"
id: paper
property int elevation: 0
color: Theme.paper
radius: 12
border.width: 0
border.color: "transparent"
Behavior on color { ColorAnimation { duration: StyleVariables.transitionFast } }
}