Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 || ""))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user