Fork the desktop off Omarchy as a self-contained system
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.active-window",
|
||||
"name": "Active window",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Title of the focused window",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "ActiveWindow.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Active window",
|
||||
"description": "Title of the focused window",
|
||||
"category": "Compositor",
|
||||
"allowMultiple": false,
|
||||
"defaultSection": "left"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "blob.active-window"
|
||||
|
||||
|
||||
readonly property var toplevel: ToplevelManager.activeToplevel
|
||||
readonly property string title: toplevel ? (toplevel.title || toplevel.appId || "") : ""
|
||||
readonly property int maxLabelWidth: Number(setting("maxWidth", 280))
|
||||
|
||||
visible: title !== "" && !vertical
|
||||
implicitWidth: visible ? Math.min(maxLabelWidth, labelText.implicitWidth) + Style.spacing.controlPaddingX * 2 : 0
|
||||
implicitHeight: barSize
|
||||
|
||||
Behavior on implicitWidth {
|
||||
NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
Item {
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Style.space(8)
|
||||
anchors.rightMargin: Style.space(8)
|
||||
clip: true
|
||||
|
||||
Text {
|
||||
id: labelText
|
||||
textFormat: Text.PlainText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
width: parent.width
|
||||
text: root.title
|
||||
color: root.bar ? root.bar.barForeground : Color.foreground
|
||||
font.family: root.bar ? root.bar.fontFamily : Style.font.family
|
||||
font.pixelSize: Style.font.body
|
||||
elide: Text.ElideRight
|
||||
opacity: 0.85
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
|
||||
onClicked: function(mouse) {
|
||||
if (!root.toplevel) return
|
||||
if (mouse.button === Qt.MiddleButton) {
|
||||
root.toplevel.close()
|
||||
} else if (mouse.button === Qt.RightButton) {
|
||||
root.toplevel.close()
|
||||
} else {
|
||||
root.toplevel.activate()
|
||||
}
|
||||
}
|
||||
onEntered: if (root.bar) root.bar.showTooltip(root, root.title)
|
||||
onExited: if (root.bar) root.bar.hideTooltip(root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.indicators",
|
||||
"name": "Indicators",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Manual state indicators",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "Indicators.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Indicators",
|
||||
"description": "Manual state indicators",
|
||||
"category": "Status",
|
||||
"allowMultiple": true,
|
||||
"schema": [
|
||||
{
|
||||
"key": "items",
|
||||
"type": "multiselect",
|
||||
"label": "Indicators",
|
||||
"description": "Choose which indicators this widget instance should show. Leave empty to show all indicators.",
|
||||
"noSelectionText": "All indicators",
|
||||
"placeholderText": "Search indicators...",
|
||||
"emptyText": "No indicators",
|
||||
"options": [
|
||||
{
|
||||
"value": "Dictation",
|
||||
"label": "Dictation",
|
||||
"description": "Voice typing status"
|
||||
},
|
||||
{
|
||||
"value": "ScreenRecording",
|
||||
"label": "Screen recording",
|
||||
"description": "GPU screen recorder status"
|
||||
},
|
||||
{
|
||||
"value": "Reminder",
|
||||
"label": "Reminder",
|
||||
"description": "Queued reminder status"
|
||||
},
|
||||
{
|
||||
"value": "NightLight",
|
||||
"label": "Night light",
|
||||
"description": "Blue-light filter"
|
||||
},
|
||||
{
|
||||
"value": "Dnd",
|
||||
"label": "Do not disturb",
|
||||
"description": "Notification silencing"
|
||||
},
|
||||
{
|
||||
"value": "StayAwake",
|
||||
"label": "Stay awake",
|
||||
"description": "Idle lock and screensaver override"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "alwaysShow",
|
||||
"type": "boolean",
|
||||
"label": "Always Show",
|
||||
"description": "Show inactive indicators without waiting for hover.",
|
||||
"defaultValue": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"blob": {
|
||||
"clonePaths": [
|
||||
{
|
||||
"source": "../indicators",
|
||||
"target": "indicators"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "blob.indicators"
|
||||
|
||||
readonly property var defaultIndicatorEntries: [ "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ]
|
||||
readonly property var indicatorEntries: indicatorEntriesFromSettings(settings)
|
||||
property var activeIndicatorIds: []
|
||||
property var indicatorActiveStates: ({})
|
||||
property bool indicatorAreaHovered: false
|
||||
property bool indicatorItemHovered: false
|
||||
readonly property bool alwaysShowIndicators: setting("alwaysShow", false) === true
|
||||
readonly property bool revealInactiveIndicators: alwaysShowIndicators || indicatorAreaHovered || indicatorItemHovered || (bar && bar.centerSectionRevealHeld === true && bar.centerHoverRevealSuppressed !== true)
|
||||
|
||||
signal refreshRequested()
|
||||
|
||||
ListModel { id: activeIndicatorModel }
|
||||
|
||||
function entryId(entry) {
|
||||
if (typeof entry === "string") return entry
|
||||
if (Util.isPlainObject(entry)) {
|
||||
var id = entry["id"]
|
||||
if (id !== undefined && id !== null && String(id) !== "") return String(id)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function entrySettings(entry) {
|
||||
if (!Util.isPlainObject(entry)) return {}
|
||||
var copy = {}
|
||||
for (var key in entry) {
|
||||
if (key === "id") continue
|
||||
copy[key] = entry[key]
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
function indicatorEntriesFromSettings(settings) {
|
||||
var source = defaultIndicatorEntries
|
||||
if (settings.items && typeof settings.items.length === "number" && settings.items.length > 0) source = settings.items
|
||||
else if (settings.indicators && typeof settings.indicators.length === "number" && settings.indicators.length > 0) source = settings.indicators
|
||||
|
||||
var result = []
|
||||
for (var i = 0; i < source.length; i++) {
|
||||
var item = source[i]
|
||||
if (typeof item !== "string" && item !== null && typeof item === "object") {
|
||||
try {
|
||||
item = JSON.parse(JSON.stringify(item))
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
var id = entryId(item)
|
||||
if (id !== "") result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function setIndicatorAreaHovered(hovered) {
|
||||
indicatorAreaHovered = hovered
|
||||
if (hovered) indicatorHideTimer.stop()
|
||||
else indicatorHideTimer.restart()
|
||||
}
|
||||
|
||||
function setIndicatorItemHovered(hovered) {
|
||||
if (hovered) {
|
||||
indicatorItemHovered = true
|
||||
indicatorHideTimer.stop()
|
||||
} else {
|
||||
indicatorHideTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
function hasIndicatorId(id) {
|
||||
for (var i = 0; i < indicatorEntries.length; i++) {
|
||||
if (entryId(indicatorEntries[i]) === id) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function entryForId(id) {
|
||||
for (var i = 0; i < indicatorEntries.length; i++) {
|
||||
var entry = indicatorEntries[i]
|
||||
if (entryId(entry) === id) return entry
|
||||
}
|
||||
|
||||
return { id: id }
|
||||
}
|
||||
|
||||
function activeModelIndex(id) {
|
||||
for (var i = 0; i < activeIndicatorModel.count; i++) {
|
||||
if (activeIndicatorModel.get(i).activeId === id) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function copyActiveStates() {
|
||||
var states = {}
|
||||
for (var id in indicatorActiveStates) {
|
||||
if (indicatorActiveStates[id] === true) states[id] = true
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
function orderedActiveIds(states, preferredOrder) {
|
||||
var ids = []
|
||||
|
||||
for (var i = 0; i < preferredOrder.length; i++) {
|
||||
var id = preferredOrder[i]
|
||||
if (ids.indexOf(id) === -1 && hasIndicatorId(id) && states[id] === true) ids.push(id)
|
||||
}
|
||||
|
||||
return ids
|
||||
}
|
||||
|
||||
function syncActiveIndicatorModel() {
|
||||
for (var i = activeIndicatorModel.count - 1; i >= 0; i--) {
|
||||
if (activeIndicatorIds.indexOf(activeIndicatorModel.get(i).activeId) === -1)
|
||||
activeIndicatorModel.remove(i)
|
||||
}
|
||||
|
||||
for (var j = 0; j < activeIndicatorIds.length; j++) {
|
||||
var id = activeIndicatorIds[j]
|
||||
var index = activeModelIndex(id)
|
||||
if (index === -1) activeIndicatorModel.insert(j, { activeId: id })
|
||||
else if (index !== j) activeIndicatorModel.move(index, j, 1)
|
||||
}
|
||||
}
|
||||
|
||||
function setIndicatorActive(entry, active) {
|
||||
var id = entryId(entry)
|
||||
if (id === "") return
|
||||
|
||||
var states = copyActiveStates()
|
||||
if (active) states[id] = true
|
||||
else delete states[id]
|
||||
|
||||
indicatorActiveStates = states
|
||||
|
||||
var ids = orderedActiveIds(states, activeIndicatorIds)
|
||||
// The active block sits closest to the clock, so newcomers go on the far
|
||||
// side of it. Appending would shove everything already showing sideways.
|
||||
if (active && ids.indexOf(id) === -1 && hasIndicatorId(id)) ids.unshift(id)
|
||||
activeIndicatorIds = ids
|
||||
syncActiveIndicatorModel()
|
||||
}
|
||||
|
||||
function syncActiveIndicatorOrder() {
|
||||
activeIndicatorIds = orderedActiveIds(indicatorActiveStates, activeIndicatorIds)
|
||||
syncActiveIndicatorModel()
|
||||
}
|
||||
|
||||
function refresh() { root.refreshRequested() }
|
||||
|
||||
onIndicatorEntriesChanged: syncActiveIndicatorOrder()
|
||||
|
||||
implicitWidth: root.vertical
|
||||
? Math.max(activeVerticalBlock.implicitWidth, inactiveVerticalArea.implicitWidth)
|
||||
: activeHorizontalBlock.implicitWidth + inactiveHorizontalArea.implicitWidth
|
||||
implicitHeight: root.vertical
|
||||
? activeVerticalBlock.implicitHeight + inactiveVerticalArea.implicitHeight
|
||||
: Math.max(activeHorizontalBlock.implicitHeight, inactiveHorizontalArea.implicitHeight)
|
||||
|
||||
IpcHandler {
|
||||
target: "blob.indicators"
|
||||
|
||||
function refresh(): void {
|
||||
root.broadcast("refresh")
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: indicatorHideTimer
|
||||
interval: 120
|
||||
onTriggered: {
|
||||
if (!root.indicatorAreaHovered)
|
||||
root.indicatorItemHovered = false
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refreshRequested()
|
||||
|
||||
Row {
|
||||
id: horizontalIndicators
|
||||
|
||||
visible: !root.vertical
|
||||
spacing: 0
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
|
||||
}
|
||||
|
||||
Item {
|
||||
id: inactiveHorizontalArea
|
||||
|
||||
implicitWidth: root.revealInactiveIndicators ? inactiveHorizontalBlock.implicitWidth : 0
|
||||
implicitHeight: Math.max(inactiveHorizontalBlock.implicitHeight, root.barSize)
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
clip: true
|
||||
|
||||
IndicatorBlock {
|
||||
id: inactiveHorizontalBlock
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
indicatorsModule: root
|
||||
indicatorEntries: root.indicatorEntries
|
||||
indicatorBlock: "inactive"
|
||||
horizontal: true
|
||||
reportActiveState: !root.vertical
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
|
||||
}
|
||||
}
|
||||
|
||||
ActiveIndicatorBlock {
|
||||
id: activeHorizontalBlock
|
||||
indicatorsModule: root
|
||||
indicatorModel: activeIndicatorModel
|
||||
horizontal: true
|
||||
reportActiveState: !root.vertical
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: verticalIndicators
|
||||
|
||||
visible: root.vertical
|
||||
spacing: 0
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
|
||||
}
|
||||
|
||||
Item {
|
||||
id: inactiveVerticalArea
|
||||
|
||||
implicitWidth: Math.max(inactiveVerticalBlock.implicitWidth, root.barSize)
|
||||
implicitHeight: root.revealInactiveIndicators ? inactiveVerticalBlock.implicitHeight : 0
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
clip: true
|
||||
|
||||
IndicatorBlock {
|
||||
id: inactiveVerticalBlock
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
indicatorsModule: root
|
||||
indicatorEntries: root.indicatorEntries
|
||||
indicatorBlock: "inactive"
|
||||
horizontal: false
|
||||
reportActiveState: root.vertical
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
|
||||
}
|
||||
}
|
||||
|
||||
ActiveIndicatorBlock {
|
||||
id: activeVerticalBlock
|
||||
indicatorsModule: root
|
||||
indicatorModel: activeIndicatorModel
|
||||
horizontal: false
|
||||
reportActiveState: root.vertical
|
||||
}
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.setIndicatorAreaHovered(hovered)
|
||||
}
|
||||
|
||||
component ActiveIndicatorBlock: Item {
|
||||
id: activeIndicatorBlockRoot
|
||||
|
||||
property var indicatorModel: null
|
||||
property var indicatorsModule: null
|
||||
property bool horizontal: true
|
||||
property bool reportActiveState: false
|
||||
|
||||
implicitWidth: blockLoader.item ? blockLoader.item.implicitWidth : 0
|
||||
implicitHeight: blockLoader.item ? blockLoader.item.implicitHeight : 0
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
|
||||
Loader {
|
||||
id: blockLoader
|
||||
|
||||
anchors.centerIn: parent
|
||||
sourceComponent: activeIndicatorBlockRoot.horizontal ? horizontalActiveIndicatorBlock : verticalActiveIndicatorBlock
|
||||
}
|
||||
|
||||
Component {
|
||||
id: horizontalActiveIndicatorBlock
|
||||
|
||||
Row {
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: activeIndicatorBlockRoot.indicatorModel
|
||||
|
||||
IndicatorLoader {
|
||||
required property string activeId
|
||||
indicatorsModule: activeIndicatorBlockRoot.indicatorsModule
|
||||
entry: activeIndicatorBlockRoot.indicatorsModule.entryForId(activeId)
|
||||
indicatorBlock: "active"
|
||||
reportActiveState: activeIndicatorBlockRoot.reportActiveState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: verticalActiveIndicatorBlock
|
||||
|
||||
Column {
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: activeIndicatorBlockRoot.indicatorModel
|
||||
|
||||
IndicatorLoader {
|
||||
required property string activeId
|
||||
indicatorsModule: activeIndicatorBlockRoot.indicatorsModule
|
||||
entry: activeIndicatorBlockRoot.indicatorsModule.entryForId(activeId)
|
||||
indicatorBlock: "active"
|
||||
reportActiveState: activeIndicatorBlockRoot.reportActiveState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component IndicatorBlock: Item {
|
||||
id: indicatorBlockRoot
|
||||
|
||||
property var indicatorEntries: []
|
||||
property var indicatorsModule: null
|
||||
property string indicatorBlock: "active"
|
||||
property bool horizontal: true
|
||||
property bool reportActiveState: false
|
||||
|
||||
implicitWidth: blockLoader.item ? blockLoader.item.implicitWidth : 0
|
||||
implicitHeight: blockLoader.item ? blockLoader.item.implicitHeight : 0
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
|
||||
Loader {
|
||||
id: blockLoader
|
||||
|
||||
anchors.centerIn: parent
|
||||
sourceComponent: indicatorBlockRoot.horizontal ? horizontalIndicatorBlock : verticalIndicatorBlock
|
||||
}
|
||||
|
||||
Component {
|
||||
id: horizontalIndicatorBlock
|
||||
|
||||
Row {
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: indicatorBlockRoot.indicatorEntries
|
||||
|
||||
IndicatorLoader {
|
||||
required property var modelData
|
||||
indicatorsModule: indicatorBlockRoot.indicatorsModule
|
||||
entry: modelData
|
||||
indicatorBlock: indicatorBlockRoot.indicatorBlock
|
||||
reportActiveState: indicatorBlockRoot.reportActiveState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: verticalIndicatorBlock
|
||||
|
||||
Column {
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: indicatorBlockRoot.indicatorEntries
|
||||
|
||||
IndicatorLoader {
|
||||
required property var modelData
|
||||
indicatorsModule: indicatorBlockRoot.indicatorsModule
|
||||
entry: modelData
|
||||
indicatorBlock: indicatorBlockRoot.indicatorBlock
|
||||
reportActiveState: indicatorBlockRoot.reportActiveState
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component IndicatorLoader: Item {
|
||||
id: indicatorSlot
|
||||
|
||||
required property var entry
|
||||
property var indicatorsModule: null
|
||||
required property string indicatorBlock
|
||||
property bool reportActiveState: false
|
||||
property bool activeStateObserved: false
|
||||
readonly property string indicatorId: root.entryId(entry)
|
||||
readonly property var indicatorSettings: root.entrySettings(entry)
|
||||
readonly property var barRef: root.bar
|
||||
|
||||
implicitWidth: indicatorSource.item && indicatorSource.item.visible ? indicatorSource.item.implicitWidth : 0
|
||||
implicitHeight: indicatorSource.item && indicatorSource.item.visible ? indicatorSource.item.implicitHeight : 0
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
onEntryChanged: {
|
||||
activeStateObserved = false
|
||||
injectProps()
|
||||
syncActiveState()
|
||||
}
|
||||
onIndicatorBlockChanged: injectProps()
|
||||
onIndicatorSettingsChanged: injectProps()
|
||||
onIndicatorsModuleChanged: {
|
||||
injectProps()
|
||||
syncActiveState()
|
||||
}
|
||||
onReportActiveStateChanged: syncActiveState()
|
||||
onBarRefChanged: injectProps()
|
||||
|
||||
Loader {
|
||||
id: indicatorSource
|
||||
|
||||
anchors.fill: parent
|
||||
source: indicatorSlot.indicatorId ? Qt.resolvedUrl("../indicators/" + indicatorSlot.indicatorId + ".qml") : ""
|
||||
onLoaded: {
|
||||
indicatorSlot.injectProps()
|
||||
indicatorSlot.syncActiveState()
|
||||
}
|
||||
onStatusChanged: if (status === Loader.Error) console.warn("Indicator loader error", indicatorSlot.indicatorId, source)
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: indicatorSource.item
|
||||
ignoreUnknownSignals: true
|
||||
function onActiveChanged() { indicatorSlot.syncActiveState() }
|
||||
}
|
||||
|
||||
function injectProps() {
|
||||
var target = indicatorSource.item
|
||||
if (!target) return
|
||||
if ("bar" in target) target.bar = root.bar
|
||||
if ("moduleName" in target) target.moduleName = indicatorId
|
||||
if ("settings" in target) target.settings = indicatorSettings
|
||||
if ("indicatorBlock" in target) target.indicatorBlock = indicatorBlock
|
||||
if ("indicatorHost" in target) target.indicatorHost = root
|
||||
if ("activeOverride" in target) target.activeOverride = indicatorBlock === "active" ? true : null
|
||||
}
|
||||
|
||||
function syncActiveState() {
|
||||
if (!reportActiveState || !indicatorsModule || !indicatorsModule.setIndicatorActive) return
|
||||
|
||||
var active = !!indicatorSource.item && indicatorSource.item.active === true
|
||||
if (indicatorBlock === "active") {
|
||||
if (active) activeStateObserved = true
|
||||
else if (!activeStateObserved) return
|
||||
}
|
||||
|
||||
indicatorsModule.setIndicatorActive(entry, active)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.keyboard-layout",
|
||||
"name": "Keyboard layout",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Current xkb layout, click cycles",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "KeyboardLayout.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Keyboard layout",
|
||||
"description": "Current xkb layout, click cycles",
|
||||
"category": "Compositor",
|
||||
"allowMultiple": false
|
||||
},
|
||||
"blob": {
|
||||
"clonePaths": [
|
||||
{
|
||||
"source": "KeyboardLayoutModel.js",
|
||||
"target": "KeyboardLayoutModel.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Io
|
||||
import qs.Ui
|
||||
import qs.Commons
|
||||
import "KeyboardLayoutModel.js" as KeyboardLayoutModel
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "blob.keyboard-layout"
|
||||
|
||||
|
||||
property string layoutFull: ""
|
||||
// The keyboard the last reading spoke for, which is the one a click switches,
|
||||
// and separately the one activelayout named as being typed on. A reading
|
||||
// confirms the first is really there, so the click has a keyboard to reach
|
||||
// from the first reading onwards rather than only after a switch, and stops
|
||||
// naming one that has been unplugged.
|
||||
property string keyboardName: ""
|
||||
property string typedKeyboardName: ""
|
||||
// Keyboards on the seat, buttons and virtual ones excluded, and whether the
|
||||
// last reading left that shape in doubt.
|
||||
property int keyboardCount: 0
|
||||
property bool keyboardUnresolved: false
|
||||
// Nothing to read or switch on the single-layout install most people run, so
|
||||
// the widget ships on the bar and stays out of the way until there are two.
|
||||
// An older Hyprland that doesn't report the list keeps showing the label.
|
||||
property bool multipleLayouts: true
|
||||
// Short language code per layout description ("English (US)": "en"), read from
|
||||
// xkb's own table rather than maintained by hand.
|
||||
property var layoutBriefs: ({})
|
||||
readonly property string layoutLabel: KeyboardLayoutModel.shortLabel(layoutFull, layoutBriefs)
|
||||
|
||||
// A query already in flight was started before this event, so it may read the
|
||||
// layout the switch replaced. Remember the request and re-run once it lands
|
||||
// rather than dropping it; nothing else would correct the label afterwards.
|
||||
property bool refreshPending: false
|
||||
|
||||
function refresh() {
|
||||
if (queryProc.running) {
|
||||
refreshPending = true
|
||||
return
|
||||
}
|
||||
|
||||
refreshPending = false
|
||||
queryProc.running = true
|
||||
}
|
||||
|
||||
// Keyboards someone can actually type on, which is not everything Hyprland
|
||||
// calls a keyboard.
|
||||
function typedKeyboards(keyboards) {
|
||||
return keyboards.filter(k => KeyboardLayoutModel.isTypedKeyboard(k.name))
|
||||
}
|
||||
|
||||
// The main flag names no keyboard for long: fcitx5 takes it with the virtual
|
||||
// keyboard it binds to inject, which leaves no typed keyboard holding it and
|
||||
// nothing to read at all, and once that unbinds it lands on whichever device
|
||||
// Hyprland saw last, a power button included. Go by layout progress instead,
|
||||
// and by the keyboard activelayout named.
|
||||
function selectKeyboard(typed) {
|
||||
return KeyboardLayoutModel.selectKeyboard(typed, root.typedKeyboardName)
|
||||
}
|
||||
|
||||
// switchxkblayout is a hyprctl command rather than a dispatcher, so it has to
|
||||
// be run rather than sent over the dispatch socket. It switches the keyboard
|
||||
// the last reading spoke for, so a click always advances the device the label
|
||||
// is describing. Switching the seat together would reach the typed keyboard
|
||||
// without having to name it, but it would also carry the buttons along, and
|
||||
// the whole read depends on those staying where they started: once a button
|
||||
// has been advanced too, a toggle that wraps the keyboard back to the first
|
||||
// layout leaves the button reading as the furthest along, and the label
|
||||
// follows the button.
|
||||
function cycleLayout() {
|
||||
if (!root.keyboardName || !root.bar) return
|
||||
root.bar.run("hyprctl switchxkblayout " + Util.shellQuote(root.keyboardName) + " next")
|
||||
refreshTimer.restart()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
briefsProc.running = true
|
||||
refresh()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Hyprland
|
||||
function onRawEvent(event) {
|
||||
if (!event || !event.name) return
|
||||
var name = String(event.name)
|
||||
// The event names the keyboard that switched ahead of the layout it moved
|
||||
// to, and that is the keyboard being typed on whatever holds the main flag.
|
||||
if (name === "activelayout") {
|
||||
const named = KeyboardLayoutModel.eventKeyboardName(event)
|
||||
if (named) root.typedKeyboardName = named
|
||||
}
|
||||
|
||||
// A reload that adds a layout to kb_layout decides whether the widget
|
||||
// shows at all, and leaves every keyboard on the layout it was already
|
||||
// reading, so it raises no activelayout to notice it by.
|
||||
if (name.indexOf("activelayout") !== -1 || name === "configreloaded") root.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: queryProc
|
||||
command: ["hyprctl", "-j", "devices"]
|
||||
onRunningChanged: {
|
||||
if (running) {
|
||||
stallTimer.restart()
|
||||
return
|
||||
}
|
||||
|
||||
stallTimer.stop()
|
||||
if (root.refreshPending) root.refresh()
|
||||
}
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
let listed
|
||||
try {
|
||||
listed = JSON.parse(text || "{}").keyboards
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
|
||||
// A query the watchdog killed reports nothing at all, and an empty
|
||||
// string parses into the same shape a seat with no keyboards would.
|
||||
// Tell them apart by the list itself, so only a reading that reached
|
||||
// hyprctl gets to speak for the seat.
|
||||
if (!Array.isArray(listed)) return
|
||||
|
||||
const typed = root.typedKeyboards(listed)
|
||||
const kb = root.selectKeyboard(typed)
|
||||
if (!kb || !kb.active_keymap) {
|
||||
// Either the last keyboard has been unplugged, which the label has to
|
||||
// stop describing and the click has to stop naming, or keyboards are
|
||||
// there and none of them reports a keymap. Both leave the shape in
|
||||
// doubt, so keep asking rather than letting a count from before it
|
||||
// changed settle the poll.
|
||||
root.keyboardUnresolved = true
|
||||
if (typed.length === 0) {
|
||||
root.layoutFull = ""
|
||||
root.keyboardName = ""
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
root.keyboardUnresolved = false
|
||||
root.keyboardCount = typed.length
|
||||
root.keyboardName = String(kb.name || "")
|
||||
root.multipleLayouts = kb.layout === undefined || String(kb.layout).indexOf(",") !== -1
|
||||
root.layoutFull = kb.active_keymap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The table only changes when xkb data is upgraded, so read it at startup and
|
||||
// leave it alone. The bar is built per monitor, so this runs once per widget.
|
||||
// The exotic rulesets cover layouts like trans (IPA) that ship in the same xkb
|
||||
// package and set just as well, so load them or those labels lose their code.
|
||||
Process {
|
||||
id: briefsProc
|
||||
command: ["xkbcli", "list", "--load-exotic"]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.layoutBriefs = KeyboardLayoutModel.layoutBriefs(text)
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: 600
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
// A query that never returns would freeze the label until the shell restarts,
|
||||
// since a Process that is already running can't be re-run. Give up on one that
|
||||
// overstays so the next refresh gets through, and ask again: the reading it
|
||||
// never delivered may have been the only one due on a settled seat, and
|
||||
// nothing else would come back for it.
|
||||
Timer {
|
||||
id: stallTimer
|
||||
interval: 5000
|
||||
onTriggered: {
|
||||
queryProc.running = false
|
||||
refreshTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
// Which keyboard on a crowded seat the label is describing can change without
|
||||
// Hyprland announcing it, since a device arriving or leaving raises no event
|
||||
// of its own, and that can only be learned by asking. Poll while there is that
|
||||
// ambiguity, until a first reading lands so a query that failed at login still
|
||||
// recovers, and while a reading has left the seat's shape in doubt. The
|
||||
// one-keyboard install has none of those, and is left alone rather than
|
||||
// spawning hyprctl forever for an answer that cannot change.
|
||||
Timer {
|
||||
interval: 10000
|
||||
running: !root.keyboardName || root.keyboardUnresolved || root.keyboardCount > 1
|
||||
repeat: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
visible: layoutLabel !== "" && multipleLayouts
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.layoutLabel
|
||||
fontSize: Style.font.caption
|
||||
horizontalMargin: 6
|
||||
tooltipText: root.layoutFull
|
||||
onPressed: function() { root.cycleLayout() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Label math for the keyboard layout widget, kept Qt-free so it can be unit
|
||||
// tested under node (test/shell.d/keyboard-layout-test.sh).
|
||||
|
||||
// xkbcli list prints YAML, and every layout and variant block pairs a brief with
|
||||
// the description hyprctl reports as the active keymap:
|
||||
//
|
||||
// - layout: 'us'
|
||||
// variant: ''
|
||||
// brief: 'en'
|
||||
// description: English (US)
|
||||
//
|
||||
// The models and option groups it also prints carry no brief of their own, and
|
||||
// a brief never carries past the block it was printed in, so neither reaches
|
||||
// the table.
|
||||
function layoutBriefs(text) {
|
||||
var briefs = {}
|
||||
var brief = ""
|
||||
|
||||
String(text || "").split("\n").forEach(function (line) {
|
||||
if (/^\s*- /.test(line)) brief = ""
|
||||
|
||||
var field = line.match(/^ (brief|description): (.*)$/)
|
||||
if (!field) return
|
||||
|
||||
if (field[1] === "brief") {
|
||||
brief = field[2].replace(/^'|'$/g, "")
|
||||
} else if (brief) {
|
||||
briefs[field[2]] = brief
|
||||
brief = ""
|
||||
}
|
||||
})
|
||||
|
||||
return briefs
|
||||
}
|
||||
|
||||
// The brief is a short language code rather than a country one, which keeps the
|
||||
// label sensible for the layouts named after a language: Esperanto reads EO and
|
||||
// Arabic reads AR. It is the same code GNOME shows in its own indicator.
|
||||
//
|
||||
// Layouts missing from the table fall back to the first word of the description,
|
||||
// which reads as ENG/POR but at least says something.
|
||||
//
|
||||
// Nearly every brief is a bare two-letter code, but a few tack a script onto it
|
||||
// (Burmese (Zawgyi) is my-zwg) and the custom layout's is a word, so drop the
|
||||
// script and cap the result at the same three characters the fallback gets.
|
||||
// The widget sits between fixed neighbours on the bar and has no room to grow.
|
||||
function shortLabel(description, briefs) {
|
||||
if (!description) return ""
|
||||
|
||||
// A description like "constructor" reaches an inherited member rather than a
|
||||
// brief, so take the lookup only when it hands back the string it promises.
|
||||
var brief = (briefs || {})[description]
|
||||
var label = typeof brief === "string" && brief ? brief.split("-")[0] : description.split(/\s+/)[0]
|
||||
return label.substring(0, 3).toUpperCase()
|
||||
}
|
||||
|
||||
// Hyprland's activelayout event pairs the keyboard that switched with the layout
|
||||
// it moved to. Quickshell cuts the event into that many fields, so a description
|
||||
// carrying a comma of its own stays in one piece; a binding old enough to hand
|
||||
// back only the raw string gets split by hand. The virtual keyboard fcitx5 binds
|
||||
// to inject announces switches too, and names a keyboard nobody types on.
|
||||
function eventKeyboardName(event) {
|
||||
var parts
|
||||
|
||||
try {
|
||||
if (event && event.parse) parts = event.parse(2)
|
||||
} catch (error) {
|
||||
}
|
||||
|
||||
if (!parts) parts = String(event && event.data ? event.data : "").split(",")
|
||||
|
||||
var name = String(parts[0] || "")
|
||||
return name.indexOf("hl-virtual-keyboard") === 0 ? "" : name
|
||||
}
|
||||
|
||||
// Hyprland reports more than keyboards as keyboards. fcitx5 binds a virtual one
|
||||
// to inject through, which keeps the us layout the input method gave it, and the
|
||||
// ACPI power button, lid switch and sleep key each arrive carrying the seat's
|
||||
// layout list without anyone ever typing on them. Both answer to switchxkblayout
|
||||
// and both can hold the main flag, so a widget that reads or switches whatever
|
||||
// the seat hands it ends up describing a button. Leave them out and what remains
|
||||
// is keyboards, which is what the rest of this file can then assume.
|
||||
//
|
||||
// Missing a name here costs the accuracy the seat had before, never a keyboard:
|
||||
// anything unrecognised stays in the list.
|
||||
var UNTYPED_KEYBOARDS = /^(hl-virtual-keyboard|power-button|sleep-button|lid-switch|video-bus)/
|
||||
|
||||
function isTypedKeyboard(name) {
|
||||
return !UNTYPED_KEYBOARDS.test(String(name || ""))
|
||||
}
|
||||
|
||||
// Every keyboard on the seat carries the same layout list unless one was given
|
||||
// its own, but only the one being typed on advances through it. So the
|
||||
// furthest-advanced is the one worth reading, and a switch names the keyboard it
|
||||
// moved, which settles a seat holding two real keyboards outright.
|
||||
//
|
||||
// The name is taken whenever a keyboard still answers to it, wherever that
|
||||
// keyboard sits in the list. Comparing positions instead would read the wrong
|
||||
// keyboard the moment one wrapped from the last layout back to the first, which
|
||||
// is the ordinary way round a pair of them. Applying a layout to the whole seat
|
||||
// names a keyboard too, but leaves every one of them on the same layout, so the
|
||||
// label reads the same whichever of them the name settles on.
|
||||
function selectKeyboard(typed, namedByEvent) {
|
||||
var keyboards = typed || []
|
||||
|
||||
return keyboards.find(function (keyboard) {
|
||||
return keyboard.name === namedByEvent
|
||||
}) || keyboards.reduce(function (furthest, keyboard) {
|
||||
return layoutIndex(keyboard) > layoutIndex(furthest) ? keyboard : furthest
|
||||
}, keyboards[0])
|
||||
}
|
||||
|
||||
function layoutIndex(keyboard) {
|
||||
return (keyboard && keyboard.active_layout_index) || 0
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
eventKeyboardName: eventKeyboardName,
|
||||
isTypedKeyboard: isTypedKeyboard,
|
||||
layoutBriefs: layoutBriefs,
|
||||
selectKeyboard: selectKeyboard,
|
||||
shortLabel: shortLabel
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.microphone",
|
||||
"name": "Microphone",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Mic input state and mute toggle",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "Microphone.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Microphone",
|
||||
"description": "Mic input state and mute toggle",
|
||||
"category": "Audio",
|
||||
"allowMultiple": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "blob.microphone"
|
||||
|
||||
|
||||
readonly property var source: Pipewire.defaultAudioSource
|
||||
readonly property bool muted: source && source.audio ? source.audio.muted : true
|
||||
readonly property real volume: source && source.audio ? source.audio.volume : 0
|
||||
readonly property var nodes: Pipewire.nodes ? Pipewire.nodes.values : []
|
||||
|
||||
readonly property var activeStreams: {
|
||||
var list = []
|
||||
for (var i = 0; i < nodes.length; i++) {
|
||||
var node = nodes[i]
|
||||
if (node && node.isStream && node.isSink === false && !node.audio?.muted) list.push(node)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
readonly property bool inUse: activeStreams.length > 0 && !muted
|
||||
|
||||
visible: source !== null
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
function toggleMute() {
|
||||
if (source && source.audio) source.audio.muted = !source.audio.muted
|
||||
}
|
||||
|
||||
PwObjectTracker { objects: root.source ? [root.source] : [] }
|
||||
|
||||
BarIconButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.muted ? "" : ""
|
||||
active: root.inUse
|
||||
tooltipText: root.muted ? "Microphone muted" : (root.inUse ? "Microphone in use" : "Microphone live")
|
||||
onPressed: function(b) {
|
||||
if (b === Qt.MiddleButton) root.bar.run("blob-shell shell toggle blob.audio")
|
||||
else root.toggleMute()
|
||||
}
|
||||
onWheelMoved: function(delta) {
|
||||
if (!root.source || !root.source.audio) return
|
||||
var step = 0.05
|
||||
root.source.audio.volume = Math.max(0, Math.min(1, root.volume + (delta > 0 ? step : -step)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.spacer",
|
||||
"name": "Spacer",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Configurable blank space",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "Spacer.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Spacer",
|
||||
"description": "Configurable blank space",
|
||||
"category": "Layout",
|
||||
"allowMultiple": true,
|
||||
"settingsForm": "spacerSettings"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import QtQuick
|
||||
import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "blob.spacer"
|
||||
|
||||
readonly property int span: settings && settings.size !== undefined ? Number(settings.size) : 12
|
||||
|
||||
implicitWidth: vertical ? barSize : span
|
||||
implicitHeight: vertical ? span : barSize
|
||||
visible: span > 0
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.system-update",
|
||||
"name": "Blob update",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Indicates available Blob updates",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "SystemUpdate.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Blob update",
|
||||
"description": "Indicates available Blob updates",
|
||||
"category": "System",
|
||||
"allowMultiple": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "blob.system-update"
|
||||
|
||||
property bool updateAvailable: false
|
||||
|
||||
function refresh() {
|
||||
if (!updateProc.running) updateProc.running = true
|
||||
}
|
||||
|
||||
function clear() { updateAvailable = false }
|
||||
|
||||
function runUpdate() {
|
||||
if (root.bar) root.bar.run("blob-launch-floating blob-update")
|
||||
}
|
||||
|
||||
visible: updateAvailable
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
IpcHandler {
|
||||
target: "blob.system-update"
|
||||
|
||||
function refresh(): void {
|
||||
root.broadcast("refresh")
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
root.broadcast("clear")
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: updateProc
|
||||
command: ["blob-update-available"]
|
||||
onExited: function(exitCode) {
|
||||
root.updateAvailable = exitCode === 0
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 21600000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
BarIconButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: "\uf021"
|
||||
slotSize: Style.bar.statusSlot
|
||||
fontSize: Style.font.caption
|
||||
tooltipText: "Pending Blob Updates"
|
||||
onPressed: root.runUpdate()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.tray",
|
||||
"name": "System tray",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Status notifier items",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "Tray.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "System tray",
|
||||
"description": "Status notifier items",
|
||||
"category": "Status",
|
||||
"allowMultiple": false
|
||||
},
|
||||
"blob": {
|
||||
"clonePaths": [
|
||||
{
|
||||
"source": "TrayModel.js",
|
||||
"target": "TrayModel.js"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,850 @@
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Effects
|
||||
import Quickshell.Services.SystemTray
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
import "TrayModel.js" as TrayModel
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "blob.tray"
|
||||
|
||||
property bool expanded: false
|
||||
property bool managePopupOpen: false
|
||||
property bool trayMenuOpen: false
|
||||
property var activeTrayItem: null
|
||||
property var activeTrayAnchor: null
|
||||
readonly property color foreground: bar ? bar.foreground : Color.foreground
|
||||
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||||
readonly property var pinnedIds: settings.pinned instanceof Array ? settings.pinned : []
|
||||
readonly property var hiddenIds: settings.hidden instanceof Array ? settings.hidden : []
|
||||
readonly property var pinnedItems: bucket("pinned")
|
||||
readonly property var drawerItems: bucket("drawer")
|
||||
readonly property var allItems: bucket("all")
|
||||
readonly property int drawerCount: drawerItems.length
|
||||
readonly property int trayItemExtent: Style.bar.iconSlot
|
||||
readonly property int trayItemGap: 0
|
||||
readonly property int trayJoinGap: 0
|
||||
readonly property int drawerExtent: drawerCount > 0 ? drawerCount * trayItemExtent + (drawerCount - 1) * trayItemGap : 0
|
||||
// Match Waybar's group/tray-expander drawer transition-duration.
|
||||
readonly property int animationDuration: 600
|
||||
property real revealProgress: expanded ? 1 : 0
|
||||
readonly property real revealExtent: drawerExtent * revealProgress
|
||||
|
||||
// Submenu drill-down state. QsMenuEntry.display() renders a *platform* menu,
|
||||
// which Quickshell refuses unless the shell root sets `//@ pragma
|
||||
// UseQApplication` - blob's shell.qml does not, so every submenu click was
|
||||
// a silent no-op ("Cannot display PlatformMenuEntry as quickshell was not
|
||||
// started in QApplication mode" in the shell log) and apps whose whole UI is
|
||||
// submenus, e.g. radiotray-ng's station list, were unusable. QsMenuEntry
|
||||
// inherits QsMenuHandle, so a child entry can feed a nested QsMenuOpener and
|
||||
// render inside this popup instead of going through the platform. Each level
|
||||
// keeps its own live opener: a child entry is owned by its parent opener's
|
||||
// model, so collapsing the stack to a single opener would destroy the very
|
||||
// entry being displayed (submenu turns up empty).
|
||||
property var submenuStack: []
|
||||
readonly property int submenuDepth: submenuStack.length
|
||||
readonly property string currentTitle: submenuDepth > 0 ? submenuStack[submenuDepth - 1].title : ""
|
||||
readonly property var currentChildren: submenuDepth > 0
|
||||
? submenuStack[submenuDepth - 1].opener.children
|
||||
: trayMenuOpener.children
|
||||
|
||||
// Changing level rebuilds the row delegates synchronously, so the next
|
||||
// row lands under a cursor that hasn't moved. Submenu clicks used to be
|
||||
// silent no-ops, which trained users to click them twice, and that second
|
||||
// click would now fire whatever entry took the spot. Ignore row clicks for
|
||||
// a beat after each level change; a deliberate follow-up click is slower.
|
||||
property bool menuLevelSettling: false
|
||||
|
||||
Component {
|
||||
id: submenuOpenerComponent
|
||||
QsMenuOpener {}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: menuLevelSettleTimer
|
||||
interval: 250
|
||||
onTriggered: root.menuLevelSettling = false
|
||||
}
|
||||
|
||||
function settleMenuLevel() {
|
||||
menuLevelSettling = true
|
||||
menuLevelSettleTimer.restart()
|
||||
}
|
||||
|
||||
function resetTrayMenu() {
|
||||
menuLevelSettling = false
|
||||
menuLevelSettleTimer.stop()
|
||||
// Flickable keeps its offset across a model swap whenever the new content
|
||||
// is still tall enough to hold it, so a menu dismissed while scrolled
|
||||
// would otherwise reopen part-way down with its first entries off screen.
|
||||
trayMenuFlick.contentY = 0
|
||||
// Clear the reactive stack before tearing anything down, so no binding can
|
||||
// read a partially-destroyed opener while this runs. Then destroy deepest
|
||||
// first: an inner opener's menu entry is owned by its parent's children
|
||||
// model, so destroying a parent first would invalidate an entry a still-
|
||||
// live child opener references.
|
||||
var openers = submenuStack
|
||||
submenuStack = []
|
||||
for (var i = openers.length - 1; i >= 0; i--) openers[i].opener.destroy()
|
||||
}
|
||||
|
||||
function enterSubmenu(entry, title) {
|
||||
var opener = submenuOpenerComponent.createObject(root, { menu: entry })
|
||||
if (!opener) return
|
||||
var stack = submenuStack.slice()
|
||||
stack.push({ opener: opener, title: title })
|
||||
submenuStack = stack
|
||||
settleMenuLevel()
|
||||
}
|
||||
|
||||
function leaveSubmenu() {
|
||||
if (submenuStack.length === 0) return
|
||||
var stack = submenuStack.slice()
|
||||
var top = stack.pop()
|
||||
submenuStack = stack
|
||||
top.opener.destroy()
|
||||
settleMenuLevel()
|
||||
}
|
||||
|
||||
function close() {
|
||||
managePopupOpen = false
|
||||
trayMenuOpen = false
|
||||
}
|
||||
|
||||
function openTrayMenu(item, anchorItem, mouse) {
|
||||
if (!item || !item.menu) {
|
||||
var point = anchorItem.QsWindow.contentItem.mapFromItem(anchorItem, mouse.x, mouse.y)
|
||||
item.display(anchorItem.QsWindow.window, point.x, point.y)
|
||||
return
|
||||
}
|
||||
|
||||
// Reset before switching items: trayMenuOpener.menu binds to
|
||||
// activeTrayItem.menu, so assigning a new item invalidates the old root's
|
||||
// children immediately, before any nested opener referencing them would
|
||||
// otherwise get torn down.
|
||||
resetTrayMenu()
|
||||
activeTrayItem = item
|
||||
activeTrayAnchor = anchorItem
|
||||
trayMenuOpen = true
|
||||
}
|
||||
|
||||
function trayIconSource(icon) {
|
||||
// Quickshell already resolves the tray icon into a ready-to-use image://
|
||||
// URL, including a "?path=" fallback search dir for apps that ship their
|
||||
// tray icon outside a standard theme (e.g. Steam's flat public/ dir). Hand
|
||||
// it straight to IconImage; guessing a theme sub-directory here only broke
|
||||
// apps whose layout didn't match the guess.
|
||||
return String(icon || "")
|
||||
}
|
||||
|
||||
// Symbolic icons ship a fixed fill (often near-white) that the host is meant
|
||||
// to recolor to its foreground; detect them by the freedesktop "-symbolic"
|
||||
// name suffix so they can be tinted instead of rendered as-is.
|
||||
function iconIsSymbolic(icon) {
|
||||
var name = String(icon || "").split("?")[0]
|
||||
return name.slice(-9) === "-symbolic"
|
||||
}
|
||||
|
||||
function trayTooltip(item) {
|
||||
return item.tooltipTitle || item.title || item.id || ""
|
||||
}
|
||||
|
||||
function classifyItem(item) {
|
||||
var iid = String(item.id || "")
|
||||
if (hiddenIds.indexOf(iid) !== -1) return "hidden"
|
||||
if (pinnedIds.indexOf(iid) !== -1) return "pinned"
|
||||
return "drawer"
|
||||
}
|
||||
|
||||
function ownedByBlob(item) {
|
||||
var layout = root.bar && root.bar.layoutConfig ? root.bar.layoutConfig : null
|
||||
return TrayModel.ownedByBlob(item, layout)
|
||||
}
|
||||
|
||||
function bucket(category) {
|
||||
var values = SystemTray.items.values
|
||||
var result = []
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var item = values[i]
|
||||
if (item.status === Status.Passive) continue
|
||||
if (ownedByBlob(item)) continue
|
||||
if (category === "all") {
|
||||
result.push(item)
|
||||
continue
|
||||
}
|
||||
if (classifyItem(item) === category) result.push(item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function persistTrayState(pinned, hidden) {
|
||||
if (!root.bar || !root.bar.shell || typeof root.bar.shell.updateEntryInline !== "function") return
|
||||
var id = root.moduleName || "blob.tray"
|
||||
root.bar.shell.updateEntryInline(id, { id: id, pinned: pinned, hidden: hidden })
|
||||
}
|
||||
|
||||
function togglePin(iid) {
|
||||
var p = pinnedIds.slice(), h = hiddenIds.slice()
|
||||
var idx = p.indexOf(iid)
|
||||
if (idx !== -1) p.splice(idx, 1)
|
||||
else {
|
||||
p.push(iid)
|
||||
var hi = h.indexOf(iid)
|
||||
if (hi !== -1) h.splice(hi, 1)
|
||||
}
|
||||
persistTrayState(p, h)
|
||||
}
|
||||
|
||||
function toggleHide(iid) {
|
||||
var p = pinnedIds.slice(), h = hiddenIds.slice()
|
||||
var idx = h.indexOf(iid)
|
||||
if (idx !== -1) h.splice(idx, 1)
|
||||
else {
|
||||
h.push(iid)
|
||||
var pi = p.indexOf(iid)
|
||||
if (pi !== -1) p.splice(pi, 1)
|
||||
}
|
||||
persistTrayState(p, h)
|
||||
}
|
||||
|
||||
visible: pinnedItems.length > 0 || drawerCount > 0
|
||||
clip: false
|
||||
implicitWidth: root.vertical ? root.barSize : trayContent.implicitWidth
|
||||
implicitHeight: root.vertical ? trayContent.implicitHeight : root.barSize
|
||||
|
||||
Behavior on revealProgress {
|
||||
NumberAnimation { duration: root.animationDuration; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
Loader {
|
||||
id: trayContent
|
||||
anchors.fill: parent
|
||||
sourceComponent: root.vertical ? verticalTray : horizontalTray
|
||||
}
|
||||
|
||||
Component {
|
||||
id: horizontalTray
|
||||
|
||||
Item {
|
||||
id: horizontalTrayRoot
|
||||
|
||||
readonly property int pinnedWidth: pinnedRow.implicitWidth
|
||||
readonly property int drawerBlockWidth: root.allItems.length > 0 ? expandIcon.implicitWidth + root.drawerExtent : 0
|
||||
|
||||
implicitWidth: pinnedWidth + drawerBlockWidth
|
||||
implicitHeight: root.barSize
|
||||
|
||||
// Mask out the empty area the collapsed drawer reserves for its slide-in,
|
||||
// so hovering it doesn't trigger expand and clicks pass through.
|
||||
containmentMask: QtObject {
|
||||
function contains(point: point): bool {
|
||||
if (point.y < 0 || point.y > horizontalTrayRoot.height) return false
|
||||
// Drawer reveals leftward; chevron sits at the right end when collapsed
|
||||
// and slides left as it opens. The visible region starts at the chevron.
|
||||
var chevronX = root.drawerExtent - root.revealExtent
|
||||
if (point.x >= chevronX && point.x <= horizontalTrayRoot.drawerBlockWidth) return true
|
||||
// Pinned items, placed to the right of the drawer block.
|
||||
var pinnedStart = horizontalTrayRoot.drawerBlockWidth
|
||||
return point.x >= pinnedStart && point.x <= horizontalTrayRoot.implicitWidth
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: drawerArea
|
||||
x: 0
|
||||
width: horizontalTrayRoot.drawerBlockWidth
|
||||
height: root.barSize
|
||||
visible: root.allItems.length > 0
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.expanded = hovered
|
||||
}
|
||||
|
||||
BarIconButton {
|
||||
id: expandIcon
|
||||
bar: root.bar
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
x: root.drawerExtent - root.revealExtent
|
||||
text: "\uf053"
|
||||
onPressed: function(button) {
|
||||
if (button === Qt.RightButton) root.managePopupOpen = !root.managePopupOpen
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: trayClip
|
||||
x: expandIcon.width
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: root.drawerExtent
|
||||
height: root.barSize
|
||||
clip: true
|
||||
|
||||
Row {
|
||||
id: trayIcons
|
||||
x: root.drawerExtent - root.revealExtent
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: root.trayItemGap
|
||||
layer.enabled: true
|
||||
|
||||
Repeater {
|
||||
model: root.drawerItems
|
||||
TrayItem {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row {
|
||||
id: pinnedRow
|
||||
x: drawerArea.x + horizontalTrayRoot.drawerBlockWidth
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: root.trayItemGap
|
||||
leftPadding: root.pinnedItems.length > 0 && root.allItems.length > 0 ? root.trayJoinGap : 0
|
||||
Repeater {
|
||||
model: root.pinnedItems
|
||||
TrayItem {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: verticalTray
|
||||
|
||||
Item {
|
||||
id: verticalTrayRoot
|
||||
|
||||
readonly property int pinnedHeight: pinnedCol.implicitHeight
|
||||
readonly property int drawerBlockHeight: root.allItems.length > 0 ? expandIcon.implicitHeight + root.drawerExtent : 0
|
||||
|
||||
implicitWidth: root.barSize
|
||||
implicitHeight: pinnedHeight + drawerBlockHeight
|
||||
|
||||
containmentMask: QtObject {
|
||||
function contains(point: point): bool {
|
||||
if (point.x < 0 || point.x > verticalTrayRoot.width) return false
|
||||
var chevronY = root.drawerExtent - root.revealExtent
|
||||
if (point.y >= chevronY && point.y <= verticalTrayRoot.drawerBlockHeight) return true
|
||||
var pinnedStart = verticalTrayRoot.drawerBlockHeight
|
||||
return point.y >= pinnedStart && point.y <= verticalTrayRoot.implicitHeight
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: drawerArea
|
||||
y: 0
|
||||
width: root.barSize
|
||||
height: verticalTrayRoot.drawerBlockHeight
|
||||
visible: root.allItems.length > 0
|
||||
|
||||
HoverHandler {
|
||||
onHoveredChanged: root.expanded = hovered
|
||||
}
|
||||
|
||||
BarIconButton {
|
||||
id: expandIcon
|
||||
bar: root.bar
|
||||
width: implicitWidth
|
||||
height: implicitHeight
|
||||
y: root.drawerExtent - root.revealExtent
|
||||
text: "\uf053"
|
||||
textRotation: 90
|
||||
onPressed: function(button) {
|
||||
if (button === Qt.RightButton) root.managePopupOpen = !root.managePopupOpen
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
id: trayClip
|
||||
y: expandIcon.height
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: root.barSize
|
||||
height: root.drawerExtent
|
||||
clip: true
|
||||
|
||||
Column {
|
||||
id: trayIcons
|
||||
y: root.drawerExtent - root.revealExtent
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: root.trayItemGap
|
||||
layer.enabled: true
|
||||
|
||||
Repeater {
|
||||
model: root.drawerItems
|
||||
TrayItem {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
id: pinnedCol
|
||||
y: drawerArea.y + verticalTrayRoot.drawerBlockHeight
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
spacing: root.trayItemGap
|
||||
topPadding: root.pinnedItems.length > 0 && root.allItems.length > 0 ? root.trayJoinGap : 0
|
||||
Repeater {
|
||||
model: root.pinnedItems
|
||||
TrayItem {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PopupCard {
|
||||
id: managePopup
|
||||
anchorItem: root
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.managePopupOpen
|
||||
contentWidth: managePopup.fittedContentWidth(Style.space(300))
|
||||
contentHeight: managePopup.fittedContentHeight(manageColumn.implicitHeight)
|
||||
|
||||
Column {
|
||||
id: manageColumn
|
||||
anchors.fill: parent
|
||||
spacing: Style.space(8)
|
||||
|
||||
Text {
|
||||
text: "Tray icons"
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.body
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Pinned icons stay visible. Hidden icons never show."
|
||||
color: Qt.darker(root.foreground, 1.4)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.caption
|
||||
wrapMode: Text.WordWrap
|
||||
width: parent.width
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.allItems.length === 0
|
||||
text: "No tray items reporting."
|
||||
color: Qt.darker(root.foreground, 1.5)
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
font.italic: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: root.allItems
|
||||
delegate: Item {
|
||||
id: rowRoot
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: manageColumn.width
|
||||
implicitHeight: 28
|
||||
|
||||
readonly property string itemId: String(modelData.id || "")
|
||||
readonly property string displayName: {
|
||||
var t = String(modelData.title || "").trim()
|
||||
if (t) return t
|
||||
var tt = String(modelData.tooltipTitle || "").trim()
|
||||
if (tt) return tt
|
||||
var id = String(modelData.id || "")
|
||||
var slash = id.lastIndexOf("/")
|
||||
return slash !== -1 ? id.substring(slash + 1) : (id || "Unknown")
|
||||
}
|
||||
readonly property bool isPinned: root.pinnedIds.indexOf(itemId) !== -1
|
||||
readonly property bool isHidden: root.hiddenIds.indexOf(itemId) !== -1
|
||||
|
||||
TrayIcon {
|
||||
id: rowIcon
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
width: 16
|
||||
height: 16
|
||||
icon: rowRoot.modelData.icon
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: rowIcon.right
|
||||
anchors.leftMargin: Style.space(10)
|
||||
anchors.right: rowHideBtn.left
|
||||
anchors.rightMargin: Style.space(8)
|
||||
text: rowRoot.displayName
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Button {
|
||||
id: rowPinBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
iconText: "\uf08d"
|
||||
text: rowRoot.isPinned ? "Unpin" : "Pin"
|
||||
foreground: root.foreground
|
||||
horizontalPadding: 8
|
||||
verticalPadding: 3
|
||||
iconSize: Style.font.bodySmall
|
||||
fontSize: Style.font.bodySmall
|
||||
onClicked: root.togglePin(rowRoot.itemId)
|
||||
}
|
||||
|
||||
Button {
|
||||
id: rowHideBtn
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: rowPinBtn.left
|
||||
anchors.rightMargin: Style.space(6)
|
||||
iconText: "\uf06e"
|
||||
text: rowRoot.isHidden ? "Show" : "Hide"
|
||||
foreground: root.foreground
|
||||
horizontalPadding: 8
|
||||
verticalPadding: 3
|
||||
iconSize: Style.font.bodySmall
|
||||
fontSize: Style.font.bodySmall
|
||||
onClicked: root.toggleHide(rowRoot.itemId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
QsMenuOpener {
|
||||
id: trayMenuOpener
|
||||
menu: root.activeTrayItem ? root.activeTrayItem.menu : null
|
||||
}
|
||||
|
||||
PopupCard {
|
||||
id: trayMenuPopup
|
||||
anchorItem: root.activeTrayAnchor || root
|
||||
owner: root
|
||||
bar: root.bar
|
||||
open: root.trayMenuOpen
|
||||
// The card fades out over 140ms (visible stays true for that whole time --
|
||||
// see PopupCard's own visible: open || card.opacity > 0), so resetting on
|
||||
// "open" would swap a live submenu for the root menu mid-fade: a visible
|
||||
// flash, and a resize/reposition if the two have different geometry. Wait
|
||||
// for the fade to actually finish. Switching to a different tray item
|
||||
// still resets immediately, from openTrayMenu() itself.
|
||||
onVisibleChanged: if (!visible) root.resetTrayMenu()
|
||||
padding: Style.space(8)
|
||||
borderColor: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.45)
|
||||
contentWidth: trayMenuPopup.fittedContentWidth(Style.space(232))
|
||||
contentHeight: trayMenuPopup.fittedContentHeight(menuHeaderHeight + trayMenuColumn.implicitHeight, Style.space(420))
|
||||
|
||||
// Column skips invisible children but keeps reporting their height, so
|
||||
// read the header's extent through its own visibility.
|
||||
readonly property int menuHeaderHeight: menuHeader.visible ? menuHeader.implicitHeight : 0
|
||||
|
||||
Column {
|
||||
id: trayMenuLayout
|
||||
anchors.fill: parent
|
||||
spacing: 0
|
||||
|
||||
// Header for a drilled-into submenu: names where we are and walks back
|
||||
// out. Pinned above the Flickable rather than scrolling with the rows,
|
||||
// so the way back stays reachable in a submenu taller than the card.
|
||||
// Only present below the root level, so the root menu is unchanged.
|
||||
Column {
|
||||
id: menuHeader
|
||||
visible: root.submenuDepth > 0
|
||||
width: trayMenuLayout.width
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
id: menuBackRow
|
||||
width: menuHeader.width
|
||||
implicitHeight: Style.space(30)
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: Math.max(2, Style.cornerRadius)
|
||||
color: backMouse.containsMouse ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent"
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
width: Style.space(22)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: "\u2039"
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(28)
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.space(10)
|
||||
text: root.currentTitle
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: backMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
if (root.menuLevelSettling) return
|
||||
// Reset before the model swap so the parent level shows from
|
||||
// the top (same ordering as the row delegate below).
|
||||
trayMenuFlick.contentY = 0
|
||||
root.leaveSubmenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Item {
|
||||
width: menuHeader.width
|
||||
implicitHeight: Style.space(11)
|
||||
|
||||
Rectangle {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(10)
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.space(10)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 1
|
||||
color: Color.popups.border
|
||||
opacity: 0.45
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
id: trayMenuFlick
|
||||
width: trayMenuLayout.width
|
||||
height: trayMenuLayout.height - trayMenuPopup.menuHeaderHeight
|
||||
contentWidth: width
|
||||
contentHeight: trayMenuColumn.implicitHeight
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
flickableDirection: Flickable.VerticalFlick
|
||||
interactive: contentHeight > height
|
||||
|
||||
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
||||
|
||||
Column {
|
||||
id: trayMenuColumn
|
||||
width: trayMenuFlick.width
|
||||
spacing: 0
|
||||
|
||||
Repeater {
|
||||
model: root.currentChildren
|
||||
|
||||
delegate: Item {
|
||||
id: menuRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string rowText: String(modelData.text || "")
|
||||
readonly property string activeTitle: root.activeTrayItem ? String(root.activeTrayItem.title || root.activeTrayItem.id || "") : ""
|
||||
// Both only ever describe the root menu; inside a submenu the first
|
||||
// rows are real entries and must not be swallowed.
|
||||
readonly property bool atRoot: root.submenuDepth === 0
|
||||
readonly property bool rootTitleEntry: atRoot && index === 0 && modelData.hasChildren && rowText.toLowerCase() === activeTitle.toLowerCase()
|
||||
readonly property bool leadingSeparator: atRoot && modelData.isSeparator && index <= 1
|
||||
readonly property bool hiddenRow: rootTitleEntry || leadingSeparator
|
||||
|
||||
visible: !hiddenRow
|
||||
width: trayMenuColumn.width
|
||||
implicitHeight: hiddenRow ? 0 : (modelData.isSeparator ? Style.space(11) : Style.space(30))
|
||||
opacity: modelData.enabled ? 1.0 : 0.45
|
||||
|
||||
Rectangle {
|
||||
visible: menuRow.modelData.isSeparator
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(10)
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.space(10)
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
height: 1
|
||||
color: Color.popups.border
|
||||
opacity: 0.45
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: !menuRow.modelData.isSeparator
|
||||
anchors.fill: parent
|
||||
radius: Math.max(2, Style.cornerRadius)
|
||||
color: rowMouse.containsMouse && menuRow.modelData.enabled ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent"
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
width: Style.space(22)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: menuRow.modelData.checkState === Qt.Checked ? "\uf00c" : ""
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
Image {
|
||||
id: menuIcon
|
||||
visible: !menuRow.modelData.isSeparator && String(menuRow.modelData.icon || "") !== ""
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Style.space(24)
|
||||
width: Style.space(16)
|
||||
height: Style.space(16)
|
||||
fillMode: Image.PreserveAspectFit
|
||||
// Decode at physical pixels: IconImage uses the logical size,
|
||||
// which leaves PNG icons upscaled and blurry on HiDPI displays.
|
||||
sourceSize.width: width * Screen.devicePixelRatio
|
||||
sourceSize.height: height * Screen.devicePixelRatio
|
||||
source: menuRow.modelData.icon
|
||||
}
|
||||
|
||||
Text {
|
||||
textFormat: Text.PlainText
|
||||
visible: !menuRow.modelData.isSeparator
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: menuIcon.visible ? Style.space(46) : Style.space(28)
|
||||
anchors.right: submenuGlyph.left
|
||||
anchors.rightMargin: Style.space(8)
|
||||
text: menuRow.rowText
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
Text {
|
||||
id: submenuGlyph
|
||||
visible: !menuRow.modelData.isSeparator && menuRow.modelData.hasChildren
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: Style.space(10)
|
||||
text: "\u203a"
|
||||
color: root.foreground
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.bodySmall
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: rowMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
enabled: !menuRow.modelData.isSeparator && menuRow.modelData.enabled
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: {
|
||||
if (root.menuLevelSettling) return
|
||||
if (menuRow.modelData.hasChildren) {
|
||||
// Reset scroll BEFORE swapping the model: the swap destroys
|
||||
// this delegate synchronously and ids stop resolving after.
|
||||
trayMenuFlick.contentY = 0
|
||||
root.enterSubmenu(menuRow.modelData, menuRow.rowText)
|
||||
} else {
|
||||
menuRow.modelData.triggered()
|
||||
root.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Renders a tray icon, recoloring symbolic icons to the bar foreground so
|
||||
// they stay visible on any theme (a raw symbolic icon keeps its baked-in
|
||||
// fill and disappears against a matching background).
|
||||
component TrayIcon: Item {
|
||||
id: trayIconRoot
|
||||
required property var icon
|
||||
readonly property bool symbolic: root.iconIsSymbolic(icon)
|
||||
|
||||
Image {
|
||||
id: trayIconImage
|
||||
anchors.fill: parent
|
||||
fillMode: Image.PreserveAspectFit
|
||||
// Decode at physical pixels: IconImage uses the logical size,
|
||||
// which leaves PNG icons upscaled and blurry on HiDPI displays.
|
||||
sourceSize.width: Math.round(Math.min(width, height) * Screen.devicePixelRatio)
|
||||
sourceSize.height: Math.round(Math.min(width, height) * Screen.devicePixelRatio)
|
||||
source: root.trayIconSource(trayIconRoot.icon)
|
||||
// Kept as a hidden layer so the effect can sample it as a texture.
|
||||
visible: !trayIconRoot.symbolic
|
||||
layer.enabled: trayIconRoot.symbolic
|
||||
}
|
||||
|
||||
MultiEffect {
|
||||
anchors.fill: trayIconImage
|
||||
source: trayIconImage
|
||||
visible: trayIconRoot.symbolic
|
||||
colorization: 1.0
|
||||
colorizationColor: root.foreground
|
||||
}
|
||||
}
|
||||
|
||||
component TrayItem: Item {
|
||||
id: trayItemRoot
|
||||
|
||||
required property var modelData
|
||||
|
||||
visible: modelData.status !== Status.Passive
|
||||
implicitWidth: visible ? root.trayItemExtent : 0
|
||||
implicitHeight: visible ? root.trayItemExtent : 0
|
||||
|
||||
function displayMenu(mouse) {
|
||||
root.openTrayMenu(trayItemRoot.modelData, trayItemRoot, mouse)
|
||||
}
|
||||
|
||||
TrayIcon {
|
||||
anchors.centerIn: parent
|
||||
width: Style.space(12)
|
||||
height: Style.space(12)
|
||||
icon: trayItemRoot.modelData.icon
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: mouseArea
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onEntered: if (root.bar) root.bar.showTooltip(trayItemRoot, root.trayTooltip(modelData))
|
||||
onExited: if (root.bar) root.bar.hideTooltip(trayItemRoot)
|
||||
onPressed: function(mouse) {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
trayItemRoot.displayMenu(mouse)
|
||||
mouse.accepted = true
|
||||
}
|
||||
}
|
||||
onClicked: function(mouse) {
|
||||
if (mouse.button === Qt.RightButton) {
|
||||
mouse.accepted = true
|
||||
} else if (mouse.button === Qt.MiddleButton) {
|
||||
trayItemRoot.modelData.secondaryActivate()
|
||||
} else if (trayItemRoot.modelData.onlyMenu) {
|
||||
trayItemRoot.displayMenu(mouse)
|
||||
} else {
|
||||
trayItemRoot.modelData.activate()
|
||||
}
|
||||
}
|
||||
onWheel: function(wheel) {
|
||||
trayItemRoot.modelData.scroll(wheel.angleDelta.y, false)
|
||||
}
|
||||
}
|
||||
|
||||
readonly property bool tooltipHovered: visible && opacity > 0 && mouseArea.containsMouse
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
function text(value) {
|
||||
return String(value || "").toLowerCase()
|
||||
}
|
||||
|
||||
function itemNamed(item, name) {
|
||||
if (!item) return false
|
||||
return text(item.id).indexOf(name) !== -1
|
||||
|| text(item.title).indexOf(name) !== -1
|
||||
|| text(item.tooltipTitle).indexOf(name) !== -1
|
||||
}
|
||||
|
||||
function entryId(entry) {
|
||||
if (typeof entry === "string") return entry
|
||||
if (entry && typeof entry === "object") {
|
||||
var id = entry.id
|
||||
if (id !== undefined && id !== null && String(id) !== "") return String(id)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function layoutHasWidget(layout, id) {
|
||||
var sections = ["left", "center", "right"]
|
||||
for (var s = 0; s < sections.length; s++) {
|
||||
var entries = layout && layout[sections[s]]
|
||||
if (!Array.isArray(entries)) continue
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
if (entryId(entries[i]) === id) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LocalSend's item shows no state, offers only Open and Quit, and its primary
|
||||
// click is a no-op, so Share > Receive is the whole surface. Hiding it by hand
|
||||
// doesn't stick either: LocalSend picks a fresh tray id every launch.
|
||||
function ownedByBlob(item, layout) {
|
||||
return itemNamed(item, "localsend")
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
itemNamed: itemNamed,
|
||||
entryId: entryId,
|
||||
layoutHasWidget: layoutHasWidget,
|
||||
ownedByBlob: ownedByBlob
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.workspaces",
|
||||
"name": "Workspaces",
|
||||
"version": "1.0.0",
|
||||
"author": "Blob",
|
||||
"description": "Workspace number indicators",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
"entryPoints": {
|
||||
"barWidget": "Workspaces.qml"
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Workspaces",
|
||||
"description": "Workspace number indicators",
|
||||
"category": "Compositor",
|
||||
"allowMultiple": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell.Hyprland
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "blob.workspaces"
|
||||
|
||||
function workspaceById(id) {
|
||||
var values = Hyprland.workspaces.values
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
if (values[i].id === id) return values[i]
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function workspaceIds() {
|
||||
var ids = [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
var values = Hyprland.workspaces.values
|
||||
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
var id = values[i].id
|
||||
if (id > 0 && id <= 10 && ids.indexOf(id) === -1) ids.push(id)
|
||||
}
|
||||
|
||||
ids.sort(function(left, right) { return left - right })
|
||||
return ids
|
||||
}
|
||||
|
||||
function focusWorkspace(id) {
|
||||
if (!root.bar) return
|
||||
root.bar.run("hyprctl dispatch " + Util.shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })"))
|
||||
}
|
||||
|
||||
readonly property real trailingGap: root.vertical ? 0 : Style.spaceReal(1.5)
|
||||
|
||||
implicitWidth: grid.implicitWidth + trailingGap
|
||||
implicitHeight: grid.implicitHeight
|
||||
|
||||
GridLayout {
|
||||
id: grid
|
||||
anchors.fill: parent
|
||||
anchors.rightMargin: root.trailingGap
|
||||
columns: root.vertical ? 1 : root.workspaceIds().length
|
||||
columnSpacing: root.vertical ? 0 : Style.space(1)
|
||||
rowSpacing: root.vertical ? Style.space(2) : 0
|
||||
|
||||
Repeater {
|
||||
model: root.workspaceIds()
|
||||
|
||||
WidgetButton {
|
||||
required property int modelData
|
||||
|
||||
readonly property var workspace: root.workspaceById(modelData)
|
||||
readonly property bool occupied: workspace !== null && workspace.toplevels.values.length > 0
|
||||
readonly property bool focused: Hyprland.focusedWorkspace !== null && Hyprland.focusedWorkspace.id === modelData
|
||||
|
||||
bar: root.bar
|
||||
text: focused ? "\uDB85\uDCFB" : (modelData === 10 ? "0" : String(modelData))
|
||||
opacity: occupied || focused ? 1 : 0.5
|
||||
horizontalMargin: 6
|
||||
verticalPadding: 6
|
||||
fixedWidth: root.vertical ? root.barSize : Style.space(20)
|
||||
fixedHeight: root.barSize
|
||||
onPressed: function() { root.focusWorkspace(modelData) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user