From a03f5892588c72b106e223087d37a7be51a6791f Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Wed, 13 May 2026 10:24:15 -0400 Subject: [PATCH] Add visual bar customizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each layout entry is now an object — {id, ...inline settings} — instead of a (name, separate modules map) pair. Multiple instances of the same widget are trivially supported (spacer is the main beneficiary). Settings travel with the entry, so reordering keeps them attached. shell.qml: - normalizeLayout converts string entries to {id} and drops invalid ones - ModuleSlot binds to a full entry; moduleName and moduleSettings derive from it via entryId/entrySettings helpers - centerAnchor lookup walks layoutEntries directly - builtinBarConfig defaults use the object form - README + bar-defaults.json updated to the new schema bar-settings/ (new Quickshell config): - Top toolbar with Reset / Save and Position / centerAnchor dropdowns - Three section editors (left/center/right) with current widgets shown as cards: name + description + move up/down/settings/remove buttons - '+ Add widget' menu per section, sorted alphabetically by display name (only spacer can repeat; everything else is one per bar) - Per-widget settings dialog (FloatingWindow) loads an inline schema for widgets in the catalog. Initial schemas: spacer (size), calendar (formats), brightness (step) - FileView { atomicWrites } writes to ~/.config/omarchy/bar.json on Save omarchy-launch-bar-settings + hypr window rules pin the settings window to a 760px floating square. controlCenter gains a 'Customize bar…' button that launches it. --- bin/omarchy-launch-bar-settings | 16 + default/hypr/apps/omarchy-bar-settings.lua | 4 + default/quickshell/bar-settings/shell.qml | 882 ++++++++++++++++++ default/quickshell/bar/README.md | 64 +- default/quickshell/bar/bar-defaults.json | 44 +- default/quickshell/bar/shell.qml | 192 ++-- .../quickshell/bar/widgets/controlCenter.qml | 10 + 7 files changed, 1102 insertions(+), 110 deletions(-) create mode 100755 bin/omarchy-launch-bar-settings create mode 100644 default/hypr/apps/omarchy-bar-settings.lua create mode 100644 default/quickshell/bar-settings/shell.qml diff --git a/bin/omarchy-launch-bar-settings b/bin/omarchy-launch-bar-settings new file mode 100755 index 00000000..178db271 --- /dev/null +++ b/bin/omarchy-launch-bar-settings @@ -0,0 +1,16 @@ +#!/bin/bash + +# omarchy:summary=Launch the Omarchy bar customizer +# omarchy:group=launch +# omarchy:name=bar-settings +# omarchy:examples=omarchy launch bar-settings + +OMARCHY_PATH="${OMARCHY_PATH:-$HOME/.local/share/omarchy}" +CONFIG_DIR="$OMARCHY_PATH/default/quickshell/bar-settings" + +if quickshell list -p "$CONFIG_DIR" 2>/dev/null | grep -q '^Instance '; then + hyprctl dispatch focuswindow 'title:Omarchy bar settings' >/dev/null 2>&1 + exit 0 +fi + +setsid uwsm-app -- env OMARCHY_PATH="$OMARCHY_PATH" quickshell -p "$CONFIG_DIR" >/dev/null 2>&1 & diff --git a/default/hypr/apps/omarchy-bar-settings.lua b/default/hypr/apps/omarchy-bar-settings.lua new file mode 100644 index 00000000..e8247838 --- /dev/null +++ b/default/hypr/apps/omarchy-bar-settings.lua @@ -0,0 +1,4 @@ +hl.window_rule({ match = { title = "^Omarchy bar settings$" }, tag = "+floating-window" }) +hl.window_rule({ match = { title = "^Omarchy bar settings$" }, size = { 760, 760 } }) +hl.window_rule({ match = { title = "^Widget settings " }, tag = "+floating-window" }) +hl.window_rule({ match = { title = "^Widget settings " }, size = { 420, 360 } }) diff --git a/default/quickshell/bar-settings/shell.qml b/default/quickshell/bar-settings/shell.qml new file mode 100644 index 00000000..3ed25d1a --- /dev/null +++ b/default/quickshell/bar-settings/shell.qml @@ -0,0 +1,882 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io + +ShellRoot { + id: root + + property string home: Quickshell.env("HOME") + property string omarchyPath: Quickshell.env("OMARCHY_PATH") || (home + "/.local/share/omarchy") + readonly property string userConfigPath: home + "/.config/omarchy/bar.json" + readonly property string defaultsPath: omarchyPath + "/default/quickshell/bar/bar-defaults.json" + + property color foreground: "#cacccc" + property color background: "#101315" + property color accent: "#cacccc" + property color urgent: "#a55555" + + property string fontFamily: "JetBrainsMono Nerd Font" + + property var defaultConfig: ({}) + property var draft: ({ position: "top", centerAnchor: "calendar", layout: { left: [], center: [], right: [] }, fontFamily: "JetBrainsMono Nerd Font" }) + property var registry: ({}) + property bool dirty: false + property int draftRevision: 0 + + function cloneJson(value) { + return JSON.parse(JSON.stringify(value || null)) + } + + function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) + } + + function mergeConfig(base, override) { + var result = cloneJson(base || {}) + if (!isPlainObject(override)) return result + for (var key in override) { + if (isPlainObject(result[key]) && isPlainObject(override[key])) + result[key] = mergeConfig(result[key], override[key]) + else + result[key] = cloneJson(override[key]) + } + return result + } + + 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 loadConfig() { + try { + var defaults = defaultsFile.text() ? JSON.parse(defaultsFile.text()) : {} + defaultConfig = defaults + } catch (e) { + console.warn("Bad defaults JSON:", e) + defaultConfig = {} + } + + var userText = userFile.text() || "{}" + var user = {} + try { user = JSON.parse(userText) } catch (e) { user = {} } + + var merged = mergeConfig(defaultConfig, user) + draft = { + position: String(merged.position || "top"), + centerAnchor: String(merged.centerAnchor || ""), + fontFamily: String(merged.fontFamily || "JetBrainsMono Nerd Font"), + layout: normalizeLayout(merged.layout || {}) + } + dirty = false + draftRevision++ + } + + function saveConfig() { + var payload = cloneJson(draft) + userFile.setText(JSON.stringify(payload, null, 2) + "\n") + dirty = false + } + + function resetToDefaults() { + userFile.setText("{}\n") + loadConfig() + } + + function markDirty() { + dirty = true + draftRevision++ + } + + function moveEntry(section, fromIndex, toIndex) { + var arr = draft.layout[section].slice() + if (toIndex < 0 || toIndex >= arr.length) return + var item = arr[fromIndex] + arr.splice(fromIndex, 1) + arr.splice(toIndex, 0, item) + draft.layout[section] = arr + markDirty() + } + + function removeEntry(section, index) { + var arr = draft.layout[section].slice() + arr.splice(index, 1) + draft.layout[section] = arr + markDirty() + } + + function addEntry(section, id) { + var arr = draft.layout[section].slice() + arr.push({ id: id }) + draft.layout[section] = arr + markDirty() + } + + function updateEntry(section, index, newEntry) { + var arr = draft.layout[section].slice() + arr[index] = cloneJson(newEntry) + draft.layout[section] = arr + markDirty() + } + + function loadTheme(raw) { + var lines = String(raw || "").split("\n") + for (var i = 0; i < lines.length; i++) { + var match = lines[i].match(/^\s*([A-Za-z0-9_-]+)\s*=\s*["']?(#[0-9A-Fa-f]{6})/) + if (!match) continue + if (match[1] === "foreground") foreground = match[2] + else if (match[1] === "background") background = match[2] + else if (match[1] === "color4" || match[1] === "accent") accent = match[2] + else if (match[1] === "red") urgent = match[2] + } + } + + // Catalog of available widgets — id → display metadata + settings schema path. + // settingsForm is an inline-defined Component name that opens when editing. + readonly property var catalog: ({ + "omarchy": { name: "Omarchy menu", description: "Launches the Omarchy menu" }, + "workspaces": { name: "Workspaces (legacy)", description: "Workspace numbers, no animation" }, + "workspacesPro": { name: "Workspaces", description: "Animated workspace switcher" }, + "activeWindow": { name: "Active window", description: "Title of the focused window" }, + "clock": { name: "Clock", description: "Date / time text" }, + "calendar": { name: "Calendar", description: "Clock with month-grid popup", settingsForm: "calendarSettings" }, + "media": { name: "Media", description: "MPRIS now-playing with controls" }, + "weather": { name: "Weather (legacy)", description: "Tiny weather pill" }, + "weatherFlyout": { name: "Weather", description: "Weather pill with detail popup" }, + "update": { name: "Updates", description: "Indicates available system updates" }, + "voxtype": { name: "Voxtype", description: "Voxtype dictation state" }, + "screenRecording": { name: "Screen recording", description: "Active recording indicator" }, + "idle": { name: "Idle (legacy)", description: "Inhibitor indicator" }, + "idleInhibitor": { name: "Keep awake", description: "Idle inhibitor toggle" }, + "notifications": { name: "DND (mako)", description: "Notification silencing indicator" }, + "notificationCenter": { name: "Notification center", description: "Recent notifications + DND (replaces mako)" }, + "tray": { name: "System tray", description: "Status notifier items" }, + "bluetooth": { name: "Bluetooth (legacy)", description: "Bluetooth status icon" }, + "bluetoothPanel": { name: "Bluetooth", description: "Bluetooth devices popup" }, + "network": { name: "Network (legacy)", description: "Wi-Fi/ethernet status" }, + "networkPanel": { name: "Network", description: "Wi-Fi list and connect" }, + "audio": { name: "Volume (legacy)", description: "Speaker icon, scroll for volume" }, + "audioPanel": { name: "Volume", description: "Volume slider, output picker, mixer" }, + "microphone": { name: "Microphone", description: "Mic input state" }, + "nightLight": { name: "Night light", description: "hyprsunset toggle" }, + "brightness": { name: "Brightness", description: "Screen brightness slider", settingsForm: "brightnessSettings" }, + "powerProfile": { name: "Power profile", description: "power-profiles-daemon selector" }, + "battery": { name: "Battery", description: "Battery percent and ETA" }, + "cpu": { name: "CPU (legacy)", description: "btop launcher" }, + "systemStats": { name: "System stats", description: "Inline CPU + RAM graphs" }, + "controlCenter": { name: "Quick settings", description: "Volume/brightness/DND/etc in one popup" }, + "powerMenu": { name: "Power menu", description: "Lock/suspend/reboot/shutdown" }, + "keyboardLayout": { name: "Keyboard layout", description: "Current xkb layout, click cycles" }, + "lockKeys": { name: "Lock keys", description: "Caps/Num/Scroll lock indicators" }, + "spacer": { name: "Spacer", description: "Configurable blank space", settingsForm: "spacerSettings" } + }) + + function widgetName(id) { + return catalog[id] ? catalog[id].name : id + } + + function widgetDescription(id) { + return catalog[id] ? (catalog[id].description || "") : "" + } + + function widgetHasSettings(id) { + return !!(catalog[id] && catalog[id].settingsForm) + } + + function availableToAdd(section) { + var existingByOther = {} + var sections = ["left", "center", "right"] + for (var s = 0; s < sections.length; s++) { + if (sections[s] === section) continue + var list = draft.layout[sections[s]] || [] + for (var i = 0; i < list.length; i++) existingByOther[list[i].id] = true + } + var existingHere = {} + var here = draft.layout[section] || [] + for (var j = 0; j < here.length; j++) existingHere[here[j].id] = true + + var ids = Object.keys(catalog).sort(function(a, b) { + return widgetName(a).localeCompare(widgetName(b)) + }) + + var result = [] + for (var k = 0; k < ids.length; k++) { + var id = ids[k] + // Allow multiple instances of `spacer` only. + var allowMultiple = id === "spacer" + if (!allowMultiple && existingHere[id]) continue + result.push({ id: id, name: widgetName(id), description: widgetDescription(id), elsewhere: !!existingByOther[id] }) + } + return result + } + + FileView { + id: defaultsFile + path: root.defaultsPath + watchChanges: true + printErrors: false + onLoaded: root.loadConfig() + onFileChanged: { reload(); root.loadConfig() } + } + + FileView { + id: userFile + path: root.userConfigPath + watchChanges: true + atomicWrites: true + printErrors: false + onLoaded: root.loadConfig() + onFileChanged: { reload(); root.loadConfig() } + } + + FileView { + path: root.home + "/.config/omarchy/current/theme/colors.toml" + watchChanges: true + printErrors: false + onLoaded: root.loadTheme(text()) + onFileChanged: { reload(); root.loadTheme(text()) } + } + + FloatingWindow { + id: window + title: "Omarchy bar settings" + color: root.background + implicitWidth: 720 + implicitHeight: 720 + minimumSize: Qt.size(560, 500) + + Rectangle { + anchors.fill: parent + color: root.background + + ColumnLayout { + anchors.fill: parent + anchors.margins: 20 + spacing: 16 + + Item { + Layout.fillWidth: true + implicitHeight: 32 + + Text { + text: "Bar settings" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 20 + font.bold: true + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + Row { + spacing: 8 + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + ActionPill { + text: "Reset to defaults" + foreground: root.urgent + onClicked: root.resetToDefaults() + } + + ActionPill { + text: root.dirty ? "Save" : "Saved" + foreground: root.dirty ? root.accent : Qt.darker(root.foreground, 1.5) + bordered: root.dirty + onClicked: if (root.dirty) root.saveConfig() + } + } + } + + Row { + Layout.fillWidth: true + spacing: 10 + + OptionDropdown { + label: "Position" + value: root.draft.position + options: ["top", "right", "bottom", "left"] + onChanged: function(v) { + root.draft.position = v + root.markDirty() + } + } + + OptionDropdown { + label: "Center anchor" + value: root.draft.centerAnchor + options: { + var list = ["(none)"] + var entries = root.draft.layout.center || [] + for (var i = 0; i < entries.length; i++) list.push(entries[i].id) + return list + } + onChanged: function(v) { + root.draft.centerAnchor = v === "(none)" ? "" : v + root.markDirty() + } + } + } + + Rectangle { + Layout.fillWidth: true + height: 1 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) + } + + Flickable { + id: bodyScroll + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + contentWidth: width + contentHeight: bodyColumn.implicitHeight + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + + ColumnLayout { + id: bodyColumn + width: bodyScroll.width + spacing: 14 + + SectionEditor { sectionKey: "left"; sectionLabel: "Left" } + SectionEditor { sectionKey: "center"; sectionLabel: "Center" } + SectionEditor { sectionKey: "right"; sectionLabel: "Right" } + } + } + } + } + } + + // ---------- Components --------------------------------------------------- + + component ActionPill: Rectangle { + id: pill + property string text: "" + property color foreground: root.foreground + property bool bordered: true + signal clicked() + + implicitWidth: pillLabel.implicitWidth + 22 + implicitHeight: 26 + radius: 4 + color: pillArea.containsMouse ? Qt.rgba(pill.foreground.r, pill.foreground.g, pill.foreground.b, 0.15) : "transparent" + border.color: pill.bordered ? pill.foreground : "transparent" + border.width: 1 + + Behavior on color { ColorAnimation { duration: 100 } } + + Text { + id: pillLabel + anchors.centerIn: parent + text: pill.text + color: pill.foreground + font.family: root.fontFamily + font.pixelSize: 11 + } + + MouseArea { + id: pillArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: pill.clicked() + } + } + + component OptionDropdown: Item { + id: dropdown + property string label: "" + property string value: "" + property var options: [] + signal changed(string value) + + implicitWidth: 240 + implicitHeight: 48 + + Column { + anchors.fill: parent + spacing: 4 + + Text { + text: dropdown.label + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: 10 + font.bold: true + } + + ComboBox { + id: combo + width: parent.width + height: 28 + font.family: root.fontFamily + font.pixelSize: 11 + model: dropdown.options + currentIndex: { + for (var i = 0; i < model.length; i++) if (model[i] === dropdown.value) return i + return 0 + } + + onActivated: function(index) { + dropdown.changed(model[index]) + } + + background: Rectangle { + color: root.background + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.4) + border.width: 1 + radius: 4 + } + + contentItem: Text { + leftPadding: 8 + rightPadding: 24 + text: combo.displayText + color: root.foreground + font: combo.font + verticalAlignment: Text.AlignVCenter + } + } + } + } + + component SectionEditor: Column { + id: section + + property string sectionKey: "" + property string sectionLabel: "" + property var entries: (root.draft.layout && root.draft.layout[section.sectionKey]) || [] + Layout.fillWidth: true + spacing: 8 + + Connections { + target: root + function onDraftRevisionChanged() { section.entries = (root.draft.layout && root.draft.layout[section.sectionKey]) || [] } + } + + Row { + width: section.width + spacing: 8 + + Text { + text: section.sectionLabel + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 14 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: "· " + section.entries.length + (section.entries.length === 1 ? " widget" : " widgets") + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + + Item { width: section.width - 200 - parent.children[0].implicitWidth - parent.children[1].implicitWidth; height: 1 } + + ActionPill { + text: "+ Add widget" + onClicked: addMenu.popup() + } + + Menu { + id: addMenu + Repeater { + model: root.availableToAdd(section.sectionKey) + delegate: MenuItem { + required property var modelData + text: modelData.name + (modelData.elsewhere ? " (elsewhere)" : "") + onTriggered: root.addEntry(section.sectionKey, modelData.id) + } + } + } + } + + 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: 4 + 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: 11 + } + } + } + } + + 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: 4 + 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 + + IconButton { + glyph: "↑" + tooltip: "Move up" + onClicked: root.moveEntry(card.sectionKey, card.entryIndex, card.entryIndex - 1) + } + IconButton { + glyph: "↓" + tooltip: "Move down" + onClicked: root.moveEntry(card.sectionKey, card.entryIndex, card.entryIndex + 1) + } + IconButton { + glyph: "⚙" + tooltip: "Settings" + visible: card.hasSettings + onClicked: settingsLoader.open(card.entry) + } + IconButton { + glyph: "✕" + tooltip: "Remove" + foreground: root.urgent + 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: 12 + 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: 10 + elide: Text.ElideRight + width: parent.width + } + } + + MouseArea { + id: cardArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + + SettingsDialog { + id: settingsLoader + anchorWindow: window + sectionKey: card.sectionKey + entryIndex: card.entryIndex + } + } + + component IconButton: Rectangle { + id: iconButton + property string glyph: "" + property string tooltip: "" + property color foreground: root.foreground + signal clicked() + + implicitWidth: 26 + implicitHeight: 26 + radius: 3 + color: iconArea.containsMouse ? Qt.rgba(iconButton.foreground.r, iconButton.foreground.g, iconButton.foreground.b, 0.18) : "transparent" + + Behavior on color { ColorAnimation { duration: 100 } } + + Text { + anchors.centerIn: parent + text: iconButton.glyph + color: iconButton.foreground + font.family: root.fontFamily + font.pixelSize: 13 + } + + MouseArea { + id: iconArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: iconButton.clicked() + } + } + + component SettingsDialog: Item { + id: dialog + property var anchorWindow: null + property string sectionKey: "" + property int entryIndex: -1 + property var workingEntry: ({}) + + function open(entry) { + workingEntry = root.cloneJson(entry) + win.visible = true + } + + function commit() { + root.updateEntry(sectionKey, entryIndex, workingEntry) + win.visible = false + } + + function discard() { + win.visible = false + } + + function fieldChanged(key, value) { + var copy = root.cloneJson(workingEntry) + copy[key] = value + workingEntry = copy + } + + FloatingWindow { + id: win + title: "Widget settings — " + root.widgetName(dialog.workingEntry.id || "") + color: root.background + implicitWidth: 380 + implicitHeight: 320 + visible: false + + Rectangle { + anchors.fill: parent + color: root.background + + ColumnLayout { + anchors.fill: parent + anchors.margins: 18 + spacing: 12 + + Text { + text: root.widgetName(dialog.workingEntry.id || "") + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 14 + font.bold: true + } + + Text { + text: root.widgetDescription(dialog.workingEntry.id || "") + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: 11 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Loader { + id: formLoader + Layout.fillWidth: true + sourceComponent: formComponent(dialog.workingEntry.id || "") + onLoaded: { + if (item && "entry" in item) item.entry = dialog.workingEntry + if (item && "fieldChanged" in item) { + item.fieldChanged.connect(function(key, value) { dialog.fieldChanged(key, value) }) + } + } + } + + Item { Layout.fillHeight: true } + + Row { + Layout.alignment: Qt.AlignRight + spacing: 8 + ActionPill { text: "Cancel"; bordered: false; onClicked: dialog.discard() } + ActionPill { text: "Apply"; onClicked: dialog.commit() } + } + } + } + } + } + + function formComponent(id) { + var cat = catalog[id] + if (!cat || !cat.settingsForm) return null + switch (cat.settingsForm) { + case "spacerSettings": return spacerSettingsComponent + case "calendarSettings": return calendarSettingsComponent + case "brightnessSettings": return brightnessSettingsComponent + default: return null + } + } + + Component { + id: spacerSettingsComponent + + Column { + id: spacerForm + signal fieldChanged(string key, var value) + property var entry: ({}) + + spacing: 8 + width: parent ? parent.width : 0 + + Text { + text: "Size (pixels)" + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: 11 + } + + SpinBox { + from: 0 + to: 256 + value: spacerForm.entry.size !== undefined ? spacerForm.entry.size : 12 + onValueChanged: spacerForm.fieldChanged("size", value) + } + } + } + + Component { + id: calendarSettingsComponent + + Column { + id: calForm + signal fieldChanged(string key, var value) + property var entry: ({}) + + spacing: 8 + width: parent ? parent.width : 0 + + Text { + text: "Horizontal format" + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: 11 + } + TextField { + text: calForm.entry.format || "dddd HH:mm" + font.family: root.fontFamily + font.pixelSize: 12 + width: parent.width + onEditingFinished: calForm.fieldChanged("format", text) + } + + Text { + text: "Alternate format (click to swap)" + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: 11 + } + TextField { + text: calForm.entry.formatAlt || "dd MMMM 'W'ww yyyy" + font.family: root.fontFamily + font.pixelSize: 12 + width: parent.width + onEditingFinished: calForm.fieldChanged("formatAlt", text) + } + + Text { + text: "Vertical format (left/right bars)" + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: 11 + } + TextField { + text: calForm.entry.verticalFormat || "HH\n—\nmm" + font.family: root.fontFamily + font.pixelSize: 12 + width: parent.width + onEditingFinished: calForm.fieldChanged("verticalFormat", text) + } + } + } + + Component { + id: brightnessSettingsComponent + + Column { + id: brightForm + signal fieldChanged(string key, var value) + property var entry: ({}) + + spacing: 8 + width: parent ? parent.width : 0 + + Text { + text: "Scroll step (% per notch)" + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: 11 + } + SpinBox { + from: 1 + to: 25 + value: brightForm.entry.step !== undefined ? brightForm.entry.step : 5 + onValueChanged: brightForm.fieldChanged("step", value) + } + } + } +} diff --git a/default/quickshell/bar/README.md b/default/quickshell/bar/README.md index f01ff553..09b80a50 100644 --- a/default/quickshell/bar/README.md +++ b/default/quickshell/bar/README.md @@ -9,21 +9,40 @@ This is the Quickshell implementation of the Omarchy status bar. - User overrides live in `~/.config/omarchy/bar.json` and are merged over defaults at runtime. - `omarchy-style-bar-position` updates only the user override file. -Example user override: +## Customizing + +The bar reads `~/.local/share/omarchy/default/quickshell/bar/bar-defaults.json`, then deep-merges `~/.config/omarchy/bar.json` on top of it. Each `layout.{left,center,right}` entry is an object: at minimum `{ "id": "" }`, plus any inline settings the widget reads. + +Launch the visual editor with `omarchy launch bar-settings` (or run `omarchy-launch-bar-settings`) to reorder widgets, add/remove them, and tweak per-widget options without editing JSON by hand. + +Example `bar.json`: ```json { "position": "top", + "centerAnchor": "calendar", "layout": { - "left": ["omarchy", "workspacesPro"], - "center": ["media", "calendar", "weatherFlyout"], - "right": ["systemStats", "notificationCenter", "bluetoothPanel", "networkPanel", "audioPanel", "brightness", "powerProfile", "battery", "powerMenu"] - }, - "centerAnchor": "calendar" + "left": [ + { "id": "omarchy" }, + { "id": "spacer", "size": 12 }, + { "id": "workspacesPro" } + ], + "center": [ + { "id": "media" }, + { "id": "calendar", "format": "HH:mm" } + ], + "right": [ + { "id": "systemStats" }, + { "id": "audioPanel" }, + { "id": "battery" }, + { "id": "controlCenter" }, + { "id": "powerMenu" } + ] + } } ``` -`centerAnchor` pins one center module to the exact horizontal/vertical center and flanks others around it. +`centerAnchor` pins one center module to the exact horizontal/vertical center and flanks others around it. Set to an empty string to disable anchoring (the center list is centered as a group). ## Module catalogue @@ -58,23 +77,18 @@ All widgets work in `top`, `bottom`, `left`, and `right` positions. Popups ancho ## Custom user modules -Add a module name to a layout list, then define it under `modules` in `~/.config/omarchy/bar.json`. +The schema accepts arbitrary module ids that you provide. Set `type` to `command` for shell-driven output or `qml` for a custom QML widget. -For simple text/JSON output, use a command module: +Command module: ```json { "layout": { - "right": ["tray", "vpn", "audioPanel", "cpu"] - }, - "modules": { - "vpn": { - "type": "command", - "exec": "~/.config/omarchy/bar/scripts/vpn-status", - "interval": 5, - "tooltip": "VPN", - "onClick": "nm-connection-editor" - } + "right": [ + { "id": "tray" }, + { "id": "vpn", "type": "command", "exec": "~/.config/omarchy/bar/scripts/vpn-status", "interval": 5, "tooltip": "VPN", "onClick": "nm-connection-editor" }, + { "id": "audioPanel" } + ] } } ``` @@ -85,17 +99,15 @@ The command may print plain text or Waybar-style JSON, for example: {"text":"󰌆","tooltip":"Work VPN","class":"active"} ``` -For advanced custom UI, use QML: +QML module: ```json { "layout": { - "right": ["gpu", "audioPanel", "cpu"] - }, - "modules": { - "gpu": { - "type": "qml" - } + "right": [ + { "id": "gpu", "type": "qml" }, + { "id": "audioPanel" } + ] } } ``` diff --git a/default/quickshell/bar/bar-defaults.json b/default/quickshell/bar/bar-defaults.json index 44d817ae..82c9a6d1 100644 --- a/default/quickshell/bar/bar-defaults.json +++ b/default/quickshell/bar/bar-defaults.json @@ -3,20 +3,34 @@ "fontFamily": "JetBrainsMono Nerd Font", "centerAnchor": "calendar", "layout": { - "left": ["omarchy", "workspacesPro", "activeWindow"], - "center": ["media", "calendar", "weatherFlyout", "update", "voxtype", "screenRecording", "idle", "notifications"], - "right": ["tray", "systemStats", "microphone", "bluetoothPanel", "networkPanel", "audioPanel", "nightLight", "brightness", "powerProfile", "battery", "controlCenter", "powerMenu"] - }, - "modules": { - "calendar": { - "format": "dddd HH:mm", - "formatAlt": "dd MMMM 'W'ww yyyy", - "verticalFormat": "HH\n—\nmm" - }, - "clock": { - "format": "dddd HH:mm", - "formatAlt": "dd MMMM 'W'ww yyyy", - "verticalFormat": "HH\n—\nmm" - } + "left": [ + { "id": "omarchy" }, + { "id": "workspacesPro" }, + { "id": "activeWindow" } + ], + "center": [ + { "id": "media" }, + { "id": "calendar", "format": "dddd HH:mm", "formatAlt": "dd MMMM 'W'ww yyyy", "verticalFormat": "HH\n—\nmm" }, + { "id": "weatherFlyout" }, + { "id": "update" }, + { "id": "voxtype" }, + { "id": "screenRecording" }, + { "id": "idle" }, + { "id": "notifications" } + ], + "right": [ + { "id": "tray" }, + { "id": "systemStats" }, + { "id": "microphone" }, + { "id": "bluetoothPanel" }, + { "id": "networkPanel" }, + { "id": "audioPanel" }, + { "id": "nightLight" }, + { "id": "brightness" }, + { "id": "powerProfile" }, + { "id": "battery" }, + { "id": "controlCenter" }, + { "id": "powerMenu" } + ] } } diff --git a/default/quickshell/bar/shell.qml b/default/quickshell/bar/shell.qml index dcd115a0..4d6cb690 100644 --- a/default/quickshell/bar/shell.qml +++ b/default/quickshell/bar/shell.qml @@ -21,22 +21,21 @@ ShellRoot { fontFamily: "JetBrainsMono Nerd Font", centerAnchor: "clock", layout: { - left: ["omarchy", "workspaces"], - center: ["clock", "weather", "update", "voxtype", "screenRecording", "idle", "notifications"], - right: ["tray", "bluetooth", "network", "audio", "cpu", "battery"] - }, - modules: { - clock: { - format: "dddd HH:mm", - formatAlt: "dd MMMM 'W'ww yyyy", - verticalFormat: "HH\n—\nmm" - } + left: [{ id: "omarchy" }, { id: "workspaces" }], + center: [ + { id: "clock", format: "dddd HH:mm", formatAlt: "dd MMMM 'W'ww yyyy", verticalFormat: "HH\n—\nmm" }, + { id: "weather" }, { id: "update" }, { id: "voxtype" }, + { id: "screenRecording" }, { id: "idle" }, { id: "notifications" } + ], + right: [ + { id: "tray" }, { id: "bluetooth" }, { id: "network" }, + { id: "audio" }, { id: "cpu" }, { id: "battery" } + ] } }) property var defaultBarConfig: builtinBarConfig property var userBarConfig: ({}) property var layoutConfig: builtinBarConfig.layout - property var moduleConfig: builtinBarConfig.modules property string centerAnchor: "clock" property int barConfigSerial: 0 property string position: "top" @@ -145,14 +144,38 @@ ShellRoot { } } + function normalizeLayoutEntry(entry) { + if (typeof entry === "string") return { id: entry } + if (isPlainObject(entry) && entry.id) return entry + return null + } + + function normalizeLayoutSection(list) { + if (!Array.isArray(list)) return [] + var result = [] + for (var i = 0; i < list.length; i++) { + var normalized = normalizeLayoutEntry(list[i]) + if (normalized) result.push(normalized) + } + return result + } + + function normalizeLayout(layout) { + if (!isPlainObject(layout)) layout = builtinBarConfig.layout + return { + left: normalizeLayoutSection(layout.left), + center: normalizeLayoutSection(layout.center), + right: normalizeLayoutSection(layout.right) + } + } + function applyBarConfig() { var config = mergeConfig(defaultBarConfig, userBarConfig) position = normalizePosition(config.position) fontFamily = String(config.fontFamily || "JetBrainsMono Nerd Font") centerAnchor = String(config.centerAnchor || "clock") - layoutConfig = isPlainObject(config.layout) ? config.layout : builtinBarConfig.layout - moduleConfig = isPlainObject(config.modules) ? config.modules : {} + layoutConfig = normalizeLayout(config.layout) barConfigSerial++ } @@ -166,42 +189,53 @@ ShellRoot { applyBarConfig() } - function layoutModules(region) { + function layoutEntries(region) { var serial = barConfigSerial - var modules = layoutConfig ? layoutConfig[region] : null - return Array.isArray(modules) ? modules : [] + var entries = layoutConfig ? layoutConfig[region] : null + return Array.isArray(entries) ? entries : [] } - function moduleSettings(name) { - var serial = barConfigSerial - var settings = moduleConfig ? moduleConfig[String(name)] : null - return isPlainObject(settings) ? settings : {} + function entrySettings(entry) { + if (!isPlainObject(entry)) return {} + var copy = {} + for (var key in entry) { + if (key === "id") continue + copy[key] = entry[key] + } + return copy } - function moduleString(name, key, fallback) { - var value = moduleSettings(name)[key] + function entryId(entry) { + if (typeof entry === "string") return entry + if (isPlainObject(entry) && entry.id) return String(entry.id) + return "" + } + + function moduleString(entry, key, fallback) { + var settings = entrySettings(entry) + var value = settings[key] return value === undefined || value === null ? fallback : String(value) } - function moduleIndex(modules, name) { - if (!Array.isArray(modules)) return -1 + function entryIndex(entries, name) { + if (!Array.isArray(entries)) return -1 - for (var i = 0; i < modules.length; i++) { - if (String(modules[i]) === name) + for (var i = 0; i < entries.length; i++) { + if (entryId(entries[i]) === name) return i } return -1 } - function modulesBefore(modules, name) { - var index = moduleIndex(modules, name) - return index <= 0 ? [] : modules.slice(0, index) + function entriesBefore(entries, name) { + var index = entryIndex(entries, name) + return index <= 0 ? [] : entries.slice(0, index) } - function modulesAfter(modules, name) { - var index = moduleIndex(modules, name) - return index === -1 ? [] : modules.slice(index + 1) + function entriesAfter(entries, name) { + var index = entryIndex(entries, name) + return index === -1 ? [] : entries.slice(index + 1) } function builtinModuleComponent(name) { @@ -241,8 +275,8 @@ ShellRoot { return value !== "" && value.indexOf("..") === -1 && value[0] !== "/" } - function customModuleType(name) { - var settings = moduleSettings(name) + function customModuleType(entry) { + var settings = entrySettings(entry) var type = String(settings.type || "") if (type) return type if (settings.exec) return "command" @@ -250,8 +284,9 @@ ShellRoot { return "" } - function customModuleSource(name) { - var settings = moduleSettings(name) + function customModuleSource(entry) { + var settings = entrySettings(entry) + var name = entryId(entry) var source = settings.source ? expandPath(settings.source) : "" if (!source && customModuleSafeName(name)) source = omarchyConfigDir + "/bar/modules/" + String(name) + ".qml" @@ -601,14 +636,26 @@ ShellRoot { root.run("hyprctl dispatch " + shellQuote("hl.dsp.focus({ workspace = \"" + id + "\" })")) } + function clockEntry() { + var serial = barConfigSerial + var sections = ["left", "center", "right"] + for (var i = 0; i < sections.length; i++) { + var entries = layoutEntries(sections[i]) + var idx = entryIndex(entries, "clock") + if (idx !== -1) return entries[idx] + } + return null + } + function clockText() { + var entry = clockEntry() if (clockAlt) - return Qt.formatDateTime(systemClock.date, moduleString("clock", "formatAlt", "dd MMMM 'W'ww yyyy")) + return Qt.formatDateTime(systemClock.date, moduleString(entry, "formatAlt", "dd MMMM 'W'ww yyyy")) if (vertical) - return Qt.formatDateTime(systemClock.date, moduleString("clock", "verticalFormat", "HH\n—\nmm")) + return Qt.formatDateTime(systemClock.date, moduleString(entry, "verticalFormat", "HH\n—\nmm")) - return Qt.formatDateTime(systemClock.date, moduleString("clock", "format", "dddd HH:mm")) + return Qt.formatDateTime(systemClock.date, moduleString(entry, "format", "dddd HH:mm")) } SystemClock { @@ -947,19 +994,26 @@ ShellRoot { Component { id: cpuModuleComponent; CpuModule {} } Component { id: batteryModuleComponent; BatteryModule {} } + function findCenterAnchorEntry() { + var entries = root.layoutEntries("center") + var idx = root.entryIndex(entries, root.centerAnchor) + return idx === -1 ? null : entries[idx] + } + component LeftModules: ModuleList { - modules: root.layoutModules("left") + entries: root.layoutEntries("left") } component RightModules: ModuleList { - modules: root.layoutModules("right") + entries: root.layoutEntries("right") } component CenterModules: Item { id: centerRoot - property var modules: root.layoutModules("center") - readonly property bool hasAnchor: root.moduleIndex(modules, root.centerAnchor) !== -1 + property var entries: root.layoutEntries("center") + readonly property bool hasAnchor: root.entryIndex(entries, root.centerAnchor) !== -1 + readonly property var anchorEntry: root.findCenterAnchorEntry() Loader { anchors.fill: parent @@ -974,13 +1028,13 @@ ShellRoot { ModuleList { visible: !centerRoot.hasAnchor - modules: centerRoot.modules + entries: centerRoot.entries anchors.centerIn: parent } ModuleList { visible: centerRoot.hasAnchor - modules: root.modulesBefore(centerRoot.modules, root.centerAnchor) + entries: root.entriesBefore(centerRoot.entries, root.centerAnchor) anchors.right: centerAnchorModule.left anchors.verticalCenter: centerAnchorModule.verticalCenter } @@ -988,13 +1042,13 @@ ShellRoot { ModuleSlot { id: centerAnchorModule visible: centerRoot.hasAnchor - moduleName: root.centerAnchor + entry: centerRoot.anchorEntry anchors.centerIn: parent } ModuleList { visible: centerRoot.hasAnchor - modules: root.modulesAfter(centerRoot.modules, root.centerAnchor) + entries: root.entriesAfter(centerRoot.entries, root.centerAnchor) anchors.left: centerAnchorModule.right anchors.verticalCenter: centerAnchorModule.verticalCenter } @@ -1009,13 +1063,13 @@ ShellRoot { ModuleList { visible: !centerRoot.hasAnchor - modules: centerRoot.modules + entries: centerRoot.entries anchors.centerIn: parent } ModuleList { visible: centerRoot.hasAnchor - modules: root.modulesBefore(centerRoot.modules, root.centerAnchor) + entries: root.entriesBefore(centerRoot.entries, root.centerAnchor) anchors.bottom: centerAnchorModule.top anchors.horizontalCenter: centerAnchorModule.horizontalCenter } @@ -1023,13 +1077,13 @@ ShellRoot { ModuleSlot { id: centerAnchorModule visible: centerRoot.hasAnchor - moduleName: root.centerAnchor + entry: centerRoot.anchorEntry anchors.centerIn: parent } ModuleList { visible: centerRoot.hasAnchor - modules: root.modulesAfter(centerRoot.modules, root.centerAnchor) + entries: root.entriesAfter(centerRoot.entries, root.centerAnchor) anchors.top: centerAnchorModule.bottom anchors.horizontalCenter: centerAnchorModule.horizontalCenter } @@ -1040,9 +1094,9 @@ ShellRoot { component ModuleList: Loader { id: moduleListRoot - property var modules: [] + property var entries: [] - visible: modules.length > 0 + visible: entries.length > 0 sourceComponent: root.vertical ? verticalModuleList : horizontalModuleList width: item ? item.implicitWidth : 0 height: item ? item.implicitHeight : 0 @@ -1054,11 +1108,11 @@ ShellRoot { spacing: 0 Repeater { - model: moduleListRoot.modules + model: moduleListRoot.entries ModuleSlot { required property var modelData - moduleName: String(modelData) + entry: modelData } } } @@ -1071,11 +1125,11 @@ ShellRoot { spacing: 0 Repeater { - model: moduleListRoot.modules + model: moduleListRoot.entries ModuleSlot { required property var modelData - moduleName: String(modelData) + entry: modelData } } } @@ -1085,8 +1139,10 @@ ShellRoot { component ModuleSlot: Item { id: slot - required property string moduleName - readonly property string customType: root.customModuleType(moduleName) + required property var entry + readonly property string moduleName: root.entryId(entry) + readonly property var moduleSettings: root.entrySettings(entry) + readonly property string customType: root.customModuleType(entry) readonly property var builtinComponent: customType ? null : root.builtinModuleComponent(moduleName) readonly property string firstPartySource: customType || builtinComponent ? "" : root.firstPartyWidgetSource(moduleName) readonly property bool qmlCustom: customType === "qml" @@ -1109,35 +1165,33 @@ ShellRoot { Loader { id: qmlLoader active: slot.qmlCustom || slot.firstParty - source: slot.qmlCustom ? root.customModuleSource(slot.moduleName) : (slot.firstParty ? slot.firstPartySource : "") + source: slot.qmlCustom ? root.customModuleSource(slot.entry) : (slot.firstParty ? slot.firstPartySource : "") anchors.fill: parent onLoaded: slot.injectProps() } + onModuleSettingsChanged: injectProps() + function injectProps() { var target = qmlLoader.item if (!target) return if ("bar" in target) target.bar = root if ("moduleName" in target) target.moduleName = moduleName - if ("settings" in target) target.settings = root.moduleSettings(moduleName) - } - - Connections { - target: root - function onBarConfigSerialChanged() { slot.injectProps() } + if ("settings" in target) target.settings = moduleSettings } Component { id: customCommandModuleComponent - CustomCommandModule { moduleName: slot.moduleName } + CustomCommandModule { entry: slot.entry } } } component CustomCommandModule: ModuleButton { id: customRoot - required property string moduleName - property var settings: root.moduleSettings(moduleName) + required property var entry + readonly property string moduleName: root.entryId(entry) + readonly property var settings: root.entrySettings(entry) property string outputText: "" property string outputTooltip: "" property bool outputActive: false diff --git a/default/quickshell/bar/widgets/controlCenter.qml b/default/quickshell/bar/widgets/controlCenter.qml index 3e1e95ce..0ec7f530 100644 --- a/default/quickshell/bar/widgets/controlCenter.qml +++ b/default/quickshell/bar/widgets/controlCenter.qml @@ -322,6 +322,16 @@ Item { color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) } + Common.PillButton { + width: parent.width + iconText: "󰙪" + text: "Customize bar…" + foreground: root.bar.foreground + horizontalPadding: 10 + verticalPadding: 8 + onClicked: { root.run("omarchy-launch-bar-settings"); root.popupOpen = false } + } + Row { width: parent.width spacing: 6