mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-02 20:28:19 +02:00
1254 lines
40 KiB
QML
1254 lines
40 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.Commons
|
|
import qs.Ui
|
|
|
|
import "./components" as Cmp
|
|
|
|
Item {
|
|
id: root
|
|
|
|
// ---------------- plugin lifecycle ---------------------------------------
|
|
property bool closingFromHost: false
|
|
|
|
function open(payloadJson) {
|
|
closingFromHost = false
|
|
window.visible = true
|
|
Qt.callLater(parkFocusOnSink)
|
|
}
|
|
|
|
function close() {
|
|
closingFromHost = true
|
|
window.visible = false
|
|
closingFromHost = false
|
|
}
|
|
|
|
// ---------------- host injections ----------------------------------------
|
|
property var barWidgetRegistry: null
|
|
property var pluginRegistry: null
|
|
property var shell: null
|
|
|
|
// ---------------- paths --------------------------------------------------
|
|
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
|
|
readonly property string home: Quickshell.env("HOME")
|
|
readonly property string userConfigPath: home + "/.config/omarchy/shell.json"
|
|
readonly property string defaultsPath: omarchyPath + "/shell/shell-defaults.json"
|
|
|
|
// ---------------- theme --------------------------------------------------
|
|
// Bar settings deliberately isn't a themable surface in shell.toml — it
|
|
// tracks the foundational palette so every theme renders consistently.
|
|
property color foreground: Color.popups.text
|
|
property color background: Color.popups.background
|
|
property color accent: Color.accent
|
|
property color urgent: Color.urgent
|
|
property string fontFamily: "monospace"
|
|
|
|
// Structural style tokens live on the shared Style singleton so theme swaps
|
|
// and Hyprland-derived values update every consumer at once.
|
|
// Aliasing them as readonly properties keeps the existing inline component
|
|
// bindings (`root.cornerRadius`, `root.focusBorderColor`, ...) working
|
|
// without sprinkling Style.* across the file.
|
|
readonly property int cornerRadius: Style.cornerRadius
|
|
readonly property color focusBorderColor: Style.focusBorderColor
|
|
readonly property color focusFillColor: Style.focusFillColor
|
|
readonly property int focusBorderWidth: Style.focusBorderWidth
|
|
|
|
// Move activeFocus to a dedicated sink Item that lives outside the body
|
|
// tree. Just clearing focus on the previously focused descendant isn't
|
|
// enough — controls like ComboBox keep an internal focused child that
|
|
// FocusScope happily restores. Forcing focus onto a known sink reliably
|
|
// clears every body focus ring.
|
|
function parkFocusOnSink() {
|
|
if (typeof navFocusSink !== "undefined" && navFocusSink) navFocusSink.forceActiveFocus()
|
|
else if (navRoot) navRoot.forceActiveFocus()
|
|
}
|
|
|
|
// Walk the visible body subtree and collect any item with
|
|
// `activeFocusOnTab: true` so j/k can move through the form.
|
|
function gatherBodyFocusables() {
|
|
var arr = []
|
|
function walk(item) {
|
|
if (!item || !item.visible || item.enabled === false) return
|
|
if (item.activeFocusOnTab === true) arr.push(item)
|
|
var children = item.children
|
|
if (!children) return
|
|
for (var i = 0; i < children.length; i++) walk(children[i])
|
|
}
|
|
if (typeof bodyScroll !== "undefined" && bodyScroll && bodyScroll.contentItem)
|
|
walk(bodyScroll.contentItem)
|
|
return arr
|
|
}
|
|
|
|
function focusFirstBodyItem() {
|
|
var items = gatherBodyFocusables()
|
|
if (items.length > 0) {
|
|
items[0].forceActiveFocus()
|
|
ensureBodyItemVisible(items[0])
|
|
} else if (navRoot) {
|
|
navRoot.forceActiveFocus()
|
|
}
|
|
}
|
|
|
|
function focusBodyDelta(delta) {
|
|
var items = gatherBodyFocusables()
|
|
if (items.length === 0) { parkFocusOnSink(); return }
|
|
var current = -1
|
|
for (var i = 0; i < items.length; i++) {
|
|
if (items[i].activeFocus) { current = i; break }
|
|
}
|
|
var next = current < 0 ? 0 : current + delta
|
|
if (next < 0) next = items.length - 1
|
|
if (next >= items.length) next = 0
|
|
items[next].forceActiveFocus()
|
|
ensureBodyItemVisible(items[next])
|
|
}
|
|
|
|
// Scroll the bodyScroll Flickable so `item` is fully on-screen, with a
|
|
// little padding above/below.
|
|
function ensureBodyItemVisible(item) {
|
|
if (!item || typeof bodyScroll === "undefined" || !bodyScroll || !bodyScroll.contentItem) return
|
|
var pos = item.mapToItem(bodyScroll.contentItem, 0, 0)
|
|
var pad = Style.space(24)
|
|
var top = pos.y - pad
|
|
var bottom = pos.y + item.height + pad
|
|
if (top < bodyScroll.contentY) {
|
|
bodyScroll.contentY = Math.max(0, top)
|
|
} else if (bottom > bodyScroll.contentY + bodyScroll.height) {
|
|
var maxY = Math.max(0, bodyScroll.contentHeight - bodyScroll.height)
|
|
bodyScroll.contentY = Math.min(maxY, bottom - bodyScroll.height)
|
|
}
|
|
}
|
|
|
|
// ---------------- bundled defaults ---------------------------------------
|
|
readonly property var builtinShellConfig: ({
|
|
version: 1,
|
|
bar: {
|
|
position: "top",
|
|
transparent: false,
|
|
centerAnchor: "Clock",
|
|
layout: {
|
|
left: [{ id: "Omarchy" }, { id: "Workspaces" }],
|
|
center: [
|
|
{ id: "Clock", format: "dddd HH:mm", formatAlt: "dd MMMM 'W'ww yyyy", verticalFormat: "HH\n\u2014\nmm" },
|
|
{ id: "Weather" }, { id: "Indicators", items: [ "Dnd", "NightLight", "StayAwake", "ScreenRecording", "Dictation" ] }, { id: "SystemUpdate" }
|
|
],
|
|
right: [
|
|
{ id: "Tray" }, { id: "BluetoothPanel" }, { id: "NetworkPanel" },
|
|
{ id: "AudioPanel" }, { id: "MonitorPanel" }, { id: "PowerPanel" }
|
|
]
|
|
}
|
|
},
|
|
plugins: []
|
|
})
|
|
|
|
property var defaultConfig: builtinShellConfig
|
|
property var draft: ({ version: 1, bar: { position: "top", transparent: false, centerAnchor: "Clock", layout: { left: [], center: [], right: [] } }, plugins: [] })
|
|
property int draftRevision: 0
|
|
property bool suppressReload: false
|
|
|
|
// When a widget action moves an entry, the Repeater rebuilds / reindexes
|
|
// cards. Remember the action group position so focus follows the moved
|
|
// widget instead of falling back to the first action on the old/new row.
|
|
property string pendingActionFocusSection: ""
|
|
property int pendingActionFocusIndex: -1
|
|
property int pendingActionFocusAction: 0
|
|
property int pendingActionFocusRevision: 0
|
|
|
|
function scheduleActionFocus(section, index, action) {
|
|
pendingActionFocusSection = section
|
|
pendingActionFocusIndex = index
|
|
pendingActionFocusAction = action
|
|
pendingActionFocusRevision++
|
|
}
|
|
|
|
function clearPendingActionFocus() {
|
|
pendingActionFocusSection = ""
|
|
pendingActionFocusIndex = -1
|
|
pendingActionFocusAction = 0
|
|
}
|
|
|
|
// ---------------- draft helpers ------------------------------------------
|
|
function normalizeDraft(source) {
|
|
var bar = Util.isPlainObject(source.bar) ? source.bar : {}
|
|
var plugins = Array.isArray(source.plugins) ? source.plugins.slice() : []
|
|
return {
|
|
version: 1,
|
|
bar: {
|
|
position: String(bar.position || "top"),
|
|
transparent: bar.transparent === true,
|
|
centerAnchor: String(bar.centerAnchor || ""),
|
|
layout: Util.normalizeLayout(bar.layout || {})
|
|
},
|
|
plugins: plugins
|
|
.map(Util.normalizeLayoutEntry)
|
|
.filter(function(e) {
|
|
if (!e) return false
|
|
var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[e.id] : null
|
|
if (manifest && manifest.__isFirstParty) return false
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
|
|
function loadConfig() {
|
|
var defaults = builtinShellConfig
|
|
var diskText = defaultsFile.text()
|
|
if (diskText) {
|
|
try {
|
|
var parsed = JSON.parse(diskText)
|
|
if (Util.isPlainObject(parsed) && parsed.version === 1) defaults = parsed
|
|
} catch (e) {
|
|
console.warn("Bad shell-defaults JSON, falling back to builtin:", e)
|
|
defaults = builtinShellConfig
|
|
}
|
|
}
|
|
defaultConfig = defaults
|
|
|
|
var userText = userFile.text() || ""
|
|
var source = defaults
|
|
if (userText.trim()) {
|
|
try {
|
|
var u = JSON.parse(userText)
|
|
if (Util.isPlainObject(u) && u.version === 1) source = u
|
|
} catch (e) {
|
|
console.warn("shell.json parse failed in panel:", e)
|
|
}
|
|
}
|
|
draft = normalizeDraft(source)
|
|
draftRevision++
|
|
}
|
|
|
|
function persistDraft() {
|
|
suppressReload = true
|
|
userFile.setText(JSON.stringify(draft, null, 2) + "\n")
|
|
}
|
|
|
|
function defaultBarDraft() {
|
|
var source = defaultConfig
|
|
if (!Util.isPlainObject(source) || !Util.isPlainObject(source.bar) || !Util.isPlainObject(source.bar.layout)) {
|
|
source = builtinShellConfig
|
|
} else {
|
|
var l = source.bar.layout
|
|
var anyEntries = (l.left && l.left.length) || (l.center && l.center.length) || (l.right && l.right.length)
|
|
if (!anyEntries) source = builtinShellConfig
|
|
}
|
|
return normalizeDraft(source).bar
|
|
}
|
|
|
|
function resetBarToDefaults() {
|
|
var next = Util.cloneJson(draft)
|
|
next.bar = Util.cloneJson(defaultBarDraft())
|
|
draft = next
|
|
draftRevision++
|
|
persistDraft()
|
|
}
|
|
|
|
function markDirty() {
|
|
draftRevision++
|
|
persistDraft()
|
|
}
|
|
|
|
function sectionArray(section) {
|
|
if (section === "plugins") return draft.plugins || []
|
|
return (draft.bar && draft.bar.layout && draft.bar.layout[section]) || []
|
|
}
|
|
|
|
function mutateSection(section, mutator) {
|
|
var arr = sectionArray(section).slice()
|
|
mutator(arr)
|
|
var nextDraft = Util.cloneJson(draft)
|
|
if (section === "plugins") nextDraft.plugins = arr
|
|
else nextDraft.bar.layout[section] = arr
|
|
draft = nextDraft
|
|
markDirty()
|
|
}
|
|
|
|
function moveEntry(section, fromIndex, toIndex, focusActionIndex) {
|
|
var arr = sectionArray(section)
|
|
if (toIndex < 0 || toIndex >= arr.length) return
|
|
mutateSection(section, function(a) {
|
|
var item = a[fromIndex]
|
|
a.splice(fromIndex, 1)
|
|
a.splice(toIndex, 0, item)
|
|
})
|
|
if (focusActionIndex !== undefined) scheduleActionFocus(section, toIndex, focusActionIndex)
|
|
}
|
|
|
|
function removeEntry(section, index) {
|
|
mutateSection(section, function(a) { a.splice(index, 1) })
|
|
}
|
|
|
|
function addEntry(section, id) {
|
|
mutateSection(section, function(a) { a.push({ id: id }) })
|
|
}
|
|
|
|
function updateEntry(section, index, newEntry) {
|
|
mutateSection(section, function(a) { a[index] = Util.cloneJson(newEntry) })
|
|
}
|
|
|
|
// ---------------- widget catalog -----------------------------------------
|
|
property int catalogRevision: 0
|
|
onBarWidgetRegistryChanged: {
|
|
catalogRevision++
|
|
if (!root.barWidgetRegistry) return
|
|
console.log("bar settings panel open. omarchyPath=" + root.omarchyPath,
|
|
"defaultsPath=" + root.defaultsPath,
|
|
"userConfigPath=" + root.userConfigPath,
|
|
"registry has",
|
|
root.barWidgetRegistry.availableIds().length,
|
|
"widgets")
|
|
}
|
|
Connections {
|
|
target: root.barWidgetRegistry
|
|
function onChanged() { root.catalogRevision++ }
|
|
}
|
|
|
|
function canonicalWidgetId(id) {
|
|
return String(id || "")
|
|
}
|
|
|
|
function widgetMetadata(id) {
|
|
var key = String(id || "")
|
|
var canonicalKey = canonicalWidgetId(key)
|
|
if (root.barWidgetRegistry && root.barWidgetRegistry.has(canonicalKey))
|
|
return root.barWidgetRegistry.metadataFor(canonicalKey) || {}
|
|
|
|
var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[key] : null
|
|
if (manifest) {
|
|
var meta = manifest.barWidget || {}
|
|
return {
|
|
displayName: meta.displayName || manifest.name || key,
|
|
name: meta.displayName || manifest.name || key,
|
|
description: meta.description || manifest.description || "",
|
|
category: meta.category || "Plugin",
|
|
allowMultiple: meta.allowMultiple === true,
|
|
settingsForm: meta.settingsForm || "",
|
|
schema: Array.isArray(meta.schema) ? meta.schema : [],
|
|
sourceDir: manifest.__sourceDir || "",
|
|
source: "plugin"
|
|
}
|
|
}
|
|
return {}
|
|
}
|
|
|
|
function widgetSourceDir(id) {
|
|
var meta = widgetMetadata(id)
|
|
return meta && meta.sourceDir ? String(meta.sourceDir) : ""
|
|
}
|
|
|
|
function widgetName(id) {
|
|
var rev = catalogRevision
|
|
var meta = widgetMetadata(id)
|
|
return meta.displayName || meta.name || id
|
|
}
|
|
|
|
function widgetDescription(id) {
|
|
var rev = catalogRevision
|
|
var meta = widgetMetadata(id)
|
|
return meta.description || ""
|
|
}
|
|
|
|
function widgetSchema(id) {
|
|
var meta = widgetMetadata(id)
|
|
return Array.isArray(meta.schema) ? meta.schema : []
|
|
}
|
|
|
|
function widgetHasSettings(id) {
|
|
var rev = catalogRevision
|
|
var meta = widgetMetadata(id)
|
|
if (meta.settingsForm) return true
|
|
if (widgetSchema(id).length > 0) return true
|
|
return false
|
|
}
|
|
|
|
function widgetAllowsMultiple(id) {
|
|
var meta = widgetMetadata(id)
|
|
if (meta.allowMultiple === true) return true
|
|
return String(id) === "Spacer"
|
|
}
|
|
|
|
function catalogIds() {
|
|
var rev = catalogRevision
|
|
var ids = {}
|
|
if (root.barWidgetRegistry) {
|
|
var registered = root.barWidgetRegistry.availableIds()
|
|
for (var i = 0; i < registered.length; i++) ids[registered[i]] = true
|
|
}
|
|
if (root.pluginRegistry && root.pluginRegistry.installedPlugins) {
|
|
var plugins = root.pluginRegistry.installedPlugins
|
|
for (var pid in plugins) {
|
|
var manifest = plugins[pid]
|
|
if (manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1)
|
|
ids[pid] = true
|
|
}
|
|
}
|
|
return Object.keys(ids)
|
|
}
|
|
|
|
function availableToAdd(section) {
|
|
var rev = catalogRevision
|
|
var barSections = ["left", "center", "right"]
|
|
|
|
var existingInBar = {}
|
|
for (var s = 0; s < barSections.length; s++) {
|
|
var list = sectionArray(barSections[s])
|
|
for (var i = 0; i < list.length; i++) existingInBar[list[i].id] = true
|
|
}
|
|
|
|
var ids = catalogIds().sort(function(a, b) { return widgetName(a).localeCompare(widgetName(b)) })
|
|
|
|
var result = []
|
|
for (var k = 0; k < ids.length; k++) {
|
|
var id = ids[k]
|
|
var meta = widgetMetadata(id)
|
|
var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null
|
|
var manifestIsBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1
|
|
var isBarWidget = !!(meta && meta.source !== "plugin") || manifestIsBarWidget
|
|
if (!isBarWidget) continue
|
|
|
|
var inSection = sectionArray(section)
|
|
var existsHere = false
|
|
for (var x = 0; x < inSection.length; x++) if (inSection[x].id === id) { existsHere = true; break }
|
|
var allowsMultiple = widgetAllowsMultiple(id)
|
|
if (!allowsMultiple && existingInBar[id]) continue
|
|
result.push({ id: id, name: widgetName(id), description: widgetDescription(id),
|
|
elsewhere: allowsMultiple && !!existingInBar[id] && !existsHere })
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ---------------- file watchers ------------------------------------------
|
|
FileView {
|
|
id: defaultsFile
|
|
path: root.defaultsPath
|
|
watchChanges: true
|
|
printErrors: true
|
|
onLoaded: root.loadConfig()
|
|
onLoadFailed: function(error) { console.warn("defaults load failed:", error, "path=" + root.defaultsPath) }
|
|
onFileChanged: reload()
|
|
}
|
|
|
|
FileView {
|
|
id: userFile
|
|
path: root.userConfigPath
|
|
watchChanges: true
|
|
atomicWrites: true
|
|
printErrors: false
|
|
onLoaded: {
|
|
if (root.suppressReload) { root.suppressReload = false; return }
|
|
root.loadConfig()
|
|
}
|
|
onFileChanged: reload()
|
|
}
|
|
|
|
// ---------------- window -------------------------------------------------
|
|
// ---------------- per-widget settings dialog state -----------------------
|
|
property bool widgetDialogVisible: false
|
|
property string widgetDialogSection: ""
|
|
property int widgetDialogIndex: -1
|
|
property var widgetDialogEntry: ({})
|
|
|
|
function openWidgetSettings(sectionKey, entryIndex, entry) {
|
|
widgetDialogEntry = Util.cloneJson(entry)
|
|
widgetDialogSection = sectionKey
|
|
widgetDialogIndex = entryIndex
|
|
widgetDialogVisible = true
|
|
}
|
|
|
|
function commitWidgetSettings() {
|
|
if (widgetDialogFormLoader.item && typeof widgetDialogFormLoader.item.saveSettings === "function") {
|
|
widgetDialogFormLoader.item.saveSettings()
|
|
} else {
|
|
root.updateEntry(widgetDialogSection, widgetDialogIndex, widgetDialogEntry)
|
|
}
|
|
widgetDialogVisible = false
|
|
}
|
|
|
|
function discardWidgetSettings() { widgetDialogVisible = false }
|
|
|
|
function widgetDialogFieldChanged(key, value) {
|
|
var copy = Util.cloneJson(widgetDialogEntry)
|
|
copy[key] = value
|
|
widgetDialogEntry = copy
|
|
}
|
|
|
|
FloatingWindow {
|
|
id: window
|
|
title: "Omarchy Bar Settings"
|
|
color: root.background
|
|
implicitWidth: Style.space(760)
|
|
implicitHeight: Style.space(620)
|
|
minimumSize: Qt.size(Style.space(620), Style.space(480))
|
|
|
|
onVisibleChanged: {
|
|
if (!visible && !root.closingFromHost && root.shell && typeof root.shell.hide === "function")
|
|
root.shell.hide("omarchy.settings")
|
|
if (visible) Qt.callLater(root.parkFocusOnSink)
|
|
}
|
|
|
|
FocusScope {
|
|
id: navRoot
|
|
anchors.fill: parent
|
|
focus: true
|
|
|
|
Component.onCompleted: navFocusSink.forceActiveFocus()
|
|
|
|
// Invisible focus sink. When no specific body item is focused,
|
|
// activeFocus lives on this 1px Item so body controls render their
|
|
// unfocused state cleanly.
|
|
Item {
|
|
id: navFocusSink
|
|
width: 1
|
|
height: 1
|
|
objectName: "navFocusSink"
|
|
}
|
|
|
|
Keys.priority: Keys.BeforeItem
|
|
Keys.onPressed: function(event) {
|
|
switch (event.key) {
|
|
case Qt.Key_Escape:
|
|
root.close(); event.accepted = true; return
|
|
case Qt.Key_J:
|
|
case Qt.Key_Down:
|
|
case Qt.Key_Tab:
|
|
root.focusBodyDelta(+1); event.accepted = true; return
|
|
case Qt.Key_K:
|
|
case Qt.Key_Up:
|
|
case Qt.Key_Backtab:
|
|
root.focusBodyDelta(-1); event.accepted = true; return
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
color: root.background
|
|
// No explicit border — the Hyprland window decoration already draws one.
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
// Header
|
|
Item {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: Math.max(Style.space(48), Style.font.heading + Style.spacing.controlPaddingY * 2)
|
|
|
|
Text {
|
|
text: "Omarchy Bar Settings"
|
|
color: root.foreground
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.heading
|
|
font.bold: true
|
|
anchors.left: parent.left
|
|
anchors.leftMargin: Style.spacing.panelPadding
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
|
|
Text {
|
|
text: "~/.config/omarchy/shell.json"
|
|
color: Qt.darker(root.foreground, 1.8)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Style.spacing.panelPadding
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: Style.spacing.hairline
|
|
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18)
|
|
}
|
|
|
|
// Content
|
|
Flickable {
|
|
id: bodyScroll
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
Layout.margins: Style.spacing.panelPadding
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: contentColumn.implicitHeight
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
|
|
ColumnLayout {
|
|
id: contentColumn
|
|
width: bodyScroll.width
|
|
spacing: Style.spacing.panelGap
|
|
|
|
BarCategory { Layout.fillWidth: true }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------- per-widget settings overlay -----------------------------------
|
|
Rectangle {
|
|
anchors.fill: parent
|
|
visible: root.widgetDialogVisible
|
|
color: Qt.rgba(0, 0, 0, 0.45)
|
|
z: 100
|
|
|
|
focus: visible
|
|
onVisibleChanged: if (visible) Qt.callLater(forceActiveFocus)
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onClicked: root.discardWidgetSettings()
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
}
|
|
|
|
Keys.priority: Keys.BeforeItem
|
|
Keys.onPressed: function(event) {
|
|
if (event.key === Qt.Key_Escape) {
|
|
root.discardWidgetSettings()
|
|
event.accepted = true
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
anchors.centerIn: parent
|
|
width: Math.min(Style.space(420), parent.width - Style.gapsOut * 2)
|
|
height: Math.min(parent.height - Style.space(60), Style.space(380))
|
|
color: root.background
|
|
radius: Style.cornerRadius
|
|
border.color: Style.normalBorderFor(root.foreground, root.accent)
|
|
border.width: Style.normalBorderWidth
|
|
|
|
MouseArea { anchors.fill: parent }
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
anchors.margins: Style.spacing.panelPadding
|
|
spacing: Style.spacing.rowPaddingX
|
|
|
|
Text {
|
|
text: root.widgetName(root.widgetDialogEntry.id || "")
|
|
color: root.foreground
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.title
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
text: root.widgetDescription(root.widgetDialogEntry.id || "")
|
|
color: Qt.darker(root.foreground, 1.4)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Flickable {
|
|
id: widgetDialogScroll
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
contentWidth: width
|
|
contentHeight: widgetDialogFormLoader.item ? widgetDialogFormLoader.item.implicitHeight : 0
|
|
clip: true
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
|
|
|
|
Loader {
|
|
id: widgetDialogFormLoader
|
|
width: widgetDialogScroll.width
|
|
sourceComponent: root.widgetDialogVisible ? formComponent(root.widgetDialogEntry.id || "") : null
|
|
onLoaded: {
|
|
if (item && "entry" in item) item.entry = root.widgetDialogEntry
|
|
if (item && "fieldChanged" in item) {
|
|
item.fieldChanged.connect(function(key, value) { root.widgetDialogFieldChanged(key, value) })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Row {
|
|
Layout.alignment: Qt.AlignRight
|
|
spacing: Style.spacing.rowGap
|
|
Button {
|
|
text: "Cancel"
|
|
foreground: root.foreground
|
|
fontFamily: root.fontFamily
|
|
focusable: true
|
|
onClicked: root.discardWidgetSettings()
|
|
}
|
|
Button {
|
|
text: "Apply"
|
|
foreground: root.foreground
|
|
fontFamily: root.fontFamily
|
|
focusable: true
|
|
bordered: true
|
|
onClicked: root.commitWidgetSettings()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===================== bar category ======================================
|
|
component BarCategory: ColumnLayout {
|
|
spacing: Style.spacing.panelGap
|
|
|
|
Text {
|
|
text: "Bar"
|
|
color: root.foreground
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.iconLarge
|
|
font.bold: true
|
|
}
|
|
|
|
Text {
|
|
text: "Drag widgets between the bar's three sections, drop in plugin widgets, and tweak per-widget options. Auto-saves to shell.json."
|
|
color: Qt.darker(root.foreground, 1.6)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
wrapMode: Text.WordWrap
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
Row {
|
|
Layout.fillWidth: true
|
|
spacing: Style.spacing.panelGap
|
|
|
|
Column {
|
|
spacing: Style.spacing.labelGap
|
|
|
|
Text {
|
|
text: "Position"
|
|
color: Qt.darker(root.foreground, 1.4)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
|
|
ButtonGroup {
|
|
options: ["top", "right", "bottom", "left"]
|
|
value: root.draft.bar.position
|
|
foreground: root.foreground
|
|
background: root.background
|
|
accent: root.accent
|
|
fontFamily: root.fontFamily
|
|
onChanged: function(v) {
|
|
if (root.draft.bar.position === v) return
|
|
var next = Util.cloneJson(root.draft)
|
|
next.bar.position = v
|
|
root.draft = next
|
|
root.markDirty()
|
|
}
|
|
}
|
|
}
|
|
|
|
Cmp.NDropdown {
|
|
label: "Center anchor"
|
|
value: root.draft.bar.centerAnchor || "(none)"
|
|
options: {
|
|
var list = ["(none)"]
|
|
var entries = root.draft.bar.layout.center || []
|
|
for (var i = 0; i < entries.length; i++) list.push(entries[i].id)
|
|
return list
|
|
}
|
|
foreground: root.foreground
|
|
background: root.background
|
|
accent: root.accent
|
|
fontFamily: root.fontFamily
|
|
cornerRadius: root.cornerRadius
|
|
onChanged: function(v) {
|
|
var next = Util.cloneJson(root.draft)
|
|
next.bar.centerAnchor = v === "(none)" ? "" : v
|
|
root.draft = next
|
|
root.markDirty()
|
|
}
|
|
}
|
|
}
|
|
|
|
Toggle {
|
|
Layout.fillWidth: true
|
|
label: "Transparent bar"
|
|
description: "Hide the bar background so the wallpaper shows through."
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
fontFamily: root.fontFamily
|
|
checked: root.draft.bar.transparent === true
|
|
onClicked: {
|
|
var next = Util.cloneJson(root.draft)
|
|
next.bar.transparent = !(next.bar.transparent === true)
|
|
root.draft = next
|
|
root.markDirty()
|
|
}
|
|
}
|
|
|
|
SectionEditor { sectionKey: "left"; sectionLabel: "Bar · Left" }
|
|
SectionEditor { sectionKey: "center"; sectionLabel: "Bar · Center" }
|
|
SectionEditor { sectionKey: "right"; sectionLabel: "Bar · Right" }
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: Style.spacing.hairline
|
|
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
|
|
}
|
|
|
|
Row {
|
|
Layout.alignment: Qt.AlignRight
|
|
Button {
|
|
text: "Reset bar to defaults"
|
|
foreground: root.urgent
|
|
fontFamily: root.fontFamily
|
|
focusable: true
|
|
bordered: true
|
|
onClicked: root.resetBarToDefaults()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===================== bar layout pieces =================================
|
|
component SectionEditor: Column {
|
|
id: section
|
|
|
|
property string sectionKey: ""
|
|
property string sectionLabel: ""
|
|
property var entries: root.sectionArray(section.sectionKey)
|
|
Layout.fillWidth: true
|
|
Layout.topMargin: Style.spacing.rowGap
|
|
spacing: Style.spacing.rowGap
|
|
|
|
Connections {
|
|
target: root
|
|
function onDraftRevisionChanged() { section.entries = root.sectionArray(section.sectionKey) }
|
|
}
|
|
|
|
RowLayout {
|
|
width: section.width
|
|
spacing: Style.spacing.rowGap
|
|
|
|
Text {
|
|
text: section.sectionLabel
|
|
color: root.foreground
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.subtitle
|
|
font.bold: true
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
|
|
Text {
|
|
text: "· " + section.entries.length + (section.entries.length === 1 ? " widget" : " widgets")
|
|
color: Qt.darker(root.foreground, 1.5)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
Layout.alignment: Qt.AlignVCenter
|
|
}
|
|
|
|
Item { Layout.fillWidth: true; implicitHeight: 1 }
|
|
|
|
SearchableDropdown {
|
|
id: addPill
|
|
showLabel: false
|
|
triggerLabel: " Add widget"
|
|
value: ""
|
|
placeholderText: "Search widgets..."
|
|
emptyText: "No widgets to add"
|
|
Layout.preferredWidth: Style.spacing.searchableDropdownWidth
|
|
Layout.alignment: Qt.AlignVCenter
|
|
options: {
|
|
var list = root.availableToAdd(section.sectionKey)
|
|
var out = []
|
|
for (var i = 0; i < list.length; i++) {
|
|
out.push({
|
|
value: list[i].id,
|
|
label: list[i].name + (list[i].elsewhere ? " (elsewhere)" : ""),
|
|
description: list[i].description || ""
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
fontFamily: root.fontFamily
|
|
onChanged: function(v) {
|
|
if (!v) return
|
|
root.addEntry(section.sectionKey, v)
|
|
addPill.value = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
Column {
|
|
Layout.fillWidth: true
|
|
width: section.width
|
|
spacing: Style.spacing.labelGap
|
|
|
|
Repeater {
|
|
model: section.entries
|
|
delegate: WidgetCard {
|
|
required property var modelData
|
|
required property int index
|
|
width: section.width
|
|
sectionKey: section.sectionKey
|
|
entryIndex: index
|
|
entry: modelData
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
visible: section.entries.length === 0
|
|
width: parent.width
|
|
height: Math.max(Style.space(32), Style.font.bodySmall + Style.spacing.controlPaddingY * 2)
|
|
radius: root.cornerRadius
|
|
color: Style.normalFillFor(root.foreground, root.accent)
|
|
border.color: Style.normalBorderFor(root.foreground, root.accent)
|
|
border.width: Style.normalBorderWidth
|
|
|
|
Text {
|
|
anchors.centerIn: parent
|
|
text: "Empty — add a widget"
|
|
color: Qt.darker(root.foreground, 1.5)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
component WidgetCard: Rectangle {
|
|
id: card
|
|
property string sectionKey: ""
|
|
property int entryIndex: -1
|
|
property var entry: ({})
|
|
readonly property string entryId: entry && entry.id ? String(entry.id) : ""
|
|
readonly property string displayName: root.widgetName(entryId)
|
|
readonly property string description: root.widgetDescription(entryId)
|
|
readonly property bool hasSettings: root.widgetHasSettings(entryId)
|
|
|
|
implicitHeight: Style.space(50)
|
|
radius: root.cornerRadius
|
|
color: cardArea.containsMouse || actionRow.activeFocus
|
|
? Style.hoverFillFor(root.foreground, root.accent)
|
|
: Style.normalFillFor(root.foreground, root.accent)
|
|
border.color: cardArea.containsMouse || actionRow.activeFocus
|
|
? Style.hoverBorderFor(root.foreground, root.accent)
|
|
: Style.normalBorderFor(root.foreground, root.accent)
|
|
border.width: cardArea.containsMouse || actionRow.activeFocus ? Style.hoverBorderWidth : Style.normalBorderWidth
|
|
|
|
Behavior on color { ColorAnimation { duration: 100 } }
|
|
|
|
function maybeRestoreActionFocus() {
|
|
if (root.pendingActionFocusSection !== card.sectionKey) return
|
|
if (root.pendingActionFocusIndex !== card.entryIndex) return
|
|
|
|
actionRow.actionIndex = root.pendingActionFocusAction
|
|
actionRow.clampActionIndex()
|
|
root.clearPendingActionFocus()
|
|
|
|
Qt.callLater(function() {
|
|
actionRow.forceActiveFocus()
|
|
root.ensureBodyItemVisible(card)
|
|
})
|
|
}
|
|
|
|
onEntryIndexChanged: maybeRestoreActionFocus()
|
|
Component.onCompleted: maybeRestoreActionFocus()
|
|
|
|
Connections {
|
|
target: root
|
|
function onPendingActionFocusRevisionChanged() { card.maybeRestoreActionFocus() }
|
|
}
|
|
|
|
Row {
|
|
id: actionRow
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: Style.spacing.controlGap
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.spacing.labelGap
|
|
activeFocusOnTab: true
|
|
|
|
property int actionIndex: 0
|
|
|
|
onActiveFocusChanged: if (activeFocus) {
|
|
clampActionIndex()
|
|
root.ensureBodyItemVisible(card)
|
|
}
|
|
|
|
function actionVisible(index) {
|
|
switch (index) {
|
|
case 0: return moveUpButton.visible && moveUpButton.enabled
|
|
case 1: return moveDownButton.visible && moveDownButton.enabled
|
|
case 2: return settingsButton.visible && settingsButton.enabled
|
|
case 3: return removeButton.visible && removeButton.enabled
|
|
}
|
|
return false
|
|
}
|
|
|
|
function firstActionIndex() {
|
|
for (var i = 0; i < 4; i++) if (actionVisible(i)) return i
|
|
return 0
|
|
}
|
|
|
|
function clampActionIndex() {
|
|
if (actionVisible(actionIndex)) return
|
|
actionIndex = firstActionIndex()
|
|
}
|
|
|
|
function moveAction(delta) {
|
|
clampActionIndex()
|
|
var next = actionIndex
|
|
while (true) {
|
|
next += delta
|
|
if (next < 0 || next > 3) return
|
|
if (actionVisible(next)) { actionIndex = next; return }
|
|
}
|
|
}
|
|
|
|
function activateAction() {
|
|
clampActionIndex()
|
|
switch (actionIndex) {
|
|
case 0: root.moveEntry(card.sectionKey, card.entryIndex, card.entryIndex - 1, actionIndex); return
|
|
case 1: root.moveEntry(card.sectionKey, card.entryIndex, card.entryIndex + 1, actionIndex); return
|
|
case 2: root.openWidgetSettings(card.sectionKey, card.entryIndex, card.entry); return
|
|
case 3: root.removeEntry(card.sectionKey, card.entryIndex); return
|
|
}
|
|
}
|
|
|
|
Keys.priority: Keys.BeforeItem
|
|
Keys.onPressed: function(event) {
|
|
if (event.key === Qt.Key_Left || event.text === "h") {
|
|
moveAction(-1); event.accepted = true; return
|
|
}
|
|
if (event.key === Qt.Key_Right || event.text === "l") {
|
|
moveAction(1); event.accepted = true; return
|
|
}
|
|
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space) {
|
|
activateAction(); event.accepted = true; return
|
|
}
|
|
}
|
|
|
|
PanelActionButton {
|
|
id: moveUpButton
|
|
iconText: ""
|
|
tooltipText: "Move up"
|
|
foreground: root.foreground
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.subtitle
|
|
size: Style.space(26)
|
|
hasCursor: actionRow.activeFocus && actionRow.actionIndex === 0
|
|
bordered: hasCursor
|
|
onHovered: function(h) { if (h) actionRow.actionIndex = 0 }
|
|
onClicked: root.moveEntry(card.sectionKey, card.entryIndex, card.entryIndex - 1, 0)
|
|
}
|
|
PanelActionButton {
|
|
id: moveDownButton
|
|
iconText: ""
|
|
tooltipText: "Move down"
|
|
foreground: root.foreground
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.subtitle
|
|
size: Style.space(26)
|
|
hasCursor: actionRow.activeFocus && actionRow.actionIndex === 1
|
|
bordered: hasCursor
|
|
onHovered: function(h) { if (h) actionRow.actionIndex = 1 }
|
|
onClicked: root.moveEntry(card.sectionKey, card.entryIndex, card.entryIndex + 1, 1)
|
|
}
|
|
PanelActionButton {
|
|
id: settingsButton
|
|
iconText: ""
|
|
tooltipText: "Settings"
|
|
foreground: root.foreground
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.subtitle
|
|
size: Style.space(26)
|
|
visible: card.hasSettings
|
|
hasCursor: actionRow.activeFocus && actionRow.actionIndex === 2
|
|
bordered: hasCursor
|
|
onVisibleChanged: if (!visible && actionRow.actionIndex === 2) actionRow.clampActionIndex()
|
|
onHovered: function(h) { if (h) actionRow.actionIndex = 2 }
|
|
onClicked: root.openWidgetSettings(card.sectionKey, card.entryIndex, card.entry)
|
|
}
|
|
PanelActionButton {
|
|
id: removeButton
|
|
iconText: ""
|
|
tooltipText: "Remove"
|
|
foreground: root.urgent
|
|
hoverColor: root.urgent
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.subtitle
|
|
size: Style.space(26)
|
|
hasCursor: actionRow.activeFocus && actionRow.actionIndex === 3
|
|
bordered: hasCursor
|
|
onHovered: function(h) { if (h) actionRow.actionIndex = 3 }
|
|
onClicked: root.removeEntry(card.sectionKey, card.entryIndex)
|
|
}
|
|
}
|
|
|
|
Column {
|
|
anchors.left: parent.left
|
|
anchors.right: actionRow.left
|
|
anchors.leftMargin: Style.spacing.rowPaddingX
|
|
anchors.rightMargin: Style.spacing.rowPaddingX
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: Style.spacing.xxs
|
|
|
|
Text {
|
|
text: card.displayName
|
|
color: root.foreground
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
font.bold: true
|
|
elide: Text.ElideRight
|
|
width: parent.width
|
|
}
|
|
Text {
|
|
visible: text !== ""
|
|
text: card.description
|
|
color: Qt.darker(root.foreground, 1.5)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
elide: Text.ElideRight
|
|
width: parent.width
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
id: cardArea
|
|
anchors.fill: parent
|
|
hoverEnabled: true
|
|
acceptedButtons: Qt.NoButton
|
|
}
|
|
}
|
|
|
|
// ---------------- per-widget form resolution -----------------------------
|
|
function formComponent(id) {
|
|
var meta = widgetMetadata(id)
|
|
if (meta && meta.settingsForm) {
|
|
switch (meta.settingsForm) {
|
|
case "spacerSettings": return spacerSettingsComponent
|
|
case "clockSettings": return clockSettingsComponent
|
|
case "weatherSettings": return weatherSettingsComponent
|
|
}
|
|
}
|
|
if (widgetSchema(id).length > 0) return dynamicSettingsComponent
|
|
return null
|
|
}
|
|
|
|
Component {
|
|
id: dynamicSettingsComponent
|
|
Cmp.DynamicSettingsForm {
|
|
schema: root.widgetSchema(entry.id || "")
|
|
pluginSourceDir: root.widgetSourceDir(entry.id || "")
|
|
foreground: root.foreground
|
|
fontFamily: root.fontFamily
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: spacerSettingsComponent
|
|
|
|
Column {
|
|
id: spacerForm
|
|
signal fieldChanged(string key, var value)
|
|
property var entry: ({})
|
|
|
|
spacing: Style.spacing.rowGap
|
|
width: parent ? parent.width : 0
|
|
|
|
NumberField {
|
|
label: "Size (pixels)"
|
|
from: 0
|
|
to: 256
|
|
value: spacerForm.entry.size !== undefined ? spacerForm.entry.size : 12
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
fontFamily: root.fontFamily
|
|
onModified: function(v) { spacerForm.fieldChanged("size", v) }
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: clockSettingsComponent
|
|
|
|
Column {
|
|
id: clockForm
|
|
signal fieldChanged(string key, var value)
|
|
property var entry: ({})
|
|
|
|
spacing: Style.spacing.rowGap
|
|
width: parent ? parent.width : 0
|
|
|
|
component ClockField: TextField {
|
|
property string fieldKey: ""
|
|
width: parent.width
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.body
|
|
onEditingFinished: if (fieldKey) clockForm.fieldChanged(fieldKey, text)
|
|
}
|
|
|
|
Text {
|
|
text: "Horizontal format"
|
|
color: Qt.darker(root.foreground, 1.4)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
ClockField {
|
|
fieldKey: "format"
|
|
text: clockForm.entry.format || "dddd HH:mm"
|
|
}
|
|
|
|
Text {
|
|
text: "Alternate format (click to toggle)"
|
|
color: Qt.darker(root.foreground, 1.4)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
ClockField {
|
|
fieldKey: "formatAlt"
|
|
text: clockForm.entry.formatAlt || "dd MMMM 'W'ww yyyy"
|
|
}
|
|
|
|
Text {
|
|
text: "Vertical format (left/right bars)"
|
|
color: Qt.darker(root.foreground, 1.4)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
ClockField {
|
|
fieldKey: "verticalFormat"
|
|
text: clockForm.entry.verticalFormat || "HH\n—\nmm"
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: weatherSettingsComponent
|
|
|
|
Column {
|
|
id: weatherForm
|
|
signal fieldChanged(string key, var value)
|
|
property var entry: ({})
|
|
|
|
spacing: Style.spacing.rowGap
|
|
width: parent ? parent.width : 0
|
|
|
|
NumberField {
|
|
label: "Auto-refresh interval (minutes)"
|
|
from: 1
|
|
to: 1440
|
|
value: weatherForm.entry.refreshMinutes !== undefined ? weatherForm.entry.refreshMinutes : 15
|
|
foreground: root.foreground
|
|
accent: root.accent
|
|
fontFamily: root.fontFamily
|
|
onModified: function(v) { weatherForm.fieldChanged("refreshMinutes", v) }
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
}
|