Consolidate omarchy-shell colors into a single shell.toml surface

Five plugins each owned their own colors.toml watcher with subtly
different parsers, plus two ad-hoc per-theme override files
(notifications.json, image-picker-colors.json). This collapses all of
that into one source of truth: Commons/Color.qml watches colors.toml +
shell.toml and exposes Color.bar.*, Color.popups.*, Color.notifications.*,
Color.menu.*, Color.imagePicker.* for every surface to bind to.

shell.toml is generated by the existing template pipeline from
default/themed/shell.toml.tpl. Themes can ship their own shell.toml to
override individual keys; everything missing falls back to the
foundational palette via root.pick(). Settings panel is intentionally
not themable beyond the foundational tokens.

last-horizon and solitude ship a minimal shell.toml to preserve their
historical 'notification border matches Hyprland active border'
behavior, which previously came from parsing the theme's hyprland.conf
(now removed).
This commit is contained in:
Ryan Hughes
2026-05-16 00:30:27 -04:00
parent 227d32544f
commit f404a81c6a
14 changed files with 199 additions and 298 deletions
@@ -47,12 +47,13 @@ Two ways to drive it:
- Shell-level summon: `omarchy-shell-ipc shell summon omarchy.image-picker '<jsonPayload>'`.
The payload can carry `imageDirs`, `imageRows`, `selectedImage`,
`selectionFile`, `doneFile`, `colorsFile`, `colorsRaw`, `showLabels`,
`filterable`. Best for in-shell callers that already speak JSON.
- Direct IPC target: `omarchy-shell-ipc image-selector open <imageDirs> <imageRowsB64> <selectedImage> <selectionFile> <doneFile> <colorsFile> <colorsRawB64> <showLabels> <filterable>`.
Positional args; `imageRowsB64` and `colorsRawB64` are base64-encoded so
embedded newlines / tabs survive the bash argv handoff. This is what
`omarchy-menu-images` uses.
`selectionFile`, `doneFile`, `showLabels`, `filterable`. Best for
in-shell callers that already speak JSON.
- Direct IPC target: `omarchy-shell-ipc image-selector open <imageDirs> <imageRowsB64> <selectedImage> <selectionFile> <doneFile> <showLabels> <filterable>`.
Positional args; `imageRowsB64` is base64-encoded so embedded newlines /
tabs survive the bash argv handoff. This is what `omarchy-menu-images`
uses. Colors come from the central shell theme singleton; there is no
per-call override surface.
The selection round-trip remains file-based: callers create a
`selection_file` and `done_file` (both `mktemp`), pass the paths, and
@@ -9,6 +9,7 @@ import Quickshell.Wayland
import Quickshell.Widgets
import QtQuick
import QtQuick.Layouts
import qs.Commons
import "common" as BarCommon
Item {
@@ -49,9 +50,11 @@ Item {
// "monospace" resolves through fontconfig at paint time, so changing the
// system font (via `omarchy-font-set`) updates the bar without a reload.
property string fontFamily: "monospace"
property color foreground: "#cacccc"
property color background: "#101315"
property color urgent: "#a55555"
// Bound to the central Color singleton so the bar tracks shell.toml's
// [bar] section. Property names kept for the rest of this file's bindings.
property color foreground: Color.bar.text
property color background: Color.bar.background
property color urgent: Color.bar.active
property string weatherText: ""
property string weatherClass: ""
property bool updateAvailable: false
@@ -438,18 +441,6 @@ Item {
}
}
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] === "red") urgent = match[2]
}
}
function updateWeather(raw) {
var data = parseModuleJson(raw)
weatherText = data.text || ""
@@ -723,26 +714,6 @@ Item {
onTriggered: root.tooltipShown = true
}
// The host owns shell.json loading and injects `barConfig`. Bar still keeps
// its own theme FileView since theme colors are independent of shell.json.
// `omarchy-theme-set` recreates the entire theme/ directory via rm+mv, which
// invalidates the inotify watch on colors.toml. Use theme.name (overwritten
// in place) to force a fresh reload after each swap.
FileView {
id: themeColorsFile
path: root.home + "/.config/omarchy/current/theme/colors.toml"
watchChanges: true
printErrors: false
onLoaded: root.loadTheme(text())
onFileChanged: reload()
}
FileView {
path: root.home + "/.config/omarchy/current/theme.name"
watchChanges: true
printErrors: false
onFileChanged: themeColorsFile.reload()
}
// Presence of the `bar-off` flag = bar hidden. Watching the parent toggles
// directory because FileView can't observe a file that doesn't exist yet,
// and the flag is created/removed by `omarchy-toggle-bar`.
@@ -1,6 +1,7 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import qs.Commons
PopupWindow {
id: root
@@ -89,8 +90,8 @@ PopupWindow {
Rectangle {
id: card
anchors.fill: parent
color: root.bar ? root.bar.background : "#101315"
border.color: root.bar ? root.bar.foreground : "#cacccc"
color: Color.popups.background
border.color: Color.popups.border
border.width: 1
radius: 0
opacity: root.open ? 1.0 : 0
@@ -4,6 +4,7 @@ import Quickshell.Wayland
import QtQuick
import QtQuick.Effects
import QtQuick.Shapes
import qs.Commons
Item {
id: root
@@ -22,7 +23,6 @@ Item {
property string imageRows: ""
property string selectionFile: Quickshell.env("OMARCHY_IMAGE_SELECTOR_SELECTION_FILE") || Quickshell.env("OMARCHY_BACKGROUND_SELECTION_FILE")
property string selectedImage: Quickshell.env("OMARCHY_IMAGE_SELECTOR_SELECTED")
property string colorsFile: Quickshell.env("OMARCHY_IMAGE_SELECTOR_COLORS_FILE") || (Quickshell.env("HOME") + "/.config/omarchy/current/theme/image-picker-colors.json")
property int selectedIndex: 0
property bool imagesLoaded: false
property bool opened: false
@@ -34,9 +34,11 @@ Item {
property string doneFile: ""
property string filterText: ""
property var doneFilesToRelease: []
property color accent: "#798186"
property color background: "#101315"
property color foreground: "#cacccc"
// Bound to the central [image-picker] section in shell.toml via Color.qml.
property color background: Color.imagePicker.background
property color foreground: Color.imagePicker.text
property color selectedBorder: Color.imagePicker.selectedBorder
property color unselectedBorder: Color.imagePicker.unselectedBorder
property int expandedWidth: 768
property int expandedHeight: 475
property int sliceWidth: 108
@@ -250,7 +252,7 @@ Item {
carousel.forceActiveFocus()
}
function openSelector(nextImageDirs, nextImageRows, nextSelectedImage, nextSelectionFile, nextDoneFile, nextColorsFile, nextColorsRaw, nextShowLabels, nextFilterable) {
function openSelector(nextImageDirs, nextImageRows, nextSelectedImage, nextSelectionFile, nextDoneFile, nextShowLabels, nextFilterable) {
if (requestActive && doneFile && doneFile !== nextDoneFile)
finishDoneFile(doneFile)
@@ -265,9 +267,6 @@ Item {
showLabels = nextShowLabels === true || nextShowLabels === "true"
filterable = nextFilterable === true || nextFilterable === "true"
filterText = ""
colorsFile = nextColorsFile || (Quickshell.env("HOME") + "/.config/omarchy/current/theme/image-picker-colors.json")
if (nextColorsRaw)
loadColors(nextColorsRaw)
imageArray = []
selectedIndex = 0
imagesLoaded = false
@@ -320,11 +319,9 @@ Item {
var sel = String(args.selectedImage || selectedImage)
var selFile = String(args.selectionFile || "")
var doneF = String(args.doneFile || "")
var colors = String(args.colorsFile || colorsFile)
var colorsRaw = String(args.colorsRaw || "")
var labels = args.showLabels === true || args.showLabels === "true"
var filter = args.filterable === true || args.filterable === "true"
openSelector(dirs, rows, sel, selFile, doneF, colors, colorsRaw, labels, filter)
openSelector(dirs, rows, sel, selFile, doneF, labels, filter)
}
function close() {
@@ -332,9 +329,9 @@ Item {
}
// IPC surface. All arguments are strings (Quickshell IPC marshalling).
// imageRows and colorsRaw can contain newlines/tabs, so the CLI caller
// base64-encodes them; everything else passes through verbatim. The two
// boolean-like fields use the literal strings "true" or "false".
// imageRows can contain newlines/tabs, so the CLI caller base64-encodes
// it; everything else passes through verbatim. The two boolean-like
// fields use the literal strings "true" or "false".
IpcHandler {
target: "image-selector"
@@ -343,14 +340,11 @@ Item {
selectedImage: string,
selectionFile: string,
doneFile: string,
colorsFile: string,
colorsRawB64: string,
showLabels: string,
filterable: string): string {
var rows = root.decodeBase64(imageRowsB64)
var colorsRaw = root.decodeBase64(colorsRawB64)
root.openSelector(imageDirs, rows, selectedImage, selectionFile, doneFile,
colorsFile, colorsRaw, showLabels, filterable)
showLabels, filterable)
return "ok"
}
@@ -363,25 +357,6 @@ Item {
}
}
// Tolerate the file being absent (e.g. theme templates not yet re-rendered
// after the rename) without a startup warning.
FileView {
path: root.colorsFile
watchChanges: true
printErrors: false
onLoaded: root.loadColors(text())
onFileChanged: reload()
}
function loadColors(raw) {
try {
var colors = JSON.parse(raw || "{}")
root.accent = colors.primary || root.accent
root.background = colors.background || root.background
root.foreground = colors.backgroundText || root.foreground
} catch (e) {}
}
Process {
id: applyProc
onExited: {
@@ -557,7 +532,7 @@ Item {
preferredRendererType: Shape.CurveRenderer
ShapePath {
fillColor: "transparent"
strokeColor: item.selected ? root.accent : root.withAlpha(root.foreground, 0.28)
strokeColor: item.selected ? root.selectedBorder : root.withAlpha(root.unselectedBorder, 0.28)
strokeWidth: item.selected ? 3 : 1
startX: item.topLeft; startY: 0
PathLine { x: item.topRight; y: 0 }
@@ -2,6 +2,7 @@ import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
import qs.Commons
Item {
id: root
@@ -15,22 +16,13 @@ Item {
// Plugin lifecycle hooks. The host calls open(payloadJson) after
// `omarchy-shell-ipc shell summon omarchy.menu ...` and close() when hidden.
property string pendingInitialMenu: "root"
property string pendingColorsRaw: ""
function decodeBase64(value) {
var s = String(value || "")
if (!s) return ""
try { return Qt.atob(s) } catch (e) { return s }
}
function open(payloadJson) {
var payload = ({})
try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = ({}) }
root.pendingInitialMenu = payload.initialMenu || payload.menu || "root"
root.pendingColorsRaw = payload.colorsRawBase64 ? root.decodeBase64(payload.colorsRawBase64) : (payload.colorsRaw || "")
if (payload.fontFamily) root.fontFamily = payload.fontFamily
if (root.pendingColorsRaw) root.loadColors(root.pendingColorsRaw)
root.openExistingMenu(root.pendingInitialMenu)
}
@@ -40,7 +32,6 @@ Item {
}
property string fontFamily: Quickshell.env("OMARCHY_MENU_FONT") || "monospace"
property string colorsFile: Quickshell.env("OMARCHY_MENU_COLORS_FILE") || (Quickshell.env("HOME") + "/.config/omarchy/current/theme/colors.toml")
property string styleFile: Quickshell.env("OMARCHY_MENU_STYLE_FILE") || (Quickshell.env("HOME") + "/.local/state/omarchy/toggles/quickshell-menu.json")
// JSONC menu definitions. The shell parses both at startup and merges
// the user file on top of the defaults, so the keybind → IPC → visible
@@ -61,9 +52,10 @@ Item {
property var navStack: []
property var providersLoaded: ({})
property var providerQueue: []
property color accent: "#89b4fa"
property color background: "#101315"
property color foreground: "#cacccc"
// Bound to the central [menu] section in shell.toml via Color.qml.
property color accent: Color.menu.selected
property color background: Color.menu.background
property color foreground: Color.menu.text
property color border: foreground
property int cornerRadius: 0
property int contentMargin: 18
@@ -624,18 +616,6 @@ Item {
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
function loadColors(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]
}
border = foreground
}
function loadStyle(raw) {
try {
var style = JSON.parse(raw || "{}")
@@ -811,13 +791,6 @@ Item {
}
}
FileView {
path: root.colorsFile
watchChanges: true
onLoaded: root.loadColors(text())
onFileChanged: { reload(); root.loadColors(text()) }
}
FileView {
path: root.styleFile
watchChanges: true
@@ -33,17 +33,6 @@ Item {
readonly property string cacheDir: home + "/.cache/omarchy/"
readonly property string imageCacheDir: cacheDir + "notification-images/"
readonly property string styleStatePath: home + "/.local/state/omarchy/toggles/quickshell-menu.json"
// Optional per-theme override file. Themes that want notification colors
// to diverge from the rest of the palette can ship this file. Schema:
// {
// "borderColor": "#hex",
// "backgroundColor": "#hex",
// "textColor": "#hex",
// "countdownColor": "#hex" // progress bar at bottom of popup
// }
// All keys optional; unset keys fall back to the live Color.* tokens.
readonly property string themeOverridePath: home + "/.config/omarchy/current/theme/notifications.json"
readonly property string themeNamePath: home + "/.config/omarchy/current/theme.name"
// Corner radius is shared with omarchy-shell menu and settings panel —
// `omarchy style corners <sharp|round>` writes this file once and every
@@ -78,57 +67,6 @@ Item {
onFileChanged: reload()
}
// ----------------------------------------------- per-theme color overrides
property string overrideBorder: ""
property string overrideBackground: ""
property string overrideText: ""
property string overrideCountdown: ""
// Match the compact mako-style layout, but keep colors live-bound to the
// current Omarchy theme unless a theme explicitly ships notifications.json.
readonly property color effectiveBorder: overrideBorder.length > 0 ? overrideBorder : Color.accent
readonly property color effectiveBackground: overrideBackground.length > 0 ? overrideBackground : Color.background
readonly property color effectiveText: overrideText.length > 0 ? overrideText : Color.foreground
readonly property color effectiveCountdown: overrideCountdown.length > 0 ? overrideCountdown : Color.accent
function loadThemeOverride(raw) {
try {
var parsed = JSON.parse(raw || "{}")
overrideBorder = typeof parsed.borderColor === "string" ? parsed.borderColor : ""
overrideBackground = typeof parsed.backgroundColor === "string" ? parsed.backgroundColor : ""
overrideText = typeof parsed.textColor === "string" ? parsed.textColor : ""
overrideCountdown = typeof parsed.countdownColor === "string" ? parsed.countdownColor : ""
} catch (e) {
overrideBorder = ""
overrideBackground = ""
overrideText = ""
overrideCountdown = ""
}
}
FileView {
id: themeOverrideFile
path: service.themeOverridePath
watchChanges: true
printErrors: false
onLoaded: service.loadThemeOverride(text())
onLoadFailed: service.loadThemeOverride("")
onFileChanged: reload()
}
// Theme switching recreates ~/.config/omarchy/current/theme, which can leave
// the notifications.json watcher attached to the old symlink target. Watch
// theme.name (overwritten in place) as the stable tripwire and force a fresh
// override reload; if the new theme has no override, onLoadFailed clears the
// old colors instead of leaving stale theme-specific notification styling.
FileView {
path: service.themeNamePath
watchChanges: true
printErrors: false
onFileChanged: themeOverrideFile.reload()
}
// Fired by IPC (`omarchy-shell-ipc notifications showHistory`) so the
// bar widget can drop its PopupCard from the same anchor a click would.
signal historyOpenRequested()
@@ -948,10 +886,6 @@ Item {
urgency: cardSlot.urgency
timestamp: cardSlot.timestamp
cornerRadius: service.cornerRadius
borderColorOverride: service.effectiveBorder
backgroundColorOverride: service.effectiveBackground
textColorOverride: service.effectiveText
countdownColorOverride: service.effectiveCountdown
fontFamily: service.shell && service.shell.bar ? service.shell.bar.fontFamily : ""
glyph: cardSlot.glyph
progress: cardSlot.progress
@@ -27,13 +27,6 @@ Rectangle {
property real progress: 1.0
property bool showProgress: false
// Container can override the theme defaults per-card. Defaults bind to
// the live Color.* tokens, so when the container leaves them alone the
// card still tracks the theme.
property color borderColorOverride: Color.border
property color backgroundColorOverride: Color.background
property color textColorOverride: Color.foreground
property color countdownColorOverride: Color.accent
// System font from shell.json bar.fontFamily, injected by the container.
property string fontFamily: ""
@@ -77,10 +70,10 @@ Rectangle {
readonly property bool hasGlyph: glyph.length > 0
readonly property bool hasSmallIcon: !mediaMode && (smallIconSource.length > 0 || hasGlyph)
readonly property color dimColor: Qt.darker(textColorOverride, 1.4)
readonly property color bodyColor: Qt.darker(textColorOverride, 1.15)
readonly property color hoverColor: Qt.rgba(textColorOverride.r, textColorOverride.g, textColorOverride.b, 0.14)
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : countdownColorOverride)
readonly property color dimColor: Qt.darker(Color.notifications.text, 1.4)
readonly property color bodyColor: Qt.darker(Color.notifications.text, 1.15)
readonly property color hoverColor: Qt.rgba(Color.notifications.text.r, Color.notifications.text.g, Color.notifications.text.b, 0.14)
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : Color.notifications.countdown)
function sanitizeBody(s) {
return String(s).replace(/<img[^>]*>/gi, "")
@@ -92,8 +85,8 @@ Rectangle {
// for symmetry except when the progress bar replaces it.
implicitHeight: mainColumn.implicitHeight + border.width * 2
radius: cornerRadius
color: backgroundColorOverride
border.color: urgency === 2 ? Color.urgent : borderColorOverride
color: Color.notifications.background
border.color: urgency === 2 ? Color.urgent : Color.notifications.border
border.width: 2
clip: true
@@ -145,7 +138,7 @@ Rectangle {
anchors.right: parent.right
anchors.bottom: parent.bottom
height: root.border.width
color: root.urgency === 2 ? Color.urgent : root.borderColorOverride
color: root.urgency === 2 ? Color.urgent : Color.notifications.border
}
MouseArea {
@@ -194,7 +187,7 @@ Rectangle {
anchors.centerIn: parent
visible: root.hasGlyph && smallIconImage.status !== Image.Ready
text: root.glyph
color: root.textColorOverride
color: Color.notifications.text
font.family: root.fontFamily
font.pixelSize: 18
}
@@ -210,7 +203,7 @@ Rectangle {
visible: root.summary.length > 0
text: root.summary
font.family: root.fontFamily
color: root.textColorOverride
color: Color.notifications.text
font.pixelSize: 13
font.bold: true
wrapMode: Text.WordWrap
@@ -243,7 +236,7 @@ Rectangle {
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 3
color: root.borderColorOverride
color: Color.notifications.border
visible: false
Rectangle {
@@ -3,6 +3,7 @@ import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import qs.Commons
import "../../ui/settings" as SettingsUi
import "./components" as Cmp
@@ -59,10 +60,12 @@ Item {
readonly property string styleStatePath: home + "/.local/state/omarchy/toggles/quickshell-menu.json"
// ---------------- theme --------------------------------------------------
property color foreground: "#cacccc"
property color background: "#101315"
property color accent: "#cacccc"
property color urgent: "#a55555"
// Settings panel 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"
// Source-of-truth for the shell-wide corner radius. Mirrors what the menu
@@ -353,18 +356,6 @@ Item {
mutateSection(section, function(a) { a[index] = cloneJson(newEntry) })
}
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]
}
}
function loadStyleState(raw) {
try {
var s = JSON.parse(raw || "{}")
@@ -559,26 +550,6 @@ Item {
onFileChanged: reload()
}
// `omarchy-theme-set` does `rm -rf current/theme && mv next-theme current/theme`.
// The atomic swap invalidates the inotify watch on colors.toml (the file's
// inode is gone), so onFileChanged never fires for theme switches. Use
// theme.name — a stable file overwritten in place — as the tripwire and
// force-reload colors.toml from its new path each time it changes.
FileView {
id: themeColorsFile
path: root.home + "/.config/omarchy/current/theme/colors.toml"
watchChanges: true
printErrors: false
onLoaded: root.loadTheme(text())
onFileChanged: reload()
}
FileView {
path: root.home + "/.config/omarchy/current/theme.name"
watchChanges: true
printErrors: false
onFileChanged: themeColorsFile.reload()
}
FileView {
path: root.styleStatePath
watchChanges: true