mirror of
https://github.com/arthur-pbty/arthur-os.git
synced 2026-08-02 04:37:49 +02:00
The three components were three takes on the same shape \u2014 a clickable rectangle with text/icon, a hot/hover state, an optional persistent border, and an optional 'selected' or 'active' highlight. CursorPill was a 30-line PillButton wrapper that added a hovered() signal; ChoiceButton was effectively PillButton with selected: bool painting an accent fill+border. Collapse them into a single qs.Ui.Button. State flags compose independently: hasCursor / hover hot fill active persistent foreground-tint fill selected accent fill + accent border bordered: true persistent 1px idle border (form primaries) focusable: true Tab focus paints the accent ring pressed pressed fill The hovered(bool) signal is now built-in, so CursorPill's wrapper is unnecessary. ButtonGroup wraps a Row+Repeater for the form-style 'pick one of N' pattern; panel-cursor-driven cases still compose Buttons directly in a Row with per-instance hasCursor wiring. Theme tokens move into a new [style] section in shell.toml: border-width = 1 focus-border-width = 3 idle-border-alpha = 0.4 hot-fill-alpha = 0.08 selected-fill-alpha = 0.18 pressed-fill-alpha = 0.22 focus-fill-alpha = 0.22 Style.qml parses these out of the same shell.toml [font] / [bar] already reads, and exposes pre-computed Style.hotFill / selectedFill / pressedFill / idleBorderColor / selectedAccentFill / borderWidth + the existing focusBorder* tokens. Themes that don't ship a [style] section get the previous defaults unchanged. Dev gallery consolidates three sections (PillButton, CursorPill, ChoiceButton) into Button + ButtonGroup, with the cursor model sections renamed accordingly.
1194 lines
37 KiB
QML
1194 lines
37 KiB
QML
import QtQuick
|
|
import QtQuick.Controls
|
|
import QtQuick.Layouts
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.Commons
|
|
import qs.Ui
|
|
|
|
import "../../ui/settings" as SettingsUi
|
|
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: {
|
|
var env = Quickshell.env("OMARCHY_PATH")
|
|
if (env) return env
|
|
var dir = String(Quickshell.shellDir || "")
|
|
while (dir.length > 1 && dir.charAt(dir.length - 1) === "/")
|
|
dir = dir.substring(0, dir.length - 1)
|
|
var suffix = "/shell"
|
|
if (dir.length > suffix.length && dir.substring(dir.length - suffix.length) === suffix)
|
|
return dir.substring(0, dir.length - suffix.length)
|
|
return Quickshell.env("HOME") + "/.local/share/omarchy"
|
|
}
|
|
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.foreground
|
|
property color background: Color.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 toggling
|
|
// `omarchy style corners` and theme swaps 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 = 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: "calendar",
|
|
layout: {
|
|
left: [{ id: "omarchy" }, { id: "workspaces" }],
|
|
center: [
|
|
{ id: "calendar", format: "dddd HH:mm", formatAlt: "dd MMMM 'W'ww yyyy", verticalFormat: "HH\n\u2014\nmm" },
|
|
{ id: "weatherFlyout" }, { id: "update" }, { id: "voxtype" },
|
|
{ id: "screenRecording" }, { id: "idleInhibitor" }, { id: "notifications" }
|
|
],
|
|
right: [
|
|
{ id: "tray" }, { id: "bluetoothPanel" }, { id: "networkPanel" },
|
|
{ id: "audioPanel" }, { id: "monitorPanel" }, { id: "battery" }
|
|
]
|
|
}
|
|
},
|
|
plugins: [{ id: "omarchy.osd" }]
|
|
})
|
|
|
|
property var defaultConfig: builtinShellConfig
|
|
property var draft: ({ version: 1, bar: { position: "top", transparent: false, centerAnchor: "calendar", layout: { left: [], center: [], right: [] } }, plugins: [] })
|
|
property int draftRevision: 0
|
|
property bool suppressReload: false
|
|
|
|
// ---------------- draft helpers ------------------------------------------
|
|
function cloneJson(value) { return JSON.parse(JSON.stringify(value || null)) }
|
|
function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value) }
|
|
|
|
function normalizeLayoutEntry(entry) {
|
|
if (typeof entry === "string") return { id: entry }
|
|
if (isPlainObject(entry) && entry.id) return cloneJson(entry)
|
|
return null
|
|
}
|
|
|
|
function normalizeLayout(layout) {
|
|
var sections = ["left", "center", "right"]
|
|
var result = {}
|
|
for (var i = 0; i < sections.length; i++) {
|
|
var s = sections[i]
|
|
var arr = []
|
|
var src = (layout && layout[s]) || []
|
|
for (var j = 0; j < src.length; j++) {
|
|
var entry = normalizeLayoutEntry(src[j])
|
|
if (entry) arr.push(entry)
|
|
}
|
|
result[s] = arr
|
|
}
|
|
return result
|
|
}
|
|
|
|
function normalizeDraft(source) {
|
|
var bar = 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 || ""),
|
|
fontFamily: "monospace",
|
|
layout: normalizeLayout(bar.layout || {})
|
|
},
|
|
plugins: plugins
|
|
.map(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 (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 (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 (!isPlainObject(source) || !isPlainObject(source.bar) || !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 = cloneJson(draft)
|
|
next.bar = 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 = cloneJson(draft)
|
|
if (section === "plugins") nextDraft.plugins = arr
|
|
else nextDraft.bar.layout[section] = arr
|
|
draft = nextDraft
|
|
markDirty()
|
|
}
|
|
|
|
function moveEntry(section, fromIndex, toIndex) {
|
|
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)
|
|
})
|
|
}
|
|
|
|
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] = cloneJson(newEntry) })
|
|
}
|
|
|
|
// ---------------- widget catalog -----------------------------------------
|
|
readonly property var builtinWidgetMeta: ({
|
|
"omarchy": { name: "Omarchy menu", description: "Launches the Omarchy menu", category: "Compositor" },
|
|
"workspaces": { name: "Workspaces", description: "Workspace number indicators", category: "Compositor" },
|
|
"clock": { name: "Clock", description: "Date / time text", category: "Time" },
|
|
"update": { name: "Updates", description: "Indicates available system updates", category: "System" },
|
|
"voxtype": { name: "Voxtype", description: "Voxtype dictation state", category: "Status" },
|
|
"screenRecording": { name: "Screen recording", description: "Active recording indicator", category: "Status" },
|
|
"notifications": { name: "DND", description: "Do-not-disturb indicator", category: "Status" },
|
|
"tray": { name: "System tray", description: "Status notifier items", category: "Status" },
|
|
"battery": { name: "Battery", description: "Battery percent and ETA", category: "System" }
|
|
})
|
|
|
|
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 widgetMetadata(id) {
|
|
var key = String(id || "")
|
|
if (root.barWidgetRegistry && root.barWidgetRegistry.has(key))
|
|
return root.barWidgetRegistry.metadataFor(key) || {}
|
|
if (builtinWidgetMeta[key]) return builtinWidgetMeta[key]
|
|
|
|
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 : [],
|
|
source: "plugin"
|
|
}
|
|
}
|
|
return {}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
for (var key in builtinWidgetMeta) ids[key] = 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 && !builtinWidgetMeta[id]) 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 = root.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 = root.cloneJson(widgetDialogEntry)
|
|
copy[key] = value
|
|
widgetDialogEntry = copy
|
|
}
|
|
|
|
FloatingWindow {
|
|
id: window
|
|
title: "Omarchy Bar Settings"
|
|
color: root.background
|
|
implicitWidth: 760
|
|
implicitHeight: 620
|
|
minimumSize: Qt.size(620, 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: 48
|
|
|
|
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: 18
|
|
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: 18
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
}
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: 1
|
|
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: 18
|
|
clip: true
|
|
contentWidth: width
|
|
contentHeight: contentColumn.implicitHeight
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
flickableDirection: Flickable.VerticalFlick
|
|
|
|
ColumnLayout {
|
|
id: contentColumn
|
|
width: bodyScroll.width
|
|
spacing: 14
|
|
|
|
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: 420
|
|
height: Math.min(parent.height - 60, 380)
|
|
color: root.background
|
|
radius: Style.cornerRadius
|
|
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.2)
|
|
border.width: 1
|
|
|
|
MouseArea { anchors.fill: parent }
|
|
|
|
ColumnLayout {
|
|
anchors.fill: parent
|
|
anchors.margins: 18
|
|
spacing: 12
|
|
|
|
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
|
|
}
|
|
|
|
Loader {
|
|
id: widgetDialogFormLoader
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
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: 8
|
|
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: 14
|
|
|
|
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: 14
|
|
|
|
PositionButtonGroup {
|
|
value: root.draft.bar.position
|
|
onChanged: function(v) {
|
|
if (root.draft.bar.position === v) return
|
|
var next = root.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 = root.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 = root.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: 1
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===================== shared chrome =====================================
|
|
component PositionButtonGroup: Item {
|
|
id: positionGroup
|
|
|
|
property string value: "top"
|
|
readonly property var positions: ["top", "right", "bottom", "left"]
|
|
|
|
signal changed(string value)
|
|
|
|
implicitWidth: positionColumn.implicitWidth
|
|
implicitHeight: positionColumn.implicitHeight
|
|
|
|
Column {
|
|
id: positionColumn
|
|
spacing: 4
|
|
|
|
Text {
|
|
text: "Position"
|
|
color: Qt.darker(root.foreground, 1.4)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.caption
|
|
font.bold: true
|
|
}
|
|
|
|
Row {
|
|
spacing: 6
|
|
|
|
Repeater {
|
|
model: positionGroup.positions
|
|
|
|
delegate: Button {
|
|
required property string modelData
|
|
|
|
text: modelData
|
|
selected: positionGroup.value === modelData
|
|
foreground: root.foreground
|
|
background: root.background
|
|
accent: root.accent
|
|
fontFamily: root.fontFamily
|
|
onClicked: positionGroup.changed(modelData)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===================== 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: 8
|
|
spacing: 8
|
|
|
|
Connections {
|
|
target: root
|
|
function onDraftRevisionChanged() { section.entries = root.sectionArray(section.sectionKey) }
|
|
}
|
|
|
|
RowLayout {
|
|
width: section.width
|
|
spacing: 8
|
|
|
|
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: 220
|
|
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: 4
|
|
|
|
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: 32
|
|
radius: root.cornerRadius
|
|
color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04)
|
|
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
|
|
border.width: 1
|
|
|
|
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: 50
|
|
radius: root.cornerRadius
|
|
color: cardArea.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03)
|
|
border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12)
|
|
border.width: 1
|
|
|
|
Behavior on color { ColorAnimation { duration: 100 } }
|
|
|
|
Row {
|
|
id: actionRow
|
|
anchors.right: parent.right
|
|
anchors.rightMargin: 8
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 4
|
|
|
|
PanelActionButton {
|
|
iconText: ""
|
|
tooltipText: "Move up"
|
|
foreground: root.foreground
|
|
panelBackground: root.background
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.subtitle
|
|
size: 26
|
|
focusable: true
|
|
onClicked: root.moveEntry(card.sectionKey, card.entryIndex, card.entryIndex - 1)
|
|
}
|
|
PanelActionButton {
|
|
iconText: ""
|
|
tooltipText: "Move down"
|
|
foreground: root.foreground
|
|
panelBackground: root.background
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.subtitle
|
|
size: 26
|
|
focusable: true
|
|
onClicked: root.moveEntry(card.sectionKey, card.entryIndex, card.entryIndex + 1)
|
|
}
|
|
PanelActionButton {
|
|
iconText: ""
|
|
tooltipText: "Settings"
|
|
foreground: root.foreground
|
|
panelBackground: root.background
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.subtitle
|
|
size: 26
|
|
focusable: true
|
|
visible: card.hasSettings
|
|
onClicked: root.openWidgetSettings(card.sectionKey, card.entryIndex, card.entry)
|
|
}
|
|
PanelActionButton {
|
|
iconText: ""
|
|
tooltipText: "Remove"
|
|
foreground: root.urgent
|
|
hoverColor: root.urgent
|
|
panelBackground: root.background
|
|
fontFamily: root.fontFamily
|
|
fontSize: Style.font.subtitle
|
|
size: 26
|
|
focusable: true
|
|
onClicked: root.removeEntry(card.sectionKey, card.entryIndex)
|
|
}
|
|
}
|
|
|
|
Column {
|
|
anchors.left: parent.left
|
|
anchors.right: actionRow.left
|
|
anchors.leftMargin: 12
|
|
anchors.rightMargin: 12
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
spacing: 2
|
|
|
|
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 "calendarSettings": return calendarSettingsComponent
|
|
case "weatherSettings": return weatherSettingsComponent
|
|
}
|
|
}
|
|
if (widgetSchema(id).length > 0) return dynamicSettingsComponent
|
|
return null
|
|
}
|
|
|
|
Component {
|
|
id: dynamicSettingsComponent
|
|
SettingsUi.DynamicSettingsForm {
|
|
schema: root.widgetSchema(entry.id || "")
|
|
foregroundColor: root.foreground
|
|
fontFamilyName: root.fontFamily
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: spacerSettingsComponent
|
|
|
|
Column {
|
|
id: spacerForm
|
|
signal fieldChanged(string key, var value)
|
|
property var entry: ({})
|
|
|
|
spacing: 8
|
|
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: calendarSettingsComponent
|
|
|
|
Column {
|
|
id: calForm
|
|
signal fieldChanged(string key, var value)
|
|
property var entry: ({})
|
|
|
|
spacing: 8
|
|
width: parent ? parent.width : 0
|
|
|
|
component CalendarField: 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) calForm.fieldChanged(fieldKey, text)
|
|
}
|
|
|
|
Text {
|
|
text: "Horizontal format"
|
|
color: Qt.darker(root.foreground, 1.4)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
CalendarField {
|
|
fieldKey: "format"
|
|
text: calForm.entry.format || "dddd HH:mm"
|
|
}
|
|
|
|
Text {
|
|
text: "Alternate format (click to swap)"
|
|
color: Qt.darker(root.foreground, 1.4)
|
|
font.family: root.fontFamily
|
|
font.pixelSize: Style.font.bodySmall
|
|
}
|
|
CalendarField {
|
|
fieldKey: "formatAlt"
|
|
text: calForm.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
|
|
}
|
|
CalendarField {
|
|
fieldKey: "verticalFormat"
|
|
text: calForm.entry.verticalFormat || "HH\n—\nmm"
|
|
}
|
|
}
|
|
}
|
|
|
|
Component {
|
|
id: weatherSettingsComponent
|
|
|
|
Column {
|
|
id: weatherForm
|
|
signal fieldChanged(string key, var value)
|
|
property var entry: ({})
|
|
|
|
spacing: 8
|
|
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) }
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
}
|