From 419cd37e14c9405f184e460103aee706876d5dd3 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 27 May 2026 12:36:45 +0200 Subject: [PATCH] Fix transparent bar contrast timing --- bin/omarchy-shell-bar-text-color | 125 +++++++++++++++++++++ shell/Ui/WidgetButton.qml | 1 + shell/plugins/bar/Bar.qml | 104 ++++++++++++++++- shell/plugins/panels/weather/BarWidget.qml | 2 +- shell/plugins/services/media/BarWidget.qml | 5 +- test/shell.d/bar-text-color-test.sh | 30 +++++ 6 files changed, 260 insertions(+), 7 deletions(-) create mode 100755 bin/omarchy-shell-bar-text-color create mode 100755 test/shell.d/bar-text-color-test.sh diff --git a/bin/omarchy-shell-bar-text-color b/bin/omarchy-shell-bar-text-color new file mode 100755 index 00000000..29a8767c --- /dev/null +++ b/bin/omarchy-shell-bar-text-color @@ -0,0 +1,125 @@ +#!/bin/bash + +# omarchy:summary=Choose a legible transparent bar text color +# omarchy:hidden=true + +set -e + +position=${1:-top} +bar_size=${2:-} +text_color=${3:-} +background_color=${4:-} +background_path="" +screen_size="" + +shift 4 2>/dev/null || true +while (($# > 0)); do + case "$1" in + --background) + background_path=${2:-} + shift 2 + ;; + --screen) + screen_size=${2:-} + shift 2 + ;; + *) + shift + ;; + esac +done + +valid_hex() { + [[ $1 =~ ^#[0-9A-Fa-f]{6}$ ]] +} + +fallback() { + printf '%s\n' "$text_color" + exit 0 +} + +contrast() { + local color="$1" + local sample="$2" + + awk -v fg="$color" -v bg="$sample" ' + function channel(hex, start) { + return strtonum("0x" substr(hex, start, 2)) / 255 + } + function linear(c) { + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ^ 2.4 + } + function luminance(hex, r, g, b) { + r = linear(channel(hex, 2)) + g = linear(channel(hex, 4)) + b = linear(channel(hex, 6)) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + BEGIN { + l1 = luminance(fg) + l2 = luminance(bg) + if (l1 < l2) { + t = l1 + l1 = l2 + l2 = t + } + printf "%.6f\n", (l1 + 0.05) / (l2 + 0.05) + } + ' +} + +valid_hex "$text_color" || fallback +valid_hex "$background_color" || fallback +[[ $position =~ ^(top|bottom|left|right)$ ]] || fallback +[[ $bar_size =~ ^[0-9]+$ ]] || fallback +omarchy-cmd-present magick || fallback + +if [[ -z $background_path ]]; then + background_path=$(readlink -f "$HOME/.config/omarchy/current/background" 2>/dev/null || true) +fi +[[ -f $background_path ]] || fallback + +if [[ -z $screen_size ]]; then + if omarchy-cmd-present hyprctl && omarchy-cmd-present jq; then + screen_size=$(hyprctl monitors -j 2>/dev/null | jq -r '.[0] | "\(.width)x\(.height)"' 2>/dev/null || true) + fi +fi +[[ $screen_size =~ ^([0-9]+)x([0-9]+)$ ]] || fallback + +screen_width=${BASH_REMATCH[1]} +screen_height=${BASH_REMATCH[2]} +((screen_width > 0 && screen_height > 0 && bar_size > 0)) || fallback + +case "$position" in +top) + crop="${screen_width}x${bar_size}+0+0" + ;; +bottom) + crop_y=$((screen_height - bar_size)) + ((crop_y >= 0)) || fallback + crop="${screen_width}x${bar_size}+0+${crop_y}" + ;; +left) + crop="${bar_size}x${screen_height}+0+0" + ;; +right) + crop_x=$((screen_width - bar_size)) + ((crop_x >= 0)) || fallback + crop="${bar_size}x${screen_height}+${crop_x}+0" + ;; +esac + +pixel=$(magick "$background_path" -auto-orient \ + -resize "${screen_width}x${screen_height}^" \ + -gravity center -extent "${screen_width}x${screen_height}" \ + -gravity NorthWest -crop "$crop" +repage \ + -resize '1x1!' -format '%[fx:int(255*r)],%[fx:int(255*g)],%[fx:int(255*b)]' info:- 2>/dev/null || true) +[[ $pixel =~ ^([0-9]+),([0-9]+),([0-9]+)$ ]] || fallback + +sample=$(printf '#%02x%02x%02x' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}") +text_contrast=$(contrast "$text_color" "$sample") +background_contrast=$(contrast "$background_color" "$sample") + +awk -v text="$text_contrast" -v background="$background_contrast" 'BEGIN { exit !(background > text) }' \ + && printf '%s\n' "$background_color" \ + || printf '%s\n' "$text_color" diff --git a/shell/Ui/WidgetButton.qml b/shell/Ui/WidgetButton.qml index 0dddce9c..e929c220 100644 --- a/shell/Ui/WidgetButton.qml +++ b/shell/Ui/WidgetButton.qml @@ -83,6 +83,7 @@ Item { verticalAlignment: Text.AlignVCenter Behavior on color { + enabled: !root.bar || root.bar.foregroundAnimationEnabled ColorAnimation { duration: 160 } } } diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 0debf477..8a0a90fb 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -37,6 +37,8 @@ Item { }) property var layoutConfig: fallbackBarConfig.layout property string centerAnchor: "" + property bool requestedTransparent: false + property bool useTransparentForeground: false property bool transparent: false property int barConfigSerial: 0 property string position: "top" @@ -46,11 +48,15 @@ Item { property string fontFamily: Style.font.family // Bound to the central Color singleton so the bar tracks shell.toml's // [bar] section. Property names kept for the rest of this file's bindings. - property color foreground: Color.bar.text + property color themeForeground: Color.bar.text + property color themeContrastForeground: Color.background + property color transparentForeground: Color.bar.text + property color foreground: useTransparentForeground ? transparentForeground : themeForeground + property bool foregroundAnimationEnabled: true property color background: Color.bar.background property color urgent: Color.bar.active - Behavior on foreground { ColorAnimation { duration: 420; easing.type: Easing.InOutCubic } } + Behavior on foreground { enabled: root.foregroundAnimationEnabled; ColorAnimation { duration: 420; easing.type: Easing.InOutCubic } } Behavior on background { ColorAnimation { duration: 420; easing.type: Easing.InOutCubic } } Behavior on urgent { ColorAnimation { duration: 420; easing.type: Easing.InOutCubic } } property var tooltipTarget: null @@ -179,7 +185,7 @@ Item { var config = Util.isPlainObject(barConfig) ? barConfig : fallbackBarConfig position = normalizePosition(config.position) - transparent = config.transparent === true + setRequestedTransparency(config.transparent === true) centerAnchor = Util.canonicalWidgetId(config.centerAnchor || "") layoutConfig = normalizeLayout(config.layout) barConfigSerial++ @@ -307,14 +313,14 @@ Item { } function toggleTransparency() { - var nextTransparent = !(root.transparent === true) + var nextTransparent = !(root.requestedTransparent === true) if (root.shell && typeof root.shell.mutateShellConfig === "function") { root.shell.mutateShellConfig(function(config) { if (!Util.isPlainObject(config.bar)) config.bar = {} config.bar.transparent = nextTransparent }) } else { - root.transparent = nextTransparent + root.setRequestedTransparency(nextTransparent) } } @@ -460,6 +466,94 @@ Item { return true } + function colorHex(colorValue) { + var c = colorValue + if (typeof c === "string") c = Qt.color(c) + function hexChannel(value) { + var s = Math.round(Util.clamp(value, 0, 1) * 255).toString(16) + return s.length < 2 ? "0" + s : s + } + return "#" + hexChannel(c.r) + hexChannel(c.g) + hexChannel(c.b) + } + + function setRequestedTransparency(value) { + var nextTransparent = value === true + requestedTransparent = nextTransparent + if (!nextTransparent) { + foregroundAnimationEnabled = false + useTransparentForeground = false + transparent = false + transparentForeground = themeForeground + restoreForegroundAnimation() + return + } + scheduleTransparentForegroundRefresh() + } + + function restoreForegroundAnimation() { + Qt.callLater(function() { + Qt.callLater(function() { root.foregroundAnimationEnabled = true }) + }) + } + + function scheduleTransparentForegroundRefresh() { + if (!requestedTransparent) { + transparentForeground = themeForeground + return + } + transparentForegroundTimer.restart() + } + + function refreshTransparentForeground() { + if (!requestedTransparent || transparentForegroundProc.running) return + + transparentForegroundProc.command = [ + "omarchy-shell-bar-text-color", + root.position, + String(root.barSize), + colorHex(root.themeForeground), + colorHex(root.themeContrastForeground) + ] + transparentForegroundProc.running = true + } + + onRequestedTransparentChanged: scheduleTransparentForegroundRefresh() + onPositionChanged: scheduleTransparentForegroundRefresh() + onThemeForegroundChanged: scheduleTransparentForegroundRefresh() + onThemeContrastForegroundChanged: scheduleTransparentForegroundRefresh() + + Timer { + id: transparentForegroundTimer + interval: 120 + repeat: false + onTriggered: root.refreshTransparentForeground() + } + + Process { + id: transparentForegroundProc + stdout: SplitParser { + onRead: function(line) { + var value = String(line || "").trim() + if (!/^#[0-9A-Fa-f]{6}$/.test(value)) return + + root.foregroundAnimationEnabled = false + root.transparentForeground = value + if (root.requestedTransparent) { + root.useTransparentForeground = true + root.transparent = true + } + root.restoreForegroundAnimation() + } + } + } + + FileView { + path: root.home + "/.config/omarchy/current" + watchChanges: true + printErrors: false + onFileChanged: root.scheduleTransparentForegroundRefresh() + } + function runProcess(process) { if (!process.running) process.running = true diff --git a/shell/plugins/panels/weather/BarWidget.qml b/shell/plugins/panels/weather/BarWidget.qml index b05020de..6f1dcea5 100644 --- a/shell/plugins/panels/weather/BarWidget.qml +++ b/shell/plugins/panels/weather/BarWidget.qml @@ -49,7 +49,7 @@ BarWidget { bar: root.bar text: panelLoader.item ? panelLoader.item.label : "" active: panelLoader.item && panelLoader.item.klass === "active" - horizontalMargin: 1 + horizontalMargin: 5 // Tooltip suppressed because the panel is the detail view. tooltipText: "" diff --git a/shell/plugins/services/media/BarWidget.qml b/shell/plugins/services/media/BarWidget.qml index 3015c7cc..05341ce4 100644 --- a/shell/plugins/services/media/BarWidget.qml +++ b/shell/plugins/services/media/BarWidget.qml @@ -37,7 +37,10 @@ BarWidget { color: activePlayer && activePlayer.isPlaying ? root.bar.foreground : Qt.darker(root.bar.foreground, 1.5) font.family: root.bar.fontFamily font.pixelSize: Style.font.body - Behavior on color { ColorAnimation { duration: 160 } } + Behavior on color { + enabled: !root.bar || root.bar.foregroundAnimationEnabled + ColorAnimation { duration: 160 } + } } Item { diff --git a/test/shell.d/bar-text-color-test.sh b/test/shell.d/bar-text-color-test.sh new file mode 100755 index 00000000..14770a99 --- /dev/null +++ b/test/shell.d/bar-text-color-test.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command magick + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +export PATH="$ROOT/bin:$PATH" + +light_top="$TMPDIR/light-top.png" +dark_top="$TMPDIR/dark-top.png" + +magick -size 100x100 xc:'#202020' -fill '#f5f5f5' -draw 'rectangle 0,0 99,19' "$light_top" +magick -size 100x100 xc:'#f5f5f5' -fill '#202020' -draw 'rectangle 0,0 99,19' "$dark_top" + +result=$(HOME="$TMPDIR" omarchy-shell-bar-text-color top 20 '#ffffff' '#101010' --background "$light_top" --screen 100x100) +[[ $result == "#101010" ]] || fail "transparent bar text switches to background color on light wallpaper" "expected #101010, got $result" +pass "transparent bar text switches to background color on light wallpaper" + +result=$(HOME="$TMPDIR" omarchy-shell-bar-text-color top 20 '#ffffff' '#101010' --background "$dark_top" --screen 100x100) +[[ $result == "#ffffff" ]] || fail "transparent bar text keeps text color on dark wallpaper" "expected #ffffff, got $result" +pass "transparent bar text keeps text color on dark wallpaper" + +result=$(HOME="$TMPDIR" omarchy-shell-bar-text-color top 20 '#ffffff' '#101010' --background "$TMPDIR/missing.png" --screen 100x100) +[[ $result == "#ffffff" ]] || fail "transparent bar text falls back to text color when sampling fails" "expected #ffffff, got $result" +pass "transparent bar text falls back to text color when sampling fails"