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:
Ryan Hughes
2026-05-18 12:09:13 -04:00
parent 42dee4d399
commit b5f7ba02c2
17 changed files with 264 additions and 343 deletions
+11
View File
@@ -12,6 +12,17 @@ active = "{{ color1 }}"
size-horizontal = 26 size-horizontal = 26
size-vertical = 28 size-vertical = 28
[style]
# State alphas used by every interactive surface in the kit (Button,
# Toggle, TextField, etc.). Foreground-tinted unless noted.
border-width = 1 # idle 1px border on inputs and bordered buttons
focus-border-width = 3 # accent ring on Tab focus
idle-border-alpha = 0.4 # alpha for the idle 1px foreground border
hot-fill-alpha = 0.08 # cursor / hover fill
selected-fill-alpha = 0.18 # selected / active / current fill
pressed-fill-alpha = 0.22 # button pressed
focus-fill-alpha = 0.22 # accent fill behind Tab focus ring
[font] [font]
# base-size is the rem root for the type scale. Every Style.font.<token> # base-size is the rem root for the type scale. Every Style.font.<token>
# derives from it (e.g. body = base, subtitle ≈ base * 1.083, # derives from it (e.g. body = base, subtitle ≈ base * 1.083,
+52 -14
View File
@@ -25,18 +25,48 @@ QtObject {
property int cornerRadius: 0 property int cornerRadius: 0
// ---------------------------------------------------------- state alphas
//
// Foreground-tinted fills + border alphas used by every interactive
// surface in the kit. Themes can override these via [style] in
// shell.toml; otherwise the kit defaults below apply.
property var styleOverrides: ({})
function styleNum(key, fallback) {
var v = styleOverrides[key]
var n = Number(v)
return isFinite(n) ? n : fallback
}
readonly property int borderWidth: Math.max(0, Math.round(styleNum("border-width", 1)))
readonly property int focusBorderWidth: Math.max(1, Math.round(styleNum("focus-border-width", 3)))
readonly property real idleBorderAlpha: styleNum("idle-border-alpha", 0.4)
readonly property real hotFillAlpha: styleNum("hot-fill-alpha", 0.08)
readonly property real selectedFillAlpha: styleNum("selected-fill-alpha", 0.18)
readonly property real pressedFillAlpha: styleNum("pressed-fill-alpha", 0.22)
readonly property real focusFillAlpha: styleNum("focus-fill-alpha", 0.22)
function alphaForeground(a) {
return Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, a)
}
function alphaAccent(a) {
return Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, a)
}
// Focus affordances. Deliberately distinct from "selected" (which uses an // Focus affordances. Deliberately distinct from "selected" (which uses an
// accent fill) so the keyboard cursor never reads as the chosen value. // accent fill) so the keyboard cursor never reads as the chosen value.
// The settings panel originated these tokens; promoting them here means
// every Ui component picks them up uniformly.
readonly property color focusBorderColor: Color.accent readonly property color focusBorderColor: Color.accent
readonly property color focusFillColor: Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.22) readonly property color focusFillColor: alphaAccent(focusFillAlpha)
readonly property int focusBorderWidth: 3
// Convenience fills used by panel rows and pills. Hot is hover/keyboard // Convenience fills used by panel rows and pills. Hot is hover/keyboard
// cursor; selected is the persistent chosen/current item state. // cursor; selected/active is the persistent chosen/current state.
readonly property color hotFill: Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, 0.08) readonly property color hotFill: alphaForeground(hotFillAlpha)
readonly property color selectedFill: Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, 0.18) readonly property color selectedFill: alphaForeground(selectedFillAlpha)
readonly property color pressedFill: alphaForeground(pressedFillAlpha)
readonly property color idleBorderColor: alphaForeground(idleBorderAlpha)
readonly property color selectedAccentFill: alphaAccent(selectedFillAlpha)
// ---------------------------------------------------------- typography // ---------------------------------------------------------- typography
// //
@@ -57,7 +87,8 @@ QtObject {
property int fontBaseSize: 12 property int fontBaseSize: 12
// Parsed maps populated by loadShell. Keep them as plain dicts so // Parsed maps populated by loadShell. Keep them as plain dicts so
// reassigning the whole property fires reactive bindings. // reassigning the whole property fires reactive bindings. styleOverrides
// is declared up top next to the helpers that consume it.
property var fontOverrides: ({}) property var fontOverrides: ({})
property var barOverrides: ({}) property var barOverrides: ({})
@@ -117,10 +148,12 @@ QtObject {
// Parse [font] base-size + per-token overrides and [bar] size-* keys // Parse [font] base-size + per-token overrides and [bar] size-* keys
// out of shell.toml. Color.qml owns the quoted-string side of the same // out of shell.toml. Color.qml owns the quoted-string side of the same
// file; we only care about the unquoted integer values here. // file; we handle the unquoted numeric tokens for [font], [bar] sizes,
// and [style] (alphas + border widths).
function loadShell(raw) { function loadShell(raw) {
var fontOut = {} var fontOut = {}
var barOut = {} var barOut = {}
var styleOut = {}
var nextBase = 12 var nextBase = 12
var text = String(raw || "") var text = String(raw || "")
if (text) { if (text) {
@@ -131,15 +164,19 @@ QtObject {
if (!line || line.charAt(0) === "#") continue if (!line || line.charAt(0) === "#") continue
var sectionMatch = line.match(/^\[([A-Za-z0-9_-]+)\]\s*(#.*)?$/) var sectionMatch = line.match(/^\[([A-Za-z0-9_-]+)\]\s*(#.*)?$/)
if (sectionMatch) { section = sectionMatch[1]; continue } if (sectionMatch) { section = sectionMatch[1]; continue }
var kv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(-?\d+)\s*(#.*)?$/) // Accept ints OR floats (alphas are 0..1).
var kv = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(-?\d+(?:\.\d+)?)\s*(#.*)?$/)
if (!kv) continue if (!kv) continue
var key = kv[1] var key = kv[1]
var val = parseInt(kv[2], 10) var raw = kv[2]
if (section === "font") { if (section === "font") {
if (key === "base-size") nextBase = val var ival = parseInt(raw, 10)
else fontOut[key] = val if (key === "base-size") nextBase = ival
else fontOut[key] = ival
} else if (section === "bar" && (key === "size-horizontal" || key === "size-vertical")) { } else if (section === "bar" && (key === "size-horizontal" || key === "size-vertical")) {
barOut[key] = val barOut[key] = parseInt(raw, 10)
} else if (section === "style") {
styleOut[key] = parseFloat(raw)
} }
} }
} }
@@ -150,6 +187,7 @@ QtObject {
fontBaseSize = nextBase fontBaseSize = nextBase
fontOverrides = fontOut fontOverrides = fontOut
barOverrides = barOut barOverrides = barOut
styleOverrides = styleOut
} }
property Process hyprctlProc: Process { property Process hyprctlProc: Process {
+66 -57
View File
@@ -2,60 +2,93 @@ import QtQuick
import QtQuick.Controls import QtQuick.Controls
import qs.Commons 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 { Rectangle {
id: root id: root
property string text: "" property string text: ""
property string iconText: "" property string iconText: ""
property string tooltipText: "" 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 foreground: Color.foreground
property color background: "transparent" property color background: "transparent"
property color hoverBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.08) property color accent: Color.accent
property color pressedBackground: Qt.rgba(foreground.r, foreground.g, foreground.b, 0.22)
property color tooltipBackground: Color.background // Sizing.
property color tooltipForeground: foreground
property string fontFamily: Style.font.family property string fontFamily: Style.font.family
property real fontSize: Style.font.body property real fontSize: Style.font.body
property real iconSize: Style.font.icon property real iconSize: Style.font.icon
property real iconRotation: 0 property real iconRotation: 0
property real horizontalPadding: 10 property real horizontalPadding: 10
property real verticalPadding: 6 property real verticalPadding: 6
property bool active: false
property bool leftAlign: 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 // Tooltip palette. Auto-rendered if tooltipText is set.
// as a mouse hover so j/k navigation and pointer hover are visually property color tooltipBackground: Color.background
// indistinguishable. Default false; bind from a panel's cursor state. property color tooltipForeground: foreground
property bool hasCursor: false
// Tab-focusable form-button mode. Enables activeFocusOnTab and signal clicked()
// Enter/Return/Space activation, and uses Style.focusBorderColor / FillColor signal rightClicked()
// for the focus ring (distinct from the panel-cursor `hot` state). Set signal hovered(bool isHovered)
// 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
activeFocusOnTab: focusable activeFocusOnTab: focusable
Keys.onReturnPressed: if (focusable) root.clicked() Keys.onReturnPressed: if (focusable) root.clicked()
Keys.onEnterPressed: if (focusable) root.clicked() Keys.onEnterPressed: if (focusable) root.clicked()
Keys.onSpacePressed: 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 { ToolTip {
visible: root.tooltipText !== "" && mouseArea.containsMouse visible: root.tooltipText !== "" && mouseArea.containsMouse
text: root.tooltipText text: root.tooltipText
delay: 400 delay: 400
padding: 0 padding: 0
background: Rectangle { background: Rectangle {
color: root.tooltipBackground color: root.tooltipBackground
border.color: root.tooltipForeground border.color: root.tooltipForeground
@@ -63,7 +96,6 @@ Rectangle {
radius: 0 radius: 0
opacity: 0.97 opacity: 0.97
} }
contentItem: Text { contentItem: Text {
text: root.tooltipText text: root.tooltipText
color: root.tooltipForeground 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 { Row {
id: row id: row
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -115,7 +119,7 @@ Rectangle {
Text { Text {
visible: root.iconText !== "" visible: root.iconText !== ""
text: root.iconText text: root.iconText
color: root.foreground color: root.selected ? root.accent : root.foreground
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: root.iconSize font.pixelSize: root.iconSize
rotation: root.iconRotation rotation: root.iconRotation
@@ -126,9 +130,10 @@ Rectangle {
Text { Text {
visible: root.text !== "" visible: root.text !== ""
text: root.text text: root.text
color: root.foreground color: root.selected ? root.accent : root.foreground
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: root.fontSize font.pixelSize: root.fontSize
font.bold: root.selected
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
} }
@@ -145,4 +150,8 @@ Rectangle {
else root.clicked() else root.clicked()
} }
} }
HoverHandler {
onHoveredChanged: root.hovered(hovered)
}
} }
+62
View File
@@ -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))
}
}
}
-80
View File
@@ -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)
}
}
-28
View File
@@ -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)
}
}
+1 -1
View File
@@ -10,7 +10,7 @@ import qs.Commons
// Defaults bind to qs.Commons.Color so a caller with no theme overrides // Defaults bind to qs.Commons.Color so a caller with no theme overrides
// just works; foreground / accent / selectionTint can be overridden per // just works; foreground / accent / selectionTint can be overridden per
// instance. Focus styling uses Style.focusBorderColor (the same accent // 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. // chrome stay consistent across the shell.
// //
// Sizing is driven by font.pixelSize + verticalPadding. The default 30px // Sizing is driven by font.pixelSize + verticalPadding. The default 30px
+1 -1
View File
@@ -21,7 +21,7 @@ Rectangle {
property string description: "" property string description: ""
property bool checked: false 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 // panels with their own keyboard cursor bind this to drive the highlight
// separately from activeFocus. Renders as a tinted fill only — the accent // separately from activeFocus. Renders as a tinted fill only — the accent
// border ring is reserved for Tab focus (activeFocus). // border ring is reserved for Tab focus (activeFocus).
+2 -3
View File
@@ -1,7 +1,7 @@
module qs.Ui module qs.Ui
ChoiceButton 1.0 ChoiceButton.qml Button 1.0 Button.qml
CursorPill 1.0 CursorPill.qml ButtonGroup 1.0 ButtonGroup.qml
CursorSurface 1.0 CursorSurface.qml CursorSurface 1.0 CursorSurface.qml
Dropdown 1.0 Dropdown.qml Dropdown 1.0 Dropdown.qml
KeyboardPanel 1.0 KeyboardPanel.qml KeyboardPanel 1.0 KeyboardPanel.qml
@@ -12,7 +12,6 @@ PanelSectionHeader 1.0 PanelSectionHeader.qml
PanelSeparator 1.0 PanelSeparator.qml PanelSeparator 1.0 PanelSeparator.qml
PanelSlider 1.0 PanelSlider.qml PanelSlider 1.0 PanelSlider.qml
PanelToolTip 1.0 PanelToolTip.qml PanelToolTip 1.0 PanelToolTip.qml
PillButton 1.0 PillButton.qml
PopupCard 1.0 PopupCard.qml PopupCard 1.0 PopupCard.qml
SearchableDropdown 1.0 SearchableDropdown.qml SearchableDropdown 1.0 SearchableDropdown.qml
TextField 1.0 TextField.qml TextField 1.0 TextField.qml
+2 -2
View File
@@ -1551,7 +1551,7 @@ Item {
elide: Text.ElideRight elide: Text.ElideRight
} }
PillButton { Button {
id: rowPinBtn id: rowPinBtn
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right anchors.right: parent.right
@@ -1565,7 +1565,7 @@ Item {
onClicked: trayRoot.togglePin(rowRoot.itemId) onClicked: trayRoot.togglePin(rowRoot.itemId)
} }
PillButton { Button {
id: rowHideBtn id: rowHideBtn
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.right: rowPinBtn.left anchors.right: rowPinBtn.left
+3 -3
View File
@@ -512,11 +512,11 @@ Item {
} }
} }
// Header pill: a CursorPill bound into the panel's "header" cursor // Header pill: a Button bound into the panel's "header" cursor
// section. CursorPill collapses what used to be a PillButton subclass + // section. Button collapses what used to be a Button subclass +
// overlay MouseArea into one component; we keep the pillIndex / activated // overlay MouseArea into one component; we keep the pillIndex / activated
// shim here so the three header pill instantiations stay readable. // shim here so the three header pill instantiations stay readable.
component HeaderPill: CursorPill { component HeaderPill: Button {
id: pill id: pill
required property int pillIndex required property int pillIndex
property bool pillEnabled: true property bool pillEnabled: true
+2 -2
View File
@@ -95,7 +95,7 @@ Item {
width: parent.width width: parent.width
implicitHeight: 28 implicitHeight: 28
PillButton { Button {
id: prevButton id: prevButton
anchors.left: parent.left anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -115,7 +115,7 @@ Item {
font.bold: true font.bold: true
} }
PillButton { Button {
id: nextButton id: nextButton
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
+3 -3
View File
@@ -198,7 +198,7 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
spacing: 6 spacing: 6
PillButton { Button {
iconText: "󰒮" iconText: "󰒮"
foreground: root.bar.foreground foreground: root.bar.foreground
horizontalPadding: 10 horizontalPadding: 10
@@ -208,7 +208,7 @@ Item {
onClicked: if (root.activePlayer) root.activePlayer.previous() onClicked: if (root.activePlayer) root.activePlayer.previous()
} }
PillButton { Button {
iconText: root.activePlayer && root.activePlayer.isPlaying ? "󰏤" : "󰐊" iconText: root.activePlayer && root.activePlayer.isPlaying ? "󰏤" : "󰐊"
foreground: root.bar.foreground foreground: root.bar.foreground
horizontalPadding: 14 horizontalPadding: 14
@@ -219,7 +219,7 @@ Item {
onClicked: if (root.activePlayer) root.activePlayer.togglePlaying() onClicked: if (root.activePlayer) root.activePlayer.togglePlaying()
} }
PillButton { Button {
iconText: "󰒭" iconText: "󰒭"
foreground: root.bar.foreground foreground: root.bar.foreground
horizontalPadding: 10 horizontalPadding: 10
+2 -2
View File
@@ -30,7 +30,7 @@ Item {
// "brightness" - single slider row, selectedIndex = -1 sentinel // "brightness" - single slider row, selectedIndex = -1 sentinel
// (mirrors audioPanel's slider rows). Only present if a // (mirrors audioPanel's slider rows). Only present if a
// controllable backlight was detected. // controllable backlight was detected.
// "scale" - 6 ChoiceButton scale presets; treated as a single // "scale" - 6 Button scale presets; treated as a single
// horizontal row from j/k's perspective. h/l moves // horizontal row from j/k's perspective. h/l moves
// between presets, identical to bluetooth's header. // between presets, identical to bluetooth's header.
// "monitors" - vertical Toggle list for enabling/disabling displays; // "monitors" - vertical Toggle list for enabling/disabling displays;
@@ -499,7 +499,7 @@ Item {
Repeater { Repeater {
model: root.scaleValues model: root.scaleValues
CursorPill { Button {
required property string modelData required property string modelData
required property int index required property int index
+3 -3
View File
@@ -697,7 +697,7 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\
} }
} }
PillButton { Button {
id: refreshBtn id: refreshBtn
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -915,7 +915,7 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\
// One DNS provider pill. The cursor + current visuals come entirely from // One DNS provider pill. The cursor + current visuals come entirely from
// CursorSurface; this component just binds them to the panel's cursor // CursorSurface; this component just binds them to the panel's cursor
// state and renders the label/tooltip/click target. // state and renders the label/tooltip/click target.
component DnsProviderPill: CursorPill { component DnsProviderPill: Button {
id: pill id: pill
required property string provider required property string provider
required property int index required property int index
@@ -928,7 +928,7 @@ iwctl station "$station" get-networks rssi-dbms 2>/dev/null \\
horizontalPadding: 10 horizontalPadding: 10
verticalPadding: 6 verticalPadding: 6
// Map the panel's domain semantics onto CursorPill's structural props: // Map the panel's domain semantics onto Button's structural props:
// `current DNS` is the pill's `active` fill; the keyboard cursor lights // `current DNS` is the pill's `active` fill; the keyboard cursor lights
// up `hasCursor`. // up `hasCursor`.
active: root.dnsProvider === provider active: root.dnsProvider === provider
+50 -140
View File
@@ -95,20 +95,19 @@ Item {
property int numberDemoValue: 15 property int numberDemoValue: 15
readonly property var visibleSections: [ readonly property var visibleSections: [
"cursor-surface", "pill-button", "cursor-pill", "panel-action-button", "cursor-surface", "button", "button-group", "panel-action-button",
"panel-tool-tip", "slider", "choice-button", "text-field", "number-field", "panel-tool-tip", "slider", "text-field", "number-field",
"toggle", "dropdown", "searchable-dropdown", "composed" "toggle", "dropdown", "searchable-dropdown", "composed"
] ]
function sectionCount(section) { function sectionCount(section) {
switch (section) { switch (section) {
case "cursor-surface": return 3 case "cursor-surface": return 3
case "pill-button": return 5 case "button": return 5
case "cursor-pill": return 4 case "button-group": return 4
case "panel-action-button": return 4 case "panel-action-button": return 4
case "panel-tool-tip": return 1 case "panel-tool-tip": return 1
case "slider": return 1 case "slider": return 1
case "choice-button": return 4
case "text-field": return 2 case "text-field": return 2
case "number-field": return 1 case "number-field": return 1
case "toggle": return 2 case "toggle": return 2
@@ -120,13 +119,11 @@ Item {
} }
// True for sections whose primitives lay out horizontally (a row of // True for sections whose primitives lay out horizontally (a row of
// pills, choice buttons, etc.) — j/k jumps to the next/prev section, // buttons) — j/k jumps to the next/prev section, h/l walks within the row.
// h/l walks within the row.
function sectionIsHorizontal(section) { function sectionIsHorizontal(section) {
return section === "pill-button" return section === "button"
|| section === "cursor-pill" || section === "button-group"
|| section === "panel-action-button" || section === "panel-action-button"
|| section === "choice-button"
} }
// True for sections where h/l should adjust a value rather than walk. // True for sections where h/l should adjust a value rather than walk.
@@ -191,7 +188,7 @@ Item {
} }
function activateCursor() { function activateCursor() {
if (focusSection === "choice-button") { if (focusSection === "button-group") {
var opts = ["top", "right", "bottom", "left"] var opts = ["top", "right", "bottom", "left"]
if (selectedIndex >= 0 && selectedIndex < opts.length) if (selectedIndex >= 0 && selectedIndex < opts.length)
root.choiceDemoValue = opts[selectedIndex] root.choiceDemoValue = opts[selectedIndex]
@@ -802,20 +799,20 @@ Item {
} }
} }
// ---- PillButton -------------------------------------------------- // ---- Button ------------------------------------------------------
Column { Column {
width: parent.width width: parent.width
spacing: 8 spacing: 8
Text { Text {
text: "PillButton" text: "Button"
color: root.foreground color: root.foreground
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.subtitle font.pixelSize: Style.font.subtitle
font.bold: true font.bold: true
} }
Text { Text {
text: "Compact rounded button with optional icon, label, tooltip, and `active` highlight. Used inside panels for inline actions (Refresh, DNS pills, Bluetooth header)." text: "The kit's only button. State flags compose: hasCursor (panel cursor / hover) paints a tinted fill; active adds a persistent highlight; bordered draws a 1px idle ring for primary form buttons; focusable enables Tab focus with the accent ring. Click below or press h/l to walk the demo cursor."
color: Qt.darker(root.foreground, 1.5) color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.caption font.pixelSize: Style.font.caption
@@ -825,100 +822,92 @@ Item {
Rectangle { Rectangle {
width: parent.width width: parent.width
implicitHeight: pillCol.implicitHeight + 24 implicitHeight: buttonRow.implicitHeight + 24
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04) color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04)
radius: Style.cornerRadius radius: Style.cornerRadius
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
border.width: 1 border.width: 1
Row { Row {
id: pillCol id: buttonRow
anchors.left: parent.left anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 14 anchors.leftMargin: 14
spacing: 6 spacing: 6
PillButton { Button {
text: "DHCP" text: "DHCP"
tooltipText: "Use DNS from DHCP" tooltipText: "Use DNS from DHCP"
tooltipBackground: root.background hasCursor: root.focusSection === "button" && root.selectedIndex === 0
tooltipForeground: root.foreground onHovered: function(h) {
foreground: root.foreground if (h) { root.focusSection = "button"; root.selectedIndex = 0 }
fontFamily: root.fontFamily }
hasCursor: root.focusSection === "pill-button" && root.selectedIndex === 0
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this) onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler { onHoveredChanged: if (hovered) { root.focusSection = "pill-button"; root.selectedIndex = 0 } }
} }
PillButton { Button {
text: "Cloudflare" text: "Cloudflare"
tooltipText: "Set DNS to Cloudflare" tooltipText: "Set DNS to Cloudflare"
tooltipBackground: root.background
tooltipForeground: root.foreground
foreground: root.foreground
fontFamily: root.fontFamily
active: true active: true
hasCursor: root.focusSection === "pill-button" && root.selectedIndex === 1 hasCursor: root.focusSection === "button" && root.selectedIndex === 1
onHovered: function(h) {
if (h) { root.focusSection = "button"; root.selectedIndex = 1 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this) onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler { onHoveredChanged: if (hovered) { root.focusSection = "pill-button"; root.selectedIndex = 1 } }
} }
PillButton { Button {
iconText: "󰑐" iconText: "󰑐"
tooltipText: "Refresh" tooltipText: "Refresh"
tooltipBackground: root.background
tooltipForeground: root.foreground
foreground: root.foreground
fontFamily: root.fontFamily
horizontalPadding: 8 horizontalPadding: 8
verticalPadding: 4 verticalPadding: 4
hasCursor: root.focusSection === "pill-button" && root.selectedIndex === 2 hasCursor: root.focusSection === "button" && root.selectedIndex === 2
onHovered: function(h) {
if (h) { root.focusSection = "button"; root.selectedIndex = 2 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this) onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler { onHoveredChanged: if (hovered) { root.focusSection = "pill-button"; root.selectedIndex = 2 } }
} }
PillButton { Button {
iconText: "󰂯" iconText: "󰂯"
text: "On" text: "On"
tooltipText: "Turn Bluetooth off" tooltipText: "Turn Bluetooth off"
tooltipBackground: root.background
tooltipForeground: root.foreground
foreground: root.foreground
fontFamily: root.fontFamily
active: true active: true
hasCursor: root.focusSection === "pill-button" && root.selectedIndex === 3 hasCursor: root.focusSection === "button" && root.selectedIndex === 3
onHovered: function(h) {
if (h) { root.focusSection = "button"; root.selectedIndex = 3 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this) onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler { onHoveredChanged: if (hovered) { root.focusSection = "pill-button"; root.selectedIndex = 3 } }
} }
PillButton { Button {
text: "Apply" text: "Apply"
foreground: root.foreground
fontFamily: root.fontFamily
focusable: true focusable: true
bordered: true bordered: true
hasCursor: root.focusSection === "pill-button" && root.selectedIndex === 4 hasCursor: root.focusSection === "button" && root.selectedIndex === 4
onHovered: function(h) {
if (h) { root.focusSection = "button"; root.selectedIndex = 4 }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this) onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
HoverHandler { onHoveredChanged: if (hovered) { root.focusSection = "pill-button"; root.selectedIndex = 4 } }
} }
} }
} }
} }
// ---- CursorPill -------------------------------------------------- // ---- ButtonGroup -------------------------------------------------
Column { Column {
width: parent.width width: parent.width
spacing: 8 spacing: 8
Text { Text {
text: "CursorPill" text: "ButtonGroup"
color: root.foreground color: root.foreground
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.subtitle font.pixelSize: Style.font.subtitle
font.bold: true font.bold: true
} }
Text { Text {
text: "PillButton with panel-cursor wiring. Bind hasCursor to your cursor state and onHovered to update it on mouse enter; clicks come from PillButton's clicked() signal. Use this for any \"pick one in a row\" UI (wifi DNS pills, bluetooth header actions). Click below or press h/l to walk the demo cursor." text: "Mutually-exclusive row of Buttons. Selected option paints the accent fill+border so the chosen value stands out from non-selected options the cursor may pass through. Click to pick or press h/l + Enter."
color: Qt.darker(root.foreground, 1.5) color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.caption font.pixelSize: Style.font.caption
@@ -928,38 +917,22 @@ Item {
Rectangle { Rectangle {
width: parent.width width: parent.width
implicitHeight: cpRow.implicitHeight + 24 implicitHeight: choiceRow.implicitHeight + 24
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04) color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04)
radius: Style.cornerRadius radius: Style.cornerRadius
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
border.width: 1 border.width: 1
Row { ButtonGroup {
id: cpRow id: choiceRow
anchors.left: parent.left anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 14 anchors.leftMargin: 14
spacing: 6 options: ["top", "right", "bottom", "left"]
value: root.choiceDemoValue
Repeater { onChanged: function(v) {
model: ["DHCP", "Cloudflare", "Google", "Custom"] root.focusSection = "button-group"
CursorPill { root.choiceDemoValue = v
required property string modelData
required property int index
text: modelData
foreground: root.foreground
tooltipBackground: root.background
tooltipForeground: root.foreground
fontFamily: root.fontFamily
tooltipText: "Pick " + modelData
hasCursor: root.focusSection === "cursor-pill" && root.selectedIndex === index
active: modelData === "Cloudflare"
onHovered: function(h) {
if (h) { root.focusSection = "cursor-pill"; root.selectedIndex = index }
}
onClicked: { root.focusSection = "cursor-pill"; root.selectedIndex = index }
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
}
} }
} }
} }
@@ -1210,69 +1183,6 @@ Item {
} }
} }
// ---- ChoiceButton --------------------------------------------------
Column {
width: parent.width
spacing: 8
Text {
text: "ChoiceButton"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.subtitle
font.bold: true
}
Text {
text: "A single button in a mutually-exclusive choice group. 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."
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
width: parent.width
wrapMode: Text.WordWrap
}
Rectangle {
width: parent.width
implicitHeight: choiceRow.implicitHeight + 24
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04)
radius: Style.cornerRadius
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10)
border.width: 1
Row {
id: choiceRow
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 14
spacing: 6
Repeater {
model: ["top", "right", "bottom", "left"]
ChoiceButton {
required property string modelData
required property int index
text: modelData
foreground: root.foreground
background: root.background
accent: root.accent
fontFamily: root.fontFamily
selected: root.choiceDemoValue === modelData
hasCursor: root.focusSection === "choice-button" && root.selectedIndex === index
onClicked: {
root.focusSection = "choice-button"
root.selectedIndex = index
root.choiceDemoValue = modelData
}
onHovered: function(h) {
if (h) { root.focusSection = "choice-button"; root.selectedIndex = index }
}
onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(this)
}
}
}
}
}
// ---- TextField ----------------------------------------------------- // ---- TextField -----------------------------------------------------
Column { Column {
width: parent.width width: parent.width
@@ -1421,7 +1331,7 @@ Item {
font.bold: true font.bold: true
} }
Text { Text {
text: "Title + description + switch. Click anywhere on the row to flip; caller updates `checked` in response. Same focus tokens as ChoiceButton." text: "Title + description + switch. Click anywhere on the row to flip; caller updates `checked` in response. Same focus tokens as Button."
color: Qt.darker(root.foreground, 1.5) color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily font.family: root.fontFamily
font.pixelSize: Style.font.caption font.pixelSize: Style.font.caption
+4 -4
View File
@@ -680,14 +680,14 @@ Item {
Row { Row {
Layout.alignment: Qt.AlignRight Layout.alignment: Qt.AlignRight
spacing: 8 spacing: 8
PillButton { Button {
text: "Cancel" text: "Cancel"
foreground: root.foreground foreground: root.foreground
fontFamily: root.fontFamily fontFamily: root.fontFamily
focusable: true focusable: true
onClicked: root.discardWidgetSettings() onClicked: root.discardWidgetSettings()
} }
PillButton { Button {
text: "Apply" text: "Apply"
foreground: root.foreground foreground: root.foreground
fontFamily: root.fontFamily fontFamily: root.fontFamily
@@ -788,7 +788,7 @@ Item {
Row { Row {
Layout.alignment: Qt.AlignRight Layout.alignment: Qt.AlignRight
PillButton { Button {
text: "Reset bar to defaults" text: "Reset bar to defaults"
foreground: root.urgent foreground: root.urgent
fontFamily: root.fontFamily fontFamily: root.fontFamily
@@ -829,7 +829,7 @@ Item {
Repeater { Repeater {
model: positionGroup.positions model: positionGroup.positions
delegate: ChoiceButton { delegate: Button {
required property string modelData required property string modelData
text: modelData text: modelData