Add visual bar customizer

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.
This commit is contained in:
Ryan Hughes
2026-05-14 02:21:47 -04:00
parent 9ddf05f230
commit a03f589258
7 changed files with 1102 additions and 110 deletions
+16
View File
@@ -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 &
@@ -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 } })
+882
View File
@@ -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)
}
}
}
}
+38 -26
View File
@@ -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": "<widget>" }`, 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" }
]
}
}
```
+29 -15
View File
@@ -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" }
]
}
}
+123 -69
View File
@@ -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
@@ -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