Unify PillButton, CursorPill, ChoiceButton into qs.Ui.Button + ButtonGroup
The three components were three takes on the same shape \u2014 a clickable rectangle with text/icon, a hot/hover state, an optional persistent border, and an optional 'selected' or 'active' highlight. CursorPill was a 30-line PillButton wrapper that added a hovered() signal; ChoiceButton was effectively PillButton with selected: bool painting an accent fill+border. Collapse them into a single qs.Ui.Button. State flags compose independently: hasCursor / hover hot fill active persistent foreground-tint fill selected accent fill + accent border bordered: true persistent 1px idle border (form primaries) focusable: true Tab focus paints the accent ring pressed pressed fill The hovered(bool) signal is now built-in, so CursorPill's wrapper is unnecessary. ButtonGroup wraps a Row+Repeater for the form-style 'pick one of N' pattern; panel-cursor-driven cases still compose Buttons directly in a Row with per-instance hasCursor wiring. Theme tokens move into a new [style] section in shell.toml: border-width = 1 focus-border-width = 3 idle-border-alpha = 0.4 hot-fill-alpha = 0.08 selected-fill-alpha = 0.18 pressed-fill-alpha = 0.22 focus-fill-alpha = 0.22 Style.qml parses these out of the same shell.toml [font] / [bar] already reads, and exposes pre-computed Style.hotFill / selectedFill / pressedFill / idleBorderColor / selectedAccentFill / borderWidth + the existing focusBorder* tokens. Themes that don't ship a [style] section get the previous defaults unchanged. Dev gallery consolidates three sections (PillButton, CursorPill, ChoiceButton) into Button + ButtonGroup, with the cursor model sections renamed accordingly.
This commit is contained in:
@@ -2,60 +2,93 @@ import QtQuick
|
||||
import QtQuick.Controls
|
||||
import qs.Commons
|
||||
|
||||
// The button. One component for every clickable thing in the kit.
|
||||
// States compose independently and are applied in priority order:
|
||||
//
|
||||
// pressed (mouse down) pressed fill
|
||||
// activeFocus (Tab focus) accent ring + accent fill
|
||||
// selected accent fill + accent border
|
||||
// active foreground tint fill (highlighted)
|
||||
// hasCursor || hover hot fill
|
||||
// idle transparent or 1px border if `bordered`
|
||||
//
|
||||
// All fills/borders come from `qs.Commons.Style` tokens, so themes
|
||||
// control the look via [style] in shell.toml.
|
||||
//
|
||||
// Emits `hovered(bool)` so panels with their own keyboard cursor model
|
||||
// can update state on mouse enter/leave.
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
property string iconText: ""
|
||||
property string tooltipText: ""
|
||||
|
||||
// State flags (see comment above for paint priority).
|
||||
property bool selected: false
|
||||
property bool active: false
|
||||
property bool hasCursor: false
|
||||
property bool focusable: false
|
||||
property bool bordered: false
|
||||
|
||||
// Colors. Defaults track the theme; per-instance overrides are honored.
|
||||
property color foreground: Color.foreground
|
||||
property color background: "transparent"
|
||||
property color hoverBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.08)
|
||||
property color pressedBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.22)
|
||||
property color tooltipBackground: Color.background
|
||||
property color tooltipForeground: foreground
|
||||
property color accent: Color.accent
|
||||
|
||||
// Sizing.
|
||||
property string fontFamily: Style.font.family
|
||||
property real fontSize: Style.font.body
|
||||
property real iconSize: Style.font.icon
|
||||
property real iconRotation: 0
|
||||
property real horizontalPadding: 10
|
||||
property real verticalPadding: 6
|
||||
property bool active: false
|
||||
property bool leftAlign: false
|
||||
property color activeBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.18)
|
||||
|
||||
// Keyboard cursor flag. When true, the pill renders the same fill + border
|
||||
// as a mouse hover — so j/k navigation and pointer hover are visually
|
||||
// indistinguishable. Default false; bind from a panel's cursor state.
|
||||
property bool hasCursor: false
|
||||
// Tooltip palette. Auto-rendered if tooltipText is set.
|
||||
property color tooltipBackground: Color.background
|
||||
property color tooltipForeground: foreground
|
||||
|
||||
// Tab-focusable form-button mode. Enables activeFocusOnTab and
|
||||
// Enter/Return/Space activation, and uses Style.focusBorderColor / FillColor
|
||||
// for the focus ring (distinct from the panel-cursor `hot` state). Set
|
||||
// true for settings buttons (Save, Cancel, Reset); leave false for the
|
||||
// panel-cursor pills (DNS picker, header actions).
|
||||
property bool focusable: false
|
||||
|
||||
// Persistent 1px foreground border at idle. Use for "primary" form buttons
|
||||
// (Save, Apply, + Add widget) so they read as buttons before the cursor
|
||||
// hits them.
|
||||
//
|
||||
// The hot/cursor state is a fill only, matching CursorSurface's canonical
|
||||
// chrome and the other panel primitives (Toggle, PanelActionButton). The
|
||||
// accent border ring is reserved for Tab focus on `focusable` buttons.
|
||||
property bool bordered: false
|
||||
signal clicked()
|
||||
signal rightClicked()
|
||||
signal hovered(bool isHovered)
|
||||
|
||||
activeFocusOnTab: focusable
|
||||
Keys.onReturnPressed: if (focusable) root.clicked()
|
||||
Keys.onEnterPressed: if (focusable) root.clicked()
|
||||
Keys.onSpacePressed: if (focusable) root.clicked()
|
||||
|
||||
implicitWidth: row.implicitWidth + horizontalPadding * 2
|
||||
implicitHeight: row.implicitHeight + verticalPadding * 2
|
||||
radius: Style.cornerRadius
|
||||
|
||||
readonly property bool hot: mouseArea.containsMouse || hasCursor
|
||||
readonly property bool _showFocusRing: focusable && activeFocus
|
||||
|
||||
color: mouseArea.pressed ? Style.pressedFill
|
||||
: _showFocusRing ? Style.focusFillColor
|
||||
: selected ? Style.selectedAccentFill
|
||||
: hot ? Style.hotFill
|
||||
: active ? Style.selectedFill
|
||||
: background
|
||||
|
||||
border.color: _showFocusRing ? Style.focusBorderColor
|
||||
: selected ? accent
|
||||
: bordered ? foreground
|
||||
: Style.idleBorderColor
|
||||
|
||||
border.width: _showFocusRing ? Style.focusBorderWidth
|
||||
: selected ? Math.max(Style.borderWidth, 2)
|
||||
: bordered ? Style.borderWidth
|
||||
: 0
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 120 } }
|
||||
|
||||
ToolTip {
|
||||
visible: root.tooltipText !== "" && mouseArea.containsMouse
|
||||
text: root.tooltipText
|
||||
delay: 400
|
||||
padding: 0
|
||||
|
||||
background: Rectangle {
|
||||
color: root.tooltipBackground
|
||||
border.color: root.tooltipForeground
|
||||
@@ -63,7 +96,6 @@ Rectangle {
|
||||
radius: 0
|
||||
opacity: 0.97
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
text: root.tooltipText
|
||||
color: root.tooltipForeground
|
||||
@@ -76,34 +108,6 @@ Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
signal clicked()
|
||||
signal rightClicked()
|
||||
|
||||
implicitWidth: row.implicitWidth + horizontalPadding * 2
|
||||
implicitHeight: row.implicitHeight + verticalPadding * 2
|
||||
radius: Style.cornerRadius
|
||||
|
||||
// Hot = mouse hover OR keyboard cursor. Pressed wins over both; otherwise
|
||||
// hot beats `active` (so a cursor on an already-active pill still reads
|
||||
// as hovered, matching CursorSurface's behaviour for navigable rows).
|
||||
readonly property bool hot: mouseArea.containsMouse || hasCursor
|
||||
|
||||
// Tab-focus styling wins over hot — the accent ring is the strongest
|
||||
// signal and shouldn't be masked by a hover landing on the focused item.
|
||||
readonly property bool _showFocusRing: focusable && activeFocus
|
||||
|
||||
color: mouseArea.pressed ? pressedBackground
|
||||
: _showFocusRing ? Style.focusFillColor
|
||||
: hot ? hoverBackground
|
||||
: (active ? activeBackground : background)
|
||||
border.width: _showFocusRing ? Style.focusBorderWidth
|
||||
: (bordered ? 1 : 0)
|
||||
border.color: _showFocusRing ? Style.focusBorderColor : foreground
|
||||
|
||||
Behavior on color {
|
||||
ColorAnimation { duration: 120 }
|
||||
}
|
||||
|
||||
Row {
|
||||
id: row
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
@@ -115,7 +119,7 @@ Rectangle {
|
||||
Text {
|
||||
visible: root.iconText !== ""
|
||||
text: root.iconText
|
||||
color: root.foreground
|
||||
color: root.selected ? root.accent : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.iconSize
|
||||
rotation: root.iconRotation
|
||||
@@ -126,9 +130,10 @@ Rectangle {
|
||||
Text {
|
||||
visible: root.text !== ""
|
||||
text: root.text
|
||||
color: root.foreground
|
||||
color: root.selected ? root.accent : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.fontSize
|
||||
font.bold: root.selected
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
}
|
||||
@@ -145,4 +150,8 @@ Rectangle {
|
||||
else root.clicked()
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.hovered(hovered)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
// Mutually-exclusive row of Buttons — the form-style "pick one of N"
|
||||
// pattern (bar position top/right/bottom/left, theme preset chips, etc.).
|
||||
// Emits `changed(value)` when the user activates a different option.
|
||||
//
|
||||
// `options` is either a plain string[] (label == value) or an array of
|
||||
// { value, label, icon?, tooltip? } objects. Mixing is fine.
|
||||
//
|
||||
// For panel-cursor-driven selection (where j/k walks a row), use bare
|
||||
// `Button { hasCursor: ... }` instances in a Row — ButtonGroup is the
|
||||
// convenience for non-cursor form contexts where you just need
|
||||
// "selected: value === optionValue" wiring.
|
||||
Row {
|
||||
id: root
|
||||
|
||||
property var options: []
|
||||
property string value: ""
|
||||
property color foreground: Color.foreground
|
||||
property color background: Color.background
|
||||
property color accent: Color.accent
|
||||
property string fontFamily: Style.font.family
|
||||
property real fontSize: Style.font.body
|
||||
property bool focusable: false
|
||||
|
||||
signal changed(string value)
|
||||
|
||||
spacing: 6
|
||||
|
||||
function optionValue(o) {
|
||||
return (o && typeof o === "object") ? String(o.value) : String(o)
|
||||
}
|
||||
function optionLabel(o) {
|
||||
return (o && typeof o === "object" && o.label !== undefined) ? String(o.label) : String(o)
|
||||
}
|
||||
function optionIcon(o) {
|
||||
return (o && typeof o === "object" && o.icon) ? String(o.icon) : ""
|
||||
}
|
||||
function optionTooltip(o) {
|
||||
return (o && typeof o === "object" && o.tooltip) ? String(o.tooltip) : ""
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.options
|
||||
|
||||
delegate: Button {
|
||||
required property var modelData
|
||||
text: root.optionLabel(modelData)
|
||||
iconText: root.optionIcon(modelData)
|
||||
tooltipText: root.optionTooltip(modelData)
|
||||
selected: root.optionValue(modelData) === root.value
|
||||
foreground: root.foreground
|
||||
background: root.background
|
||||
accent: root.accent
|
||||
fontFamily: root.fontFamily
|
||||
fontSize: root.fontSize
|
||||
focusable: root.focusable
|
||||
onClicked: root.changed(root.optionValue(modelData))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import QtQuick
|
||||
import qs.Commons
|
||||
|
||||
// A single button in a mutually-exclusive choice group (a Row of these
|
||||
// makes a "segmented control"). Distinct from PillButton because it has
|
||||
// a real `selected` state semantic — used for picking between options
|
||||
// (bar position: top/bottom/left/right), not for momentary actions.
|
||||
//
|
||||
// Selected styling uses the accent fill+border; focus styling uses the
|
||||
// Style.focusBorderColor outline so keyboard nav can land on a non-selected
|
||||
// option without it reading as the chosen one. This separation matters in
|
||||
// the settings panel and any future "pick one" UI.
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
property string text: ""
|
||||
property bool selected: false
|
||||
|
||||
// Panel-cursor flag. Same role as PillButton.hasCursor: panels that own
|
||||
// their own cursor state bind this to drive the keyboard highlight
|
||||
// separately from real activeFocus. Cursor renders as a fill only —
|
||||
// CursorSurface is the canonical chrome — while Tab focus adds the
|
||||
// accent border ring on top.
|
||||
property bool hasCursor: false
|
||||
property bool borderlessHighlight: false
|
||||
|
||||
property color foreground: Color.foreground
|
||||
property color background: Color.background
|
||||
property color accent: Color.accent
|
||||
property string fontFamily: Style.font.family
|
||||
property real fontSize: Style.font.body
|
||||
|
||||
signal clicked()
|
||||
signal hovered(bool isHovered)
|
||||
|
||||
activeFocusOnTab: true
|
||||
Keys.onReturnPressed: root.clicked()
|
||||
Keys.onEnterPressed: root.clicked()
|
||||
Keys.onSpacePressed: root.clicked()
|
||||
|
||||
implicitWidth: Math.max(56, label.implicitWidth + 22)
|
||||
implicitHeight: 28
|
||||
radius: Style.cornerRadius
|
||||
|
||||
color: selected
|
||||
? Qt.rgba(accent.r, accent.g, accent.b, 0.18)
|
||||
: ((mouse.containsMouse || hasCursor) ? Qt.rgba(foreground.r, foreground.g, foreground.b, 0.08) : background)
|
||||
border.color: selected
|
||||
? accent
|
||||
: (activeFocus ? foreground : Qt.rgba(foreground.r, foreground.g, foreground.b, 0.4))
|
||||
border.width: borderlessHighlight ? (activeFocus ? 2 : 0)
|
||||
: (selected ? 2 : (activeFocus ? 2 : 1))
|
||||
|
||||
Behavior on color { ColorAnimation { duration: 100 } }
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
text: root.text
|
||||
color: root.selected ? root.accent : root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: root.fontSize
|
||||
font.bold: root.selected
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
root.forceActiveFocus()
|
||||
root.clicked()
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.hovered(hovered)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import QtQuick
|
||||
|
||||
// PillButton that participates in a panel's single-cursor model. Use
|
||||
// inside a row of pills (DNS providers, bluetooth header actions, choice
|
||||
// chips) where mouse hover and keyboard cursor should land in the same
|
||||
// place.
|
||||
//
|
||||
// Caller binds `hasCursor` to the panel's cursor state and listens to
|
||||
// `hovered(bool)` to update that state when the mouse enters or leaves.
|
||||
// This is structurally a wrapper around PillButton with one extra
|
||||
// HoverHandler — but extracting it lets every panel use the same wiring
|
||||
// idiom and lets plugin authors drop into the same cursor model without
|
||||
// touching internals.
|
||||
//
|
||||
// Why HoverHandler instead of a MouseArea overlay: HoverHandler doesn't
|
||||
// steal pointer events from PillButton's internal click MouseArea, so
|
||||
// clicks still reach the underlying button. An overlay MouseArea with
|
||||
// acceptedButtons: Qt.NoButton works but is fragile around tooltip
|
||||
// timing and event propagation.
|
||||
PillButton {
|
||||
id: root
|
||||
|
||||
signal hovered(bool isHovered)
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.hovered(hovered)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import qs.Commons
|
||||
// Defaults bind to qs.Commons.Color so a caller with no theme overrides
|
||||
// just works; foreground / accent / selectionTint can be overridden per
|
||||
// instance. Focus styling uses Style.focusBorderColor (the same accent
|
||||
// ring Toggle and ChoiceButton paint) so keyboard cursor and form-focus
|
||||
// ring Toggle and Button paint) so keyboard cursor and form focus
|
||||
// chrome stay consistent across the shell.
|
||||
//
|
||||
// Sizing is driven by font.pixelSize + verticalPadding. The default 30px
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ Rectangle {
|
||||
property string description: ""
|
||||
property bool checked: false
|
||||
|
||||
// Panel-cursor flag. Same role as PillButton.hasCursor / ChoiceButton.hasCursor:
|
||||
// Panel-cursor flag. Same role as Button.hasCursor:
|
||||
// panels with their own keyboard cursor bind this to drive the highlight
|
||||
// separately from activeFocus. Renders as a tinted fill only — the accent
|
||||
// border ring is reserved for Tab focus (activeFocus).
|
||||
|
||||
+2
-3
@@ -1,7 +1,7 @@
|
||||
module qs.Ui
|
||||
|
||||
ChoiceButton 1.0 ChoiceButton.qml
|
||||
CursorPill 1.0 CursorPill.qml
|
||||
Button 1.0 Button.qml
|
||||
ButtonGroup 1.0 ButtonGroup.qml
|
||||
CursorSurface 1.0 CursorSurface.qml
|
||||
Dropdown 1.0 Dropdown.qml
|
||||
KeyboardPanel 1.0 KeyboardPanel.qml
|
||||
@@ -12,7 +12,6 @@ PanelSectionHeader 1.0 PanelSectionHeader.qml
|
||||
PanelSeparator 1.0 PanelSeparator.qml
|
||||
PanelSlider 1.0 PanelSlider.qml
|
||||
PanelToolTip 1.0 PanelToolTip.qml
|
||||
PillButton 1.0 PillButton.qml
|
||||
PopupCard 1.0 PopupCard.qml
|
||||
SearchableDropdown 1.0 SearchableDropdown.qml
|
||||
TextField 1.0 TextField.qml
|
||||
|
||||
Reference in New Issue
Block a user