From c7da0f74aaf6cec4d0a17ea832d8fe0d21c31b10 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Thu, 14 May 2026 01:24:46 -0400 Subject: [PATCH] Replace bar-settings with unified settings plugin Consolidates bar-settings into a single 'settings' plugin with sidebar categories: Defaults, Style, Bar, System, Plugins. Updates supporting commands (omarchy-launch-settings, omarchy-style-corners-quickshell, omarchy-theme-list-with-previews, omarchy-hyprland-monitor-scaling-set) and refreshes sidebar glyphs to Nerd Font icons. --- bin/omarchy-hyprland-monitor-scaling-cycle | 35 +- bin/omarchy-hyprland-monitor-scaling-set | 45 + bin/omarchy-launch-bar-settings | 8 - bin/omarchy-launch-settings | 23 + bin/omarchy-shell-ipc | 2 +- bin/omarchy-style-corners | 3 +- bin/omarchy-style-corners-quickshell | 36 + bin/omarchy-style-corners-walker | 10 +- bin/omarchy-theme-list-with-previews | 26 + bin/omarchy-toggle-bar | 53 +- default/hypr/autostart.lua | 2 +- default/omarchy/omarchy-menu.jsonc | 2 +- .../omarchy-shell/Commons/Color.qml | 10 + default/quickshell/omarchy-shell/README.md | 10 +- .../omarchy-shell/Services/UI/BarService.qml | 4 +- .../omarchy-shell/compat/noctalia/README.md | 2 +- .../omarchy-shell/plugins/README.md | 6 +- .../plugins/bar-settings/BarSettingsPanel.qml | 1505 ---------- .../plugins/bar-settings/manifest.json | 11 - .../omarchy-shell/plugins/bar/Bar.qml | 34 +- .../omarchy-shell/plugins/bar/README.md | 6 +- .../plugins/bar/widgets/controlCenter.qml | 4 +- .../plugins/settings/SettingsPanel.qml | 2633 +++++++++++++++++ .../plugins/settings/components/NDropdown.qml | 134 + .../plugins/settings/manifest.json | 11 + .../omarchy-shell/services/PluginRegistry.qml | 2 +- default/quickshell/omarchy-shell/shell.qml | 2 +- 27 files changed, 3023 insertions(+), 1596 deletions(-) create mode 100755 bin/omarchy-hyprland-monitor-scaling-set delete mode 100755 bin/omarchy-launch-bar-settings create mode 100755 bin/omarchy-launch-settings create mode 100755 bin/omarchy-style-corners-quickshell create mode 100755 bin/omarchy-theme-list-with-previews delete mode 100644 default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml delete mode 100644 default/quickshell/omarchy-shell/plugins/bar-settings/manifest.json create mode 100644 default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml create mode 100644 default/quickshell/omarchy-shell/plugins/settings/components/NDropdown.qml create mode 100644 default/quickshell/omarchy-shell/plugins/settings/manifest.json diff --git a/bin/omarchy-hyprland-monitor-scaling-cycle b/bin/omarchy-hyprland-monitor-scaling-cycle index 9addd89c..ce66c564 100755 --- a/bin/omarchy-hyprland-monitor-scaling-cycle +++ b/bin/omarchy-hyprland-monitor-scaling-cycle @@ -1,20 +1,15 @@ #!/bin/bash # omarchy:summary=Cycle focused Hyprland monitor scaling through 1x, 1.25x, 1.6x, 2x, 3x, and 4x +# omarchy:args=[--reverse] +# omarchy:examples=omarchy-hyprland-monitor-scaling-cycle | omarchy-hyprland-monitor-scaling-cycle --reverse -MONITOR_INFO=$(hyprctl monitors -j | jq -r '.[] | select(.focused == true)') -ACTIVE_MONITOR=$(echo "$MONITOR_INFO" | jq -r '.name') -CURRENT_SCALE=$(echo "$MONITOR_INFO" | jq -r '.scale') -WIDTH=$(echo "$MONITOR_INFO" | jq -r '.width') -HEIGHT=$(echo "$MONITOR_INFO" | jq -r '.height') -REFRESH_RATE=$(echo "$MONITOR_INFO" | jq -r '.refreshRate') +CURRENT_SCALE=$(hyprctl monitors -j | jq -r '.[] | select(.focused == true) | .scale') -# Cycle through monitor/GDK scale pairs: 1 → 1.25 → 1.6 → 2 → 3 → 4 → 1 (or reverse with --reverse) SCALES=(1 1.25 1.6 2 3 4) -GDK_SCALES=(1 1.25 1.75 2 3 4) # Find the index of the scale closest to the current one (Hyprland may -# snap fractional scales to nearby values, so we can't match exactly) +# snap fractional scales to nearby values, so we can't match exactly). CURRENT_IDX=$(awk -v s="$CURRENT_SCALE" -v list="${SCALES[*]}" 'BEGIN { n = split(list, arr, " ") best = 0; best_diff = 1e9 @@ -31,24 +26,4 @@ else NEW_IDX=$(( (CURRENT_IDX + 1) % ${#SCALES[@]} )) fi -NEW_SCALE=${SCALES[$NEW_IDX]} -NEW_GDK_SCALE=${GDK_SCALES[$NEW_IDX]} - -hyprctl eval "hl.monitor({ output = \"$ACTIVE_MONITOR\", mode = \"${WIDTH}x${HEIGHT}@${REFRESH_RATE}\", position = \"auto\", scale = $NEW_SCALE })" >/dev/null - -# Persist to monitors.lua if the user still has Omarchy's generic catch-all -# defaults, so the scale survives reboots. -MONITOR_LUA="$HOME/.config/hypr/monitors.lua" -if [[ -f $MONITOR_LUA ]] && grep -q '^local omarchy_monitor_scale = ' "$MONITOR_LUA"; then - sed -i -E \ - -e "s|^local omarchy_monitor_scale = .*|local omarchy_monitor_scale = ${NEW_SCALE}|" \ - -e "s|^local omarchy_gdk_scale = .*|local omarchy_gdk_scale = ${NEW_GDK_SCALE}|" \ - "$MONITOR_LUA" -elif [[ -f $MONITOR_LUA ]] && grep -Eq '^hl\.monitor\(\{ output = "", mode = "preferred", position = "auto", scale = ("auto"|[0-9.]+) \}\)' "$MONITOR_LUA"; then - sed -i -E \ - -e "s|^(hl\.monitor\(\{ output = \"\", mode = \"preferred\", position = \"auto\", scale = )([^ ]+)( \}\))|\\1${NEW_SCALE}\\3|" \ - -e 's|^hl\.env\("GDK_SCALE", ".*"\)|hl.env("GDK_SCALE", "'"$NEW_GDK_SCALE"'")|' \ - "$MONITOR_LUA" -fi - -notify-send -u low "󰍹 Display scaling set to ${NEW_SCALE}x" +exec omarchy-hyprland-monitor-scaling-set "${SCALES[$NEW_IDX]}" diff --git a/bin/omarchy-hyprland-monitor-scaling-set b/bin/omarchy-hyprland-monitor-scaling-set new file mode 100755 index 00000000..518bbdc1 --- /dev/null +++ b/bin/omarchy-hyprland-monitor-scaling-set @@ -0,0 +1,45 @@ +#!/bin/bash + +# omarchy:summary=Set focused Hyprland monitor scaling to a specific value +# omarchy:args=<1|1.25|1.6|2|3|4> +# omarchy:examples=omarchy-hyprland-monitor-scaling-set 1.6 | omarchy-hyprland-monitor-scaling-set 2 + +NEW_SCALE="$1" + +case "$NEW_SCALE" in + 1) NEW_GDK_SCALE=1 ;; + 1.25) NEW_GDK_SCALE=1.25 ;; + 1.6) NEW_GDK_SCALE=1.75 ;; + 2) NEW_GDK_SCALE=2 ;; + 3) NEW_GDK_SCALE=3 ;; + 4) NEW_GDK_SCALE=4 ;; + *) + echo "Usage: omarchy-hyprland-monitor-scaling-set <1|1.25|1.6|2|3|4>" >&2 + exit 1 + ;; +esac + +MONITOR_INFO=$(hyprctl monitors -j | jq -r '.[] | select(.focused == true)') +ACTIVE_MONITOR=$(echo "$MONITOR_INFO" | jq -r '.name') +WIDTH=$(echo "$MONITOR_INFO" | jq -r '.width') +HEIGHT=$(echo "$MONITOR_INFO" | jq -r '.height') +REFRESH_RATE=$(echo "$MONITOR_INFO" | jq -r '.refreshRate') + +hyprctl eval "hl.monitor({ output = \"$ACTIVE_MONITOR\", mode = \"${WIDTH}x${HEIGHT}@${REFRESH_RATE}\", position = \"auto\", scale = $NEW_SCALE })" >/dev/null + +# Persist to monitors.lua if the user still has Omarchy's generic catch-all +# defaults, so the scale survives reboots. +MONITOR_LUA="$HOME/.config/hypr/monitors.lua" +if [[ -f $MONITOR_LUA ]] && grep -q '^local omarchy_monitor_scale = ' "$MONITOR_LUA"; then + sed -i -E \ + -e "s|^local omarchy_monitor_scale = .*|local omarchy_monitor_scale = ${NEW_SCALE}|" \ + -e "s|^local omarchy_gdk_scale = .*|local omarchy_gdk_scale = ${NEW_GDK_SCALE}|" \ + "$MONITOR_LUA" +elif [[ -f $MONITOR_LUA ]] && grep -Eq '^hl\.monitor\(\{ output = "", mode = "preferred", position = "auto", scale = ("auto"|[0-9.]+) \}\)' "$MONITOR_LUA"; then + sed -i -E \ + -e "s|^(hl\.monitor\(\{ output = \"\", mode = \"preferred\", position = \"auto\", scale = )([^ ]+)( \}\))|\\1${NEW_SCALE}\\3|" \ + -e 's|^hl\.env\("GDK_SCALE", ".*"\)|hl.env("GDK_SCALE", "'"$NEW_GDK_SCALE"'")|' \ + "$MONITOR_LUA" +fi + +notify-send -u low "󰍹 Display scaling set to ${NEW_SCALE}x" diff --git a/bin/omarchy-launch-bar-settings b/bin/omarchy-launch-bar-settings deleted file mode 100755 index 1e85c073..00000000 --- a/bin/omarchy-launch-bar-settings +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Launch the Omarchy bar customizer -# omarchy:group=launch -# omarchy:name=bar-settings -# omarchy:examples=omarchy launch bar-settings - -exec omarchy-shell-ipc shell summon omarchy.bar-settings "{}" diff --git a/bin/omarchy-launch-settings b/bin/omarchy-launch-settings new file mode 100755 index 00000000..00887875 --- /dev/null +++ b/bin/omarchy-launch-settings @@ -0,0 +1,23 @@ +#!/bin/bash + +# omarchy:summary=Launch the Omarchy settings panel +# omarchy:group=launch +# omarchy:name=settings +# omarchy:args=[bar|plugins|defaults|style] +# omarchy:examples=omarchy launch settings | omarchy launch settings defaults | omarchy launch settings style + +category="${1:-}" +payload="{}" +case "$category" in + bar|plugins|defaults|style) + payload=$(printf '{"category":"%s"}' "$category") + ;; + "") + ;; + *) + echo "Usage: omarchy-launch-settings [bar|plugins|defaults|style]" >&2 + exit 1 + ;; +esac + +exec omarchy-shell-ipc shell summon omarchy.settings "$payload" diff --git a/bin/omarchy-shell-ipc b/bin/omarchy-shell-ipc index fe83401b..2639ee3b 100755 --- a/bin/omarchy-shell-ipc +++ b/bin/omarchy-shell-ipc @@ -11,7 +11,7 @@ Starts omarchy-shell if not running, then forwards a quickshell ipc call. Examples: omarchy-shell-ipc shell ping - omarchy-shell-ipc shell summon omarchy.bar-settings "{}" + omarchy-shell-ipc shell summon omarchy.settings "{}" omarchy-shell-ipc shell hide omarchy.image-picker omarchy-shell-ipc shell listPlugins omarchy-shell-ipc shell listShellConfig diff --git a/bin/omarchy-style-corners b/bin/omarchy-style-corners index 60a1f4d3..005b90a2 100755 --- a/bin/omarchy-style-corners +++ b/bin/omarchy-style-corners @@ -1,6 +1,6 @@ #!/bin/bash -# omarchy:summary=Set Hyprland, Hyprlock, Mako, and Walker corners to sharp or round +# omarchy:summary=Set Hyprland, Hyprlock, Mako, Walker, and Quickshell corners to sharp or round # omarchy:args= # omarchy:examples=omarchy style corners round | omarchy style corners sharp @@ -13,6 +13,7 @@ omarchy-style-corners-hyprland "$1" omarchy-style-corners-hyprlock "$1" omarchy-style-corners-mako "$1" omarchy-style-corners-walker "$1" +omarchy-style-corners-quickshell "$1" case $1 in sharp) omarchy-notification-send "Sharp corners enabled" -g 󰝣 ;; diff --git a/bin/omarchy-style-corners-quickshell b/bin/omarchy-style-corners-quickshell new file mode 100755 index 00000000..248307a6 --- /dev/null +++ b/bin/omarchy-style-corners-quickshell @@ -0,0 +1,36 @@ +#!/bin/bash + +# omarchy:summary=Set or toggle corner radius for the omarchy-shell menu and settings panel +# omarchy:args=[toggle|sharp|round] +# omarchy:examples=omarchy style corners quickshell toggle | omarchy style corners quickshell round | omarchy style corners quickshell sharp + +STYLE_FILE="$HOME/.local/state/omarchy/toggles/quickshell-menu.json" + +current_radius() { + [[ -f $STYLE_FILE ]] || { echo 0; return; } + local r + r=$(grep -oE '"radius"[[:space:]]*:[[:space:]]*[0-9]+' "$STYLE_FILE" | grep -oE '[0-9]+$') + echo "${r:-0}" +} + +set_radius() { + local radius="$1" + mkdir -p "$(dirname "$STYLE_FILE")" + printf '{ "radius": %s }\n' "$radius" >"$STYLE_FILE" +} + +case "${1:-toggle}" in + round) set_radius 6 ;; + sharp) set_radius 0 ;; + toggle) + if (( $(current_radius) > 0 )); then + set_radius 0 + else + set_radius 6 + fi + ;; + *) + echo "Usage: omarchy-style-corners-quickshell [toggle|sharp|round]" + exit 1 + ;; +esac diff --git a/bin/omarchy-style-corners-walker b/bin/omarchy-style-corners-walker index 266efc96..e78c56bd 100755 --- a/bin/omarchy-style-corners-walker +++ b/bin/omarchy-style-corners-walker @@ -10,15 +10,7 @@ set_radius() { local radius="$1" mkdir -p "$(dirname "$TOGGLES_CSS")" - touch "$TOGGLES_CSS" - - if grep -q '^ border-radius:' "$TOGGLES_CSS"; then - sed -i "s/^ border-radius:.*/ border-radius: ${radius}px;/" "$TOGGLES_CSS" - elif grep -q '^border-radius:' "$TOGGLES_CSS"; then - sed -i "s/^border-radius:.*/border-radius: ${radius}px;/" "$TOGGLES_CSS" - else - echo ".box-wrapper { border-radius: ${radius}px; }" >>"$TOGGLES_CSS" - fi + printf '.box-wrapper { border-radius: %spx; }\n' "$radius" >"$TOGGLES_CSS" } case "${1:-toggle}" in diff --git a/bin/omarchy-theme-list-with-previews b/bin/omarchy-theme-list-with-previews new file mode 100755 index 00000000..71d60b22 --- /dev/null +++ b/bin/omarchy-theme-list-with-previews @@ -0,0 +1,26 @@ +#!/bin/bash + +# omarchy:summary=List themes with preview image paths (tab-separated: name\tslug\tpreview) +# omarchy:hidden=true + +OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} + +omarchy-theme-list 2>/dev/null | while IFS= read -r name; do + [[ -n $name ]] || continue + slug=$(printf '%s' "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-') + preview="" + for base in "$HOME/.config/omarchy/themes" "$OMARCHY_PATH/themes"; do + [[ -d $base/$slug ]] || continue + for ext in png jpg jpeg webp; do + if [[ -f $base/$slug/preview.$ext ]]; then + preview="$base/$slug/preview.$ext" + break 2 + fi + done + if [[ -z $preview && -d $base/$slug/backgrounds ]]; then + preview=$(find -L "$base/$slug/backgrounds" -maxdepth 1 -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) 2>/dev/null | sort | head -n1) + [[ -n $preview ]] && break + fi + done + printf '%s\t%s\t%s\n' "$name" "$slug" "$preview" +done diff --git a/bin/omarchy-toggle-bar b/bin/omarchy-toggle-bar index 68128687..68a1eca4 100755 --- a/bin/omarchy-toggle-bar +++ b/bin/omarchy-toggle-bar @@ -1,15 +1,48 @@ #!/bin/bash -# omarchy:summary=Toggle bar visibility -# omarchy:examples=omarchy toggle bar +# omarchy:summary=Toggle bar visibility without killing the Omarchy shell +# omarchy:args=[toggle|show|hide] +# omarchy:examples=omarchy toggle bar | omarchy toggle bar hide | omarchy toggle bar show -OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy} -CONFIG_DIR="$OMARCHY_PATH/default/quickshell/omarchy-shell" +FLAG="$HOME/.local/state/omarchy/toggles/bar-off" -omarchy-toggle bar-off +apply_state() { + case "$1" in + hidden) + mkdir -p "$(dirname "$FLAG")" + touch "$FLAG" + ;; + visible) + rm -f "$FLAG" + ;; + esac +} -if quickshell list -p "$CONFIG_DIR" 2>/dev/null | grep -q '^Instance '; then - quickshell kill -p "$CONFIG_DIR" >/dev/null 2>&1 || true -else - uwsm-app -- env OMARCHY_PATH="$OMARCHY_PATH" quickshell -p "$CONFIG_DIR" >/dev/null 2>&1 & -fi +case "${1:-toggle}" in + hide|off) + apply_state hidden + state=hidden + ;; + show|on) + apply_state visible + state=visible + ;; + toggle|"") + if [[ -f $FLAG ]]; then + apply_state visible + state=visible + else + apply_state hidden + state=hidden + fi + ;; + *) + echo "Usage: omarchy-toggle-bar [toggle|show|hide]" >&2 + exit 1 + ;; +esac + +case "$state" in + visible) omarchy-notification-send "Bar shown" -g 󰍜 ;; + hidden) omarchy-notification-send "Bar hidden" -g 󰍜 ;; +esac diff --git a/default/hypr/autostart.lua b/default/hypr/autostart.lua index 34c80e4c..e4659402 100644 --- a/default/hypr/autostart.lua +++ b/default/hypr/autostart.lua @@ -1,7 +1,7 @@ hl.on("hyprland.start", function() hl.exec_cmd("uwsm-app -- hypridle") hl.exec_cmd("uwsm-app -- mako") - hl.exec_cmd("! omarchy-toggle-enabled bar-off && omarchy-restart-quickshell") + hl.exec_cmd("omarchy-restart-quickshell") hl.exec_cmd("uwsm-app -- fcitx5 --disable notificationitem") hl.exec_cmd("uwsm-app -- swaybg -i ~/.config/omarchy/current/background -m fill") hl.exec_cmd("/usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1") diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 3070341e..53dd8757 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -166,7 +166,7 @@ "setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","keywords":"night light","action":"open_in_editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"}, "setup.config.swayosd": {"icon":"","label":"Swayosd","action":"open_in_editor ~/.config/swayosd/config.toml && omarchy-restart-swayosd"}, "setup.config.walker": {"icon":"󰌧","label":"Walker","keywords":"launcher","action":"open_in_editor ~/.config/walker/config.toml && omarchy-restart-walker"}, - "setup.config.bar": {"icon":"󰍜","label":"Bar","keywords":"quickshell shell config","action":"omarchy-launch-bar-settings"}, + "setup.config.bar": {"icon":"󰍜","label":"Bar","keywords":"quickshell shell config","action":"omarchy-launch-settings bar"}, "setup.config.xcompose": {"icon":"󰞅","label":"XCompose","keywords":"compose key","action":"open_in_editor ~/.XCompose && omarchy-restart-xcompose"}, // Install diff --git a/default/quickshell/omarchy-shell/Commons/Color.qml b/default/quickshell/omarchy-shell/Commons/Color.qml index ff0ded28..8b6be52c 100644 --- a/default/quickshell/omarchy-shell/Commons/Color.qml +++ b/default/quickshell/omarchy-shell/Commons/Color.qml @@ -101,11 +101,21 @@ QtObject { } } + // `omarchy-theme-set` recreates the theme/ directory via rm+mv, which kills + // the inotify watch on colors.toml. Use theme.name (overwritten in place) as + // a tripwire that forces a fresh reload after each swap. property FileView themeFile: FileView { + id: themeColorsFile path: Quickshell.env("HOME") + "/.config/omarchy/current/theme/colors.toml" watchChanges: true printErrors: false onLoaded: root.loadTheme(text()) onFileChanged: reload() } + property FileView themeNameFile: FileView { + path: Quickshell.env("HOME") + "/.config/omarchy/current/theme.name" + watchChanges: true + printErrors: false + onFileChanged: themeColorsFile.reload() + } } diff --git a/default/quickshell/omarchy-shell/README.md b/default/quickshell/omarchy-shell/README.md index 3751f4dd..db8b5c3c 100644 --- a/default/quickshell/omarchy-shell/README.md +++ b/default/quickshell/omarchy-shell/README.md @@ -28,7 +28,7 @@ default/quickshell/omarchy-shell/ DynamicSettingsForm.qml renders plugin-declared schemas plugins/ bar/ first-party plugins (see plugins/README.md) - bar-settings/ + settings/ image-picker/ menu/ ``` @@ -87,7 +87,7 @@ The full schema lives in `services/PluginRegistry.qml`. The directory must contain a `manifest.json` plus the QML files referenced from its `entryPoints`. 2. `omarchy-shell-ipc shell rescanPlugins` — or open the Plugin Manager - tab in `omarchy launch bar-settings` and click **Rescan**. + tab in `omarchy launch settings` and click **Rescan**. 3. Enable the plugin (Plugin Manager **Enable** toggle, or `omarchy-shell-ipc shell setPluginEnabled true`). 4. If it's a `bar-widget`, add it to a layout section from the bar editor. @@ -128,7 +128,7 @@ is the canonical way for other Omarchy CLIs to talk to the shell. ``` omarchy-shell-ipc shell ping -omarchy-shell-ipc shell summon omarchy.bar-settings "{}" +omarchy-shell-ipc shell summon omarchy.settings "{}" omarchy-shell-ipc shell listPlugins omarchy-shell-ipc shell rescanPlugins ``` @@ -152,7 +152,7 @@ The `shell-defaults.json` bundled with the shell describes the fresh-install state. When the user has no `shell.json`, the shell uses the defaults verbatim. Once the user customizes anything, `shell.json` becomes the authoritative file — we do **not** deep-merge defaults back -in. Pressing **Reset to defaults** in `omarchy launch bar-settings` +in. Pressing **Reset to defaults** in `omarchy launch settings` rewrites `shell.json` from the current `shell-defaults.json`. ### shell.json shape @@ -174,7 +174,7 @@ rewrites `shell.json` from the current `shell-defaults.json`. } }, "plugins": [ - { "id": "omarchy.bar-settings" }, + { "id": "omarchy.settings" }, { "id": "omarchy.image-picker" } ] } diff --git a/default/quickshell/omarchy-shell/Services/UI/BarService.qml b/default/quickshell/omarchy-shell/Services/UI/BarService.qml index df5c8f13..5d12a0f9 100644 --- a/default/quickshell/omarchy-shell/Services/UI/BarService.qml +++ b/default/quickshell/omarchy-shell/Services/UI/BarService.qml @@ -66,14 +66,14 @@ QtObject { // from a right-click handler. function openWidgetSettings(screen, section, sectionWidgetIndex, widgetId, widgetSettings) { if (bar && bar.shell && typeof bar.shell.summon === "function") { - bar.shell.summon("omarchy.bar-settings", + bar.shell.summon("omarchy.settings", JSON.stringify({ focusWidgetId: widgetId, section: section, index: sectionWidgetIndex })) } } function openPluginSettings(screen, manifest) { if (bar && bar.shell && typeof bar.shell.summon === "function") { - bar.shell.summon("omarchy.bar-settings", + bar.shell.summon("omarchy.settings", JSON.stringify({ focusPluginId: manifest ? manifest.id : "" })) } } diff --git a/default/quickshell/omarchy-shell/compat/noctalia/README.md b/default/quickshell/omarchy-shell/compat/noctalia/README.md index ca03f1c8..88d0d677 100644 --- a/default/quickshell/omarchy-shell/compat/noctalia/README.md +++ b/default/quickshell/omarchy-shell/compat/noctalia/README.md @@ -22,7 +22,7 @@ Add it via the bar customizer or by editing `~/.config/omarchy/shell.json` direc |---|---| | `entryPoints.barWidget` | yes — registered through `BarWidgetRegistry` | | `entryPoints.panel` | yes — opened via `pluginApi.openPanel()` | -| `entryPoints.settings` | yes — embedded in the bar-settings dialog | +| `entryPoints.settings` | yes — embedded in the settings dialog | | `entryPoints.main` | yes — instantiated as a hidden service, exposed via `pluginApi.mainInstance` | | `entryPoints.desktopWidget` | **no** — skipped with a console warning | | `entryPoints.launcherProvider` | **no** — skipped with a console warning | diff --git a/default/quickshell/omarchy-shell/plugins/README.md b/default/quickshell/omarchy-shell/plugins/README.md index 2479d218..d8689d14 100644 --- a/default/quickshell/omarchy-shell/plugins/README.md +++ b/default/quickshell/omarchy-shell/plugins/README.md @@ -11,7 +11,7 @@ User-installed plugins live alongside these conceptually but on disk under | Plugin | id | kinds | activation | entry point | |------------------|--------------------------|--------------|-------------|----------------------------------------------| | Bar | `omarchy.bar` | `bar` | persistent | `bar/Bar.qml` | -| Bar settings | `omarchy.bar-settings` | `panel` | on-demand | `bar-settings/BarSettingsPanel.qml` | +| Settings | `omarchy.settings` | `panel` | on-demand | `settings/SettingsPanel.qml` | | Image picker | `omarchy.image-picker` | `overlay` | on-demand | `image-picker/ImagePicker.qml` | | Omarchy menu | `omarchy.menu` | `menu` | on-demand | `menu/Menu.qml` | @@ -27,8 +27,8 @@ and customization schema. ## Bar settings Visual editor for the entire shell config. Summoned by -`omarchy-shell-ipc shell summon omarchy.bar-settings "{}"` (which is what -`omarchy launch bar-settings` ultimately calls). Provides: +`omarchy-shell-ipc shell summon omarchy.settings "{}"` (which is what +`omarchy launch settings` ultimately calls). Provides: - per-section add/move/remove/edit of bar widget entries - a separate "Other plugins" section for panels, overlays, services, diff --git a/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml b/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml deleted file mode 100644 index 5eb2cd24..00000000 --- a/default/quickshell/omarchy-shell/plugins/bar-settings/BarSettingsPanel.qml +++ /dev/null @@ -1,1505 +0,0 @@ -import QtQuick -import QtQuick.Controls -import QtQuick.Layouts -import Quickshell -import Quickshell.Io - -import "../../ui/settings" as SettingsUi - -Item { - id: root - - // Plugin lifecycle hooks. omarchy-shell calls open(payloadJson) on summon - // and close() on hide. The Loader stays mounted while shell thinks the panel - // is open, so reopening after a WM close must explicitly re-show the window. - property bool closingFromHost: false - - function open(payloadJson) { - closingFromHost = false - window.visible = true - } - - function close() { - closingFromHost = true - window.visible = false - closingFromHost = false - } - - // Injected by the host shell when the panel is summoned. Shared instances - // so the panel sees the same registry state the bar wrote into. - property var barWidgetRegistry: null - property var pluginRegistry: null - // The host shell. Used to look up pluginApi for Noctalia plugins so their - // Settings.qml can read/write via pluginApi.saveSettings(). - property var shell: null - - // Not `required` so the Loader-based instantiation can satisfy it via - // onLoaded; we still gracefully fall back to deriving from shellDir for - // standalone QML tooling. - property string omarchyPath: { - var env = Quickshell.env("OMARCHY_PATH") - if (env) return env - var dir = String(Quickshell.shellDir || "") - if (dir.indexOf("/default/quickshell/omarchy-shell") !== -1) - return dir.substring(0, dir.indexOf("/default/quickshell/omarchy-shell")) - return Quickshell.env("HOME") + "/.local/share/omarchy" - } - readonly property string home: Quickshell.env("HOME") - readonly property string userConfigPath: home + "/.config/omarchy/shell.json" - readonly property string defaultsPath: omarchyPath + "/default/quickshell/omarchy-shell/shell-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 string activeTab: "layout" - - // Bundled fallback so 'Reset to defaults' never produces an empty config - // even if shell-defaults.json fails to load. Keep in rough sync with the - // file shipped at default/quickshell/omarchy-shell/shell-defaults.json. - readonly property var builtinShellConfig: ({ - version: 1, - bar: { - position: "top", - fontFamily: "JetBrainsMono Nerd Font", - centerAnchor: "calendar", - layout: { - left: [{ id: "omarchy" }, { id: "workspaces" }, { 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: "battery" }, { id: "controlCenter" } - ] - } - }, - plugins: [] - }) - - property var defaultConfig: builtinShellConfig - // The draft mirrors the full shell.json on-disk shape. Editors operate on - // draft.bar.layout.* and draft.plugins; the file we persist is `draft`. - property var draft: ({ version: 1, bar: { position: "top", centerAnchor: "calendar", fontFamily: "JetBrainsMono Nerd Font", layout: { left: [], center: [], right: [] } }, plugins: [] }) - property var registry: ({}) - property int draftRevision: 0 - property bool suppressReload: false - - 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() { - var defaults = builtinShellConfig - var diskText = defaultsFile.text() - if (diskText) { - try { - var parsed = JSON.parse(diskText) - if (isPlainObject(parsed) && parsed.version === 1) defaults = parsed - } catch (e) { - console.warn("Bad shell-defaults JSON, falling back to builtin:", e) - defaults = builtinShellConfig - } - } - defaultConfig = defaults - - // shell.json is canonical when present and valid; otherwise we fall back - // to defaults. We do NOT deep-merge — once the user has a shell.json, the - // file's contents are authoritative. - var userText = userFile.text() || "" - var source = defaults - if (userText.trim()) { - try { - var u = JSON.parse(userText) - if (isPlainObject(u) && u.version === 1) source = u - } catch (e) { - console.warn("shell.json parse failed in panel:", e) - } - } - draft = normalizeDraft(source) - draftRevision++ - } - - function normalizeDraft(source) { - var bar = isPlainObject(source.bar) ? source.bar : {} - var plugins = Array.isArray(source.plugins) ? source.plugins.slice() : [] - return { - version: 1, - bar: { - position: String(bar.position || "top"), - centerAnchor: String(bar.centerAnchor || ""), - fontFamily: String(bar.fontFamily || "JetBrainsMono Nerd Font"), - layout: normalizeLayout(bar.layout || {}) - }, - plugins: plugins - .map(normalizeLayoutEntry) - .filter(function(e) { - if (!e) return false - // Drop first-party panel/overlay/menu plugins from the user's - // plugins[] — they're shell infrastructure, summon-on-demand, - // and don't belong in user-editable lists. - var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[e.id] : null - if (manifest && manifest.__isFirstParty) return false - return true - }) - } - } - - function persistDraft() { - // Suppress the inotify callback that this write triggers so the FileView - // reload doesn't race with rapid edits and clobber them. - suppressReload = true - userFile.setText(JSON.stringify(draft, null, 2) + "\n") - } - - function resetToDefaults() { - // Always fall back to the bundled builtin if defaultConfig wound up empty - // (path resolution failed or defaultsFile hasn't finished loading), so - // Reset never zeroes the bar out. - var source = defaultConfig - if (!isPlainObject(source) || !isPlainObject(source.bar) || !isPlainObject(source.bar.layout)) { - source = builtinShellConfig - } else { - var l = source.bar.layout - var anyEntries = (l.left && l.left.length) || (l.center && l.center.length) || (l.right && l.right.length) - if (!anyEntries) source = builtinShellConfig - } - var payload = normalizeDraft(source) - // Update the GUI synchronously — the suppressed file-watch callback won't - // fire loadConfig, so the draft would otherwise stay stale. - draft = payload - draftRevision++ - suppressReload = true - userFile.setText(JSON.stringify(payload, null, 2) + "\n") - } - - function markDirty() { - draftRevision++ - persistDraft() - } - - // Section list helpers operate on a virtual section name. "left" / "center" - // / "right" address draft.bar.layout.
; "plugins" addresses - // draft.plugins. The mutators replace the whole `draft` so any binding that - // reads it re-evaluates. - function sectionArray(section) { - if (section === "plugins") return draft.plugins || [] - return (draft.bar && draft.bar.layout && draft.bar.layout[section]) || [] - } - - function mutateSection(section, mutator) { - var arr = sectionArray(section).slice() - mutator(arr) - var nextDraft = cloneJson(draft) - if (section === "plugins") nextDraft.plugins = arr - else nextDraft.bar.layout[section] = arr - draft = nextDraft - markDirty() - } - - function moveEntry(section, fromIndex, toIndex) { - var arr = sectionArray(section) - if (toIndex < 0 || toIndex >= arr.length) return - mutateSection(section, function(a) { - var item = a[fromIndex] - a.splice(fromIndex, 1) - a.splice(toIndex, 0, item) - }) - } - - function removeEntry(section, index) { - mutateSection(section, function(a) { a.splice(index, 1) }) - } - - function addEntry(section, id) { - mutateSection(section, function(a) { a.push({ id: id }) }) - } - - function updateEntry(section, index, newEntry) { - mutateSection(section, function(a) { a[index] = cloneJson(newEntry) }) - } - - 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 is derived live from BarWidgetRegistry (first-party + third-party - // plugin widgets registered at runtime) plus a small legacy descriptor map - // for builtins that Bar.qml renders inline via builtinModuleComponent and - // hasn't migrated into the registry yet. The merged catalog is rebuilt - // whenever the registry revision changes. - readonly property var legacyWidgetMeta: ({ - "omarchy": { name: "Omarchy menu", description: "Launches the Omarchy menu", category: "Compositor" }, - "workspaces": { name: "Workspaces", description: "Workspace number indicators", category: "Compositor" }, - "clock": { name: "Clock", description: "Date / time text", category: "Time" }, - "weather": { name: "Weather (legacy)", description: "Tiny weather pill", category: "Info" }, - "update": { name: "Updates", description: "Indicates available system updates", category: "System" }, - "voxtype": { name: "Voxtype", description: "Voxtype dictation state", category: "Status" }, - "screenRecording": { name: "Screen recording", description: "Active recording indicator", category: "Status" }, - "idle": { name: "Idle (legacy)", description: "Inhibitor indicator", category: "Status" }, - "notifications": { name: "DND (mako)", description: "Notification silencing indicator", category: "Status" }, - "tray": { name: "System tray", description: "Status notifier items", category: "Status" }, - "bluetooth": { name: "Bluetooth (legacy)", description: "Bluetooth status icon", category: "Network" }, - "network": { name: "Network (legacy)", description: "Wi-Fi / ethernet status", category: "Network" }, - "audio": { name: "Volume (legacy)", description: "Speaker icon, scroll for volume", category: "Audio" }, - "cpu": { name: "CPU (legacy)", description: "btop launcher", category: "System" }, - "battery": { name: "Battery", description: "Battery percent and ETA", category: "System" } - }) - - property int catalogRevision: 0 - // Bump on every registry assignment (including the initial null → instance - // injection from Loader.onLoaded) so bindings that derive from - // widgetMetadata pick up the new state. The host injects the registry - // asynchronously via the Loader, so we also log once it lands. - onBarWidgetRegistryChanged: { - catalogRevision++ - if (!root.barWidgetRegistry) return - console.log("bar-settings open. omarchyPath=" + root.omarchyPath, - "defaultsPath=" + root.defaultsPath, - "userConfigPath=" + root.userConfigPath, - "registry has", - root.barWidgetRegistry.availableIds().length, - "widgets") - } - Connections { - target: root.barWidgetRegistry - function onChanged() { - root.catalogRevision++ - } - } - - - function widgetMetadata(id) { - var key = String(id || "") - if (root.barWidgetRegistry && root.barWidgetRegistry.has(key)) - return root.barWidgetRegistry.metadataFor(key) || {} - if (legacyWidgetMeta[key]) return legacyWidgetMeta[key] - - // If a plugin widget failed to instantiate, it may not be in - // BarWidgetRegistry yet. Still use the manifest metadata so cards/dialogs - // show "Model Usage" instead of the raw id "noctalia.model-usage" and the - // settings gear can still expose the plugin's Settings.qml. - var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[key] : null - if (manifest) { - var meta = manifest.barWidget || {} - return { - displayName: meta.displayName || manifest.name || key, - name: meta.displayName || manifest.name || key, - description: meta.description || manifest.description || "", - category: meta.category || (manifest.__noctaliaCompat ? "Noctalia" : "Plugin"), - allowMultiple: meta.allowMultiple === true, - settingsForm: meta.settingsForm || "", - schema: Array.isArray(meta.schema) ? meta.schema : [], - source: "plugin" - } - } - return {} - } - - function widgetName(id) { - var rev = catalogRevision - var meta = widgetMetadata(id) - return meta.displayName || meta.name || id - } - - function widgetDescription(id) { - var rev = catalogRevision - var meta = widgetMetadata(id) - return meta.description || "" - } - - function widgetSchema(id) { - var meta = widgetMetadata(id) - return Array.isArray(meta.schema) ? meta.schema : [] - } - - function widgetHasSettings(id) { - var rev = catalogRevision - var meta = widgetMetadata(id) - if (meta.settingsForm) return true - if (widgetSchema(id).length > 0) return true - // Noctalia plugins ship their own Settings.qml — surface the gear icon - // even though we don't render a built-in form for them. - var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null - if (manifest && manifest.__noctaliaCompat && manifest.entryPoints && manifest.entryPoints.settings) - return true - return false - } - - function widgetIsPlugin(id) { - var meta = widgetMetadata(id) - return meta.source === "plugin" - } - - function widgetIsNoctaliaPlugin(id) { - var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null - return !!(manifest && manifest.__noctaliaCompat) - } - - function widgetAllowsMultiple(id) { - var meta = widgetMetadata(id) - if (meta.allowMultiple === true) return true - return String(id) === "spacer" - } - - function catalogIds() { - var rev = catalogRevision - var ids = {} - if (root.barWidgetRegistry) { - var registered = root.barWidgetRegistry.availableIds() - for (var i = 0; i < registered.length; i++) ids[registered[i]] = true - } - if (root.pluginRegistry && root.pluginRegistry.installedPlugins) { - var plugins = root.pluginRegistry.installedPlugins - for (var pid in plugins) { - var manifest = plugins[pid] - if (manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1) - ids[pid] = true - } - } - for (var key in legacyWidgetMeta) ids[key] = true - return Object.keys(ids) - } - - // section is one of "left" / "center" / "right" for bar widgets, or - // "plugins" for the non-bar plugin list. The catalogue we offer differs: - // bar sections accept anything with kind `bar-widget` (or anything in the - // legacy widget metadata); the plugins section accepts panel/overlay/menu/ - // service kinds. - function availableToAdd(section) { - var rev = catalogRevision - var isBarSection = section === "left" || section === "center" || section === "right" - var barSections = ["left", "center", "right"] - - var existingInBar = {} - for (var s = 0; s < barSections.length; s++) { - var list = sectionArray(barSections[s]) - for (var i = 0; i < list.length; i++) existingInBar[list[i].id] = true - } - var existingInPlugins = {} - var pluginList = sectionArray("plugins") - for (var p = 0; p < pluginList.length; p++) existingInPlugins[pluginList[p].id] = true - - var ids = catalogIds().sort(function(a, b) { - return widgetName(a).localeCompare(widgetName(b)) - }) - - var result = [] - for (var k = 0; k < ids.length; k++) { - var id = ids[k] - var meta = widgetMetadata(id) - var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null - var manifestIsBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1 - var isBarWidget = !!(meta && meta.source !== "plugin") || manifestIsBarWidget - if (isBarSection) { - if (!isBarWidget && !legacyWidgetMeta[id]) continue - var inSection = sectionArray(section) - var existsHere = false - for (var x = 0; x < inSection.length; x++) if (inSection[x].id === id) { existsHere = true; break } - var allowsMultiple = widgetAllowsMultiple(id) - // Hard block: if it's already placed in any bar section and the - // widget doesn't permit multiple instances, drop it from the menu - // entirely rather than offer an "(elsewhere)" entry that would - // silently move/duplicate state. - if (!allowsMultiple && existingInBar[id]) continue - // For widgets that do allow multiple (spacer), the `elsewhere` - // hint is informational only. - result.push({ id: id, name: widgetName(id), description: widgetDescription(id), - elsewhere: allowsMultiple && !!existingInBar[id] && !existsHere, - isNoctalia: widgetIsNoctaliaPlugin(id) }) - } else { - // Plugins section: accept third-party plugins with any non-bar kind. - // First-party panels/overlays (bar-settings, image-picker) are - // shell infrastructure and don't belong in user-editable lists. - if (!manifest) continue - if (manifest.__isFirstParty) continue - if (existingInPlugins[id]) continue - result.push({ id: id, name: widgetName(id), description: widgetDescription(id), elsewhere: false, - isNoctalia: widgetIsNoctaliaPlugin(id) }) - } - } - return result - } - - FileView { - id: defaultsFile - path: root.defaultsPath - watchChanges: true - printErrors: true - onLoaded: root.loadConfig() - onLoadFailed: function(error) { console.warn("defaults load failed:", error, "path=" + root.defaultsPath) } - onFileChanged: reload() - } - - FileView { - id: userFile - path: root.userConfigPath - watchChanges: true - atomicWrites: true - printErrors: false - onLoaded: { - if (root.suppressReload) { - root.suppressReload = false - return - } - root.loadConfig() - } - onFileChanged: reload() - } - - FileView { - path: root.home + "/.config/omarchy/current/theme/colors.toml" - watchChanges: true - printErrors: false - onLoaded: root.loadTheme(text()) - onFileChanged: reload() - } - - FloatingWindow { - id: window - title: "Omarchy bar settings" - color: root.background - implicitWidth: 720 - implicitHeight: 720 - minimumSize: Qt.size(560, 500) - - onVisibleChanged: { - if (!visible && !root.closingFromHost && root.shell && typeof root.shell.hide === "function") - root.shell.hide("omarchy.bar-settings") - } - - 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 - - Text { - text: "Auto-saving to ~/.config/omarchy/shell.json" - color: Qt.darker(root.foreground, 1.5) - font.family: root.fontFamily - font.pixelSize: 11 - anchors.verticalCenter: parent.verticalCenter - } - - ActionPill { - text: "Reset to defaults" - foreground: root.urgent - onClicked: root.resetToDefaults() - } - } - } - - Row { - Layout.fillWidth: true - spacing: 10 - - OptionDropdown { - label: "Position" - value: root.draft.bar.position - options: ["top", "right", "bottom", "left"] - onChanged: function(v) { - var next = root.cloneJson(root.draft) - next.bar.position = v - root.draft = next - root.markDirty() - } - } - - OptionDropdown { - label: "Center anchor" - value: root.draft.bar.centerAnchor - options: { - var list = ["(none)"] - var entries = root.draft.bar.layout.center || [] - for (var i = 0; i < entries.length; i++) list.push(entries[i].id) - return list - } - onChanged: function(v) { - var next = root.cloneJson(root.draft) - next.bar.centerAnchor = v === "(none)" ? "" : v - root.draft = next - root.markDirty() - } - } - } - - Rectangle { - Layout.fillWidth: true - height: 1 - color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) - } - - Row { - Layout.fillWidth: true - spacing: 0 - - TabButton { - label: "Layout" - selected: root.activeTab === "layout" - onClicked: root.activeTab = "layout" - } - TabButton { - label: "Plugins" - selected: root.activeTab === "plugins" - onClicked: root.activeTab = "plugins" - } - } - - 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: activeTabContent.implicitHeight - boundsBehavior: Flickable.StopAtBounds - flickableDirection: Flickable.VerticalFlick - - ColumnLayout { - id: activeTabContent - width: bodyScroll.width - spacing: 14 - - ColumnLayout { - visible: root.activeTab === "layout" - Layout.fillWidth: true - spacing: 14 - - SectionEditor { sectionKey: "left"; sectionLabel: "Bar · Left" } - SectionEditor { sectionKey: "center"; sectionLabel: "Bar · Center" } - SectionEditor { sectionKey: "right"; sectionLabel: "Bar · Right" } - - Rectangle { - Layout.fillWidth: true - height: 1 - color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) - } - - SectionEditor { sectionKey: "plugins"; sectionLabel: "Other plugins" } - } - - PluginManager { - visible: root.activeTab === "plugins" - Layout.fillWidth: true - } - } - } - } - } - } - - // ---------- 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.sectionArray(section.sectionKey) - Layout.fillWidth: true - spacing: 8 - - Connections { - target: root - function onDraftRevisionChanged() { section.entries = root.sectionArray(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 - // Suffix order matches what users skim left-to-right: - // name first, origin badge, then "elsewhere" if shared across sections. - text: modelData.name - + (modelData.isNoctalia ? " (Noctalia)" : "") - + (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() { - // Native forms update workingEntry through fieldChanged(). Noctalia - // Settings.qml components keep their own editSettings state and expose a - // saveSettings() method that writes via pluginApi.saveSettings(). Avoid - // overwriting that freshly-saved entry with the stale workingEntry shell. - if (formLoader.item && typeof formLoader.item.saveSettings === "function") { - formLoader.item.saveSettings() - } else { - 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() } - } - } - } - } - } - - // Resolution order: - // 1) First-party inline forms (id-keyed switch). - // 2) Noctalia plugin's own Settings.qml — we load it inside a Loader - // so the plugin renders its native UI and writes via pluginApi. - // 3) Generic DynamicSettingsForm built from manifest schema entries. - function formComponent(id) { - var meta = widgetMetadata(id) - if (meta && meta.settingsForm) { - switch (meta.settingsForm) { - case "spacerSettings": return spacerSettingsComponent - case "calendarSettings": return calendarSettingsComponent - } - } - var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null - if (manifest && manifest.__noctaliaCompat && manifest.entryPoints && manifest.entryPoints.settings) - return noctaliaSettingsComponent - if (widgetSchema(id).length > 0) return dynamicSettingsComponent - return null - } - - Component { - id: dynamicSettingsComponent - SettingsUi.DynamicSettingsForm { - schema: root.widgetSchema(entry.id || "") - foregroundColor: root.foreground - fontFamilyName: root.fontFamily - } - } - - // Loader stub for Noctalia plugins that bundle a Settings.qml. We load the - // plugin's form and inject pluginApi. The outer Omarchy dialog owns the - // Apply button, so this wrapper must forward saveSettings() to the loaded - // Noctalia form. - Component { - id: noctaliaSettingsComponent - - Item { - id: noctaliaForm - property var entry: ({}) - property string pluginId: entry && entry.id ? String(entry.id) : "" - property var manifest: pluginId && root.pluginRegistry - ? root.pluginRegistry.installedPlugins[pluginId] : null - - function saveSettings() { - if (settingsLoader.item && typeof settingsLoader.item.saveSettings === "function") { - settingsLoader.item.saveSettings() - } else { - console.warn("Noctalia settings form has no saveSettings():", pluginId) - } - } - - implicitHeight: settingsLoader.item ? settingsLoader.item.implicitHeight : 0 - implicitWidth: settingsLoader.item ? settingsLoader.item.implicitWidth : 0 - - Loader { - id: settingsLoader - anchors.fill: parent - source: { - if (!noctaliaForm.manifest) return "" - return root.pluginRegistry.entryPointUrl(noctaliaForm.manifest, "settings") - } - asynchronous: false - onLoaded: { - if (!item) return - var api = (root.shell && typeof root.shell.noctaliaPluginApiFor === "function") - ? root.shell.noctaliaPluginApiFor(noctaliaForm.pluginId) : null - if (api && "pluginApi" in item) item.pluginApi = api - if ("screen" in item && root.QsWindow && root.QsWindow.window) - item.screen = root.QsWindow.window.screen - } - onStatusChanged: { - if (status === Loader.Error) { - console.warn("noctalia Settings.qml failed for " + noctaliaForm.pluginId + ":", - sourceComponent ? sourceComponent.errorString() : "") - } - } - } - } - } - - 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 - onValueModified: 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 TabButton: Rectangle { - id: tab - property string label: "" - property bool selected: false - signal clicked() - - implicitWidth: tabLabel.implicitWidth + 28 - implicitHeight: 32 - color: tabArea.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : "transparent" - - Behavior on color { ColorAnimation { duration: 120 } } - - Text { - id: tabLabel - anchors.centerIn: parent - text: tab.label - color: tab.selected ? root.foreground : Qt.darker(root.foreground, 1.6) - font.family: root.fontFamily - font.pixelSize: 12 - font.bold: tab.selected - } - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: 2 - color: tab.selected ? root.foreground : "transparent" - } - - MouseArea { - id: tabArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: tab.clicked() - } - } - - component PluginManager: Column { - id: pm - - property int registryRevision: root.pluginRegistry ? root.pluginRegistry.registryRevision : 0 - Connections { - target: root.pluginRegistry - function onPluginsChanged() { pm.registryRevision = root.pluginRegistry.registryRevision } - } - - function pluginList() { - var rev = pm.registryRevision - if (!root.pluginRegistry) return [] - var plugins = root.pluginRegistry.installedPlugins - var ids = Object.keys(plugins).sort(function(a, b) { - var fa = !!plugins[a].__isFirstParty, fb = !!plugins[b].__isFirstParty - if (fa !== fb) return fa ? -1 : 1 - return String(plugins[a].name || a).localeCompare(String(plugins[b].name || b)) - }) - var rows = [] - for (var i = 0; i < ids.length; i++) { - var id = ids[i] - var m = plugins[id] - rows.push({ - id: id, - manifest: m, - enabled: root.pluginRegistry.isEnabled(id), - firstParty: !!m.__isFirstParty - }) - } - return rows - } - - spacing: 10 - width: parent ? parent.width : 0 - - Row { - spacing: 8 - width: parent.width - - Text { - text: "Plugins" - color: root.foreground - font.family: root.fontFamily - font.pixelSize: 14 - font.bold: true - anchors.verticalCenter: parent.verticalCenter - } - - Text { - text: "· " + (root.pluginRegistry ? Object.keys(root.pluginRegistry.installedPlugins).length : 0) + " installed" - color: Qt.darker(root.foreground, 1.5) - font.family: root.fontFamily - font.pixelSize: 11 - anchors.verticalCenter: parent.verticalCenter - } - - Item { width: parent.width - 280; height: 1 } - - ActionPill { - text: "Rescan" - onClicked: root.pluginRegistry.rescan() - } - } - - Text { - text: "Drop plugins at ~/.config/omarchy/plugins//" - color: Qt.darker(root.foreground, 1.6) - font.family: root.fontFamily - font.pixelSize: 10 - } - - Repeater { - model: pm.pluginList() - delegate: PluginRow { - required property var modelData - width: pm.width - manifest: modelData.manifest - pluginId: modelData.id - pluginEnabled: modelData.enabled - firstParty: modelData.firstParty - } - } - - Text { - visible: pm.pluginList().length === 0 - text: "No plugins discovered yet." - color: Qt.darker(root.foreground, 1.5) - font.family: root.fontFamily - font.pixelSize: 11 - } - } - - component PluginRow: Rectangle { - id: row - property var manifest: ({}) - property string pluginId: "" - property bool pluginEnabled: false - property bool firstParty: false - property bool expanded: false - - radius: 4 - color: rowArea.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 - implicitHeight: rowContent.implicitHeight + 16 - - Behavior on color { ColorAnimation { duration: 100 } } - - Column { - id: rowContent - anchors.left: parent.left - anchors.right: parent.right - anchors.top: parent.top - anchors.margins: 8 - spacing: 4 - - Row { - spacing: 8 - width: parent.width - - Column { - spacing: 2 - width: parent.width - 110 - - Text { - text: row.manifest && row.manifest.name ? row.manifest.name : row.pluginId - color: root.foreground - font.family: root.fontFamily - font.pixelSize: 12 - font.bold: true - elide: Text.ElideRight - width: parent.width - } - Text { - text: { - var bits = [] - if (row.manifest && row.manifest.version) bits.push("v" + row.manifest.version) - if (row.manifest && row.manifest.author) bits.push(row.manifest.author) - bits.push(row.firstParty ? "first-party" : "third-party") - return bits.join(" · ") - } - color: Qt.darker(root.foreground, 1.5) - font.family: root.fontFamily - font.pixelSize: 10 - } - Text { - visible: !!(row.manifest && row.manifest.description) - text: row.manifest ? (row.manifest.description || "") : "" - color: Qt.darker(root.foreground, 1.3) - font.family: root.fontFamily - font.pixelSize: 10 - wrapMode: Text.WordWrap - width: parent.width - } - } - - Item { width: 8; height: 1 } - - Item { - implicitWidth: enabledSwitch.implicitWidth - implicitHeight: enabledSwitch.implicitHeight - anchors.verticalCenter: parent.verticalCenter - - Switch { - id: enabledSwitch - checked: row.pluginEnabled - enabled: !row.firstParty - opacity: row.firstParty ? 0.45 : 1 - ToolTip.visible: row.firstParty && hoverArea.containsMouse - ToolTip.delay: 300 - ToolTip.text: "First-party plugin — always enabled" - onToggled: root.pluginRegistry.setEnabled(row.pluginId, checked) - } - - // Switch.enabled=false also disables mouse tracking, so the tooltip - // never sees a hover event. Layer a transparent hover-only MouseArea - // on top to surface the explanation. - MouseArea { - id: hoverArea - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.NoButton - visible: row.firstParty - cursorShape: Qt.ForbiddenCursor - } - } - } - - Row { - visible: !!(row.manifest && row.manifest.barWidget && Array.isArray(row.manifest.barWidget.schema) && row.manifest.barWidget.schema.length > 0) - spacing: 6 - - Text { - text: row.expanded ? "▾ Options" : "▸ Options" - color: Qt.darker(root.foreground, 1.4) - font.family: root.fontFamily - font.pixelSize: 10 - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: row.expanded = !row.expanded - } - } - } - - Repeater { - model: row.expanded && row.manifest && row.manifest.barWidget && Array.isArray(row.manifest.barWidget.schema) ? row.manifest.barWidget.schema : [] - delegate: Text { - required property var modelData - text: "• " + (modelData.label || modelData.key) + " (" + (modelData.type || "string") + ")" - color: Qt.darker(root.foreground, 1.3) - font.family: root.fontFamily - font.pixelSize: 10 - leftPadding: 12 - } - } - } - - MouseArea { - id: rowArea - anchors.fill: parent - hoverEnabled: true - acceptedButtons: Qt.NoButton - } - } -} diff --git a/default/quickshell/omarchy-shell/plugins/bar-settings/manifest.json b/default/quickshell/omarchy-shell/plugins/bar-settings/manifest.json deleted file mode 100644 index 95407a48..00000000 --- a/default/quickshell/omarchy-shell/plugins/bar-settings/manifest.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "schemaVersion": 1, - "id": "omarchy.bar-settings", - "name": "Bar settings", - "version": "1.0.0", - "author": "Omarchy", - "description": "Visual customizer for the Omarchy bar", - "kinds": ["panel"], - "activation": "on-demand", - "entryPoints": { "panel": "BarSettingsPanel.qml" } -} diff --git a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml index 520923b4..ef1ebb0f 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/Bar.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/Bar.qml @@ -16,7 +16,7 @@ Item { // The omarchy-shell host injects omarchyPath when it instantiates this Bar. // Default fallback keeps the file loadable in isolation (e.g. for QML tooling). required property string omarchyPath - // Injected by the host shell. Shared with the bar-settings panel so both + // Injected by the host shell. Shared with the settings panel so both // see the same widget catalogue. required property var barWidgetRegistry // Injected by the host shell every time shell.json is reloaded. Holds the @@ -29,6 +29,10 @@ Item { // Injected by the host shell. Used so Noctalia plugins that reach for // shell-wide APIs (openPanel, currentScreen) can do so via pluginApi. property var shell: null + // Mirrors the on-disk `bar-off` flag so the user can hide the bar without + // killing the entire shell. Wired to BarPanel.visible below; updated by the + // FileView watcher further down. + property bool barHidden: false property string home: Quickshell.env("HOME") property string omarchyConfigDir: home + "/.config/omarchy" property var fallbackBarConfig: ({ @@ -691,13 +695,39 @@ Item { // 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`. + Process { + id: barHiddenProbe + running: true + command: ["bash", "-lc", "[[ -f $HOME/.local/state/omarchy/toggles/bar-off ]] && echo yes || echo no"] + stdout: SplitParser { onRead: function(line) { root.barHidden = String(line).trim() === "yes" } } + } + FileView { + path: root.home + "/.local/state/omarchy/toggles" + watchChanges: true + printErrors: false + onFileChanged: barHiddenProbe.running = true + } Process { id: weatherProc @@ -844,6 +874,8 @@ Item { component BarPanel: PanelWindow { id: barWindow + visible: !root.barHidden + anchors { top: root.position === "top" || root.vertical bottom: root.position === "bottom" || root.vertical diff --git a/default/quickshell/omarchy-shell/plugins/bar/README.md b/default/quickshell/omarchy-shell/plugins/bar/README.md index 82a0cf19..bc8d8864 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/README.md +++ b/default/quickshell/omarchy-shell/plugins/bar/README.md @@ -14,9 +14,9 @@ the shell for its whole session. ## Customizing -The bar config lives under the `bar:` key of [`~/.config/omarchy/shell.json`](../../README.md#shelljson-shape). Out of the box the shell uses [`shell-defaults.json`](../../shell-defaults.json). Once you customize anything via `omarchy launch bar-settings` or by editing shell.json directly, your file is canonical — there is no deep-merge. +The bar config lives under the `bar:` key of [`~/.config/omarchy/shell.json`](../../README.md#shelljson-shape). Out of the box the shell uses [`shell-defaults.json`](../../shell-defaults.json). Once you customize anything via `omarchy launch settings` or by editing shell.json directly, your file is canonical — there is no deep-merge. -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. +Launch the visual editor with `omarchy launch settings` (or run `omarchy-launch-settings`) to reorder widgets, add/remove them, and tweak per-widget options without editing JSON by hand. Example `shell.json` (bar subtree only shown): @@ -171,5 +171,5 @@ Third-party widgets ship as separate plugins under `~/.config/omarchy/plugins//` with their own `manifest.json` declaring `kinds: ["bar-widget"]` and a `barWidget` entry point. See [../../README.md](../../README.md) for the manifest schema and the -Plugin Manager tab in `omarchy launch bar-settings` for enable/disable +Plugin Manager tab in `omarchy launch settings` for enable/disable controls. diff --git a/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml b/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml index d04bf39b..784ee8dc 100644 --- a/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml +++ b/default/quickshell/omarchy-shell/plugins/bar/widgets/controlCenter.qml @@ -362,11 +362,11 @@ Item { Common.PillButton { width: parent.width iconText: "󰙪" - text: "Customize bar…" + text: "Settings…" foreground: root.bar.foreground horizontalPadding: 10 verticalPadding: 8 - onClicked: { root.run("omarchy-launch-bar-settings"); root.popupOpen = false } + onClicked: { root.run("omarchy-launch-settings"); root.popupOpen = false } } } } diff --git a/default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml b/default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml new file mode 100644 index 00000000..441d4062 --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/settings/SettingsPanel.qml @@ -0,0 +1,2633 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io + +import "../../ui/settings" as SettingsUi +import "./components" as Cmp + +Item { + id: root + + // ---------------- plugin lifecycle --------------------------------------- + property bool closingFromHost: false + + function open(payloadJson) { + closingFromHost = false + var payload = ({}) + try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = ({}) } + + if (payload && typeof payload.category === "string" && root.categoryIds.indexOf(payload.category) !== -1) { + root.activeCategory = payload.category + } else if (payload && (payload.focusWidgetId || payload.section || payload.focusPluginId)) { + // Noctalia-compat callers ask us to focus a specific widget/plugin — + // route them to the relevant tab. Widgets live under Bar; otherwise + // open the Plugins tab. + root.activeCategory = payload.focusPluginId ? "plugins" : "bar" + } + + window.visible = true + } + + function close() { + closingFromHost = true + window.visible = false + closingFromHost = false + } + + // ---------------- host injections ---------------------------------------- + property var barWidgetRegistry: null + property var pluginRegistry: null + property var shell: null + + // ---------------- paths -------------------------------------------------- + property string omarchyPath: { + var env = Quickshell.env("OMARCHY_PATH") + if (env) return env + var dir = String(Quickshell.shellDir || "") + if (dir.indexOf("/default/quickshell/omarchy-shell") !== -1) + return dir.substring(0, dir.indexOf("/default/quickshell/omarchy-shell")) + return Quickshell.env("HOME") + "/.local/share/omarchy" + } + readonly property string home: Quickshell.env("HOME") + readonly property string userConfigPath: home + "/.config/omarchy/shell.json" + readonly property string defaultsPath: omarchyPath + "/default/quickshell/omarchy-shell/shell-defaults.json" + 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" + property string fontFamily: "JetBrainsMono Nerd Font" + + // Source-of-truth for the shell-wide corner radius. Mirrors what the menu + // reads from quickshell-menu.json so `omarchy style corners ` + // flips both surfaces together. + property int cornerRadius: 0 + + // ---------------- navigation --------------------------------------------- + readonly property var categoryIds: ["defaults", "style", "bar", "system", "plugins"] + property string activeCategory: "bar" + + // ---------------- bundled defaults --------------------------------------- + readonly property var builtinShellConfig: ({ + version: 1, + bar: { + position: "top", + fontFamily: "JetBrainsMono Nerd Font", + centerAnchor: "calendar", + layout: { + left: [{ id: "omarchy" }, { id: "workspaces" }, { 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: "battery" }, { id: "controlCenter" } + ] + } + }, + plugins: [] + }) + + property var defaultConfig: builtinShellConfig + property var draft: ({ version: 1, bar: { position: "top", centerAnchor: "calendar", fontFamily: "JetBrainsMono Nerd Font", layout: { left: [], center: [], right: [] } }, plugins: [] }) + property int draftRevision: 0 + property bool suppressReload: false + + // ---------------- draft helpers ------------------------------------------ + function cloneJson(value) { return JSON.parse(JSON.stringify(value || null)) } + function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value) } + + function normalizeLayoutEntry(entry) { + if (typeof entry === "string") return { id: entry } + if (isPlainObject(entry) && entry.id) return cloneJson(entry) + return null + } + + function normalizeLayout(layout) { + var sections = ["left", "center", "right"] + var result = {} + for (var i = 0; i < sections.length; i++) { + var s = sections[i] + var arr = [] + var src = (layout && layout[s]) || [] + for (var j = 0; j < src.length; j++) { + var entry = normalizeLayoutEntry(src[j]) + if (entry) arr.push(entry) + } + result[s] = arr + } + return result + } + + function normalizeDraft(source) { + var bar = isPlainObject(source.bar) ? source.bar : {} + var plugins = Array.isArray(source.plugins) ? source.plugins.slice() : [] + return { + version: 1, + bar: { + position: String(bar.position || "top"), + centerAnchor: String(bar.centerAnchor || ""), + fontFamily: String(bar.fontFamily || "JetBrainsMono Nerd Font"), + layout: normalizeLayout(bar.layout || {}) + }, + plugins: plugins + .map(normalizeLayoutEntry) + .filter(function(e) { + if (!e) return false + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[e.id] : null + if (manifest && manifest.__isFirstParty) return false + return true + }) + } + } + + function loadConfig() { + var defaults = builtinShellConfig + var diskText = defaultsFile.text() + if (diskText) { + try { + var parsed = JSON.parse(diskText) + if (isPlainObject(parsed) && parsed.version === 1) defaults = parsed + } catch (e) { + console.warn("Bad shell-defaults JSON, falling back to builtin:", e) + defaults = builtinShellConfig + } + } + defaultConfig = defaults + + var userText = userFile.text() || "" + var source = defaults + if (userText.trim()) { + try { + var u = JSON.parse(userText) + if (isPlainObject(u) && u.version === 1) source = u + } catch (e) { + console.warn("shell.json parse failed in panel:", e) + } + } + draft = normalizeDraft(source) + draftRevision++ + } + + function persistDraft() { + suppressReload = true + userFile.setText(JSON.stringify(draft, null, 2) + "\n") + } + + function resetToDefaults() { + var source = defaultConfig + if (!isPlainObject(source) || !isPlainObject(source.bar) || !isPlainObject(source.bar.layout)) { + source = builtinShellConfig + } else { + var l = source.bar.layout + var anyEntries = (l.left && l.left.length) || (l.center && l.center.length) || (l.right && l.right.length) + if (!anyEntries) source = builtinShellConfig + } + var payload = normalizeDraft(source) + draft = payload + draftRevision++ + suppressReload = true + userFile.setText(JSON.stringify(payload, null, 2) + "\n") + } + + function markDirty() { + draftRevision++ + persistDraft() + } + + function sectionArray(section) { + if (section === "plugins") return draft.plugins || [] + return (draft.bar && draft.bar.layout && draft.bar.layout[section]) || [] + } + + function mutateSection(section, mutator) { + var arr = sectionArray(section).slice() + mutator(arr) + var nextDraft = cloneJson(draft) + if (section === "plugins") nextDraft.plugins = arr + else nextDraft.bar.layout[section] = arr + draft = nextDraft + markDirty() + } + + function moveEntry(section, fromIndex, toIndex) { + var arr = sectionArray(section) + if (toIndex < 0 || toIndex >= arr.length) return + mutateSection(section, function(a) { + var item = a[fromIndex] + a.splice(fromIndex, 1) + a.splice(toIndex, 0, item) + }) + } + + function removeEntry(section, index) { + mutateSection(section, function(a) { a.splice(index, 1) }) + } + + function addEntry(section, id) { + mutateSection(section, function(a) { a.push({ id: id }) }) + } + + function updateEntry(section, index, newEntry) { + mutateSection(section, function(a) { a[index] = cloneJson(newEntry) }) + } + + 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 || "{}") + var n = Number(s.radius) + cornerRadius = isFinite(n) ? n : 0 + } catch (e) { + cornerRadius = 0 + } + } + + // ---------------- widget catalog ----------------------------------------- + readonly property var legacyWidgetMeta: ({ + "omarchy": { name: "Omarchy menu", description: "Launches the Omarchy menu", category: "Compositor" }, + "workspaces": { name: "Workspaces", description: "Workspace number indicators", category: "Compositor" }, + "clock": { name: "Clock", description: "Date / time text", category: "Time" }, + "weather": { name: "Weather (legacy)", description: "Tiny weather pill", category: "Info" }, + "update": { name: "Updates", description: "Indicates available system updates", category: "System" }, + "voxtype": { name: "Voxtype", description: "Voxtype dictation state", category: "Status" }, + "screenRecording": { name: "Screen recording", description: "Active recording indicator", category: "Status" }, + "idle": { name: "Idle (legacy)", description: "Inhibitor indicator", category: "Status" }, + "notifications": { name: "DND (mako)", description: "Notification silencing indicator", category: "Status" }, + "tray": { name: "System tray", description: "Status notifier items", category: "Status" }, + "bluetooth": { name: "Bluetooth (legacy)", description: "Bluetooth status icon", category: "Network" }, + "network": { name: "Network (legacy)", description: "Wi-Fi / ethernet status", category: "Network" }, + "audio": { name: "Volume (legacy)", description: "Speaker icon, scroll for volume", category: "Audio" }, + "cpu": { name: "CPU (legacy)", description: "btop launcher", category: "System" }, + "battery": { name: "Battery", description: "Battery percent and ETA", category: "System" } + }) + + property int catalogRevision: 0 + onBarWidgetRegistryChanged: { + catalogRevision++ + if (!root.barWidgetRegistry) return + console.log("settings panel open. omarchyPath=" + root.omarchyPath, + "defaultsPath=" + root.defaultsPath, + "userConfigPath=" + root.userConfigPath, + "registry has", + root.barWidgetRegistry.availableIds().length, + "widgets") + } + Connections { + target: root.barWidgetRegistry + function onChanged() { root.catalogRevision++ } + } + + function widgetMetadata(id) { + var key = String(id || "") + if (root.barWidgetRegistry && root.barWidgetRegistry.has(key)) + return root.barWidgetRegistry.metadataFor(key) || {} + if (legacyWidgetMeta[key]) return legacyWidgetMeta[key] + + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[key] : null + if (manifest) { + var meta = manifest.barWidget || {} + return { + displayName: meta.displayName || manifest.name || key, + name: meta.displayName || manifest.name || key, + description: meta.description || manifest.description || "", + category: meta.category || (manifest.__noctaliaCompat ? "Noctalia" : "Plugin"), + allowMultiple: meta.allowMultiple === true, + settingsForm: meta.settingsForm || "", + schema: Array.isArray(meta.schema) ? meta.schema : [], + source: "plugin" + } + } + return {} + } + + function widgetName(id) { + var rev = catalogRevision + var meta = widgetMetadata(id) + return meta.displayName || meta.name || id + } + + function widgetDescription(id) { + var rev = catalogRevision + var meta = widgetMetadata(id) + return meta.description || "" + } + + function widgetSchema(id) { + var meta = widgetMetadata(id) + return Array.isArray(meta.schema) ? meta.schema : [] + } + + function widgetHasSettings(id) { + var rev = catalogRevision + var meta = widgetMetadata(id) + if (meta.settingsForm) return true + if (widgetSchema(id).length > 0) return true + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null + if (manifest && manifest.__noctaliaCompat && manifest.entryPoints && manifest.entryPoints.settings) + return true + return false + } + + function widgetIsNoctaliaPlugin(id) { + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null + return !!(manifest && manifest.__noctaliaCompat) + } + + function widgetAllowsMultiple(id) { + var meta = widgetMetadata(id) + if (meta.allowMultiple === true) return true + return String(id) === "spacer" + } + + function catalogIds() { + var rev = catalogRevision + var ids = {} + if (root.barWidgetRegistry) { + var registered = root.barWidgetRegistry.availableIds() + for (var i = 0; i < registered.length; i++) ids[registered[i]] = true + } + if (root.pluginRegistry && root.pluginRegistry.installedPlugins) { + var plugins = root.pluginRegistry.installedPlugins + for (var pid in plugins) { + var manifest = plugins[pid] + if (manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1) + ids[pid] = true + } + } + for (var key in legacyWidgetMeta) ids[key] = true + return Object.keys(ids) + } + + function availableToAdd(section) { + var rev = catalogRevision + var isBarSection = section === "left" || section === "center" || section === "right" + var barSections = ["left", "center", "right"] + + var existingInBar = {} + for (var s = 0; s < barSections.length; s++) { + var list = sectionArray(barSections[s]) + for (var i = 0; i < list.length; i++) existingInBar[list[i].id] = true + } + var existingInPlugins = {} + var pluginList = sectionArray("plugins") + for (var p = 0; p < pluginList.length; p++) existingInPlugins[pluginList[p].id] = true + + var ids = catalogIds().sort(function(a, b) { return widgetName(a).localeCompare(widgetName(b)) }) + + var result = [] + for (var k = 0; k < ids.length; k++) { + var id = ids[k] + var meta = widgetMetadata(id) + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null + var manifestIsBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1 + var isBarWidget = !!(meta && meta.source !== "plugin") || manifestIsBarWidget + if (isBarSection) { + if (!isBarWidget && !legacyWidgetMeta[id]) continue + var inSection = sectionArray(section) + var existsHere = false + for (var x = 0; x < inSection.length; x++) if (inSection[x].id === id) { existsHere = true; break } + var allowsMultiple = widgetAllowsMultiple(id) + if (!allowsMultiple && existingInBar[id]) continue + result.push({ id: id, name: widgetName(id), description: widgetDescription(id), + elsewhere: allowsMultiple && !!existingInBar[id] && !existsHere, + isNoctalia: widgetIsNoctaliaPlugin(id) }) + } else { + if (!manifest) continue + if (manifest.__isFirstParty) continue + if (existingInPlugins[id]) continue + result.push({ id: id, name: widgetName(id), description: widgetDescription(id), elsewhere: false, + isNoctalia: widgetIsNoctaliaPlugin(id) }) + } + } + return result + } + + // ---------------- file watchers ------------------------------------------ + FileView { + id: defaultsFile + path: root.defaultsPath + watchChanges: true + printErrors: true + onLoaded: root.loadConfig() + onLoadFailed: function(error) { console.warn("defaults load failed:", error, "path=" + root.defaultsPath) } + onFileChanged: reload() + } + + FileView { + id: userFile + path: root.userConfigPath + watchChanges: true + atomicWrites: true + printErrors: false + onLoaded: { + if (root.suppressReload) { root.suppressReload = false; return } + root.loadConfig() + } + onFileChanged: reload() + } + + // `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 + printErrors: false + onLoaded: root.loadStyleState(text()) + onFileChanged: reload() + } + + // ---------------- window ------------------------------------------------- + FloatingWindow { + id: window + title: "Omarchy Settings" + color: root.background + implicitWidth: 880 + implicitHeight: 620 + minimumSize: Qt.size(700, 480) + + onVisibleChanged: { + if (!visible && !root.closingFromHost && root.shell && typeof root.shell.hide === "function") + root.shell.hide("omarchy.settings") + } + + Rectangle { + anchors.fill: parent + color: root.background + // No explicit border — the Hyprland window decoration already draws one. + + ColumnLayout { + anchors.fill: parent + spacing: 0 + + // Header + Item { + Layout.fillWidth: true + Layout.preferredHeight: 48 + + Text { + text: "Omarchy Settings" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 16 + font.bold: true + anchors.left: parent.left + anchors.leftMargin: 18 + anchors.verticalCenter: parent.verticalCenter + } + + Text { + text: "~/.config/omarchy/shell.json" + color: Qt.darker(root.foreground, 1.8) + font.family: root.fontFamily + font.pixelSize: 10 + anchors.right: parent.right + anchors.rightMargin: 18 + anchors.verticalCenter: parent.verticalCenter + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18) + } + + // Sidebar + content + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: true + spacing: 0 + + // Sidebar + Item { + Layout.preferredWidth: 180 + Layout.fillHeight: true + + Rectangle { + anchors.fill: parent + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03) + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 2 + + SidebarRow { categoryId: "defaults"; label: "Defaults"; glyph: "󰀻" } + SidebarRow { categoryId: "style"; label: "Style"; glyph: "󰏘" } + SidebarRow { categoryId: "bar"; label: "Bar"; glyph: "󰛼" } + SidebarRow { categoryId: "system"; label: "System"; glyph: "󰒓" } + SidebarRow { categoryId: "plugins"; label: "Plugins"; glyph: "󰐱" } + + Item { Layout.fillHeight: true } + } + } + + Rectangle { + Layout.fillHeight: true + Layout.preferredWidth: 1 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18) + } + + // Content + Item { + Layout.fillWidth: true + Layout.fillHeight: true + + Flickable { + id: bodyScroll + anchors.fill: parent + anchors.margins: 18 + clip: true + contentWidth: width + contentHeight: contentColumn.implicitHeight + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + + ColumnLayout { + id: contentColumn + width: bodyScroll.width + spacing: 14 + + BarCategory { visible: root.activeCategory === "bar" ; Layout.fillWidth: true } + PluginManager { visible: root.activeCategory === "plugins" ; Layout.fillWidth: true } + DefaultsCategory{ visible: root.activeCategory === "defaults" ; Layout.fillWidth: true } + StyleCategory { visible: root.activeCategory === "style" ; Layout.fillWidth: true } + SystemCategory { visible: root.activeCategory === "system" ; Layout.fillWidth: true } + } + } + } + } + } + } + } + + // ===================== sidebar row ======================================= + component SidebarRow: Rectangle { + id: sb + property string categoryId: "" + property string label: "" + property string glyph: "" + readonly property bool active: root.activeCategory === categoryId + + Layout.fillWidth: true + Layout.preferredHeight: 30 + radius: root.cornerRadius + color: sb.active + ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.14) + : (sbArea.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.07) : "transparent") + border.color: sb.active ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.25) : "transparent" + border.width: 1 + + Behavior on color { ColorAnimation { duration: 100 } } + + Row { + anchors.left: parent.left + anchors.leftMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 10 + + Text { + text: sb.glyph + color: sb.active ? root.accent : Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: 13 + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: sb.label + color: sb.active ? root.foreground : Qt.darker(root.foreground, 1.2) + font.family: root.fontFamily + font.pixelSize: 12 + font.bold: sb.active + anchors.verticalCenter: parent.verticalCenter + } + } + + MouseArea { + id: sbArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.activeCategory = sb.categoryId + } + } + + // ===================== bar category ====================================== + component BarCategory: ColumnLayout { + spacing: 14 + + Text { + text: "Bar" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 18 + font.bold: true + } + + Text { + text: "Drag widgets between the bar's three sections, drop in plugin widgets, and tweak per-widget options. Auto-saves to shell.json." + color: Qt.darker(root.foreground, 1.6) + font.family: root.fontFamily + font.pixelSize: 11 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Row { + Layout.fillWidth: true + spacing: 14 + + Cmp.NDropdown { + label: "Position" + value: root.draft.bar.position + options: ["top", "right", "bottom", "left"] + foreground: root.foreground + background: root.background + accent: root.accent + fontFamily: root.fontFamily + cornerRadius: root.cornerRadius + onChanged: function(v) { + var next = root.cloneJson(root.draft) + next.bar.position = v + root.draft = next + root.markDirty() + } + } + + Cmp.NDropdown { + label: "Center anchor" + value: root.draft.bar.centerAnchor || "(none)" + options: { + var list = ["(none)"] + var entries = root.draft.bar.layout.center || [] + for (var i = 0; i < entries.length; i++) list.push(entries[i].id) + return list + } + foreground: root.foreground + background: root.background + accent: root.accent + fontFamily: root.fontFamily + cornerRadius: root.cornerRadius + onChanged: function(v) { + var next = root.cloneJson(root.draft) + next.bar.centerAnchor = v === "(none)" ? "" : v + root.draft = next + root.markDirty() + } + } + } + + SectionEditor { sectionKey: "left"; sectionLabel: "Bar · Left" } + SectionEditor { sectionKey: "center"; sectionLabel: "Bar · Center" } + SectionEditor { sectionKey: "right"; sectionLabel: "Bar · Right" } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 1 + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) + } + + Row { + Layout.alignment: Qt.AlignRight + ActionPill { + text: "Reset to defaults" + foreground: root.urgent + onClicked: root.resetToDefaults() + } + } + } + + // ===================== defaults category ================================= + component DefaultsCategory: ColumnLayout { + spacing: 14 + + property string terminalCurrent: "" + property string browserCurrent: "" + property string editorCurrent: "" + property int refreshTick: 0 + + function refresh() { + refreshTick++ + readTerminalProc.running = true + readBrowserProc.running = true + readEditorProc.running = true + } + + Process { + id: readTerminalProc + command: ["omarchy-default-terminal"] + stdout: SplitParser { onRead: function(line) { terminalCurrent = String(line).trim() } } + } + Process { + id: readBrowserProc + command: ["omarchy-default-browser"] + stdout: SplitParser { onRead: function(line) { browserCurrent = String(line).trim() } } + } + Process { + id: readEditorProc + command: ["omarchy-default-editor"] + stdout: SplitParser { onRead: function(line) { editorCurrent = String(line).trim() } } + } + // Refresh after the write has actually finished — kicking off a read + // synchronously after .running = true races the bash apply and ends up + // displaying the *previous* default. + Process { + id: applyDefaultsProc + onExited: refresh() + } + + function applyDefault(group, value) { + var cmd = "" + if (group === "terminal") cmd = "omarchy-default-terminal " + value + else if (group === "browser") cmd = "omarchy-default-browser " + value + else if (group === "editor") cmd = "omarchy-default-editor " + value + if (!cmd) return + applyDefaultsProc.command = ["bash", "-lc", cmd] + applyDefaultsProc.running = true + } + + Component.onCompleted: refresh() + onVisibleChanged: if (visible) refresh() + + Text { + text: "Defaults" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 18 + font.bold: true + } + + Text { + text: "Pick the terminal, browser, and editor Omarchy hands off to when launching apps." + color: Qt.darker(root.foreground, 1.6) + font.family: root.fontFamily + font.pixelSize: 11 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + DefaultsGroup { + title: "Terminal" + description: "Used by Super+Return and xdg-terminal-exec." + options: [ + { id: "alacritty", label: "Alacritty", cmd: "alacritty" }, + { id: "foot", label: "Foot", cmd: "foot" }, + { id: "ghostty", label: "Ghostty", cmd: "ghostty" }, + { id: "kitty", label: "Kitty", cmd: "kitty" } + ] + currentId: terminalCurrent + onPicked: function(id) { applyDefault("terminal", id) } + } + + DefaultsGroup { + title: "Browser" + description: "Used for x-scheme-handler/http and HTML files." + options: [ + { id: "chromium", label: "Chromium", cmd: "chromium" }, + { id: "chrome", label: "Chrome", cmd: "google-chrome-stable" }, + { id: "brave", label: "Brave", cmd: "brave" }, + { id: "brave-origin", label: "Brave Origin", cmd: "brave-origin-beta" }, + { id: "edge", label: "Edge", cmd: "microsoft-edge-stable" }, + { id: "firefox", label: "Firefox", cmd: "firefox" }, + { id: "zen", label: "Zen", cmd: "zen-browser" } + ] + currentId: browserCurrent + onPicked: function(id) { applyDefault("browser", id) } + } + + DefaultsGroup { + title: "Editor" + description: "Sets $EDITOR. Takes effect after the next login." + options: [ + { id: "nvim", label: "Neovim", cmd: "nvim" }, + { id: "code", label: "VSCode", cmd: "code" }, + { id: "cursor", label: "Cursor", cmd: "cursor" }, + { id: "zeditor", label: "Zed", cmd: "zeditor" }, + { id: "sublime_text", label: "Sublime Text", cmd: "sublime_text" }, + { id: "helix", label: "Helix", cmd: "helix" }, + { id: "vim", label: "Vim", cmd: "vim" }, + { id: "emacs", label: "Emacs", cmd: "emacs" } + ] + currentId: editorCurrent + onPicked: function(id) { applyDefault("editor", id) } + } + } + + component DefaultsGroup: Column { + id: dg + property string title: "" + property string description: "" + property var options: [] + property string currentId: "" + signal picked(string id) + + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 6 + + Row { + width: dg.width + spacing: 8 + Text { + text: dg.title + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: "· " + dg.description + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + Column { + width: dg.width + spacing: 4 + + Repeater { + model: dg.options + delegate: DefaultsRow { + required property var modelData + width: dg.width + optionId: modelData.id + optionLabel: modelData.label + checkCmd: modelData.cmd + selected: dg.currentId === modelData.id + onClicked: dg.picked(optionId) + } + } + } + } + + component DefaultsRow: Rectangle { + id: dr + property string optionId: "" + property string optionLabel: "" + property string checkCmd: "" + property bool selected: false + property bool available: false + signal clicked() + + implicitHeight: 38 + radius: root.cornerRadius + color: drArea.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: dr.selected + ? root.accent + : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) + border.width: 1 + opacity: dr.available ? 1 : 0.45 + + Behavior on color { ColorAnimation { duration: 100 } } + + // Probe availability via `command -v`. Cached for the lifetime of the row. + Process { + id: probeProc + command: ["bash", "-lc", "command -v " + dr.checkCmd + " >/dev/null && echo yes || echo no"] + stdout: SplitParser { onRead: function(line) { dr.available = String(line).trim() === "yes" } } + Component.onCompleted: running = true + } + + Row { + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.right: trailRow.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 10 + + Text { + text: dr.selected ? "●" : "○" + color: dr.selected ? root.accent : Qt.darker(root.foreground, 1.3) + font.family: root.fontFamily + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: dr.optionLabel + color: dr.selected ? root.foreground : Qt.darker(root.foreground, 1.05) + font.family: root.fontFamily + font.pixelSize: 12 + font.bold: dr.selected + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: dr.checkCmd + color: Qt.darker(root.foreground, 1.7) + font.family: root.fontFamily + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + id: trailRow + anchors.right: parent.right + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 6 + + Text { + visible: !dr.available + text: "not installed" + color: Qt.darker(root.foreground, 1.7) + font.family: root.fontFamily + font.pixelSize: 10 + } + Text { + visible: dr.selected + text: "default" + color: root.accent + font.family: root.fontFamily + font.pixelSize: 10 + } + } + + MouseArea { + id: drArea + anchors.fill: parent + hoverEnabled: true + cursorShape: dr.available ? Qt.PointingHandCursor : Qt.ForbiddenCursor + onClicked: if (dr.available && !dr.selected) dr.clicked() + } + } + + // ===================== style category ==================================== + component StyleCategory: ColumnLayout { + spacing: 14 + + property string currentCorners: root.cornerRadius > 0 ? "round" : "sharp" + property bool barOn: true + property bool gapsOn: true + property bool oneWinSquare: false + property string monitorName: "" + property string monitorScale: "" + property string themeName: "" + property string themeSlug: "" + property string themePreview: "" + property string fontName: "" + property string backgroundPath: "" + property string backgroundName: "" + property var fontsList: [] + property int refreshTick: 0 + + function refresh() { + refreshTick++ + readBarProc.running = true + readGapsProc.running = true + readOneWinSqProc.running = true + readMonitorProc.running = true + readThemeProc.running = true + readFontProc.running = true + readFontsListProc.running = true + readBackgroundProc.running = true + } + + Process { id: applyStyleProc; onExited: refresh() } + + // Emits 3 lines: display name, slug, preview path — for the *current* + // theme only. Cheap enough that we don't bother caching the full list. + Process { + id: readThemeProc + command: ["bash", "-lc", + "name=$(omarchy-theme-current 2>/dev/null); " + + "slug=$(cat $HOME/.config/omarchy/current/theme.name 2>/dev/null); " + + "preview=''; " + + "for base in \"$HOME/.config/omarchy/themes\" \"$OMARCHY_PATH/themes\"; do " + + " [[ -d $base/$slug ]] || continue; " + + " for ext in png jpg jpeg webp; do " + + " if [[ -f $base/$slug/preview.$ext ]]; then preview=\"$base/$slug/preview.$ext\"; break 2; fi; " + + " done; " + + " if [[ -z $preview && -d $base/$slug/backgrounds ]]; then " + + " preview=$(find -L \"$base/$slug/backgrounds\" -maxdepth 1 -type f \\( -iname '*.jpg' -o -iname '*.png' -o -iname '*.webp' \\) 2>/dev/null | sort | head -n1); " + + " [[ -n $preview ]] && break; " + + " fi; " + + "done; " + + "printf '%s\\n%s\\n%s\\n' \"$name\" \"$slug\" \"$preview\"" + ] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var lines = String(text || "").split("\n") + themeName = (lines[0] || "").trim() + themeSlug = (lines[1] || "").trim() + themePreview = (lines[2] || "").trim() + } + } + } + Process { + id: readFontProc + command: ["omarchy-font-current"] + stdout: SplitParser { onRead: function(line) { fontName = String(line).trim() } } + } + Process { + id: readFontsListProc + command: ["omarchy-font-list"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: fontsList = String(text || "").trim().split("\n").filter(function(x) { return x.length > 0 }) + } + } + Process { + id: readBackgroundProc + command: ["bash", "-lc", "readlink -f $HOME/.config/omarchy/current/background 2>/dev/null"] + stdout: SplitParser { onRead: function(line) { + backgroundPath = String(line).trim() + var i = backgroundPath.lastIndexOf("/") + backgroundName = i >= 0 ? backgroundPath.substring(i + 1) : backgroundPath + } } + } + + // Pick up theme/background changes that happen via the menu / CLI without + // going through the Style category's own buttons. + FileView { + path: root.home + "/.config/omarchy/current/theme.name" + watchChanges: true + printErrors: false + onFileChanged: refresh() + onLoaded: refresh() + } + FileView { + path: root.home + "/.config/alacritty/alacritty.toml" + watchChanges: true + printErrors: false + onFileChanged: refresh() + } + + // Re-read when any toggle flag changes on disk — covers CLI/menu paths + // that mutate `~/.local/state/omarchy/toggles/*` without going through us. + FileView { + path: root.home + "/.local/state/omarchy/toggles" + watchChanges: true + printErrors: false + onFileChanged: refresh() + } + FileView { + path: root.home + "/.local/state/omarchy/toggles/hypr" + watchChanges: true + printErrors: false + onFileChanged: refresh() + } + + Process { + id: readBarProc + command: ["bash", "-lc", "[[ -f $HOME/.local/state/omarchy/toggles/bar-off ]] && echo no || echo yes"] + stdout: SplitParser { onRead: function(line) { barOn = String(line).trim() === "yes" } } + } + Process { + id: readGapsProc + command: ["bash", "-lc", "[[ -f $HOME/.local/state/omarchy/toggles/hypr/window-no-gaps.lua ]] && echo no || echo yes"] + stdout: SplitParser { onRead: function(line) { gapsOn = String(line).trim() === "yes" } } + } + Process { + id: readOneWinSqProc + command: ["bash", "-lc", "[[ -f $HOME/.local/state/omarchy/toggles/hypr/single-window-aspect-ratio.lua ]] && echo yes || echo no"] + stdout: SplitParser { onRead: function(line) { oneWinSquare = String(line).trim() === "yes" } } + } + // Snaps the scale Hyprland reports (which may be fractional / drifted) to + // the nearest value in the canonical list. Used by the scale picker to + // know which chip to highlight. + function snapScale(raw) { + var n = parseFloat(raw) + if (!isFinite(n)) return "" + var scales = ["1", "1.25", "1.6", "2", "3", "4"] + var best = scales[0], bestDiff = Infinity + for (var i = 0; i < scales.length; i++) { + var d = Math.abs(n - parseFloat(scales[i])) + if (d < bestDiff) { bestDiff = d; best = scales[i] } + } + return best + } + + Process { + id: readMonitorProc + command: ["bash", "-lc", "hyprctl monitors -j 2>/dev/null | jq -r '.[] | select(.focused == true) | \"\\(.name)\\t\\(.scale)\"'"] + stdout: SplitParser { onRead: function(line) { + var parts = String(line).trim().split("\t") + if (parts.length >= 2) { + monitorName = parts[0] + monitorScale = snapScale(parts[1]) + } + } } + } + + function runStyle(cmd) { + applyStyleProc.command = ["bash", "-lc", cmd] + applyStyleProc.running = true + } + + Component.onCompleted: refresh() + onVisibleChanged: if (visible) refresh() + + Text { + text: "Style" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 18 + font.bold: true + } + + Text { + text: "Look-and-feel of the shell, lock screen, and windows." + color: Qt.darker(root.foreground, 1.6) + font.family: root.fontFamily + font.pixelSize: 11 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + // Theme + Background side by side. Both defer to the existing Walker + // pickers; the rows here just surface the current selection. + RowLayout { + Layout.fillWidth: true + spacing: 14 + + ThumbLaunchRow { + Layout.fillWidth: true + label: "Theme" + currentValue: themeName || "—" + thumbnailPath: themePreview + buttonText: "Choose theme…" + onLaunch: runStyle("bash -lc 'theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set \"$theme\"'") + } + + ThumbLaunchRow { + Layout.fillWidth: true + label: "Background" + currentValue: backgroundName || "—" + thumbnailPath: backgroundPath + buttonText: "Choose background…" + onLaunch: runStyle("bash -lc 'background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set \"$background\"'") + } + } + + StyleToggleRow { + label: "Window gaps" + description: "Tile windows with the default gap between them." + isOn: gapsOn + onToggle: runStyle("omarchy-hyprland-window-gaps-toggle") + } + + StyleToggleRow { + label: "1-window square ratio" + description: "Constrain a solo tiled window to a square aspect." + isOn: oneWinSquare + onToggle: runStyle("omarchy-hyprland-window-single-square-aspect-toggle") + } + + StyleToggleRow { + label: "Bar" + description: "Show the omarchy bar. The shell keeps running either way, so menus and this panel stay reachable." + isOn: barOn + onToggle: runStyle("omarchy-toggle-bar") + } + + // Corner style — a real picker since this changes the whole shell's look. + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 6 + + Row { + spacing: 8 + Text { + text: "Corners" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: "· Sharp matches the retro TUI look; round softens windows, menus, and notifications." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + spacing: 8 + + StyleOptionTile { + tileLabel: "Sharp" + tileHint: "0px radius" + selected: currentCorners === "sharp" + onClicked: runStyle("omarchy-style-corners sharp") + } + StyleOptionTile { + tileLabel: "Round" + tileHint: "6px radius" + selected: currentCorners === "round" + onClicked: runStyle("omarchy-style-corners round") + } + } + } + + // Monitor scaling — explicit picker. The Super+Plus/Minus shortcuts still + // call `omarchy-hyprland-monitor-scaling-cycle` for keyboard cycling. + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 6 + + Row { + spacing: 8 + Text { + text: "Monitor scaling" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: "· Pick a scale for " + (monitorName || "the focused monitor") + ". Keyboard shortcut still cycles." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + spacing: 8 + + Repeater { + model: ["1", "1.25", "1.6", "2", "3", "4"] + delegate: StyleChip { + required property var modelData + label: modelData + "×" + selected: monitorScale === modelData + onClicked: runStyle("omarchy-hyprland-monitor-scaling-set " + modelData) + } + } + } + } + + // Font picker — each row rendered in its own typeface. Sits at the + // bottom because it's long; everything else above is one-line tall. + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 6 + + Row { + spacing: 8 + Text { + text: "Font" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: "· " + (fontName || "—") + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + ColumnLayout { + Layout.fillWidth: true + spacing: 4 + + Repeater { + model: fontsList + delegate: FontRow { + required property var modelData + Layout.fillWidth: true + fontFamilyName: modelData + selected: modelData === fontName + onPicked: runStyle("omarchy-font-set \"" + modelData + "\"") + } + } + } + } + } + + // ===================== system category =================================== + component SystemCategory: ColumnLayout { + spacing: 14 + + property string powerProfile: "" + property var powerProfiles: [] + property bool nightlightOn: false + property bool dndOn: false + property bool idleOn: false + property bool screensaverOn: false + property bool suspendAvailable: true + property int refreshTick: 0 + + function refresh() { + refreshTick++ + readPowerProc.running = true + readPowerListProc.running = true + readNightlightProc.running = true + readDndProc.running = true + readIdleProc.running = true + readScreensaverProc.running = true + readSuspendProc.running = true + } + + Process { id: applySystemProc; onExited: refresh() } + + Process { + id: readPowerProc + command: ["bash", "-lc", "powerprofilesctl get 2>/dev/null"] + stdout: SplitParser { onRead: function(line) { powerProfile = String(line).trim() } } + } + Process { + id: readPowerListProc + command: ["bash", "-lc", "omarchy-powerprofiles-list 2>/dev/null"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: powerProfiles = String(text || "").trim().split("\n").filter(function(x) { return x.length > 0 }) + } + } + Process { + id: readNightlightProc + command: ["bash", "-lc", "hyprctl hyprsunset temperature 2>/dev/null | grep -oE '[0-9]+' | head -n1 || echo 6000"] + stdout: SplitParser { onRead: function(line) { + var n = parseInt(String(line).trim(), 10) + nightlightOn = isFinite(n) && n < 5500 + } } + } + Process { + id: readDndProc + command: ["bash", "-lc", "makoctl mode 2>/dev/null | grep -q do-not-disturb && echo yes || echo no"] + stdout: SplitParser { onRead: function(line) { dndOn = String(line).trim() === "yes" } } + } + Process { + id: readIdleProc + command: ["bash", "-lc", "pgrep -x hypridle >/dev/null && echo yes || echo no"] + stdout: SplitParser { onRead: function(line) { idleOn = String(line).trim() === "yes" } } + } + Process { + id: readScreensaverProc + command: ["bash", "-lc", "[[ -f $HOME/.local/state/omarchy/toggles/screensaver-off ]] && echo no || echo yes"] + stdout: SplitParser { onRead: function(line) { screensaverOn = String(line).trim() === "yes" } } + } + Process { + id: readSuspendProc + command: ["bash", "-lc", "[[ -f $HOME/.local/state/omarchy/toggles/suspend-off ]] && echo no || echo yes"] + stdout: SplitParser { onRead: function(line) { suspendAvailable = String(line).trim() === "yes" } } + } + + function runSystem(cmd) { + applySystemProc.command = ["bash", "-lc", cmd] + applySystemProc.running = true + } + + // Catch external state changes (CLI / menu / power-profile daemon). + FileView { + path: root.home + "/.local/state/omarchy/toggles" + watchChanges: true + printErrors: false + onFileChanged: refresh() + } + + Component.onCompleted: refresh() + onVisibleChanged: if (visible) refresh() + + Text { + text: "System" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 18 + font.bold: true + } + + Text { + text: "System-level behavior — power, notifications, idle, screensaver, suspend." + color: Qt.darker(root.foreground, 1.6) + font.family: root.fontFamily + font.pixelSize: 11 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + ColumnLayout { + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 6 + + Row { + spacing: 8 + Text { + text: "Power profile" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: true + anchors.verticalCenter: parent.verticalCenter + } + Text { + text: "· Pick how aggressively the CPU clocks down. Reads via power-profiles-daemon." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + anchors.verticalCenter: parent.verticalCenter + } + } + + Row { + spacing: 8 + + Repeater { + model: powerProfiles + delegate: StyleChip { + required property var modelData + label: modelData.charAt(0).toUpperCase() + modelData.slice(1).replace("-", " ") + selected: powerProfile === modelData + onClicked: runSystem("powerprofilesctl set " + modelData) + } + } + } + } + + StyleToggleRow { + label: "Nightlight" + description: "Lower screen colour temperature in the evening." + isOn: nightlightOn + onToggle: runSystem("omarchy-toggle-nightlight") + } + + StyleToggleRow { + label: "Notifications" + description: dndOn ? "Do-not-disturb is on — Mako is silencing." : "Notifications post normally via Mako." + isOn: !dndOn + onToggle: runSystem("omarchy-toggle-notification-silencing") + } + + StyleToggleRow { + label: "Idle locking" + description: "Lock the screen when idle (hypridle)." + isOn: idleOn + onToggle: runSystem("omarchy-toggle-idle") + } + + StyleToggleRow { + label: "Screensaver" + description: "Allow the screensaver to engage during idle." + isOn: screensaverOn + onToggle: runSystem("omarchy-toggle-screensaver") + } + + StyleToggleRow { + label: "Suspend in system menu" + description: "Show 'Suspend' in the system power menu." + isOn: suspendAvailable + onToggle: runSystem("omarchy-toggle-suspend") + } + } + + component StyleOptionTile: Rectangle { + id: tile + property string tileLabel: "" + property string tileHint: "" + property bool selected: false + signal clicked() + + implicitWidth: 140 + implicitHeight: 52 + radius: root.cornerRadius + color: tileArea.containsMouse + ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) + : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03) + border.color: tile.selected ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18) + border.width: tile.selected ? 2 : 1 + + Behavior on color { ColorAnimation { duration: 100 } } + + Text { + id: tileTitle + text: tile.tileLabel + color: tile.selected ? root.foreground : Qt.darker(root.foreground, 1.1) + font.family: root.fontFamily + font.pixelSize: 13 + font.bold: tile.selected + horizontalAlignment: Text.AlignHCenter + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: 10 + } + Text { + text: tile.tileHint + color: Qt.darker(root.foreground, 1.6) + font.family: root.fontFamily + font.pixelSize: 10 + horizontalAlignment: Text.AlignHCenter + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: tileTitle.bottom + anchors.topMargin: 2 + } + + MouseArea { + id: tileArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: tile.clicked() + } + } + + // Compact, single-line variant of StyleOptionTile — used for the monitor + // scaling chip row where 6 options need to fit on one line. + component StyleChip: Rectangle { + id: chip + property string label: "" + property bool selected: false + signal clicked() + + implicitWidth: chipText.implicitWidth + 22 + implicitHeight: 30 + radius: root.cornerRadius + color: chipArea.containsMouse + ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) + : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03) + border.color: chip.selected ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.18) + border.width: chip.selected ? 2 : 1 + + Behavior on color { ColorAnimation { duration: 100 } } + + Text { + id: chipText + anchors.centerIn: parent + text: chip.label + color: chip.selected ? root.foreground : Qt.darker(root.foreground, 1.1) + font.family: root.fontFamily + font.pixelSize: 12 + font.bold: chip.selected + } + + MouseArea { + id: chipArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: chip.clicked() + } + } + + // Row showing a thumbnail of the current value on the left, label + current + // value in the middle, and a launch button on the right. Used for theme and + // background where the actual picker is an external Walker UI. + component ThumbLaunchRow: Rectangle { + id: tlr + property string label: "" + property string currentValue: "—" + property string thumbnailPath: "" + property string buttonText: "Choose…" + signal launch() + + Layout.fillWidth: true + implicitHeight: 64 + radius: root.cornerRadius + color: tlrArea.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 } } + + Rectangle { + id: tlrThumb + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + anchors.margins: 8 + width: height * 1.6 + radius: root.cornerRadius + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.06) + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.2) + border.width: 1 + clip: true + + Image { + anchors.fill: parent + anchors.margins: 1 + source: tlr.thumbnailPath ? ("file://" + tlr.thumbnailPath) : "" + visible: tlr.thumbnailPath !== "" + fillMode: Image.PreserveAspectCrop + sourceSize.width: 240 + asynchronous: true + cache: true + } + } + + Column { + anchors.left: tlrThumb.right + anchors.leftMargin: 14 + anchors.right: tlrButton.left + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + text: tlr.label + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 12 + font.bold: true + } + Text { + text: tlr.currentValue + color: Qt.darker(root.foreground, 1.3) + font.family: root.fontFamily + font.pixelSize: 11 + elide: Text.ElideRight + width: parent.width + } + } + + ActionPill { + id: tlrButton + anchors.right: parent.right + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + text: tlr.buttonText + onClicked: tlr.launch() + } + + MouseArea { + id: tlrArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + } + + // Row showing a font name rendered in its own typeface, plus a short sample + // string in the same font. Selected = accent border + bold. + component FontRow: Rectangle { + id: fr + property string fontFamilyName: "" + property bool selected: false + signal picked() + + implicitHeight: 44 + radius: root.cornerRadius + color: frArea.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: fr.selected ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) + border.width: fr.selected ? 2 : 1 + + Behavior on color { ColorAnimation { duration: 100 } } + + Row { + anchors.fill: parent + anchors.leftMargin: 14 + anchors.rightMargin: 14 + spacing: 14 + + Text { + text: fr.fontFamilyName + color: fr.selected ? root.accent : root.foreground + font.family: fr.fontFamilyName + font.pixelSize: 13 + font.bold: fr.selected + anchors.verticalCenter: parent.verticalCenter + width: 220 + elide: Text.ElideRight + } + Text { + text: "The quick brown fox jumps over the lazy dog 0123" + color: Qt.darker(root.foreground, 1.3) + font.family: fr.fontFamilyName + font.pixelSize: 12 + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + width: parent.width - 234 + } + } + + MouseArea { + id: frArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: if (!fr.selected) fr.picked() + } + } + + + component StyleToggleRow: Rectangle { + id: tr + property string label: "" + property string description: "" + property bool isOn: false + signal toggle() + + Layout.fillWidth: true + implicitHeight: 46 + radius: root.cornerRadius + color: trArea.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 } } + + Column { + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.right: trSwitch.left + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + text: tr.label + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 12 + font.bold: true + } + Text { + text: tr.description + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + elide: Text.ElideRight + width: parent.width + } + } + + Rectangle { + id: trSwitch + anchors.right: parent.right + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + width: 48 + height: 22 + radius: root.cornerRadius + color: tr.isOn + ? root.accent + : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.10) + border.color: tr.isOn ? root.accent : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.4) + border.width: 1 + + Rectangle { + width: 16 + height: 16 + radius: root.cornerRadius + color: tr.isOn ? root.background : root.foreground + anchors.verticalCenter: parent.verticalCenter + x: tr.isOn ? parent.width - width - 3 : 3 + + Behavior on x { NumberAnimation { duration: 120 } } + } + } + + MouseArea { + id: trArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: tr.toggle() + } + } + + // ===================== shared chrome ===================================== + 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: root.cornerRadius + 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 IconButton: Rectangle { + id: iconButton + property string glyph: "" + property string tooltip: "" + property color foreground: root.foreground + signal clicked() + + implicitWidth: 26 + implicitHeight: 26 + radius: root.cornerRadius + 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() + } + } + + // ===================== bar layout pieces ================================= + component SectionEditor: Column { + id: section + + property string sectionKey: "" + property string sectionLabel: "" + property var entries: root.sectionArray(section.sectionKey) + Layout.fillWidth: true + Layout.topMargin: 8 + spacing: 8 + + Connections { + target: root + function onDraftRevisionChanged() { section.entries = root.sectionArray(section.sectionKey) } + } + + Row { + width: section.width + spacing: 8 + + Text { + text: section.sectionLabel + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 13 + 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: Math.max(0, section.width - 200 - 100) + height: 1 + } + + ActionPill { + id: addPill + text: "+ Add widget" + onClicked: addPopup.open() + } + } + + // Styled add-widget popup. Anchored under the "+ Add widget" pill, + // pulls fresh data from the host's availableToAdd() each open. + Popup { + id: addPopup + parent: addPill + x: addPill.width - width + y: addPill.height + 4 + width: 280 + implicitHeight: Math.min(addList.contentHeight + 2, 340) + padding: 1 + modal: false + focus: true + + background: Rectangle { + color: root.background + border.color: root.foreground + border.width: 1 + radius: root.cornerRadius + } + + contentItem: ListView { + id: addList + clip: true + model: root.availableToAdd(section.sectionKey) + boundsBehavior: Flickable.StopAtBounds + + delegate: Rectangle { + required property var modelData + width: addList.width + height: 36 + color: addArea.containsMouse + ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) + : "transparent" + + Column { + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: 10 + anchors.rightMargin: 10 + anchors.verticalCenter: parent.verticalCenter + spacing: 1 + + Text { + text: modelData.name + + (modelData.isNoctalia ? " (Noctalia)" : "") + + (modelData.elsewhere ? " (elsewhere)" : "") + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 12 + elide: Text.ElideRight + width: parent.width + } + Text { + visible: text !== "" + text: modelData.description || "" + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + elide: Text.ElideRight + width: parent.width + } + } + + MouseArea { + id: addArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.addEntry(section.sectionKey, modelData.id) + addPopup.close() + } + } + } + } + } + + Column { + Layout.fillWidth: true + width: section.width + spacing: 4 + + Repeater { + model: section.entries + delegate: WidgetCard { + required property var modelData + required property int index + width: section.width + sectionKey: section.sectionKey + entryIndex: index + entry: modelData + } + } + + Rectangle { + visible: section.entries.length === 0 + width: parent.width + height: 32 + radius: root.cornerRadius + color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.04) + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) + border.width: 1 + + Text { + anchors.centerIn: parent + text: "Empty — add a widget" + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 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: root.cornerRadius + color: cardArea.containsMouse ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.08) : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.03) + border.color: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) + border.width: 1 + + Behavior on color { ColorAnimation { duration: 100 } } + + Row { + id: actionRow + anchors.right: parent.right + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + + 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 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() { + if (formLoader.item && typeof formLoader.item.saveSettings === "function") { + formLoader.item.saveSettings() + } else { + 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() } + } + } + } + } + } + + // ---------------- per-widget form resolution ----------------------------- + function formComponent(id) { + var meta = widgetMetadata(id) + if (meta && meta.settingsForm) { + switch (meta.settingsForm) { + case "spacerSettings": return spacerSettingsComponent + case "calendarSettings": return calendarSettingsComponent + } + } + var manifest = root.pluginRegistry ? root.pluginRegistry.installedPlugins[id] : null + if (manifest && manifest.__noctaliaCompat && manifest.entryPoints && manifest.entryPoints.settings) + return noctaliaSettingsComponent + if (widgetSchema(id).length > 0) return dynamicSettingsComponent + return null + } + + Component { + id: dynamicSettingsComponent + SettingsUi.DynamicSettingsForm { + schema: root.widgetSchema(entry.id || "") + foregroundColor: root.foreground + fontFamilyName: root.fontFamily + } + } + + Component { + id: noctaliaSettingsComponent + + Item { + id: noctaliaForm + property var entry: ({}) + property string pluginId: entry && entry.id ? String(entry.id) : "" + property var manifest: pluginId && root.pluginRegistry + ? root.pluginRegistry.installedPlugins[pluginId] : null + + function saveSettings() { + if (settingsLoader.item && typeof settingsLoader.item.saveSettings === "function") { + settingsLoader.item.saveSettings() + } else { + console.warn("Noctalia settings form has no saveSettings():", pluginId) + } + } + + implicitHeight: settingsLoader.item ? settingsLoader.item.implicitHeight : 0 + implicitWidth: settingsLoader.item ? settingsLoader.item.implicitWidth : 0 + + Loader { + id: settingsLoader + anchors.fill: parent + source: { + if (!noctaliaForm.manifest) return "" + return root.pluginRegistry.entryPointUrl(noctaliaForm.manifest, "settings") + } + asynchronous: false + onLoaded: { + if (!item) return + var api = (root.shell && typeof root.shell.noctaliaPluginApiFor === "function") + ? root.shell.noctaliaPluginApiFor(noctaliaForm.pluginId) : null + if (api && "pluginApi" in item) item.pluginApi = api + if ("screen" in item && root.QsWindow && root.QsWindow.window) + item.screen = root.QsWindow.window.screen + } + onStatusChanged: { + if (status === Loader.Error) { + console.warn("noctalia Settings.qml failed for " + noctaliaForm.pluginId + ":", + sourceComponent ? sourceComponent.errorString() : "") + } + } + } + } + } + + 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 + onValueModified: 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) + } + } + } + + // ===================== plugins category ================================== + component PluginManager: Column { + id: pm + + property int registryRevision: root.pluginRegistry ? root.pluginRegistry.registryRevision : 0 + Connections { + target: root.pluginRegistry + function onPluginsChanged() { pm.registryRevision = root.pluginRegistry.registryRevision } + } + + function pluginList() { + var rev = pm.registryRevision + if (!root.pluginRegistry) return [] + var plugins = root.pluginRegistry.installedPlugins + var ids = Object.keys(plugins).sort(function(a, b) { + var fa = !!plugins[a].__isFirstParty, fb = !!plugins[b].__isFirstParty + if (fa !== fb) return fa ? -1 : 1 + return String(plugins[a].name || a).localeCompare(String(plugins[b].name || b)) + }) + var rows = [] + for (var i = 0; i < ids.length; i++) { + var id = ids[i] + var m = plugins[id] + rows.push({ + id: id, + manifest: m, + enabled: root.pluginRegistry.isEnabled(id), + firstParty: !!m.__isFirstParty + }) + } + return rows + } + + spacing: 14 + Layout.fillWidth: true + width: parent ? parent.width : 0 + + Text { + text: "Plugins" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 18 + font.bold: true + } + + Text { + text: "Drop plugins at ~/.config/omarchy/plugins//, then click Rescan. First-party plugins are always enabled." + color: Qt.darker(root.foreground, 1.6) + font.family: root.fontFamily + font.pixelSize: 11 + wrapMode: Text.WordWrap + width: pm.width + } + + Row { + spacing: 8 + width: pm.width + + Text { + text: (root.pluginRegistry ? Object.keys(root.pluginRegistry.installedPlugins).length : 0) + " installed" + color: Qt.darker(root.foreground, 1.3) + font.family: root.fontFamily + font.pixelSize: 11 + anchors.verticalCenter: parent.verticalCenter + } + + Item { width: Math.max(0, pm.width - 200); height: 1 } + + ActionPill { + text: "Rescan" + onClicked: root.pluginRegistry.rescan() + } + } + + Repeater { + model: pm.pluginList() + delegate: PluginRow { + required property var modelData + width: pm.width + manifest: modelData.manifest + pluginId: modelData.id + pluginEnabled: modelData.enabled + firstParty: modelData.firstParty + } + } + + Text { + visible: pm.pluginList().length === 0 + text: "No plugins discovered yet." + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 11 + } + } + + component PluginRow: Rectangle { + id: row + property var manifest: ({}) + property string pluginId: "" + property bool pluginEnabled: false + property bool firstParty: false + property bool expanded: false + + radius: root.cornerRadius + color: rowArea.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 + implicitHeight: rowContent.implicitHeight + 16 + + Behavior on color { ColorAnimation { duration: 100 } } + + Column { + id: rowContent + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: 8 + spacing: 4 + + Row { + spacing: 8 + width: parent.width + + Column { + spacing: 2 + width: parent.width - 110 + + Text { + text: row.manifest && row.manifest.name ? row.manifest.name : row.pluginId + color: root.foreground + font.family: root.fontFamily + font.pixelSize: 12 + font.bold: true + elide: Text.ElideRight + width: parent.width + } + Text { + text: { + var bits = [] + if (row.manifest && row.manifest.version) bits.push("v" + row.manifest.version) + if (row.manifest && row.manifest.author) bits.push(row.manifest.author) + bits.push(row.firstParty ? "first-party" : "third-party") + return bits.join(" · ") + } + color: Qt.darker(root.foreground, 1.5) + font.family: root.fontFamily + font.pixelSize: 10 + } + Text { + visible: !!(row.manifest && row.manifest.description) + text: row.manifest ? (row.manifest.description || "") : "" + color: Qt.darker(root.foreground, 1.3) + font.family: root.fontFamily + font.pixelSize: 10 + wrapMode: Text.WordWrap + width: parent.width + } + } + + Item { width: 8; height: 1 } + + Item { + implicitWidth: enabledSwitch.implicitWidth + implicitHeight: enabledSwitch.implicitHeight + anchors.verticalCenter: parent.verticalCenter + + Switch { + id: enabledSwitch + checked: row.pluginEnabled + enabled: !row.firstParty + opacity: row.firstParty ? 0.45 : 1 + ToolTip.visible: row.firstParty && hoverArea.containsMouse + ToolTip.delay: 300 + ToolTip.text: "First-party plugin — always enabled" + onToggled: root.pluginRegistry.setEnabled(row.pluginId, checked) + } + + MouseArea { + id: hoverArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + visible: row.firstParty + cursorShape: Qt.ForbiddenCursor + } + } + } + + Row { + visible: !!(row.manifest && row.manifest.barWidget && Array.isArray(row.manifest.barWidget.schema) && row.manifest.barWidget.schema.length > 0) + spacing: 6 + + Text { + text: row.expanded ? "▾ Options" : "▸ Options" + color: Qt.darker(root.foreground, 1.4) + font.family: root.fontFamily + font.pixelSize: 10 + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: row.expanded = !row.expanded + } + } + } + + Repeater { + model: row.expanded && row.manifest && row.manifest.barWidget && Array.isArray(row.manifest.barWidget.schema) ? row.manifest.barWidget.schema : [] + delegate: Text { + required property var modelData + text: "• " + (modelData.label || modelData.key) + " (" + (modelData.type || "string") + ")" + color: Qt.darker(root.foreground, 1.3) + font.family: root.fontFamily + font.pixelSize: 10 + leftPadding: 12 + } + } + } + + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + } + } +} diff --git a/default/quickshell/omarchy-shell/plugins/settings/components/NDropdown.qml b/default/quickshell/omarchy-shell/plugins/settings/components/NDropdown.qml new file mode 100644 index 00000000..98d56a2e --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/settings/components/NDropdown.qml @@ -0,0 +1,134 @@ +import QtQuick +import QtQuick.Controls + +// Themed ComboBox + popup. Anchors below the trigger, paints with the host +// shell's foreground/background palette, and inherits the shell-wide corner +// radius so nothing renders rounded when the user has set sharp corners. +Item { + id: root + + property string label: "" + property string value: "" + property var options: [] + property color foreground: "#cacccc" + property color background: "#101315" + property color accent: "#cacccc" + property string fontFamily: "JetBrainsMono Nerd Font" + property int cornerRadius: 0 + property int rowHeight: 28 + property int popupRowHeight: 28 + property bool showLabel: true + + signal changed(string value) + + implicitWidth: 240 + implicitHeight: showLabel ? rowHeight + 18 : rowHeight + + Column { + anchors.fill: parent + spacing: 4 + + Text { + visible: root.showLabel && root.label !== "" + text: root.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: root.rowHeight + font.family: root.fontFamily + font.pixelSize: 12 + model: root.options + currentIndex: { + for (var i = 0; i < model.length; i++) if (model[i] === root.value) return i + return -1 + } + onActivated: function(index) { + if (index >= 0 && index < model.length) root.changed(model[index]) + } + + background: Rectangle { + color: root.background + border.color: combo.activeFocus + ? root.accent + : Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.4) + border.width: 1 + radius: root.cornerRadius + } + + contentItem: Text { + leftPadding: 8 + rightPadding: 24 + text: combo.displayText + color: root.foreground + font: combo.font + verticalAlignment: Text.AlignVCenter + } + + indicator: Text { + x: combo.width - width - 8 + y: combo.topPadding + (combo.availableHeight - height) / 2 + text: "▾" + color: Qt.darker(root.foreground, 1.2) + font.family: root.fontFamily + font.pixelSize: 10 + } + + popup: Popup { + // Anchor the popup directly under the field, full width, sharp/round + // matching the shell radius. Override the native white-with-blue look. + y: combo.height + width: combo.width + implicitHeight: Math.min(contentItem.implicitHeight, root.popupRowHeight * 8) + padding: 1 + + background: Rectangle { + color: root.background + border.color: root.foreground + border.width: 1 + radius: root.cornerRadius + } + + contentItem: ListView { + clip: true + implicitHeight: contentHeight + model: combo.delegateModel + currentIndex: combo.highlightedIndex + boundsBehavior: Flickable.StopAtBounds + } + } + + delegate: ItemDelegate { + required property var modelData + required property int index + + width: combo.width + height: root.popupRowHeight + padding: 0 + + contentItem: Text { + text: String(modelData) + color: index === combo.highlightedIndex ? root.accent : root.foreground + font.family: root.fontFamily + font.pixelSize: 12 + leftPadding: 10 + rightPadding: 10 + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + color: index === combo.highlightedIndex + ? Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.12) + : "transparent" + radius: 0 + } + } + } + } +} diff --git a/default/quickshell/omarchy-shell/plugins/settings/manifest.json b/default/quickshell/omarchy-shell/plugins/settings/manifest.json new file mode 100644 index 00000000..d27ad8b6 --- /dev/null +++ b/default/quickshell/omarchy-shell/plugins/settings/manifest.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "id": "omarchy.settings", + "name": "Omarchy Settings", + "version": "1.0.0", + "author": "Omarchy", + "description": "Customize the bar, plugins, defaults, and look-and-feel of Omarchy", + "kinds": ["panel"], + "activation": "on-demand", + "entryPoints": { "panel": "SettingsPanel.qml" } +} diff --git a/default/quickshell/omarchy-shell/services/PluginRegistry.qml b/default/quickshell/omarchy-shell/services/PluginRegistry.qml index 8bc400fb..672c2c53 100644 --- a/default/quickshell/omarchy-shell/services/PluginRegistry.qml +++ b/default/quickshell/omarchy-shell/services/PluginRegistry.qml @@ -197,7 +197,7 @@ QtObject { // // Special cases (implicitly always enabled, no shell.json entry needed): // - plugins whose `kinds` contains "bar" are mounted directly by the host. - // - first-party plugins are shell infrastructure (bar-settings, + // - first-party plugins are shell infrastructure (settings, // image-picker, ...). Requiring users to add them to plugins[] just to // summon them was a footgun: a stock shell.json with `plugins: []` would // silently make `omarchy launch bar-settings` a no-op. diff --git a/default/quickshell/omarchy-shell/shell.qml b/default/quickshell/omarchy-shell/shell.qml index df8928ce..76432da8 100644 --- a/default/quickshell/omarchy-shell/shell.qml +++ b/default/quickshell/omarchy-shell/shell.qml @@ -54,7 +54,7 @@ ShellRoot { } }, plugins: [ - { id: "omarchy.bar-settings" }, + { id: "omarchy.settings" }, { id: "omarchy.image-picker" } ] })