Fork the desktop off Omarchy as a self-contained system

This commit is contained in:
2026-09-19 23:50:39 -04:00
parent 16a56f49a1
commit 9501f2bb4d
559 changed files with 43273 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
import QtQuick
import Quickshell
import qs.Commons
WidgetButton {
id: root
property Component iconComponent: null
property real slotSize: Style.bar.iconSlot
property real opticalSize: Style.bar.iconCanvas
property bool debugOpticalBounds: Quickshell.env("BLOB_DEBUG_BAR_ICONS") === "1"
readonly property real opticalCenterErrorX: glyph.visible ? glyph.paintedCenterX - opticalCanvas.width / 2 : 0
readonly property real glyphPaintedWidth: glyph.visible ? glyph.tightWidth : 0
readonly property real glyphBaselineY: glyph.visible ? glyph.baselineY : 0
readonly property int glyphFontSize: glyph.visible ? glyph.renderedFontSize : 0
labelVisible: false
hasVisualContent: text !== "" || iconComponent !== null
fontSize: Style.bar.iconFont
fixedWidth: vertical ? -1 : slotSize
fixedHeight: vertical ? slotSize : -1
Item {
id: opticalCanvas
anchors.centerIn: parent
width: root.opticalSize
height: root.opticalSize
OpticalGlyph {
id: glyph
anchors.fill: parent
visible: root.iconComponent === null
text: root.text
fontFamily: root.fontFamily
fontSize: root.fontSize
color: root.active && root.useActiveColor ? root.activeColor : root.foreground
rotation: root.textRotation
debugBounds: root.debugOpticalBounds
}
Loader {
anchors.fill: parent
visible: root.iconComponent !== null
sourceComponent: root.iconComponent
}
Rectangle {
visible: root.debugOpticalBounds && root.iconComponent !== null
anchors.fill: parent
color: "transparent"
border.width: 1
border.color: "#4488ff"
}
}
Rectangle {
visible: root.debugOpticalBounds
anchors.fill: parent
color: "transparent"
border.width: 1
border.color: "#ff4455"
}
}
+51
View File
@@ -0,0 +1,51 @@
import QtQuick
import qs.Commons
BarIconButton {
id: root
property string moduleName: ""
property var settings: ({})
property string activeText: ""
property string inactiveText: activeText
property string activeTooltipText: ""
property string inactiveTooltipText: activeTooltipText
property string indicatorBlock: "single"
property var indicatorHost: null
property var activeOverride: null
readonly property bool effectiveActive: activeOverride === null || activeOverride === undefined ? active : activeOverride === true
readonly property bool belongsInBlock: indicatorBlock === "active" ? effectiveActive : (indicatorBlock === "inactive" ? !effectiveActive : true)
readonly property bool inactiveRevealed: !effectiveActive && !!indicatorHost && indicatorHost.revealInactiveIndicators
function extractData(raw) {
return Util.parseModuleJson(raw)
}
function syncIndicatorOpacity() {
root.opacity = !belongsInBlock ? 0 : (effectiveActive ? 1 : (inactiveRevealed ? 0.45 : 0))
}
Component.onCompleted: syncIndicatorOpacity()
onActiveChanged: syncIndicatorOpacity()
onEffectiveActiveChanged: syncIndicatorOpacity()
onBarChanged: syncIndicatorOpacity()
onBelongsInBlockChanged: syncIndicatorOpacity()
onInactiveRevealedChanged: syncIndicatorOpacity()
onIndicatorBlockChanged: syncIndicatorOpacity()
visible: belongsInBlock && (text !== "" || keepSpace)
text: effectiveActive ? activeText : inactiveText
tooltipText: effectiveActive ? activeTooltipText : inactiveTooltipText
keepSpace: true
dimmed: !effectiveActive
concealed: !effectiveActive && !inactiveRevealed
interactive: belongsInBlock && (effectiveActive || indicatorBlock === "inactive" || inactiveRevealed)
useActiveColor: false
maintainIndicatorReveal: indicatorBlock === "inactive"
revealHost: indicatorHost
fontSize: Style.font.caption
horizontalMargin: 5
verticalPadding: 5
fixedWidth: vertical ? -1 : Style.bar.statusSlot
fixedHeight: vertical ? Style.bar.statusSlot : -1
}
+45
View File
@@ -0,0 +1,45 @@
import QtQuick
import qs.Commons
// Base item every bar widget extends. Codifies the three properties the
// bar host injects into each widget slot:
// bar - the host Bar instance (foreground/background/run/etc).
// moduleName - widget's canonical id, used by the host registry to look
// up settings and to disambiguate inline IPC routes.
// settings - per-widget overrides read from shell.json's layout entry.
//
// Widgets are free to add their own properties, signals, and child items.
Item {
id: root
property QtObject bar: null
property string moduleName: ""
property var settings: ({})
// Bar geometry, lifted off the host. Widgets read these constantly to pick
// between horizontal/vertical layouts; defining them on the base keeps the
// `bar ? bar.x : fallback` ternary out of every widget body.
readonly property bool vertical: bar ? bar.vertical : false
readonly property int barSize: bar ? bar.barSize : Style.bar.sizeHorizontal
// Run `method` on every live instance of this widget. An IPC target only
// ever routes to one handler, but a bar surface exists per monitor, so the
// instance that owns the target relays the call to its peers — otherwise a
// refresh would land on a single screen and leave the others stale.
function broadcast(method) {
var items = bar && typeof bar.moduleWidgets === "function"
? bar.moduleWidgets(moduleName) : [root]
for (var i = 0; i < items.length; i++) {
if (items[i] && typeof items[i][method] === "function") items[i][method]()
}
}
// Read a single value from this widget's inline shell.json entry, with a
// fallback for missing/null values. Every widget that takes user-tunable
// settings needs this; defining it once on the base keeps the wiring
// consistent.
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
}
+54
View File
@@ -0,0 +1,54 @@
import QtQuick
import QtQuick.Shapes
import qs.Commons
import "../Commons/BorderGeometry.js" as Geometry
// Visual-only border renderer. It draws closed side-run contours, or a
// compound winding path for all four sides, so asymmetric/gradient borders
// do not require touching odd-even paths. Flat uniform borders use Rectangle.border.
Item {
id: root
property var borderSpec: Border.none()
property real radius: 0
readonly property var _widths: borderSpec && borderSpec.widths ? borderSpec.widths : Geometry.parseWidthSpec(0, 0)
readonly property bool hasBorder: Geometry.maxWidth(_widths) > 0
readonly property var _gradient: borderSpec && borderSpec.gradient ? borderSpec.gradient : ({ colors: [], angle: 0, enabled: false })
readonly property var _colors: _gradient.enabled ? _gradient.colors : [Border.color(borderSpec), Border.color(borderSpec)]
readonly property var _endpoints: Geometry.gradientEndpoints(width, height, _gradient.angle || 0)
readonly property string _path: Geometry.ringPath(width, height, radius, _widths)
visible: hasBorder && width > 0 && height > 0
anchors.fill: parent
z: 100000
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillRule: ShapePath.WindingFill
strokeWidth: 0
fillGradient: LinearGradient {
x1: root._endpoints.x1
y1: root._endpoints.y1
x2: root._endpoints.x2
y2: root._endpoints.y2
GradientStop { position: Geometry.stopPosition(root._colors, 0); color: Geometry.stopColor(root._colors, 0) }
GradientStop { position: Geometry.stopPosition(root._colors, 1); color: Geometry.stopColor(root._colors, 1) }
GradientStop { position: Geometry.stopPosition(root._colors, 2); color: Geometry.stopColor(root._colors, 2) }
GradientStop { position: Geometry.stopPosition(root._colors, 3); color: Geometry.stopColor(root._colors, 3) }
GradientStop { position: Geometry.stopPosition(root._colors, 4); color: Geometry.stopColor(root._colors, 4) }
GradientStop { position: Geometry.stopPosition(root._colors, 5); color: Geometry.stopColor(root._colors, 5) }
GradientStop { position: Geometry.stopPosition(root._colors, 6); color: Geometry.stopColor(root._colors, 6) }
GradientStop { position: Geometry.stopPosition(root._colors, 7); color: Geometry.stopColor(root._colors, 7) }
GradientStop { position: Geometry.stopPosition(root._colors, 8); color: Geometry.stopColor(root._colors, 8) }
GradientStop { position: Geometry.stopPosition(root._colors, 9); color: Geometry.stopColor(root._colors, 9) }
}
PathSvg { path: root._path }
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import QtQuick
import qs.Commons
// Rectangle-compatible surface with Blob border specs. Uses native
// Rectangle.border for cheap flat/uniform borders and BorderOverlay for
// gradients or per-side widths.
Rectangle {
id: root
property var borderSpec: Border.none()
property real padding: 0
property real topPadding: padding
property real rightPadding: padding
property real bottomPadding: padding
property real leftPadding: padding
readonly property real borderTop: Border.top(borderSpec)
readonly property real borderRight: Border.right(borderSpec)
readonly property real borderBottom: Border.bottom(borderSpec)
readonly property real borderLeft: Border.left(borderSpec)
readonly property real contentTopInset: borderTop + topPadding
readonly property real contentRightInset: borderRight + rightPadding
readonly property real contentBottomInset: borderBottom + bottomPadding
readonly property real contentLeftInset: borderLeft + leftPadding
readonly property bool usesOverlayBorder: Border.needsOverlay(borderSpec)
border.color: Border.canUseNative(borderSpec) ? Border.color(borderSpec) : "transparent"
border.width: Border.canUseNative(borderSpec) ? Border.uniformWidth(borderSpec) : 0
Loader {
anchors.fill: parent
active: root.usesOverlayBorder
sourceComponent: BorderOverlay {
anchors.fill: parent
radius: root.radius
borderSpec: root.borderSpec
}
}
}
+209
View File
@@ -0,0 +1,209 @@
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) focus fill + focus border token
// hasCursor || hover hover-cursor fill (+ border if `bordered`)
// selected selected fill + optional selected border
// active selected fill
// idle transparent or normal border if `bordered`
//
// All fills/borders come from `qs.Commons.Style` tokens, so themes
// control the look via [controls] in shell.toml.
//
// Emits `hovered(bool)` so panels with their own keyboard cursor model
// can update state on mouse enter/leave.
BorderSurface {
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 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 bool iconSpinning: false
property real horizontalPadding: Style.spacing.controlPaddingX
property real verticalPadding: Style.spacing.controlPaddingY
property bool leftAlign: false
leftPadding: horizontalPadding
rightPadding: horizontalPadding
topPadding: verticalPadding
bottomPadding: verticalPadding
// Tooltip palette. Auto-rendered if tooltipText is set. Defaults pull
// from [tooltip] in shell.toml; override per-instance only when a button
// intentionally wants a tooltip that diverges from the theme.
property color tooltipBackground: Color.tooltip.background
property color tooltipForeground: Color.tooltip.text
property color tooltipBorder: Color.tooltip.border
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()
// Reserve the largest border any visual state can paint. Otherwise a
// borderless idle button grows by a pixel per side on hover/focus and
// relayouts neighboring controls.
implicitWidth: row.implicitWidth + horizontalPadding * 2 + _reservedBorderLeft + _reservedBorderRight
implicitHeight: row.implicitHeight + verticalPadding * 2 + _reservedBorderTop + _reservedBorderBottom
radius: Style.cornerRadius
readonly property bool hot: mouseArea.containsMouse || hasCursor
readonly property bool _showFocusRing: focusable && activeFocus
readonly property color _selectedColor: Style.selectedStateColor(root.foreground, root.accent)
readonly property var _tooltipBorderSpec: Border.localOrSurfaceSpec("tooltip", "border", root.tooltipBorder, Color.tooltip.border, Math.max(1, Style.normalBorderWidth))
readonly property var _focusBorderSpec: Border.controlSpec("focus", root.foreground, root.accent)
readonly property var _hoverBorderSpec: Border.controlSpec("hover-cursor", root.foreground, root.accent)
readonly property var _selectedBorderSpec: Border.controlSpec("selected", root.foreground, root.accent)
readonly property var _normalBorderSpec: Border.controlSpec("normal", root.foreground, root.accent)
readonly property real _reservedBorderTop: Math.max(
focusable ? Border.top(_focusBorderSpec) : 0,
Border.top(_hoverBorderSpec),
Border.top(_selectedBorderSpec),
bordered ? Border.top(_normalBorderSpec) : 0)
readonly property real _reservedBorderRight: Math.max(
focusable ? Border.right(_focusBorderSpec) : 0,
Border.right(_hoverBorderSpec),
Border.right(_selectedBorderSpec),
bordered ? Border.right(_normalBorderSpec) : 0)
readonly property real _reservedBorderBottom: Math.max(
focusable ? Border.bottom(_focusBorderSpec) : 0,
Border.bottom(_hoverBorderSpec),
Border.bottom(_selectedBorderSpec),
bordered ? Border.bottom(_normalBorderSpec) : 0)
readonly property real _reservedBorderLeft: Math.max(
focusable ? Border.left(_focusBorderSpec) : 0,
Border.left(_hoverBorderSpec),
Border.left(_selectedBorderSpec),
bordered ? Border.left(_normalBorderSpec) : 0)
readonly property real _reservedContentLeftInset: _reservedBorderLeft + leftPadding
readonly property var _borderSpec: _showFocusRing ? _focusBorderSpec
: hot ? _hoverBorderSpec
: selected ? (Border.controlHasWidth("selected") ? _selectedBorderSpec : (bordered ? _normalBorderSpec : Border.none()))
: bordered ? _normalBorderSpec
: Border.none()
color: mouseArea.pressed ? Style.pressedFillFor(root.foreground, root.accent)
: _showFocusRing ? Style.focusFillFor(root.foreground, root.accent)
: hot ? Style.hoverFillFor(root.foreground, root.accent)
: selected ? Style.selectedFillFor(root.foreground, root.accent)
: active ? Style.selectedFillFor(root.foreground, root.accent)
: background
// Border follows the same state precedence as fill. Buttons stay
// borderless at rest unless `bordered` is set, but hover-cursor/focus
// always use the shared cursor border so the keyboard target is visible
// and consistent with the rest of the kit. Selected borders are off by
// default for plain buttons; explicitly bordered buttons keep their
// normal border when selected unless selected-border-width opts in to a
// dedicated selected border.
borderSpec: _borderSpec
Behavior on color { ColorAnimation { duration: 120 } }
ToolTip {
visible: root.tooltipText !== "" && mouseArea.containsMouse
text: root.tooltipText
delay: 400
padding: 0
background: BorderSurface {
color: root.tooltipBackground
borderSpec: root._tooltipBorderSpec
radius: 0
}
contentItem: Text {
textFormat: Text.PlainText
text: root.tooltipText
color: root.tooltipForeground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
leftPadding: Border.left(root._tooltipBorderSpec) + Style.spacing.controlPaddingX
rightPadding: Border.right(root._tooltipBorderSpec) + Style.spacing.controlPaddingX
topPadding: Border.top(root._tooltipBorderSpec) + Style.spacing.controlPaddingY
bottomPadding: Border.bottom(root._tooltipBorderSpec) + Style.spacing.controlPaddingY
}
}
Row {
id: row
anchors.verticalCenter: parent.verticalCenter
anchors.left: root.leftAlign ? parent.left : undefined
anchors.leftMargin: root.leftAlign ? root._reservedContentLeftInset : 0
anchors.horizontalCenter: root.leftAlign ? undefined : parent.horizontalCenter
spacing: Style.spacing.controlGap
Text {
textFormat: Text.PlainText
visible: root.iconText !== ""
text: root.iconText
color: root.selected ? root._selectedColor : root.foreground
font.family: root.fontFamily
font.pixelSize: root.iconSize
rotation: root.iconSpinning ? 0 : root.iconRotation
transformOrigin: Item.Center
anchors.verticalCenter: parent.verticalCenter
RotationAnimation on rotation {
from: 0
to: 360
duration: 900
loops: Animation.Infinite
running: root.iconSpinning
}
}
Text {
textFormat: Text.PlainText
visible: root.text !== ""
text: root.text
color: root.selected ? root._selectedColor : root.foreground
font.family: root.fontFamily
font.pixelSize: root.fontSize
font.bold: root.selected
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: function(mouse) {
if (root.focusable) root.forceActiveFocus()
if (mouse.button === Qt.RightButton) root.rightClicked()
else root.clicked()
}
}
HoverHandler {
onHoveredChanged: root.hovered(hovered)
}
}
+132
View File
@@ -0,0 +1,132 @@
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.
//
// Keyboard navigation. The group itself is a single Tab stop, not one
// stop per chip — so in a form that walks `activeFocusOnTab` items with
// Tab / j / k, the cursor enters the group as a unit. Once focused,
// h / l / Left / Right walks between chips and Enter / Space activates
// the current one. The selected chip is the default landing point so
// users see their existing choice on arrival.
//
// Panel-cursor consumers (the bar widget panels) drive `cursorIndex`
// directly and listen on `hovered` to sync the mouse — Tab focus and
// `cursorIndex` are independent; either one paints the chip's hot
// state, and the bar widget panels never give Tab focus to a
// ButtonGroup so they only see the cursorIndex path.
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: true
// -1 disables the external cursor highlight (the panel-cursor case).
// Driven by a containing panel; the group's own Tab-focus h/l
// tracking is internal and lives in _focusedIndex.
property int cursorIndex: -1
// Internal: which chip h / l / Left / Right is currently sitting on
// when the group itself has Tab focus. Reset to the selected option
// each time focus arrives so the user sees their existing choice.
property int _focusedIndex: -1
signal changed(string value)
signal hovered(int index, bool isHovered)
spacing: Style.spacing.md
activeFocusOnTab: focusable
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) : ""
}
function selectedOptionIndex() {
for (var i = 0; i < options.length; i++)
if (optionValue(options[i]) === value) return i
return -1
}
function activateFocused() {
if (_focusedIndex < 0 || _focusedIndex >= options.length) return
var v = optionValue(options[_focusedIndex])
root.changed(v)
}
onActiveFocusChanged: {
if (activeFocus) {
var idx = selectedOptionIndex()
_focusedIndex = idx < 0 ? 0 : idx
} else {
_focusedIndex = -1
}
}
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Left || event.key === Qt.Key_H
|| event.text === "h") {
_focusedIndex = Math.max(0, (_focusedIndex < 0 ? 0 : _focusedIndex) - 1)
event.accepted = true
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_L
|| event.text === "l") {
var max = options.length - 1
var next = (_focusedIndex < 0 ? 0 : _focusedIndex) + 1
_focusedIndex = Math.min(max, next)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space) {
activateFocused()
event.accepted = true
}
}
Repeater {
model: root.options
delegate: Button {
required property var modelData
required property int index
text: root.optionLabel(modelData)
iconText: root.optionIcon(modelData)
tooltipText: root.optionTooltip(modelData)
selected: root.optionValue(modelData) === root.value
// Chip lights up when either the external panel cursor lands here
// or the group has Tab focus and h/l has walked to this index.
hasCursor: root.cursorIndex === index
|| (root.activeFocus && root._focusedIndex === index)
// Every chip carries the standard bordered-button chrome so the
// group reads as a row of distinct options. selected / hover-cursor /
// focus are all painted by Button from Style's shared state tokens.
bordered: true
foreground: root.foreground
background: root.background
accent: root.accent
fontFamily: root.fontFamily
fontSize: root.fontSize
onClicked: root.changed(root.optionValue(modelData))
onHovered: function(h) { root.hovered(index, h) }
}
}
}
+133
View File
@@ -0,0 +1,133 @@
import QtQuick
import qs.Commons
Item {
id: root
property bool opened: false
property string message: ""
property string cancelText: "Cancel"
property string confirmText: "Confirm"
property int selectedIndex: 1
property color background: Color.background
property color foreground: Color.foreground
property color scrim: Util.alpha(Color.background, 0.7)
property color selectedBackground: Util.alpha(Color.foreground, 0.08)
property color selectedText: Color.accent
property string fontFamily: Style.font.family
property int cornerRadius: Style.cornerRadius
signal canceled()
signal confirmed()
function handleKey(event) {
if (!root.opened) return false
if (event.key === Qt.Key_Escape) {
root.canceled()
return true
} else if (event.key === Qt.Key_Left || event.key === Qt.Key_Right || event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) {
root.selectedIndex = root.selectedIndex === 0 ? 1 : 0
return true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (root.selectedIndex === 0) root.canceled()
else root.confirmed()
return true
}
return false
}
visible: opened
Rectangle {
anchors.fill: parent
color: root.scrim
MouseArea { anchors.fill: parent; onClicked: root.canceled() }
BorderSurface {
id: card
width: Math.min(parent.width - Style.space(32), Style.space(370))
// Grows with the wrapped message so narrow hosts (like the menu card)
// don't squeeze the text into the buttons.
height: card.contentTopInset + card.contentBottomInset + messageText.implicitHeight + Style.space(20) + Style.space(34)
anchors.centerIn: parent
color: root.background
borderSpec: Border.flat(root.selectedText, Style.normalBorderWidth)
padding: Style.space(18)
radius: root.cornerRadius
MouseArea { anchors.fill: parent; onClicked: {} }
Item {
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
Text {
id: messageText
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
text: root.message
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
wrapMode: Text.WordWrap
}
Row {
anchors.right: parent.right
anchors.bottom: parent.bottom
spacing: Style.space(10)
Repeater {
model: [root.cancelText, root.confirmText]
BorderSurface {
required property int index
required property string modelData
readonly property bool selected: root.selectedIndex === index
readonly property bool destructive: index === 1
width: Style.space(88)
height: Style.space(34)
color: selected
? (destructive ? Util.alpha(Color.urgent, 0.22) : root.selectedBackground)
: "transparent"
borderSpec: Border.flat(destructive
? (selected ? Color.urgent : Util.alpha(Color.urgent, 0.56))
: (selected ? root.selectedText : Util.alpha(root.foreground, 0.38)), Style.normalBorderWidth)
radius: 0
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: modelData
color: destructive ? (selected ? Color.urgent : root.foreground) : (selected ? root.selectedText : root.foreground)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.selectedIndex = index
onClicked: {
if (index === 0) root.canceled()
else root.confirmed()
}
}
}
}
}
}
}
}
}
+41
View File
@@ -0,0 +1,41 @@
import QtQuick
import qs.Commons
// Shared visual chrome for keyboard-and-mouse-navigable items inside a panel.
// Contract: items must NOT read `containsMouse` for color/border. Mouse
// hover updates the panel's cursor state at the root; visuals derive from
// `hasCursor` / `current`. That's what guarantees a single highlight on
// screen at any time across both keyboard and mouse interaction.
//
// Cursor paint is always the shared hover-cursor fill plus optional
// hover-cursor border. `outline` remains as a compatibility flag for
// callers that used to request border-only rows, but slider rows still
// receive the same hover-cursor background as every other row.
BorderSurface {
id: root
property bool hasCursor: false
property bool current: false
property bool outline: false
property bool bordered: false
property color foreground: Color.foreground
property color accent: Color.accent
property color fill: Style.hoverFillFor(foreground, accent)
property color currentFill: Style.selectedFillFor(foreground, accent)
radius: Style.cornerRadius
color: hasCursor ? fill : (current ? currentFill : "transparent")
borderSpec: root.hasCursor
? Border.controlSpec("hover-cursor", root.foreground, root.accent)
: (root.current
? Border.controlSpec("selected", root.foreground, root.accent)
: (root.bordered
? Border.controlSpec("normal", root.foreground, root.accent)
: Border.none()))
Behavior on color {
ColorAnimation { duration: 60 }
}
}
+244
View File
@@ -0,0 +1,244 @@
import QtQuick
import QtQuick.Controls
import qs.Commons
// Themed single-select dropdown. Trigger row paints with the kit's focus
// chrome; the popup anchors below and uses Color.popups.background +
// Color.popups.border so it reads as a panel surface rather than the
// platform-native ComboBox look.
//
// `options` accepts either a plain string[] or an array of
// { value, label } objects (label is what we render; value is what we
// emit). Mixing is fine — each row is interpreted independently.
//
// Keyboard: Tab to focus the trigger, Enter/Space opens, Esc closes,
// j/k or Up/Down walks options inside the open popup, Enter selects.
// A sibling SearchableDropdown reuses the same visuals but adds an
// embedded filter input — keep the two separate so each stays simple.
Item {
id: root
property string label: ""
property string value: ""
property var options: []
property color foreground: Color.popups.text
property color background: Color.popups.background
property color popupBorder: Color.popups.border
property color accent: Color.accent
readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Color.popups.border, Style.normalBorderWidth)
property string fontFamily: Style.font.family
property int rowHeight: Style.spacing.controlHeight
property int popupRowHeight: Style.spacing.popupRowHeight
property bool showLabel: true
// Panel-cursor flag. When true, the trigger renders the shared
// hover-cursor state. Active Qt focus defaults to the same visuals.
// Emits `hovered(bool)` on pointer enter/leave so the panel can keep
// its cursor state in sync with the mouse.
property bool hasCursor: false
// popupOpen + open()/close()/toggle() let a parent panel know when the
// dropdown owns keys (its embedded ListView is active) and suspend its
// own keyCatcher so j/k inside the popup don't double-drive the panel
// cursor.
readonly property bool popupOpen: popup.opened
function open() { popup.open() }
function close() { popup.close() }
function toggle() { popup.opened ? popup.close() : popup.open() }
signal changed(string value)
signal hovered(bool isHovered)
function optionValue(o) {
return (o && typeof o === "object") ? String(o.value) : String(o)
}
function optionLabel(o) {
return (o && typeof o === "object") ? String(o.label) : String(o)
}
function currentLabel() {
for (var i = 0; i < options.length; i++) {
if (optionValue(options[i]) === value) return optionLabel(options[i])
}
return value
}
implicitWidth: Style.spacing.dropdownWidth
implicitHeight: showLabel && label !== "" ? rowHeight + Style.spacing.huge : rowHeight
Column {
anchors.fill: parent
spacing: Style.spacing.labelGap
Text {
textFormat: Text.PlainText
visible: root.showLabel && root.label !== ""
text: root.label
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
BorderSurface {
id: trigger
width: parent.width
height: root.rowHeight
radius: Style.cornerRadius
readonly property bool _focused: trigger.activeFocus
readonly property bool _hot: triggerHover.hovered || root.hasCursor
readonly property var _borderSpec: Border.controlSpec(trigger._focused ? "focus" : (trigger._hot ? "hover-cursor" : "normal"), root.foreground, root.accent)
color: Style.controlFill(trigger._focused, trigger._hot, root.foreground, root.accent)
borderSpec: _borderSpec
activeFocusOnTab: true
HoverHandler {
id: triggerHover
onHoveredChanged: root.hovered(hovered)
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space || event.key === Qt.Key_Down) {
popup.opened ? popup.close() : popup.open()
event.accepted = true
} else if (event.key === Qt.Key_Escape && popup.opened) {
popup.close(); event.accepted = true
}
}
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: chevron.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: trigger.borderLeft + Style.spacing.controlPaddingX
anchors.rightMargin: trigger.borderRight + Style.spacing.md
text: root.currentLabel()
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
id: chevron
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: trigger.borderRight + Style.spacing.controlGap
text: "󰅀"
color: Qt.darker(root.foreground, 1.2)
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
trigger.forceActiveFocus()
popup.opened ? popup.close() : popup.open()
}
}
Popup {
id: popup
x: 0
y: trigger.height + Style.spacing.xxs
width: trigger.width
implicitHeight: Math.min(root.options.length * root.popupRowHeight + Math.max(0, root.options.length - 1) * Style.spacing.labelGap + Style.spacing.xxs,
root.popupRowHeight * 8 + 7 * Style.spacing.labelGap + Style.spacing.xxs)
padding: Style.spacing.hairline
leftPadding: Border.left(root.popupBorderSpec) + Style.spacing.hairline
rightPadding: Border.right(root.popupBorderSpec) + Style.spacing.hairline
topPadding: Border.top(root.popupBorderSpec) + Style.spacing.hairline
bottomPadding: Border.bottom(root.popupBorderSpec) + Style.spacing.hairline
focus: true
background: BorderSurface {
color: root.background
borderSpec: root.popupBorderSpec
radius: Style.cornerRadius
}
onOpened: {
optionList.currentIndex = Math.max(0, optionList.indexOfValue(root.value))
optionList.forceActiveFocus()
}
contentItem: ListView {
id: optionList
spacing: Style.spacing.labelGap
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) { popup.close(); event.accepted = true }
else if (event.key === Qt.Key_Down || event.text === "j") {
optionList.currentIndex = Math.min(root.options.length - 1, optionList.currentIndex + 1)
event.accepted = true
} else if (event.key === Qt.Key_Up || event.text === "k") {
optionList.currentIndex = Math.max(0, optionList.currentIndex - 1)
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
optionList.selectCurrent(); event.accepted = true
}
}
implicitHeight: contentHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.options
currentIndex: -1
function indexOfValue(v) {
for (var i = 0; i < root.options.length; i++)
if (root.optionValue(root.options[i]) === v) return i
return -1
}
function selectCurrent() {
if (currentIndex < 0 || currentIndex >= root.options.length) return
var v = root.optionValue(root.options[currentIndex])
root.value = v
root.changed(v)
popup.close()
}
delegate: Rectangle {
required property var modelData
required property int index
width: optionList.width
height: root.popupRowHeight
color: index === optionList.currentIndex
? Style.hoverFillFor(root.foreground, root.accent)
: "transparent"
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.spacing.controlPaddingX
anchors.rightMargin: Style.spacing.controlPaddingX
text: root.optionLabel(modelData)
color: index === optionList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: optionList.currentIndex = parent.index
onClicked: optionList.selectCurrent()
}
}
}
}
}
}
}
+418
View File
@@ -0,0 +1,418 @@
import QtQuick
import Quickshell
import Quickshell.Wayland
import qs.Commons
// Layer-shell popup attached to a bar widget icon, designed for
// click-driven AND keyboard-driven panels (e.g. SUPER+CTRL+W summon).
//
// Built on PanelWindow with a brief WlrKeyboardFocus.Exclusive prime followed
// by OnDemand rather than PopupWindow (xdg-popup). The prime acquires focus
// both when the surface maps and when it reopens while still mapped for its
// fade-out. xdg-popups don't get that — they only receive keys after a
// click/hover routes focus through their parent surface — so keyboard-summoned
// popups fell flat without it.
//
// Exclusive would also grant map-time focus, but it makes Hyprland route
// *every* pointer event to the exclusive surface no matter which output
// the cursor is over, which leaves clicks on any other monitor unable to
// reach the dismissal surfaces below.
//
// API is a subset of Common.PopupCard: anchorItem, owner, bar, open,
// padding, margin, contentWidth/Height, centerOnBar, default contentItem.
// Missing on purpose (for now): triggerMode ("hover"), containsMouse.
//
// Positioning: full-screen layer-shell with the card placed inside at
// `cardOrigin`. We use the bar window's height/width for the perpendicular
// axis (away-from-bar) because mapToItem on the anchor returns
// bar-content-relative coords with internal layout offsets baked in
// (e.g. ~13px from the bar's vertical centering of its widget row). The
// parallel axis (along-the-bar) uses the anchor's content x/y since the
// bar spans full screen on that axis.
//
// Outside-click dismissal: an overlay MouseArea catches clicks, with the
// QsWindow.mask subtracting the bar strip so clicks on the bar still
// reach the bar widgets (activePopout coordinator hands off to another
// popup if the user clicks a different bar icon).
PanelWindow {
id: root
required property Item anchorItem
required property QtObject bar
property var owner: null
property int margin: Style.gapsOut
property int padding: Style.spacing.popupPadding
property int contentWidth: Style.space(280)
property int contentHeight: Style.space(200)
property var borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, Math.max(1, Style.space(2)))
property bool centerOnBar: false
property bool open: false
property int gap: Style.gapsOut // distance between bar edge and panel
property bool popoutSwitching: false
property bool popoutSwitchClosing: false
property bool focusPrimed: false
// Item that should take keyboard focus once the panel maps. Typically a
// PanelKeyCatcher inside the panel content. Layer-shell grants focus to the
// surface during the Exclusive prime, but Qt still needs an active-focus
// target inside the surface for Keys.onPressed handlers to fire. Schedule
// the focus through Qt.callLater so it runs after the surface is fully
// mapped and child items have completed layout.
property Item focusTarget: null
default property alias contentItem: contentHolder.children
readonly property var coordinatorKey: owner || root
readonly property var anchorWindow: anchorItem ? anchorItem.QsWindow.window : null
readonly property string barPos: bar ? bar.position : "top"
function close() {
if (owner && "close" in owner) owner.close()
else root.open = false
}
function beginFocusPrime() {
if (open && backingWindowVisible) focusPrimeTimer.restart()
}
// --- screen + lifetime ---------------------------------------------------
screen: anchorWindow ? anchorWindow.screen : null
visible: open || card.opacity > 0 || popoutSwitching
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "blob-keyboard-panel"
WlrLayershell.layer: WlrLayer.Overlay
// Keyboard focus follows `open` (NOT `visible`). The window remains
// mapped during the fade-out so the opacity animation has something to
// animate, but keyboard/click ownership must release the moment the
// logical close fires — otherwise the user is locked out for 140ms.
//
// Prime with Exclusive on every open, then settle on OnDemand. Hyprland
// focuses OnDemand when a surface first maps, but not when an already-mapped
// fade-out surface changes from None back to OnDemand. Exclusive also takes
// focus when the previously focused application has constrained the pointer.
// The brief prime covers both cases; OnDemand then releases compositor-wide
// pointer hit-testing so clicks can reach the dismissal windows below.
WlrLayershell.keyboardFocus: open
? (focusPrimed ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive)
: WlrKeyboardFocus.None
onBackingWindowVisibleChanged: beginFocusPrime()
// Full-screen layer-shell. The visible card is positioned inside via
// `cardOrigin`. The `mask` below makes the bar area click-through (so
// the user can click another bar icon while the panel is open and the
// activePopout coordinator swaps to that popup); everywhere else, the
// overlay catches the click and dismisses via the MouseArea below.
anchors {
top: true
bottom: true
left: true
right: true
}
// Clickable region is the whole screen. Clicks in the bar strip are
// forwarded to registered bar buttons so switching between panel icons
// works in one click even when the overlay surface is above the bar.
readonly property real _barStripSize: {
if (!bar) return 0
var actual = (root.barPos === "top" || root.barPos === "bottom") ? root.barH : root.barW
return Math.max(bar.barSize, actual) + root.gap
}
mask: Region {
width: root.screenW
height: root.screenH
}
// Track every layout change between the bar's contentItem and the
// anchor item. `transform` updates whenever any item in that chain
// moves/resizes, which is what makes the position binding below
// actually reactive — mapToItem on its own is a one-shot.
TransformWatcher {
id: anchorWatcher
a: anchorWindow ? anchorWindow.contentItem : null
b: anchorItem
}
// Anchor item's position within the bar's content surface. For a
// full-width top bar, the content x maps directly to screen x; the y
// returned here has the bar's internal padding baked in (e.g. ~13px
// from vertical centering of the widget row), which is why `cardOrigin`
// below uses `barH` for the perpendicular axis instead of this y.
readonly property point anchorScreenPos: {
anchorWatcher.transform // reactive dependency
if (!anchorItem || !anchorWindow) return Qt.point(0, 0)
return anchorItem.mapToItem(anchorWindow.contentItem, 0, 0)
}
readonly property real anchorW: anchorItem ? anchorItem.width : 0
readonly property real anchorH: anchorItem ? anchorItem.height : 0
readonly property real screenW: screen ? screen.width : 0
readonly property real screenH: screen ? screen.height : 0
readonly property real availableCardWidth: screenW > 0
? Math.max(120, screenW - ((barPos === "left" || barPos === "right") ? barW + gap + margin : margin * 2))
: 0
readonly property real availableCardHeight: screenH > 0
? Math.max(120, screenH - ((barPos === "top" || barPos === "bottom") ? barH + gap + margin : margin * 2))
: 0
readonly property real verticalContentInset: padding * 2 + Border.top(borderSpec) + Border.bottom(borderSpec)
function fittedContentWidth(width, cap) {
var desired = Math.max(1, Number(width) || 1)
var maxWidth = root.availableCardWidth > 0 ? root.availableCardWidth : desired
if (cap !== undefined && Number(cap) > 0) maxWidth = Math.min(maxWidth, Number(cap))
return Math.round(Math.min(desired, maxWidth))
}
function fittedContentHeight(implicitHeight, cap) {
var desired = Math.max(root.verticalContentInset, (Number(implicitHeight) || 0) + root.verticalContentInset)
var maxHeight = root.availableCardHeight > 0 ? root.availableCardHeight : desired
if (cap !== undefined && Number(cap) > 0) maxHeight = Math.min(maxHeight, Number(cap))
return Math.round(Math.min(desired, maxHeight))
}
function cappedContentHeight(height) {
var desired = Math.max(root.padding * 2, Number(height) || root.padding * 2)
var maxHeight = root.availableCardHeight > 0 ? root.availableCardHeight : desired
return Math.round(Math.min(desired, maxHeight))
}
// Desired top-left of the card in screen coordinates. For the
// perpendicular axis (away-from-bar) we anchor to the bar window's edge
// directly — not the anchor item's y/x — because mapToItem(barContent)
// returns coordinates in the bar's content space, which can be offset
// from the bar surface's screen-anchored corner by internal layout
// (centering wrappers, padding). The bar's surface IS aligned to its
// anchored screen edge, so using `barW`/`barH` gives the right edge
// regardless of how the bar's internal widgets are positioned. For the
// parallel axis (along the bar) the anchor item's reported position is
// still consistent with the bar content origin, so it's accurate for
// centering the card under the icon.
readonly property real barW: anchorWindow ? anchorWindow.width : screenW
readonly property real barH: anchorWindow ? anchorWindow.height : 0
readonly property point cardOrigin: {
if (!anchorItem || !bar) return Qt.point(margin, margin)
var x = 0, y = 0
if (centerOnBar && (barPos === "top" || barPos === "bottom")) {
x = screenW / 2 - contentWidth / 2
y = barPos === "bottom" ? screenH - barH - contentHeight - gap : barH + gap
} else if (centerOnBar) {
x = barPos === "left" ? barW + gap : screenW - barW - contentWidth - gap
y = screenH / 2 - contentHeight / 2
} else if (barPos === "bottom") {
x = anchorScreenPos.x + anchorW / 2 - contentWidth / 2
y = screenH - barH - contentHeight - gap
} else if (barPos === "left") {
x = barW + gap
y = anchorScreenPos.y + anchorH / 2 - contentHeight / 2
} else if (barPos === "right") {
x = screenW - barW - contentWidth - gap
y = anchorScreenPos.y + anchorH / 2 - contentHeight / 2
} else { // "top" (default)
x = anchorScreenPos.x + anchorW / 2 - contentWidth / 2
y = barH + gap
}
x = Math.max(margin, Math.min(x, screenW - contentWidth - margin))
y = Math.max(margin, Math.min(y, screenH - contentHeight - margin))
return Qt.point(Math.round(x), Math.round(y))
}
// --- popout coordination (same-bar single-popout model) -----------------
// Coordinate on `open`, not `visible`. `visible` lags into the fade-out
// animation, which made ownership transfer to a sibling popup race.
onOpenChanged: {
if (open) {
focusPrimed = false
beginFocusPrime()
if (focusTarget) Qt.callLater(function() {
if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus()
})
} else {
focusPrimeTimer.stop()
focusPrimed = false
}
if (!bar) return
if (open) {
popoutSwitchClosing = false
popoutSwitching = bar.activePopout && bar.activePopout !== coordinatorKey
bar.requestPopout(coordinatorKey)
if (popoutSwitching) popoutSwitchTimer.restart()
} else {
popoutSwitchClosing = !!(owner && owner.popoutSwitchClosing)
popoutSwitching = false
if (bar.activePopout === coordinatorKey) bar.releasePopout(coordinatorKey)
if (popoutSwitchClosing) closeSwitchTimer.restart()
}
}
Timer {
id: focusPrimeTimer
// Leave enough time for multiple Qt/Wayland commit cycles after the
// backing window becomes visible while keeping the compositor-wide
// Exclusive phase imperceptibly short. This interval is covered by the
// immediate hide/re-summon acceptance case.
interval: 75
onTriggered: if (root.open) root.focusPrimed = true
}
Timer {
id: popoutSwitchTimer
interval: 150
onTriggered: root.popoutSwitching = false
}
Timer {
id: closeSwitchTimer
interval: 1
onTriggered: root.popoutSwitchClosing = false
}
// --- outside-click dismissal --------------------------------------------
// Catches clicks anywhere in the clickable region (i.e. everywhere on
// screen except the bar strip, which is masked out). The card has its
// own MouseArea below so clicks on it don't bubble up here. Disabled
// during the fade-out so the dying overlay doesn't swallow clicks that
// were meant for the apps behind it.
MouseArea {
id: dismissArea
anchors.fill: parent
enabled: root.open
acceptedButtons: Qt.AllButtons
hoverEnabled: true
property bool hoveringBar: false
cursorShape: hoveringBar ? Qt.PointingHandCursor : Qt.ArrowCursor
function inBarRegion(px, py) {
if (root.barPos === "bottom") return py >= root.screenH - root._barStripSize
if (root.barPos === "left") return px <= root._barStripSize
if (root.barPos === "right") return px >= root.screenW - root._barStripSize
return py <= root._barStripSize
}
function barPoint(px, py) {
if (root.barPos === "bottom") return Qt.point(px, py - (root.screenH - root.barH))
if (root.barPos === "right") return Qt.point(px - (root.screenW - root.barW), py)
return Qt.point(px, py)
}
function pressTargetAt(px, py) {
if (!root.anchorWindow || !root.anchorWindow.contentItem || !root.bar || !root.bar.clickTargets) return null
var p = barPoint(px, py)
var targets = root.bar.clickTargets
for (var i = targets.length - 1; i >= 0; i--) {
var target = targets[i]
if (!target || !target.triggerPress || target.visible === false || target.opacity === 0 || !target.mapToItem) continue
if (root.bar.targetBelongsToWindow && !root.bar.targetBelongsToWindow(target, root.anchorWindow)) continue
var pos = root.anchorWindow.itemPosition(target)
if (p.x >= pos.x && p.x <= pos.x + target.width && p.y >= pos.y && p.y <= pos.y + target.height) return target
}
return null
}
function forwardBarClick(px, py, button) {
if (button !== Qt.LeftButton && button !== Qt.RightButton && button !== Qt.MiddleButton) return false
var target = pressTargetAt(px, py)
if (!target) return false
target.triggerPress(button)
return true
}
onPositionChanged: function(mouse) { hoveringBar = inBarRegion(mouse.x, mouse.y) }
onExited: hoveringBar = false
onClicked: function(mouse) {
// While Exclusive is priming, Hyprland may route a click from another
// output here with translated coordinates. Never interpret that as a
// click on this output's bar.
if (root.focusPrimed && inBarRegion(mouse.x, mouse.y) && forwardBarClick(mouse.x, mouse.y, mouse.button)) return
root.close()
}
}
// The panel surface only spans the anchor's screen, and the compositor
// hit-tests pointer input per output, so `dismissArea` above can never see
// a click on another monitor. Give every other output a transparent twin
// whose only job is to catch that click. They exist only while the panel is
// logically open (not during the fade-out, matching `dismissArea.enabled`).
//
// Keyboard focus is None: these must catch the pointer without taking focus
// from the panel when the cursor merely crosses onto their output.
Variants {
model: root.open ? Quickshell.screens : []
delegate: Component {
PanelWindow {
required property var modelData
screen: modelData
// Compare by output name: the anchor screen must be known before any
// twin maps, or a twin would cover the panel's own output.
visible: root.open && !!root.screen && modelData.name !== root.screen.name
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: "blob-keyboard-panel-dismiss"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
anchors {
top: true
bottom: true
left: true
right: true
}
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
onPressed: root.close()
}
}
}
}
// --- card ----------------------------------------------------------------
BorderSurface {
id: card
x: root.cardOrigin.x
y: root.cardOrigin.y
width: root.contentWidth
height: root.contentHeight
color: Color.popups.background
borderSpec: root.borderSpec
padding: root.padding
radius: Style.cornerRadius
opacity: root.open || root.popoutSwitching ? 1.0 : 0
Behavior on opacity {
enabled: !root.popoutSwitching && !root.popoutSwitchClosing
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
// Swallow clicks on the card so they don't bubble to the dismissal
// MouseArea behind us.
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.AllButtons
}
Item {
id: contentHolder
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
opacity: root.popoutSwitching ? (root.open ? 1.0 : 0) : 1.0
Behavior on opacity {
enabled: root.popoutSwitching
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
}
}
}
+623
View File
@@ -0,0 +1,623 @@
import QtQuick
import QtQuick.Controls as QQC
import QtQuick.Window
import Quickshell.Io
import qs.Commons
// Searchable multi-select dropdown. Trigger shape matches Dropdown /
// SearchableDropdown; the popup shows a search field, an optional refresh
// button, and a checkbox list. Click rows to toggle. Use when callers
// need to pick zero or more items from a (possibly long, possibly
// dynamic) list.
//
// Options are either:
// - static `options`: string[] or [{ value, label, description? }]
// - dynamic `optionsCommand`: argv array. The command's stdout is
// parsed as JSON when it trims to start with `[`, otherwise as
// one option per non-empty newline. Re-runs whenever the popup opens
// and via the refresh button.
//
// `values` is the persisted selection — always an array of strings.
// Emits `changed(values)` whenever the selection mutates.
Item {
id: root
property string label: ""
property var values: []
property var options: []
property var optionsCommand: []
property string optionsCommandCwd: ""
property string placeholderText: "Search..."
property string emptyText: "No options"
property string noSelectionText: "None selected"
property string triggerLabel: ""
property bool showLabel: true
property color foreground: Color.popups.text
property color background: Color.popups.background
property color popupBorder: Color.popups.border
property color accent: Color.accent
readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Color.popups.border, Style.normalBorderWidth)
property string fontFamily: Style.font.family
property int rowHeight: Style.spacing.controlHeight
property int popupRowHeight: Style.spacing.popupRowHeight
property int popupMinHeight: Style.spacing.searchablePopupMinHeight
property bool hasCursor: false
readonly property bool popupOpen: popup.opened
function open() { popup.open() }
function close() { popup.close() }
function toggle() { popup.opened ? popup.close() : popup.open() }
signal changed(var values)
signal hovered(bool isHovered)
// Loaded options after merging static + dynamic. Always normalized
// into the [{ value, label, description }] shape for delegate use.
property var resolvedOptions: []
property bool loadingOptions: false
property string optionsError: ""
function normalizeOption(o) {
if (o && typeof o === "object") {
return {
value: String(o.value),
label: String(o.label !== undefined ? o.label : o.value),
description: o.description ? String(o.description) : ""
}
}
var s = String(o)
return { value: s, label: s, description: "" }
}
// QML schema arrays sometimes arrive as JSValue lists that fail
// `Array.isArray`. arrayFrom accepts anything array-like (`.length`
// numeric) and returns a real JS array so the rest of the component can
// rely on standard array operations.
function arrayFrom(v) {
if (!v || typeof v.length !== "number" || typeof v === "string") return []
var out = []
for (var i = 0; i < v.length; i++) out.push(v[i])
return out
}
function normalizeAll(arr) {
var out = []
var src = arrayFrom(arr)
var seen = ({})
for (var i = 0; i < src.length; i++) {
var n = normalizeOption(src[i])
if (!n.value) continue
if (seen[n.value]) continue
seen[n.value] = true
out.push(n)
}
return out
}
function valueSet() {
var set = ({})
var arr = arrayFrom(values)
for (var i = 0; i < arr.length; i++) set[String(arr[i])] = true
return set
}
function isSelected(value) {
var set = valueSet()
return !!set[String(value)]
}
function toggleValue(value) {
var v = String(value)
var arr = arrayFrom(values)
var idx = arr.indexOf(v)
if (idx === -1) arr.push(v)
else arr.splice(idx, 1)
root.values = arr
root.changed(arr)
}
function selectionLabel() {
var arr = arrayFrom(values)
if (arr.length === 0) return ""
var labels = []
var byValue = ({})
for (var i = 0; i < resolvedOptions.length; i++)
byValue[resolvedOptions[i].value] = resolvedOptions[i].label
for (var j = 0; j < arr.length; j++)
labels.push(byValue[String(arr[j])] || String(arr[j]))
if (labels.length <= 3) return labels.join(", ")
return arr.length + " selected"
}
property var filtered: resolvedOptions
function recomputeFiltered() {
var q = searchField.text.toLowerCase()
if (!q) { filtered = resolvedOptions; return }
var out = []
for (var i = 0; i < resolvedOptions.length; i++) {
var o = resolvedOptions[i]
if (o.label.toLowerCase().indexOf(q) !== -1
|| o.description.toLowerCase().indexOf(q) !== -1
|| o.value.toLowerCase().indexOf(q) !== -1) out.push(o)
}
filtered = out
}
function rebuildFromStatic() {
resolvedOptions = normalizeAll(options)
recomputeFiltered()
}
// Parse stdout from a dynamic optionsCommand into `{ options, error }`.
// Output starting with `[` is parsed strictly as JSON — a malformed array
// surfaces as an error rather than silently falling back to newline
// parsing, which would render the broken text as a literal option label.
// Output not starting with `[` is treated as one value per non-empty line.
function parseCommandOutput(text) {
var raw = String(text || "").trim()
if (raw === "") return { options: [], error: "" }
if (raw.charAt(0) === "[") {
try {
var parsed = JSON.parse(raw)
return { options: parsed, error: "" }
} catch (e) {
return { options: [], error: "Options command emitted invalid JSON" }
}
}
var out = []
var lines = raw.split(/\r?\n/)
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (line !== "") out.push(line)
}
return { options: out, error: "" }
}
// Monotonic request id so stale stdout/exit signals from a previous
// refresh can't clobber the resolvedOptions of a newer refresh, and
// a runaway command can be detected after a timeout.
property int refreshSeq: 0
readonly property int refreshTimeoutMs: 6000
function refresh() {
var cmd = arrayFrom(optionsCommand)
if (cmd.length === 0) {
rebuildFromStatic()
return
}
refreshSeq++
loadingOptions = true
optionsError = ""
optionsProcess.command = cmd
optionsProcess.workingDirectory = optionsCommandCwd
optionsProcess.running = false
optionsProcess.running = true
refreshTimeoutTimer.restart()
}
Timer {
id: refreshTimeoutTimer
interval: root.refreshTimeoutMs
repeat: false
onTriggered: {
if (!root.loadingOptions) return
optionsProcess.running = false
root.loadingOptions = false
root.optionsError = "Options command timed out"
}
}
onOptionsChanged: if (arrayFrom(optionsCommand).length === 0) rebuildFromStatic()
onOptionsCommandChanged: refresh()
Component.onCompleted: refresh()
Process {
id: optionsProcess
running: false
command: []
property int seq: 0
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
if (optionsProcess.seq !== root.refreshSeq) return
var result = root.parseCommandOutput(text)
if (result.error) {
root.optionsError = result.error
root.resolvedOptions = root.normalizeAll(root.options)
} else {
var combined = root.arrayFrom(root.options)
for (var j = 0; j < result.options.length; j++) combined.push(result.options[j])
root.resolvedOptions = root.normalizeAll(combined)
}
root.recomputeFiltered()
root.loadingOptions = false
refreshTimeoutTimer.stop()
}
}
onRunningChanged: if (running) seq = root.refreshSeq
onExited: function(exitCode, exitStatus) {
if (seq !== root.refreshSeq) return
refreshTimeoutTimer.stop()
if (exitCode !== 0) {
root.loadingOptions = false
root.optionsError = "Options command exited " + exitCode
}
}
}
implicitWidth: Style.spacing.searchableDropdownWidth
implicitHeight: showLabel && label !== "" ? rowHeight + Style.spacing.huge : rowHeight
Column {
anchors.fill: parent
spacing: Style.spacing.labelGap
Text {
textFormat: Text.PlainText
visible: root.showLabel && root.label !== ""
text: root.label
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
BorderSurface {
id: trigger
width: parent.width
height: root.rowHeight
radius: Style.cornerRadius
readonly property bool _focused: trigger.activeFocus
readonly property bool _hot: triggerHover.hovered || root.hasCursor
readonly property var _borderSpec: Border.controlSpec(trigger._focused ? "focus" : (trigger._hot ? "hover-cursor" : "normal"), root.foreground, root.accent)
color: Style.controlFill(trigger._focused, trigger._hot, root.foreground, root.accent)
borderSpec: _borderSpec
activeFocusOnTab: true
HoverHandler {
id: triggerHover
onHoveredChanged: root.hovered(hovered)
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space || event.key === Qt.Key_Down) {
popup.opened ? popup.close() : popup.open()
event.accepted = true
} else if (event.key === Qt.Key_Escape && popup.opened) {
popup.close(); event.accepted = true
}
}
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: chevron.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: trigger.borderLeft + Style.spacing.controlPaddingX
anchors.rightMargin: trigger.borderRight + Style.spacing.md
text: root.selectionLabel() || root.triggerLabel || root.noSelectionText
color: root.selectionLabel() ? root.foreground : Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
id: chevron
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: trigger.borderRight + Style.spacing.controlGap
text: "󰅀"
color: Qt.darker(root.foreground, 1.2)
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
trigger.forceActiveFocus()
popup.opened ? popup.close() : popup.open()
}
}
QQC.Popup {
id: popup
// Reparent to the window's content item so the popup is free of any
// clipping ancestor. Position
// and available height are recomputed on open and any time the
// trigger's geometry changes, since a binding on mapToItem alone
// won't reliably re-evaluate when ancestors scroll or resize.
parent: trigger.Window.window ? trigger.Window.window.contentItem : trigger
property real _anchorX: 0
property real _anchorY: 0
property real _availableBelow: 0
readonly property real _windowHeight: parent ? parent.height : 0
readonly property real _idealContent: resultList.contentHeight + Style.space(50)
readonly property real _maxRowsHeight: root.popupRowHeight * 6 + 5 * Style.spacing.labelGap + Style.space(50)
function reposition() {
if (!parent) return
var p = trigger.mapToItem(parent, 0, trigger.height + Style.spacing.xxs)
_anchorX = p.x
_anchorY = p.y
_availableBelow = Math.max(0, _windowHeight - _anchorY - Style.space(12))
}
x: _anchorX
y: _anchorY
width: trigger.width
// Clamp to whatever fits below the trigger; don't force popupMinHeight
// when there isn't room, otherwise the popup overflows the window.
implicitHeight: Math.min(_availableBelow, _idealContent, _maxRowsHeight)
padding: Style.spacing.hairline
leftPadding: Border.left(root.popupBorderSpec) + Style.spacing.hairline
rightPadding: Border.right(root.popupBorderSpec) + Style.spacing.hairline
topPadding: Border.top(root.popupBorderSpec) + Style.spacing.hairline
bottomPadding: Border.bottom(root.popupBorderSpec) + Style.spacing.hairline
focus: true
Connections {
target: trigger
function onXChanged() { popup.reposition() }
function onYChanged() { popup.reposition() }
function onWidthChanged() { popup.reposition() }
function onHeightChanged() { popup.reposition() }
}
background: BorderSurface {
color: root.background
borderSpec: root.popupBorderSpec
radius: Style.cornerRadius
}
onOpened: {
reposition()
searchField.text = ""
root.refresh()
root.recomputeFiltered()
Qt.callLater(function() { searchField.forceActiveFocus() })
}
onClosed: searchField.text = ""
contentItem: Column {
spacing: 0
Item {
id: searchHeader
width: parent.width
height: root.popupRowHeight + Style.spacing.controlPaddingX
Row {
anchors.fill: parent
anchors.margins: Style.spacing.md
spacing: Style.spacing.rowGap
TextField {
id: searchField
width: parent.width - refreshButton.width - parent.spacing
height: parent.height
placeholderText: root.placeholderText
foreground: root.foreground
accent: root.accent
font.family: root.fontFamily
font.pixelSize: Style.font.body
onTextChanged: {
root.recomputeFiltered()
if (resultList.count > 0) resultList.currentIndex = 0
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
popup.close(); event.accepted = true
} else if (event.key === Qt.Key_Down) {
if (resultList.count > 0) {
resultList.currentIndex = 0
resultList.forceActiveFocus()
}
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (resultList.count > 0) {
resultList.currentIndex = 0
resultList.toggleCurrent()
}
event.accepted = true
}
}
}
BorderSurface {
id: refreshButton
visible: root.arrayFrom(root.optionsCommand).length > 0
enabled: !root.loadingOptions
width: parent.height
height: parent.height
radius: Style.cornerRadius
color: refreshHover.hovered
? Style.hoverFillFor(root.foreground, root.accent)
: Style.normalFillFor(root.foreground, root.accent)
borderSpec: refreshHover.hovered
? Border.controlSpec("hover-cursor", root.foreground, root.accent)
: Border.controlSpec("normal", root.foreground, root.accent)
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: root.loadingOptions ? "󰦖" : "󰑐"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
RotationAnimator on rotation {
running: root.loadingOptions
from: 0; to: 360
duration: 800
loops: Animation.Infinite
}
}
HoverHandler { id: refreshHover }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.refresh()
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: Util.alpha(root.foreground, 0.10)
}
Item {
width: parent.width
height: popup.height - searchHeader.height - Style.spacing.xxs - 1
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
visible: resultList.count === 0
text: root.loadingOptions ? "Loading…" : (root.optionsError !== "" ? root.optionsError : root.emptyText)
color: Qt.darker(root.foreground, 1.6)
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
ListView {
id: resultList
anchors.fill: parent
spacing: Style.spacing.labelGap
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.filtered
currentIndex: -1
keyNavigationEnabled: false
function toggleCurrent() {
if (currentIndex < 0 || currentIndex >= root.filtered.length) return
root.toggleValue(root.filtered[currentIndex].value)
}
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
popup.close(); event.accepted = true
} else if (event.key === Qt.Key_Down || event.text === "j") {
if (resultList.currentIndex >= resultList.count - 1) {
event.accepted = true; return
}
resultList.currentIndex = resultList.currentIndex + 1
event.accepted = true
} else if (event.key === Qt.Key_Up || event.text === "k") {
if (resultList.currentIndex <= 0) {
searchField.forceActiveFocus()
event.accepted = true; return
}
resultList.currentIndex = resultList.currentIndex - 1
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space) {
resultList.toggleCurrent(); event.accepted = true
}
}
delegate: Rectangle {
required property var modelData
required property int index
readonly property bool selected: root.isSelected(modelData.value)
width: resultList.width
height: Math.max(root.popupRowHeight, rowContent.implicitHeight + Style.spacing.rowPaddingX)
color: index === resultList.currentIndex
? Style.hoverFillFor(root.foreground, root.accent)
: "transparent"
Row {
id: rowContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.spacing.controlPaddingX
anchors.rightMargin: Style.spacing.controlPaddingX
spacing: Style.spacing.rowGap
BorderSurface {
id: checkbox
width: Style.space(16)
height: Style.space(16)
radius: Math.max(2, Style.cornerRadius / 2)
anchors.verticalCenter: parent.verticalCenter
color: selected ? Style.selectedFillFor(root.foreground, root.accent) : "transparent"
borderSpec: selected
? Border.controlSpec("selected", root.foreground, root.accent)
: Border.controlSpec("normal", root.foreground, root.accent)
Text {
anchors.centerIn: parent
visible: selected
text: "✓"
color: Style.selectedStateColor(root.foreground, root.accent)
font.family: root.fontFamily
font.pixelSize: Math.round(checkbox.height * 0.85)
font.bold: true
}
}
Column {
width: parent.width - checkbox.width - parent.spacing
anchors.verticalCenter: parent.verticalCenter
spacing: Style.spacing.xxs
Text {
textFormat: Text.PlainText
text: modelData.label
color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
width: parent.width
}
Text {
textFormat: Text.PlainText
visible: text !== ""
text: modelData.description
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
width: parent.width
}
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: resultList.currentIndex = parent.index
onClicked: root.toggleValue(modelData.value)
}
}
}
}
}
}
}
}
}
+85
View File
@@ -0,0 +1,85 @@
import QtQuick
import QtQuick.Controls as QQC
import qs.Commons
Column {
id: root
property string label: ""
property int value: 0
property int from: 0
property int to: 100
property int stepSize: 1
property color foreground: Color.foreground
property color accent: Color.accent
property string fontFamily: Style.font.family
property real fontSize: Style.font.body
property real fieldWidth: Style.spacing.numberFieldWidth
property bool hasCursor: false
property bool _hovered: false
property alias field: spin
signal modified(int value)
signal hovered(bool on)
spacing: Style.spacing.md
Text {
textFormat: Text.PlainText
visible: root.label !== ""
text: root.label
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
QQC.SpinBox {
id: spin
width: root.fieldWidth
implicitHeight: Math.max(Style.spacing.controlHeight, root.fontSize + Style.spacing.controlPaddingY * 2)
from: root.from
to: root.to
stepSize: root.stepSize
value: root.value
editable: true
font.family: root.fontFamily
font.pixelSize: root.fontSize
readonly property bool _focused: spin.activeFocus
readonly property bool _hot: root._hovered || root.hasCursor
readonly property var _borderSpec: Border.controlSpec(_focused ? "focus" : (_hot ? "hover-cursor" : "normal"), root.foreground, root.accent)
leftPadding: Border.left(_borderSpec) + Style.spacing.controlPaddingX
rightPadding: Border.right(_borderSpec) + Style.spacing.controlPaddingX
topPadding: Border.top(_borderSpec)
bottomPadding: Border.bottom(_borderSpec)
onValueModified: root.modified(value)
background: BorderSurface {
color: Style.controlFill(spin._focused, spin._hot, root.foreground, root.accent)
borderSpec: spin._borderSpec
radius: Style.cornerRadius
HoverHandler {
onHoveredChanged: {
root._hovered = hovered
root.hovered(hovered)
}
}
}
contentItem: TextInput {
text: spin.displayText
font: spin.font
color: root.foreground
selectionColor: Style.selectionFillFor(root.foreground, root.accent)
selectedTextColor: root.foreground
horizontalAlignment: Qt.AlignHCenter
verticalAlignment: Qt.AlignVCenter
readOnly: !spin.editable
validator: spin.validator
inputMethodHints: Qt.ImhFormattedNumbersOnly
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import QtQuick
import qs.Commons
Item {
id: root
property string text: ""
property string fontFamily: Style.font.family
property real fontSize: Style.font.body
property color color: Color.foreground
property bool debugBounds: false
readonly property int renderedFontSize: Math.max(1, Math.round(fontSize))
readonly property real tightWidth: Math.max(1, glyphMetrics.tightBoundingRect.width)
readonly property real horizontalCorrection: glyph.implicitWidth / 2 - (glyphMetrics.tightBoundingRect.x + tightWidth / 2)
readonly property real paintedCenterX: glyph.x + glyphMetrics.tightBoundingRect.x + tightWidth / 2
readonly property real baselineY: glyph.y + glyph.baselineOffset
TextMetrics {
id: glyphMetrics
font.family: root.fontFamily
font.pixelSize: root.renderedFontSize
text: root.text
}
Text {
id: glyph
textFormat: Text.PlainText
// Keep the shared line box and baseline intact. Correcting only the
// horizontal painted bounds avoids per-glyph vertical drift.
anchors.centerIn: parent
anchors.horizontalCenterOffset: root.horizontalCorrection
text: root.text
color: root.color
font.family: root.fontFamily
font.pixelSize: root.renderedFontSize
renderType: Text.NativeRendering
}
Rectangle {
visible: root.debugBounds
anchors.fill: parent
color: "transparent"
border.width: 1
border.color: "#4488ff"
}
Rectangle {
visible: root.debugBounds
x: 0
y: Math.round(root.baselineY)
width: parent.width
height: 1
color: "#44ff88"
}
}
+59
View File
@@ -0,0 +1,59 @@
import QtQuick
import Quickshell.Io
import qs.Commons
// Base item for plugin popup widgets. Many first-party plugins expose a bar
// button plus a popup from one QML entry point; this base owns the shared
// IPC-backed open/close lifecycle while implementations own button behavior,
// keyboard navigation, and content.
Item {
id: root
property QtObject bar: null
property string moduleName: ""
property var settings: ({})
property string ipcTarget: ""
property bool manageIpc: true
property alias controller: panelController
property bool popoutSwitching: false
property bool popoutSwitchClosing: false
readonly property bool opened: panelController.open
readonly property color barForeground: bar ? bar.barForeground : Color.foreground
function open() { panelController.show() }
function close() { panelController.hide() }
function closeForPopoutSwitch() {
popoutSwitchClosing = true
close()
Qt.callLater(function() { popoutSwitchClosing = false })
}
function toggle() { opened ? close() : open() }
function switchPanel(direction) {
if (bar && typeof bar.switchPanelFrom === "function") return bar.switchPanelFrom(root, direction)
return false
}
// Read a single value from this panel's inline shell.json entry, with a
// fallback for missing/null values. Matches BarWidget.setting().
function setting(name, fallback) {
var value = settings ? settings[name] : undefined
return value === undefined || value === null ? fallback : value
}
PanelController {
id: panelController
}
IpcHandler {
enabled: root.manageIpc && root.ipcTarget !== ""
target: root.ipcTarget
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
}
}
+100
View File
@@ -0,0 +1,100 @@
import QtQuick
import qs.Commons
// Small (22×22 by default) icon button used at the right edge of panel rows
// for inline actions — forget network, confirm passphrase, unpair device,
// etc. Two visual modes are supported via `hoverColor`:
// - default: hoverColor === foreground → subtle foreground-tint hover
// - urgent: hoverColor === bar.urgent → red-tint hover for destructive
// actions like forget/unpair
//
// `enabled` gates clicks and dims the icon. The component owns its own
// hover state visuals; mouse hover does NOT update any panel cursor state
// here because action buttons are not cursor targets — the row they live
// in is.
//
// Set `focusable: true` to make the button keyboard-tabbable with the
// shared hover-cursor/focus tokens. Use this
// in form contexts where Tab walks a list
// of controls; leave it false for the right-edge actions on panel rows
// where the row's CursorSurface owns the keyboard cursor.
//
// Set `hasCursor: true` to have the button render the same hover state as
// mouse hover — so a panel's keyboard cursor lands on it identically.
// Use this when a PanelActionButton is itself the cursor target (rather
// than living inside a CursorSurface row). Emits `hovered(bool)` on
// pointer enter/leave so the panel can update its cursor state to match.
BorderSurface {
id: root
property string iconText: ""
property string tooltipText: ""
property color foreground: Color.foreground
property color hoverColor: foreground
property string fontFamily: Style.font.family
property real fontSize: Style.font.icon
property real size: Math.max(Style.space(22), fontSize + Style.spacing.sm * 2)
property bool focusable: false
property bool hasCursor: false
property bool bordered: false
signal clicked()
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: size
implicitHeight: size
radius: Style.cornerRadius
readonly property bool _showFocusRing: focusable && activeFocus
readonly property bool _hot: (mouse.containsMouse || root.hasCursor) && root.enabled
readonly property var _borderSpec: _showFocusRing
? Border.controlSpec("focus", hoverColor, hoverColor)
: (_hot && bordered
? Border.controlSpec("hover-cursor", hoverColor, hoverColor)
: (bordered ? Border.controlSpec("normal", foreground, Color.accent) : Border.none()))
color: _showFocusRing
? Style.focusFillFor(hoverColor, hoverColor)
: (_hot
? Style.hoverFillFor(hoverColor, hoverColor)
: "transparent")
borderSpec: _borderSpec
Behavior on color { ColorAnimation { duration: 60 } }
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: root.iconText
color: root.enabled
? (root._hot ? root.hoverColor : root.foreground)
: Qt.darker(root.foreground, 2.0)
font.family: root.fontFamily
font.pixelSize: root.fontSize
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: root.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
enabled: root.enabled
onContainsMouseChanged: root.hovered(containsMouse)
onClicked: {
if (root.focusable) root.forceActiveFocus()
root.clicked()
}
}
PanelToolTip {
visible: root.tooltipText !== "" && mouse.containsMouse
text: root.tooltipText
fontFamily: root.fontFamily
}
}
+16
View File
@@ -0,0 +1,16 @@
import QtQuick
// Stores the open state for a shell panel. Panel owns the public lifecycle
// methods and IPC wiring; this object only keeps state separate from the
// panel implementation's own properties.
//
// Usage:
// PanelController { id: panelController }
QtObject {
id: root
property bool open: false
function toggle() { open = !open }
function show() { if (!open) open = true }
function hide() { open = false }
}
+111
View File
@@ -0,0 +1,111 @@
import QtQuick
import qs.Commons
Item {
id: root
property Component iconComponent: null
property string title: ""
property string meta: ""
property string detail: ""
property color foreground: Color.foreground
property string fontFamily: Style.font.family
property real iconSize: Style.font.display
property real iconOpacity: 1.0
property alias metaOpacity: metaText.opacity
// Optional control pinned to the trailing edge of the hero — a ToggleSwitch,
// a small button. The hero centers it against the labels and reserves the
// space itself, so callers never do the geometry.
property Component trailingControl: null
readonly property color dim: Qt.darker(foreground, 1.4)
readonly property real trailingInset: trailingLoader.item && trailingLoader.item.visible ? trailingLoader.width + Style.space(12) : 0
width: parent ? parent.width : implicitWidth
implicitHeight: Math.max(iconLoader.implicitHeight, heroLabels.implicitHeight, trailingLoader.implicitHeight)
Loader {
id: iconLoader
sourceComponent: root.iconComponent
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
opacity: root.iconOpacity
}
Column {
id: heroLabels
anchors.left: iconLoader.right
anchors.leftMargin: Style.space(14)
anchors.right: parent.right
anchors.rightMargin: root.trailingInset
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Row {
id: titleRow
visible: root.title !== "" || detailPill.visible
width: parent.width
Text {
textFormat: Text.PlainText
visible: root.title !== ""
text: root.title
width: Math.min(implicitWidth, Math.max(0, parent.width - (detailPill.visible ? detailPill.implicitWidth + Style.space(8) : 0)))
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
}
Item {
width: Math.max(0, parent.width - parent.children[0].width - detailPill.implicitWidth)
height: 1
}
BorderSurface {
id: detailPill
visible: root.detail !== ""
implicitWidth: detailText.implicitWidth + Style.space(10)
implicitHeight: detailText.implicitHeight + Style.space(4)
anchors.verticalCenter: parent.verticalCenter
color: "transparent"
borderSpec: Border.controlSpec("normal", root.foreground, Color.accent)
radius: Style.cornerRadius
Text {
id: detailText
textFormat: Text.PlainText
anchors.centerIn: parent
text: root.detail
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
}
}
}
Text {
id: metaText
textFormat: Text.PlainText
width: parent.width
text: root.meta.toUpperCase()
visible: text !== ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.2
elide: Text.ElideRight
}
}
Loader {
id: trailingLoader
sourceComponent: root.trailingControl
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
}
}
+85
View File
@@ -0,0 +1,85 @@
import QtQuick
// Drop-in key dispatcher for keyboard-driven panels. Wraps panel content
// and emits semantic signals so each panel keeps its own state machine
// (focusSection, selectedIndex, action rules) while the boilerplate
// key handling lives here.
//
// Usage:
// Common.KeyboardPanel {
// ...
// PanelKeyCatcher {
// anchors.fill: parent
// onMoveRequested: function(dx, dy) { root.moveCursor(dx, dy) }
// onActivateRequested: root.activateCursor()
// onCloseRequested: root.close()
// onDeleteRequested: root.deleteSelected()
// onTextKey: function(t) { if (t === "r") root.refresh() }
//
// Column { ... panel content ... }
// }
// }
//
// Keys.priority: Keys.BeforeItem means this handler gets keys first,
// even when a descendant has activeFocus. That's what lets Up/Down
// arrows drive the cursor instead of being consumed by an inner
// Flickable's built-in scroll handling. When a panel has an inline
// editor (wifi passphrase, gallery TextField demo) the panel must
// set `blocked: editor.activeFocus` so this handler short-circuits
// and the editor receives keys normally.
//
// blocked: when true, ALL keys are forwarded to descendants without
// triggering signals.
Item {
id: root
property bool blocked: false
signal moveRequested(int dx, int dy)
signal activateRequested()
signal returnRequested()
signal closeRequested()
signal deleteRequested()
signal tabRequested(int direction)
signal textKey(string text)
focus: true
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (blocked) return
if (event.key === Qt.Key_Escape) {
closeRequested(); event.accepted = true; return
}
if (event.key === Qt.Key_Tab || event.key === Qt.Key_Backtab) {
tabRequested((event.modifiers & Qt.ShiftModifier) || event.key === Qt.Key_Backtab ? -1 : 1)
event.accepted = true
return
}
if (event.key === Qt.Key_Down || event.text === "j") {
moveRequested(0, 1); event.accepted = true; return
}
if (event.key === Qt.Key_Up || event.text === "k") {
moveRequested(0, -1); event.accepted = true; return
}
if (event.key === Qt.Key_Right || event.text === "l") {
moveRequested(1, 0); event.accepted = true; return
}
if (event.key === Qt.Key_Left || event.text === "h") {
moveRequested(-1, 0); event.accepted = true; return
}
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
returnRequested()
activateRequested(); event.accepted = true; return
}
if (event.key === Qt.Key_Space) {
activateRequested(); event.accepted = true; return
}
if (event.text === "x" || event.text === "X") {
deleteRequested(); event.accepted = true; return
}
if (event.text && event.text.length === 1) {
textKey(event.text)
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import QtQuick
import qs.Commons
// Small-caps-style label that introduces a panel section ("DNS provider",
// "Wi-Fi networks", "Output device", "Paired devices"). Sits between a
// PanelSeparator and the content rows.
Text {
id: root
property color foreground: Color.foreground
property string fontFamily: Style.font.family
property real fontSize: Style.font.caption
// Callers bind `text` from outside this file, so the default has to be set
// here. AutoText would let a section title that happens to carry a device or
// network name promote itself to rich text.
textFormat: Text.PlainText
color: Qt.darker(foreground, 1.4)
font.family: fontFamily
font.pixelSize: fontSize
font.bold: true
// Glyphs can paint above the box Text reserves for them: JetBrainsMono Nerd
// Font's outlines run 10% of the em past its own ascent, and a patched or
// user-chosen family can be worse. That sliver is invisible in normal flow,
// but a header sitting at the top of a clipping list — bluetooth's device
// list, network's station list — loses it to the clip and renders beheaded.
// Reserve the overshoot here so every panel is covered at once.
topPadding: Math.ceil(fontSize * 0.15)
}
+18
View File
@@ -0,0 +1,18 @@
import QtQuick
import qs.Commons
// 1px horizontal divider for panel sections. The alpha-on-foreground tint
// keeps the rule legible against the panel background without competing
// with text or borders.
Rectangle {
id: root
property color foreground: Color.foreground
property real strength: 0.12
width: parent ? parent.width : implicitWidth
implicitWidth: 100
implicitHeight: 1
height: 1
color: Qt.rgba(foreground.r, foreground.g, foreground.b, strength)
}
+149
View File
@@ -0,0 +1,149 @@
import QtQuick
import qs.Commons
Item {
id: root
property QtObject bar: null
property real value: 0
property real minimum: 0
property real maximum: 1
property real step: 0.05
property bool integer: false
property color trackColor: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "#333"
property color fillColor: bar ? bar.foreground : Color.foreground
property color knobColor: bar ? bar.foreground : Color.foreground
property bool dragging: false
property real trackHeight: Math.max(4, Math.round(Style.spacing.controlHeight * 0.11))
property real knobSize: Math.max(14, Math.round(Style.spacing.controlHeight * 0.38))
property real liveValue: value
// macOS-style notches. When > 1, that many evenly-spaced tick marks are cut
// into the track (drawn in the panel background color, so only the part
// crossing the track shows). Purely visual — snapping is the caller's job via
// `integer`/`step` or an index-based value. Default 0 leaves the track plain.
property int tickCount: 0
property color tickColor: bar ? bar.background : Color.background
onValueChanged: if (!dragging) liveValue = value
signal moved(real value)
signal released(real value)
// Right-click is a secondary action on the whole track — audio uses it to
// mute the channel the slider belongs to. Dragging stays left-button only.
signal rightClicked()
implicitWidth: Style.space(200)
implicitHeight: Math.max(Style.space(22), knobSize + Style.spacing.md)
readonly property real range: Math.max(0.0001, maximum - minimum)
readonly property real progress: Math.max(0, Math.min(1, (liveValue - minimum) / range))
readonly property bool _hot: mouseArea.containsMouse || root.dragging
Rectangle {
id: track
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.right: parent.right
height: root.trackHeight
radius: height / 2
color: root.trackColor
}
Rectangle {
id: fill
anchors.verticalCenter: track.verticalCenter
anchors.left: track.left
height: track.height
radius: track.radius
color: root.fillColor
width: track.width * root.progress
Behavior on width {
enabled: !root.dragging
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
}
Repeater {
model: root.tickCount > 1 ? root.tickCount : 0
Rectangle {
required property int index
width: Math.max(1, Style.space(2))
height: root.trackHeight + Style.space(4)
radius: 1
color: root.tickColor
anchors.verticalCenter: track.verticalCenter
x: Math.max(0, Math.min(track.width - width,
track.width * (index / (root.tickCount - 1)) - width / 2))
}
}
BorderSurface {
id: knob
width: root.knobSize
height: root.knobSize
radius: root.knobSize / 2
color: root.knobColor
borderSpec: Border.flat(root.bar ? root.bar.background : "#101315", Math.max(1, Style.space(2)))
anchors.verticalCenter: track.verticalCenter
x: Math.max(0, Math.min(track.width - width, track.width * root.progress - width / 2))
scale: root._hot ? 1.15 : 1.0
Behavior on x {
enabled: !root.dragging
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
Behavior on scale {
NumberAnimation { duration: 110; easing.type: Easing.OutCubic }
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
function valueFromX(x) {
var clamped = Math.max(0, Math.min(track.width, x))
var raw = root.minimum + (clamped / track.width) * root.range
if (root.integer) raw = Math.round(raw)
return Math.max(root.minimum, Math.min(root.maximum, raw))
}
onPressed: function(mouse) {
if (mouse.button !== Qt.LeftButton) return
root.dragging = true
var next = valueFromX(mouse.x)
root.liveValue = next
root.moved(next)
}
onClicked: function(mouse) {
if (mouse.button === Qt.RightButton) root.rightClicked()
}
onPositionChanged: function(mouse) {
if (!root.dragging) return
var next = valueFromX(mouse.x)
root.liveValue = next
root.moved(next)
}
onReleased: function(mouse) {
if (mouse.button !== Qt.LeftButton) return
root.dragging = false
root.released(root.liveValue)
root.liveValue = root.value
}
onWheel: function(wheel) {
var delta = wheel.angleDelta.y > 0 ? root.step : -root.step
var next = Math.max(root.minimum, Math.min(root.maximum, root.liveValue + delta))
if (root.integer) next = Math.round(next)
root.liveValue = next
root.moved(next)
root.released(next)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import QtQuick
import QtQuick.Controls
import qs.Commons
// Styled wrapper around Qt Quick Controls ToolTip. Drop-in: declare inside
// the hovered item and bind `visible` to the hover state, e.g.
// PanelToolTip {
// visible: mouse.containsMouse
// text: "Forget network"
// }
//
// Defaults pull from [tooltip] in shell.toml via Color.tooltip.*. Override
// the panel* properties per-instance only when you need a tooltip that
// intentionally diverges from the theme.
//
// Property names are prefixed `panel*` to avoid clashing with ToolTip's
// built-in `background`/`font` properties.
ToolTip {
id: root
property color panelForeground: Color.tooltip.text
property color panelBackground: Color.tooltip.background
property color panelBorder: Color.tooltip.border
property string fontFamily: Style.font.family
property real fontSize: Style.font.bodySmall
readonly property var panelBorderSpec: Border.localOrSurfaceSpec("tooltip", "border", panelBorder, Color.tooltip.border, Style.normalBorderWidth)
delay: 400
padding: 0
background: BorderSurface {
color: root.panelBackground
borderSpec: root.panelBorderSpec
radius: Style.cornerRadius
}
contentItem: Text {
textFormat: Text.PlainText
text: root.text
color: root.panelForeground
font.family: root.fontFamily
font.pixelSize: root.fontSize
leftPadding: Border.left(root.panelBorderSpec) + Style.spacing.controlPaddingX
rightPadding: Border.right(root.panelBorderSpec) + Style.spacing.controlPaddingX
topPadding: Border.top(root.panelBorderSpec) + Style.spacing.controlPaddingY
bottomPadding: Border.bottom(root.panelBorderSpec) + Style.spacing.controlPaddingY
}
}
+87
View File
@@ -0,0 +1,87 @@
import QtQuick
// Bar surface exposed to an installed third-party widget. Scalar presentation
// state is mirrored by Bar.qml and operations are delegated through scoped
// callbacks. The facade avoids direct host-Bar injection; it cannot isolate a
// visual child from the parent hierarchy of the QML scene that renders it.
QtObject {
id: api
required property string pluginId
required property string moduleName
property var shell: null
property color foreground: "transparent"
property color barForeground: "transparent"
property color background: "transparent"
property color urgent: "transparent"
property string fontFamily: ""
property string position: "top"
property bool vertical: false
property int barSize: 0
property bool transparent: false
property bool foregroundAnimationEnabled: true
property bool centerSectionRevealHeld: false
property bool _centerHoverRevealSuppressed: false
readonly property bool centerHoverRevealSuppressed: _centerHoverRevealSuppressed
property var activePopout: null
property var clickTargets: []
property var layoutConfig: ({})
readonly property var foreignPopoutMarker: ({ foreign: true })
property var _showTooltip: null
property var _hideTooltip: null
property var _registerClickTarget: null
property var _unregisterClickTarget: null
property var _requestPopout: null
property var _releasePopout: null
property var _switchPanelFrom: null
property var _targetBelongsToWindow: null
property var _moduleWidgets: null
property var _run: null
property var _setCenterHoverRevealSuppressed: null
function setCenterHoverRevealSuppressed(value) {
if (_setCenterHoverRevealSuppressed) _setCenterHoverRevealSuppressed(!!value)
}
function showTooltip(target, text) {
if (_showTooltip) _showTooltip(target, String(text || ""))
}
function hideTooltip(target) {
if (_hideTooltip) _hideTooltip(target)
}
function registerClickTarget(target) {
if (_registerClickTarget) _registerClickTarget(target)
}
function unregisterClickTarget(target) {
if (_unregisterClickTarget) _unregisterClickTarget(target)
}
function requestPopout(owner) {
if (_requestPopout) _requestPopout(owner)
}
function releasePopout(owner) {
if (_releasePopout) _releasePopout(owner)
}
function switchPanelFrom(owner, direction) {
return _switchPanelFrom ? _switchPanelFrom(owner, direction) : false
}
function targetBelongsToWindow(target, window) {
return _targetBelongsToWindow ? _targetBelongsToWindow(target, window) : false
}
function moduleWidgets(id) {
return _moduleWidgets ? _moduleWidgets(String(id || "")) : []
}
function run(command) {
if (_run) _run(String(command || ""))
}
}
+54
View File
@@ -0,0 +1,54 @@
import QtQuick
// Filters synthetic hover churn from moving delegates under a stationary
// pointer. Call reset() after keyboard/list mutations, then moved() from a
// row MouseArea's onPositionChanged before changing cursor selection. A
// transition known to originate from the pointer can call allowInitialSample()
// so the item under the stationary pointer remains selected.
QtObject {
id: root
property Item referenceItem: null
property real threshold: 1
property bool primed: false
property bool initialSampleAllowed: false
property real lastX: 0
property real lastY: 0
function reset() {
root.primed = false
root.initialSampleAllowed = false
root.lastX = 0
root.lastY = 0
}
function allowInitialSample() {
root.reset()
root.initialSampleAllowed = true
}
function moved(item, mouse) {
if (!item || !mouse) {
root.reset()
return false
}
var target = root.referenceItem || item
var point = item.mapToItem(target, mouse.x, mouse.y)
var firstSample = !root.primed
var didMove = !firstSample
? Math.abs(point.x - root.lastX) > root.threshold || Math.abs(point.y - root.lastY) > root.threshold
: root.initialSampleAllowed
// Keep the previous accepted position while filtering jitter so slow,
// sub-threshold steps accumulate into deliberate pointer movement.
if (firstSample || didMove) {
root.lastX = point.x
root.lastY = point.y
}
root.primed = true
root.initialSampleAllowed = false
return didMove
}
}
+176
View File
@@ -0,0 +1,176 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import qs.Commons
PopupWindow {
id: root
required property Item anchorItem
required property QtObject bar
property var owner: null
property int margin: Style.gapsOut
property int padding: Style.spacing.popupPadding
property int contentWidth: Style.space(280)
property int contentHeight: Style.space(200)
property color borderColor: Color.popups.border
property var borderSpec: Border.localOrSurfaceSpec("popups", "border", borderColor, Color.popups.border, Math.max(1, Style.space(2)))
property bool open: false
property bool centerOnBar: false
// "click" — uses HyprlandFocusGrab so clicking outside dismisses the popup.
// "hover" — passive overlay; the owning widget controls open via hover.
property string triggerMode: "click"
readonly property var coordinatorKey: owner || root
readonly property var anchorWindow: anchorItem ? anchorItem.QsWindow.window : null
readonly property var popupScreen: anchorWindow ? anchorWindow.screen : null
readonly property bool containsMouse: cardHover.hovered
readonly property real screenW: popupScreen ? popupScreen.width : 0
readonly property real screenH: popupScreen ? popupScreen.height : 0
readonly property real barW: anchorWindow ? anchorWindow.width : 0
readonly property real barH: anchorWindow ? anchorWindow.height : 0
readonly property real availableCardWidth: screenW > 0
? Math.max(120, screenW - ((bar && (bar.position === "left" || bar.position === "right")) ? barW : 0) - root.margin * 2)
: 0
readonly property real availableCardHeight: screenH > 0
? Math.max(120, screenH - ((bar && (bar.position === "top" || bar.position === "bottom")) ? barH : 0) - root.margin * 2)
: 0
readonly property real verticalContentInset: padding * 2 + Border.top(borderSpec) + Border.bottom(borderSpec)
function fittedContentWidth(width, cap) {
var desired = Math.max(1, Number(width) || 1)
var maxWidth = root.availableCardWidth > 0 ? root.availableCardWidth : desired
if (cap !== undefined && Number(cap) > 0) maxWidth = Math.min(maxWidth, Number(cap))
return Math.round(Math.min(desired, maxWidth))
}
function fittedContentHeight(implicitHeight, cap) {
var desired = Math.max(root.verticalContentInset, (Number(implicitHeight) || 0) + root.verticalContentInset)
var maxHeight = root.availableCardHeight > 0 ? root.availableCardHeight : desired
if (cap !== undefined && Number(cap) > 0) maxHeight = Math.min(maxHeight, Number(cap))
return Math.round(Math.min(desired, maxHeight))
}
function cappedContentHeight(height) {
var desired = Math.max(root.padding * 2, Number(height) || root.padding * 2)
var maxHeight = root.availableCardHeight > 0 ? root.availableCardHeight : desired
return Math.round(Math.min(desired, maxHeight))
}
function close() {
if (owner && "close" in owner) owner.close()
else root.open = false
}
default property alias contentItem: contentHolder.children
visible: open || card.opacity > 0
color: "transparent"
implicitWidth: contentWidth
implicitHeight: contentHeight
onOpenChanged: {
if (!bar) return
if (open) bar.requestPopout(coordinatorKey)
else if (bar.activePopout === coordinatorKey) bar.releasePopout(coordinatorKey)
}
// Outside-click dismissal via Hyprland's focus grab. While `active`, input
// is routed only to the listed windows; clicking anywhere else clears the
// grab and we close the popup. Skipped for hover-mode popups so the cursor
// can move freely between the trigger and the popup.
HyprlandFocusGrab {
active: root.open && root.triggerMode === "click"
windows: root.anchorWindow ? [root, root.anchorWindow] : [root]
onCleared: root.close()
}
anchor {
id: popupAnchor
window: anchorItem ? anchorItem.QsWindow.window : null
adjustment: PopupAdjustment.Slide
edges: Edges.Top | Edges.Left
gravity: Edges.Bottom | Edges.Right
rect.width: 1
rect.height: 1
onAnchoring: {
if (!root.anchorItem || !root.bar) return
var target = root.anchorItem
var popupWidth = root.implicitWidth
var popupHeight = root.implicitHeight
var localX = target.width / 2 - popupWidth / 2
var localY = target.height + root.margin
if (root.bar.position === "bottom") {
localY = -popupHeight - root.margin
} else if (root.bar.position === "left") {
localX = target.width + root.margin
localY = target.height / 2 - popupHeight / 2
} else if (root.bar.position === "right") {
localX = -popupWidth - root.margin
localY = target.height / 2 - popupHeight / 2
}
var window = target.QsWindow.window
if (!window) return
if (root.centerOnBar) {
var cx = 0;
var cy = 0;
if (root.bar.position === "top" || root.bar.position === "bottom") {
cx = window.width / 2 - popupWidth / 2
cy = root.bar.position === "bottom" ? -popupHeight - root.margin : window.height + root.margin
cx = Math.max(root.margin, Math.min(cx, window.width - popupWidth - root.margin))
} else {
cx = root.bar.position === "left" ? window.width + root.margin : -popupWidth - root.margin
cy = window.height / 2 - popupHeight / 2
cy = Math.max(root.margin, Math.min(cy, window.height - popupHeight - root.margin))
}
popupAnchor.rect.x = Math.round(cx)
popupAnchor.rect.y = Math.round(cy)
return
}
var point = window.contentItem.mapFromItem(target, localX, localY)
if (root.bar.position === "top" || root.bar.position === "bottom") {
point.x = Math.max(root.margin, Math.min(point.x, window.width - popupWidth - root.margin))
} else {
point.y = Math.max(root.margin, Math.min(point.y, window.height - popupHeight - root.margin))
}
popupAnchor.rect.x = Math.round(point.x)
popupAnchor.rect.y = Math.round(point.y)
}
}
BorderSurface {
id: card
anchors.fill: parent
color: Color.popups.background
borderSpec: root.borderSpec
padding: root.padding
radius: Style.cornerRadius
opacity: root.open ? 1.0 : 0
Behavior on opacity {
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
Item {
id: contentHolder
anchors.fill: parent
anchors.topMargin: card.contentTopInset
anchors.rightMargin: card.contentRightInset
anchors.bottomMargin: card.contentBottomInset
anchors.leftMargin: card.contentLeftInset
}
HoverHandler {
id: cardHover
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import QtQuick
// Hyprland leaves an already-mapped layer surface at its old global position
// when its monitor moves within the layout: undocking disables the internal
// panel, the external monitor shifts to x=0, and long-lived surfaces such as
// the bar and background keep rendering at the old offset — or entirely
// off-screen — until they are unmapped and remapped. Watch the screen's
// origin and pulse `remapping` when it moves; the owning window folds that
// into its `visible` binding so the compositor re-places the surface at the
// monitor's new origin.
Item {
id: root
required property var window
readonly property var screen: window ? window.screen : null
// Fold into the window's binding: visible: <shown> && !guard.remapping
property bool remapping: false
visible: false
// A layout reshuffle can move the monitor more than once before it lands.
// Let the positions settle before the single remap pulse.
Timer {
id: settleTimer
interval: 200
onTriggered: root.remapping = true
}
// Hold the surface unmapped for a beat so the compositor processes the
// unmap before the remap instead of coalescing them into a no-op.
Timer {
interval: 50
running: root.remapping
onTriggered: root.remapping = false
}
Connections {
target: root.screen
function onXChanged() { settleTimer.restart() }
function onYChanged() { settleTimer.restart() }
}
}
+353
View File
@@ -0,0 +1,353 @@
import QtQuick
import QtQuick.Controls as QQC
import qs.Commons
// Searchable single-select dropdown. Same trigger shape as Dropdown, but
// the popup leads with an embedded TextField that filters the option
// list in real time. Use for pickers with enough options that scanning
// is friction.
//
// Filtering is case-insensitive substring against each option's label.
// Options can be string[] or [{ value, label, description? }] — the same
// shape Dropdown accepts. The filter clears whenever the popup closes.
//
// Keyboard: Tab to focus the trigger, Enter/Space opens (search focused
// immediately). Down arrow from the search jumps to the first match;
// Up from the first match returns to the search. Enter selects, Esc
// closes (and clears the filter).
Item {
id: root
property string label: ""
property string value: ""
property var options: []
property string placeholderText: "Search..."
property string emptyText: "No matches"
property string triggerLabel: ""
property color foreground: Color.popups.text
property color background: Color.popups.background
property color popupBorder: Color.popups.border
property color accent: Color.accent
readonly property var popupBorderSpec: Border.localOrSurfaceSpec("popups", "border", popupBorder, Color.popups.border, Style.normalBorderWidth)
property string fontFamily: Style.font.family
property int rowHeight: Style.spacing.controlHeight
property int popupRowHeight: Style.spacing.popupRowHeight
property int popupMinHeight: Style.spacing.searchablePopupMinHeight
property bool showLabel: true
// Panel-cursor flag. When true, the trigger renders the shared
// hover-cursor state. Active Qt focus defaults to the same visuals.
// Emits `hovered(bool)` on pointer enter/leave so the panel can keep
// its cursor state in sync with the mouse.
property bool hasCursor: false
// popupOpen + open()/close()/toggle() let a parent panel know when the
// dropdown owns keys (search field + result list are active) and
// suspend its own keyCatcher so typing into the filter doesn't drive
// the panel cursor.
readonly property bool popupOpen: popup.opened
function open() { popup.open() }
function close() { popup.close() }
function toggle() { popup.opened ? popup.close() : popup.open() }
signal changed(string value)
signal hovered(bool isHovered)
function optionValue(o) {
return (o && typeof o === "object") ? String(o.value) : String(o)
}
function optionLabel(o) {
return (o && typeof o === "object") ? String(o.label) : String(o)
}
function optionDescription(o) {
return (o && typeof o === "object" && o.description) ? String(o.description) : ""
}
function currentLabel() {
for (var i = 0; i < options.length; i++) {
if (optionValue(options[i]) === value) return optionLabel(options[i])
}
return value
}
property var filtered: options
function recomputeFiltered() {
var q = searchField.text.toLowerCase()
if (!q) { filtered = options; return }
var out = []
for (var i = 0; i < options.length; i++) {
var lbl = optionLabel(options[i]).toLowerCase()
var desc = optionDescription(options[i]).toLowerCase()
if (lbl.indexOf(q) !== -1 || desc.indexOf(q) !== -1) out.push(options[i])
}
filtered = out
}
onOptionsChanged: recomputeFiltered()
implicitWidth: Style.spacing.searchableDropdownWidth
implicitHeight: showLabel && label !== "" ? rowHeight + Style.spacing.huge : rowHeight
Column {
anchors.fill: parent
spacing: Style.spacing.labelGap
Text {
textFormat: Text.PlainText
visible: root.showLabel && root.label !== ""
text: root.label
color: Qt.darker(root.foreground, 1.4)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
BorderSurface {
id: trigger
width: parent.width
height: root.rowHeight
radius: Style.cornerRadius
readonly property bool _focused: trigger.activeFocus
readonly property bool _hot: triggerHover.hovered || root.hasCursor
readonly property var _borderSpec: Border.controlSpec(trigger._focused ? "focus" : (trigger._hot ? "hover-cursor" : "normal"), root.foreground, root.accent)
color: Style.controlFill(trigger._focused, trigger._hot, root.foreground, root.accent)
borderSpec: _borderSpec
activeFocusOnTab: true
HoverHandler {
id: triggerHover
onHoveredChanged: root.hovered(hovered)
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter
|| event.key === Qt.Key_Space || event.key === Qt.Key_Down) {
popup.opened ? popup.close() : popup.open()
event.accepted = true
} else if (event.key === Qt.Key_Escape && popup.opened) {
popup.close(); event.accepted = true
}
}
Text {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.right: chevron.left
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: trigger.borderLeft + Style.spacing.controlPaddingX
anchors.rightMargin: trigger.borderRight + Style.spacing.md
text: root.currentLabel() || root.triggerLabel || root.placeholderText
color: (root.currentLabel() || root.triggerLabel) ? root.foreground : Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
}
Text {
id: chevron
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.rightMargin: trigger.borderRight + Style.spacing.controlGap
text: "󰅀"
color: Qt.darker(root.foreground, 1.2)
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: {
trigger.forceActiveFocus()
popup.opened ? popup.close() : popup.open()
}
}
QQC.Popup {
id: popup
x: 0
y: trigger.height + Style.spacing.xxs
width: trigger.width
implicitHeight: Math.max(root.popupMinHeight,
Math.min(resultList.contentHeight + Style.space(50),
root.popupRowHeight * 6 + 5 * Style.spacing.labelGap + Style.space(50)))
padding: Style.spacing.hairline
leftPadding: Border.left(root.popupBorderSpec) + Style.spacing.hairline
rightPadding: Border.right(root.popupBorderSpec) + Style.spacing.hairline
topPadding: Border.top(root.popupBorderSpec) + Style.spacing.hairline
bottomPadding: Border.bottom(root.popupBorderSpec) + Style.spacing.hairline
focus: true
background: BorderSurface {
color: root.background
borderSpec: root.popupBorderSpec
radius: Style.cornerRadius
}
onOpened: {
searchField.text = ""
root.recomputeFiltered()
Qt.callLater(function() { searchField.forceActiveFocus() })
}
onClosed: searchField.text = ""
contentItem: Column {
spacing: 0
Item {
id: searchHeader
width: parent.width
height: root.popupRowHeight + Style.spacing.controlPaddingX
TextField {
id: searchField
anchors.fill: parent
anchors.margins: Style.spacing.md
placeholderText: root.placeholderText
foreground: root.foreground
accent: root.accent
font.family: root.fontFamily
font.pixelSize: Style.font.body
onTextChanged: {
root.recomputeFiltered()
if (resultList.count > 0) resultList.currentIndex = 0
}
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
popup.close(); event.accepted = true
} else if (event.key === Qt.Key_Down) {
if (resultList.count > 0) {
resultList.currentIndex = 0
resultList.forceActiveFocus()
}
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
if (resultList.count > 0) {
resultList.currentIndex = 0
resultList.selectCurrent()
}
event.accepted = true
}
}
}
}
Rectangle {
width: parent.width
height: 1
color: Util.alpha(root.foreground, 0.10)
}
Item {
width: parent.width
height: popup.height - searchHeader.height - Style.spacing.xxs - 1
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
visible: resultList.count === 0
text: root.emptyText
color: Qt.darker(root.foreground, 1.6)
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
ListView {
id: resultList
anchors.fill: parent
spacing: Style.spacing.labelGap
clip: true
boundsBehavior: Flickable.StopAtBounds
model: root.filtered
currentIndex: -1
keyNavigationEnabled: false
function selectCurrent() {
if (currentIndex < 0 || currentIndex >= root.filtered.length) return
var v = root.optionValue(root.filtered[currentIndex])
root.value = v
root.changed(v)
popup.close()
}
Keys.priority: Keys.BeforeItem
Keys.onPressed: function(event) {
if (event.key === Qt.Key_Escape) {
popup.close(); event.accepted = true
} else if (event.key === Qt.Key_Down || event.text === "j") {
if (resultList.currentIndex >= resultList.count - 1) {
event.accepted = true; return
}
resultList.currentIndex = resultList.currentIndex + 1
event.accepted = true
} else if (event.key === Qt.Key_Up || event.text === "k") {
if (resultList.currentIndex <= 0) {
searchField.forceActiveFocus()
event.accepted = true; return
}
resultList.currentIndex = resultList.currentIndex - 1
event.accepted = true
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
resultList.selectCurrent(); event.accepted = true
}
}
delegate: Rectangle {
required property var modelData
required property int index
width: resultList.width
height: Math.max(root.popupRowHeight, rowContent.implicitHeight + Style.spacing.rowPaddingX)
color: index === resultList.currentIndex
? Style.hoverFillFor(root.foreground, root.accent)
: "transparent"
Column {
id: rowContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: Style.spacing.controlPaddingX
anchors.rightMargin: Style.spacing.controlPaddingX
spacing: Style.spacing.xxs
Text {
textFormat: Text.PlainText
text: root.optionLabel(modelData)
color: index === resultList.currentIndex ? Style.hoverStateColor(root.foreground, root.accent) : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
width: parent.width
}
Text {
textFormat: Text.PlainText
visible: text !== ""
text: root.optionDescription(modelData)
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
width: parent.width
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: resultList.currentIndex = parent.index
onClicked: resultList.selectCurrent()
}
}
}
}
}
}
}
}
}
+412
View File
@@ -0,0 +1,412 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Shapes
import Quickshell
import Quickshell.Wayland
import qs.Commons
import qs.Ui
// Centered speed test overlay shared by the network and disk speed tests. No
// card: like the Tucson's floating cluster, the two dials sit directly on a
// darkened scrim -- open 270° arcs, faint tick rings, hubless gradient
// needles, and a digital readout in the middle. Esc, the scrim, or the corner
// dismiss close it; the needles sweep to full scale and back on open, then
// track the live readings. Callers name the dials, the unit, and the scale.
PanelWindow {
id: root
required property string fontFamily
required property bool running
required property string leftLabel
required property string rightLabel
property string unit: "Mbps"
property string title: ""
property string layerNamespace: "blob-speed-test"
property string runAgainTooltip: "Measure again"
property real leftValue: 0
property real rightValue: 0
property bool leftLive: false
property bool rightLive: false
property string error: ""
property bool open: false
// Full-scale latch points for the dials, smallest first. The first stop is
// the base scale a fresh run starts from.
property var scaleStops: [100, 250, 500, 1000, 2500, 5000, 10000]
property real fullScale: scaleStops[0]
signal closeRequested()
signal runAgainRequested()
readonly property bool failed: error !== ""
function resetScale() {
fullScale = scaleStops[0]
}
function expandScale(value) {
// Either reading ranges the entire cluster upward. Keeping this latch on
// the overlay ensures both dials always describe the same scale.
for (var i = 0; i < scaleStops.length; i++) {
if (value <= scaleStops[i] * 0.92) {
if (scaleStops[i] > fullScale) fullScale = scaleStops[i]
return
}
}
fullScale = scaleStops[scaleStops.length - 1]
}
onRunningChanged: if (running) resetScale()
onScaleStopsChanged: resetScale()
onLeftValueChanged: expandScale(leftValue)
onRightValueChanged: expandScale(rightValue)
Behavior on fullScale {
NumberAnimation { duration: 400; easing.type: Easing.OutCubic }
}
// The scrim below is a fixed near-black regardless of theme, so text and
// ticks on it need a fixed light palette, not the themed bar.foreground.
readonly property color onScrim: "white"
readonly property color onScrimDim: Qt.rgba(1, 1, 1, 0.55)
readonly property color onScrimUrgent: "#ff6b6b"
visible: open
// The window is instantiated hidden, so re-acquire focus after mapping and
// fire the ignition sweep once the surface is actually on screen.
onOpenChanged: {
if (open) Qt.callLater(function() {
if (!root.open) return
keyCatcher.forceActiveFocus()
leftDial.ignite()
rightDial.ignite()
})
}
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WlrLayershell.namespace: root.layerNamespace
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
// Deep scrim: with no card behind them, the floating dials need the
// backdrop to carry the contrast on any wallpaper, like the near-black
// panel behind a real cluster.
Rectangle {
anchors.fill: parent
color: Qt.rgba(0, 0, 0, 0.78)
MouseArea {
anchors.fill: parent
onClicked: root.closeRequested()
}
}
Item {
id: keyCatcher
anchors.fill: parent
focus: true
Keys.onEscapePressed: root.closeRequested()
Keys.onReturnPressed: if (!root.running) root.runAgainRequested()
Keys.onEnterPressed: if (!root.running) root.runAgainRequested()
Item {
id: cluster
anchors.centerIn: parent
width: content.implicitWidth
height: content.implicitHeight
// Narrow or heavily scaled outputs: shrink the whole cluster rather
// than clipping it at the screen edge.
scale: Math.min(1,
(keyCatcher.width - Style.space(32)) / Math.max(1, width),
(keyCatcher.height - Style.space(32)) / Math.max(1, height))
// Swallow clicks so only the scrim outside the cluster dismisses.
MouseArea { anchors.fill: parent; onClicked: {} }
ColumnLayout {
id: content
anchors.fill: parent
spacing: Style.space(16)
Text {
textFormat: Text.PlainText
visible: root.title !== ""
text: root.title.toUpperCase()
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 2
Layout.fillWidth: true
horizontalAlignment: Text.AlignHCenter
}
Row {
spacing: Style.space(48)
Layout.alignment: Qt.AlignHCenter
SpeedDial {
id: leftDial
label: root.leftLabel
value: root.leftValue
live: root.leftLive
}
SpeedDial {
id: rightDial
label: root.rightLabel
value: root.rightValue
live: root.rightLive
}
}
// Centered on the dial pair. Fades rather than unmounts while a run
// is in flight, so the cluster never shifts.
Button {
text: "Run Again"
tooltipText: root.runAgainTooltip
bordered: true
enabled: !root.running
opacity: root.running ? 0 : 1
foreground: root.onScrim
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
horizontalPadding: Style.space(14)
verticalPadding: Style.space(4)
Layout.alignment: Qt.AlignHCenter
onClicked: root.runAgainRequested()
Behavior on opacity {
NumberAnimation { duration: 240; easing.type: Easing.OutCubic }
}
}
Text {
textFormat: Text.PlainText
visible: root.failed
text: root.error
color: root.onScrimUrgent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.Wrap
Layout.fillWidth: true
Layout.maximumWidth: Style.space(440)
horizontalAlignment: Text.AlignHCenter
}
}
}
}
// One floating cluster dial: an open 270° scale with the gap at the
// bottom, a faint tick ring, a glowing accent value arc, a hubless needle
// that fades toward the pivot, and a digital readout in the middle. All
// writes to the needle funnel through `shown` so the ignition sweep and
// live readings share one animation.
component SpeedDial: Item {
id: dial
required property string label
required property real value
required property bool live
readonly property real diameter: Style.space(210)
// 0° = 3 o'clock, increasing clockwise (PathAngleArc's convention).
readonly property real dialStart: 135
readonly property real dialSweep: 270
readonly property int tickCount: 46
readonly property real arcWidth: Style.space(4)
readonly property real arcRadius: diameter / 2 - arcWidth
readonly property color trackColor: Qt.rgba(1, 1, 1, 0.14)
readonly property color minorTickColor: Qt.rgba(1, 1, 1, 0.12)
readonly property color majorTickColor: Qt.rgba(1, 1, 1, 0.3)
// The dial that isn't measuring yet sits dimmed until it gets a figure.
readonly property bool engaged: live || value > 0
property real shown: 0
// The digital readout stays on the real figure while the ignition sweep
// drives the needle -- a cluster sweeps its gauges, not its numerals.
readonly property real reading: ignition.running ? value : shown
readonly property real fullScale: root.fullScale
readonly property real fraction: fullScale > 0 ? Math.max(0, Math.min(1, shown / fullScale)) : 0
readonly property bool arcVisible: fraction > 0.004
width: diameter
height: diameter
opacity: engaged ? 1 : 0.5
Behavior on opacity {
NumberAnimation { duration: 240; easing.type: Easing.OutCubic }
}
// Live readings land once a second; glide between them rather than snap.
Behavior on shown {
enabled: !ignition.running
NumberAnimation { duration: 600; easing.type: Easing.OutCubic }
}
onValueChanged: {
if (!ignition.running) shown = value
}
function ignite() {
ignition.restart()
}
// Car-cluster power-on: needle sweeps to full scale and falls back before
// the live figures take over.
SequentialAnimation {
id: ignition
NumberAnimation { target: dial; property: "shown"; to: dial.fullScale; duration: 550; easing.type: Easing.InOutCubic }
NumberAnimation { target: dial; property: "shown"; to: 0; duration: 650; easing.type: Easing.OutCubic }
onFinished: dial.shown = dial.value
}
Shape {
anchors.fill: parent
preferredRendererType: Shape.CurveRenderer
// Track: the full scale, always visible, dim.
ShapePath {
strokeWidth: dial.arcWidth
strokeColor: dial.trackColor
fillColor: "transparent"
capStyle: ShapePath.RoundCap
PathAngleArc {
centerX: dial.width / 2
centerY: dial.height / 2
radiusX: dial.arcRadius
radiusY: dial.arcRadius
startAngle: dial.dialStart
sweepAngle: dial.dialSweep
}
}
// Soft under-glow beneath the value arc, standing in for the backlit
// ring of a real cluster. Both arcs go transparent at rest, or their
// round caps would leave a stray dot at the foot of the scale.
ShapePath {
strokeWidth: dial.arcWidth * 3
strokeColor: dial.arcVisible ? Qt.rgba(Color.accent.r, Color.accent.g, Color.accent.b, 0.18) : "transparent"
fillColor: "transparent"
capStyle: ShapePath.RoundCap
PathAngleArc {
centerX: dial.width / 2
centerY: dial.height / 2
radiusX: dial.arcRadius
radiusY: dial.arcRadius
startAngle: dial.dialStart
sweepAngle: dial.dialSweep * dial.fraction
}
}
// Value: fills behind the needle.
ShapePath {
strokeWidth: dial.arcWidth
strokeColor: dial.arcVisible ? Color.accent : "transparent"
fillColor: "transparent"
capStyle: ShapePath.RoundCap
PathAngleArc {
centerX: dial.width / 2
centerY: dial.height / 2
radiusX: dial.arcRadius
radiusY: dial.arcRadius
startAngle: dial.dialStart
sweepAngle: dial.dialSweep * dial.fraction
}
}
}
// Faint tick ring just inside the arc; every fifth tick is a major.
Repeater {
model: dial.tickCount
Item {
required property int index
readonly property bool major: index % 5 === 0
anchors.fill: parent
rotation: dial.dialStart + (index / (dial.tickCount - 1)) * dial.dialSweep - 270
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: dial.arcWidth * 2 + (parent.major ? 0 : Style.space(2))
width: parent.major ? Math.max(2, Style.space(2)) : 1
height: parent.major ? Style.space(10) : Style.space(6)
radius: width / 2
color: parent.major ? dial.majorTickColor : dial.minorTickColor
}
}
}
// Hubless needle: a slender sliver that fades out toward the pivot, so
// it reads as floating like the rest of the cluster.
Item {
anchors.fill: parent
rotation: dial.dialStart + dial.fraction * dial.dialSweep - 270
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
y: dial.arcWidth * 2 + Style.space(10)
width: Math.max(2, Style.space(3))
height: dial.diameter * 0.32
radius: width / 2
gradient: Gradient {
GradientStop { position: 0.0; color: Color.accent }
GradientStop { position: 0.55; color: Color.accent }
GradientStop { position: 1.0; color: "transparent" }
}
}
}
Column {
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.verticalCenter
anchors.topMargin: Style.space(14)
spacing: 0
Text {
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
// Both branches go through the locale: a reading is a measurement, so
// its separators follow the system's number conventions rather than the
// interface language. toFixed would have hardcoded a dot below 10 while
// everything above it was already grouped for the locale.
text: dial.reading < 10
? dial.reading.toLocaleString(Qt.locale(), 'f', 1)
: Math.round(dial.reading).toLocaleString(Qt.locale(), 'f', 0)
color: root.onScrim
font.family: root.fontFamily
font.pixelSize: Style.font.display
font.bold: true
}
Text {
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
text: root.unit
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
// The 90° gap at the bottom of the scale is where a cluster prints its
// unit; here it names the direction.
Text {
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
text: dial.label
color: root.onScrimDim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
font.letterSpacing: 1.5
}
}
}
+57
View File
@@ -0,0 +1,57 @@
import QtQuick
import QtQuick.Controls
import qs.Commons
// Single-line text input with the kit's focus + selection styling. Inherits
// from Qt Quick Controls TextField so the underlying type's API (text,
// placeholderText, accepted, editingFinished, validator, ...) is available
// to callers without re-exposing each property.
//
// Defaults bind to qs.Commons.Color so a caller with no theme overrides
// just works; foreground / accent / selectionTint can be overridden per
// instance. activeFocus and mouse hover / panel cursor use the same
// hover-cursor defaults, so text inputs match Button, Toggle, and Dropdown.
//
// Sizing is driven by font.pixelSize + verticalPadding. The default 30px
// implicitHeight fits dialog forms; inline callers (wifi's row-embedded
// passphrase prompt) drop verticalPadding to match a 22-26px row.
TextField {
id: root
property color foreground: Color.foreground
property color accent: Color.accent
property color selectionTint: Style.selectionFillFor(foreground, accent)
property bool password: false
property real horizontalPadding: Style.spacing.controlPaddingX
property real verticalPadding: Style.spacing.inputPaddingY
// Panel-cursor flag. When true (and the field isn't already focused),
// the background paints the shared hover/cursor state.
// For mouse-enter/leave the consumer reads QQC TextField's inherited
// `hovered` property (via onHoveredChanged) — we don't add a sibling
// signal because the inherited property would shadow it.
property bool hasCursor: false
readonly property bool _focused: activeFocus
readonly property bool _hot: hovered || hasCursor
readonly property var _borderSpec: Border.controlSpec(_focused ? "focus" : (_hot ? "hover-cursor" : "normal"), root.foreground, root.accent)
echoMode: password ? TextInput.Password : TextInput.Normal
font.family: Style.font.family
font.pixelSize: Style.font.body
color: foreground
selectionColor: selectionTint
selectedTextColor: foreground
placeholderTextColor: Qt.darker(foreground, 1.6)
leftPadding: horizontalPadding + Border.left(_borderSpec)
rightPadding: horizontalPadding + Border.right(_borderSpec)
topPadding: verticalPadding + Border.top(_borderSpec)
bottomPadding: verticalPadding + Border.bottom(_borderSpec)
background: BorderSurface {
color: Style.controlFill(root._focused, root._hot, root.foreground, root.accent)
borderSpec: root._borderSpec
radius: Style.cornerRadius
}
}
+117
View File
@@ -0,0 +1,117 @@
import QtQuick
import qs.Commons
// Labeled toggle row: title + optional description on the left, a
// `ToggleSwitch` on the right. Clicking anywhere on the row emits `clicked()`;
// consumers flip `checked` in response (the component is stateless about the
// actual value so it composes cleanly with model-driven UI).
//
// Cursor and focus styling match the rest of the kit: hasCursor / mouse
// hover and activeFocus share the hover-cursor defaults.
//
// `rounded` is forwarded to the switch, which auto-detects from
// Style.cornerRadius: pill shape when Hyprland corners are rounded, square on
// sharp. Callers can override per-instance.
BorderSurface {
id: root
property string label: ""
property string description: ""
property bool checked: false
// Panel-cursor flag. Same role as Button.hasCursor:
// panels with their own keyboard cursor bind this to drive the highlight
// separately from activeFocus. Visuals use the same hover-cursor tokens.
property bool hasCursor: false
// Switch shape follows the theme by default: pill on round, square on sharp.
// Override per-instance if a caller wants the opposite.
property bool rounded: Style.cornerRadius > 0
property color foreground: Color.foreground
property color accent: Color.accent
property string fontFamily: Style.font.family
property real titleSize: Style.font.subtitle
property real descriptionSize: Style.font.caption
signal clicked()
signal hovered(bool isHovered)
activeFocusOnTab: true
Keys.onReturnPressed: root.clicked()
Keys.onEnterPressed: root.clicked()
Keys.onSpacePressed: root.clicked()
implicitHeight: Math.max(54, content.implicitHeight + Style.spacing.huge)
implicitWidth: Style.space(240)
radius: Style.cornerRadius
readonly property bool _hot: hasCursor || mouse.containsMouse
readonly property var _borderSpec: Border.controlSpec(activeFocus ? "focus" : (_hot ? "hover-cursor" : "normal"), foreground, accent)
color: Style.controlFill(activeFocus, _hot, foreground, accent)
borderSpec: _borderSpec
Behavior on color { ColorAnimation { duration: 100 } }
Row {
id: content
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: root.borderLeft + Style.spacing.rowPaddingX
anchors.rightMargin: root.borderRight + Style.spacing.rowPaddingX
spacing: Style.spacing.rowPaddingX
Column {
width: parent.width - track.width - parent.spacing
spacing: Style.spacing.xs
anchors.verticalCenter: parent.verticalCenter
Text {
textFormat: Text.PlainText
text: root.label
color: root.foreground
font.family: root.fontFamily
font.pixelSize: root.titleSize
font.bold: true
elide: Text.ElideRight
width: parent.width
}
Text {
textFormat: Text.PlainText
visible: root.description !== ""
text: root.description
color: Qt.darker(root.foreground, 1.5)
font.family: root.fontFamily
font.pixelSize: root.descriptionSize
wrapMode: Text.WordWrap
width: parent.width
}
}
// The row owns the click, so the switch is presentation only here.
ToggleSwitch {
id: track
checked: root.checked
rounded: root.rounded
foreground: root.foreground
accent: root.accent
interactive: false
anchors.verticalCenter: parent.verticalCenter
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.clicked()
}
HoverHandler {
onHoveredChanged: root.hovered(hovered)
}
}
+111
View File
@@ -0,0 +1,111 @@
import QtQuick
import qs.Commons
// Bare on/off switch: a track with a sliding knob and no label. This is the
// switch `Toggle` parks at the end of its labeled row, factored out so panel
// headers and other compact controls render the identical thing.
//
// The caller owns the value: bind `checked` to real state and flip it in
// response to `toggled()`. Services that already track a desired state
// optimistically (see the Tailscale service's `_desired`) get an instant knob
// throw for free, because `checked` is already the optimistic value.
//
// `busy` marks an operation in flight and swallows further clicks, but leaves
// hover, cursor, and tooltips alone so the control does not flicker every time
// a background refresh runs.
//
// The cursor is a ring drawn outside the track rather than a state on the
// track itself: themes give normal chrome a stronger border than hover-cursor
// (0.4 vs 0.25 by default), which is right for controls that are borderless at
// rest but would make a bordered track go *fainter* under the cursor. On the
// panel background the ring reads immediately. `cursorRing` follows
// `interactive` — a switch whose surrounding row owns the click owns the
// cursor too.
//
// `rounded` auto-detects from Style.cornerRadius so the switch follows the
// theme: pill shape when Hyprland corners are rounded, square on sharp.
Item {
id: root
property bool checked: false
property bool busy: false
// Off when the surrounding row owns the click, as in `Toggle`.
property bool interactive: true
// Panel-cursor flag. Same role as Button.hasCursor: panels with their own
// keyboard cursor bind this to drive the highlight separately from hover.
property bool hasCursor: false
property bool cursorRing: interactive
property int cursorPad: Style.space(6)
property bool rounded: Style.cornerRadius > 0
property color foreground: Color.foreground
property color accent: Color.accent
signal toggled()
signal hovered(bool isHovered)
readonly property alias containsMouse: mouse.containsMouse
readonly property bool hot: hasCursor || mouse.containsMouse
// `trackHeight` is settable so a compact placement — a switch riding a panel
// section header, say — can ask for a genuinely smaller control instead of
// scaling a big one down, which lands the track and knob on fractional pixels
// and blurs their edges. The derived sizes only carry floors low enough to
// stay out of an override's way; at the default track height each one is
// already above its floor, so nothing about the normal switch changes.
property int trackHeight: Math.max(22, Math.round(Style.spacing.controlHeight * 0.55))
property int trackWidth: Math.round(trackHeight * 1.9)
property int knobSize: Math.max(6, Math.round(trackHeight * 0.72))
property int knobInset: Math.max(1, Math.round((trackHeight - knobSize) / 2))
readonly property int _pad: cursorRing ? cursorPad : 0
implicitWidth: trackWidth + _pad * 2
implicitHeight: trackHeight + _pad * 2
BorderSurface {
anchors.fill: parent
visible: root.cursorRing && root.hot
color: "transparent"
radius: Style.cornerRadius
borderSpec: Border.controlSpec("hover-cursor", root.foreground, root.accent)
}
BorderSurface {
id: track
width: root.trackWidth
height: root.trackHeight
anchors.centerIn: parent
radius: root.rounded ? height / 2 : 0
color: root.checked
? Style.selectedFillFor(root.foreground, root.accent)
: Style.normalFillFor(root.foreground, root.accent)
borderSpec: Border.controlSpec(root.checked ? "selected" : "normal", root.foreground, root.accent)
Behavior on color { ColorAnimation { duration: 120 } }
Rectangle {
width: root.knobSize
height: root.knobSize
radius: root.rounded ? height / 2 : 0
x: root.checked ? track.width - width - root.knobInset : root.knobInset
anchors.verticalCenter: parent.verticalCenter
color: root.checked ? Style.selectedStateColor(root.foreground, root.accent) : Qt.darker(root.foreground, 1.25)
Behavior on x { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } }
Behavior on color { ColorAnimation { duration: 120 } }
}
}
MouseArea {
id: mouse
anchors.fill: parent
enabled: root.interactive
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onContainsMouseChanged: root.hovered(containsMouse)
onClicked: if (!root.busy) root.toggled()
}
}
+119
View File
@@ -0,0 +1,119 @@
import QtQuick
import qs.Commons
Item {
id: root
property var bar: null
property string text: ""
property string fontFamily: bar ? bar.fontFamily : Style.font.family
property real fontSize: Style.font.body
property color foreground: bar ? bar.barForeground : Color.foreground
property color activeColor: bar ? bar.urgent : Color.urgent
property bool active: false
property real horizontalMargin: 8.5
property real verticalPadding: 6
property real fixedWidth: -1
property real fixedHeight: -1
property real textRotation: 0
property bool keepSpace: false
property bool dimmed: false
property bool concealed: false
property bool interactive: true
property bool pressable: true
property bool useActiveColor: true
property bool maintainIndicatorReveal: false
property bool labelVisible: true
property bool hasVisualContent: text !== ""
property var revealHost: bar
property string tooltipText: ""
property var registeredBar: null
signal pressed(int button)
signal wheelMoved(int delta)
function triggerPress(button) {
if (root.bar) root.bar.hideTooltip(root)
root.pressed(button)
}
function hideOwnTooltip() {
if (root.bar) root.bar.hideTooltip(root)
}
function syncClickRegistration() {
if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(root)
registeredBar = root.bar
if (registeredBar && registeredBar.registerClickTarget) registeredBar.registerClickTarget(root)
}
onBarChanged: syncClickRegistration()
onVisibleChanged: if (!visible) hideOwnTooltip()
onInteractiveChanged: if (!interactive) hideOwnTooltip()
onConcealedChanged: if (concealed) hideOwnTooltip()
Component.onCompleted: syncClickRegistration()
Component.onDestruction: if (registeredBar && registeredBar.unregisterClickTarget) registeredBar.unregisterClickTarget(root)
readonly property bool vertical: bar ? bar.vertical : false
readonly property int barSize: bar ? bar.barSize : Style.bar.sizeHorizontal
readonly property real scaledHorizontalMargin: Style.spaceReal(horizontalMargin)
readonly property real scaledVerticalPadding: Style.spaceReal(verticalPadding)
readonly property bool tooltipHovered: visible && interactive && !concealed && mouseArea.containsMouse
// Width of the painted label, for bar chrome that wants to line up with the
// text rather than with the slot it sits in. Zero on icon-only buttons.
readonly property real labelWidth: label.visible ? label.implicitWidth : 0
visible: hasVisualContent || keepSpace
opacity: !hasVisualContent || concealed ? 0 : (dimmed ? 0.45 : 1)
implicitWidth: fixedWidth > 0 ? fixedWidth : (vertical ? barSize : Math.max(12, label.implicitWidth + scaledHorizontalMargin * 2))
implicitHeight: fixedHeight > 0 ? fixedHeight : (vertical ? Math.max(12, label.implicitHeight + scaledVerticalPadding * 2) : barSize)
Behavior on opacity {
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
}
Text {
id: label
textFormat: Text.PlainText
visible: root.labelVisible
anchors.centerIn: parent
text: root.text
color: root.active && root.useActiveColor ? root.activeColor : root.foreground
font.family: root.fontFamily
font.pixelSize: root.fontSize
renderType: Text.NativeRendering
rotation: root.textRotation
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
Behavior on color {
enabled: !root.bar || root.bar.foregroundAnimationEnabled
ColorAnimation { duration: 160 }
}
}
MouseArea {
id: mouseArea
anchors.fill: parent
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
enabled: root.interactive
hoverEnabled: true
cursorShape: root.pressable ? Qt.PointingHandCursor : Qt.ArrowCursor
onEntered: {
if (root.bar) {
root.bar.showTooltip(root, root.tooltipText)
}
if (root.maintainIndicatorReveal && root.revealHost && root.revealHost.setIndicatorItemHovered)
root.revealHost.setIndicatorItemHovered(true)
}
onExited: {
if (root.bar) {
root.bar.hideTooltip(root)
}
if (root.maintainIndicatorReveal && root.revealHost && root.revealHost.setIndicatorItemHovered)
root.revealHost.setIndicatorItemHovered(false)
}
onClicked: function(mouse) { if (root.pressable) root.triggerPress(mouse.button) }
onWheel: function(wheel) { root.wheelMoved(wheel.angleDelta.y) }
}
}
+35
View File
@@ -0,0 +1,35 @@
module qs.Ui
BarIndicator 1.0 BarIndicator.qml
BarIconButton 1.0 BarIconButton.qml
BarWidget 1.0 BarWidget.qml
BorderOverlay 1.0 BorderOverlay.qml
BorderSurface 1.0 BorderSurface.qml
Button 1.0 Button.qml
ButtonGroup 1.0 ButtonGroup.qml
ConfirmDialog 1.0 ConfirmDialog.qml
CursorSurface 1.0 CursorSurface.qml
Dropdown 1.0 Dropdown.qml
KeyboardPanel 1.0 KeyboardPanel.qml
MultiSelect 1.0 MultiSelect.qml
NumberField 1.0 NumberField.qml
OpticalGlyph 1.0 OpticalGlyph.qml
Panel 1.0 Panel.qml
PanelActionButton 1.0 PanelActionButton.qml
PanelController 1.0 PanelController.qml
PanelKeyCatcher 1.0 PanelKeyCatcher.qml
PanelHero 1.0 PanelHero.qml
PanelSectionHeader 1.0 PanelSectionHeader.qml
PanelSeparator 1.0 PanelSeparator.qml
PanelSlider 1.0 PanelSlider.qml
PanelToolTip 1.0 PanelToolTip.qml
PluginBarApi 1.0 PluginBarApi.qml
PointerMoveGate 1.0 PointerMoveGate.qml
ScreenMoveRemap 1.0 ScreenMoveRemap.qml
PopupCard 1.0 PopupCard.qml
SearchableDropdown 1.0 SearchableDropdown.qml
SpeedTestOverlay 1.0 SpeedTestOverlay.qml
TextField 1.0 TextField.qml
Toggle 1.0 Toggle.qml
ToggleSwitch 1.0 ToggleSwitch.qml
WidgetButton 1.0 WidgetButton.qml