From 248659de5a4ce1364703601a70b56624e9817c46 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 24 Jul 2026 16:41:01 -0700 Subject: [PATCH] Give every OSD card exact padding on all four sides Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LfqzqpU1T6pQRsmpbGxBs5 --- bin/omarchy-osd | 9 +-- bin/omarchy-system-logout | 2 +- bin/omarchy-system-reboot | 2 +- bin/omarchy-system-shutdown | 2 +- shell/plugins/osd/Osd.qml | 116 +++++++++++++++++++++++----------- shell/plugins/osd/OsdModel.js | 11 ++-- test/shell.d/osd-test.sh | 22 +------ 7 files changed, 96 insertions(+), 68 deletions(-) diff --git a/bin/omarchy-osd b/bin/omarchy-osd index 08debdff..99442989 100755 --- a/bin/omarchy-osd +++ b/bin/omarchy-osd @@ -1,8 +1,8 @@ #!/bin/bash # omarchy:summary=Show the Omarchy Quickshell on-screen display -# omarchy:args=[-i|--icon ] [-m|--message ] [-p|--progress <0-100>] [-d|--duration ] [-f|--fit] -# omarchy:examples=omarchy osd -i brightness -p 50 | omarchy osd -m "Hello" | omarchy osd -m "Done" --fit +# omarchy:args=[-i|--icon ] [-m|--message ] [-p|--progress <0-100>] [-d|--duration ] +# omarchy:examples=omarchy osd -i brightness -p 50 | omarchy osd -m "Hello" set -euo pipefail @@ -12,7 +12,6 @@ progress="" progress_text="" max="100" duration="" -fit=0 while (($#)); do case $1 in @@ -20,7 +19,6 @@ while (($#)); do -m|--message) message="${2:-}"; shift 2 ;; -p|--progress) progress="${2:-}"; shift 2 ;; -d|--duration) duration="${2:-}"; shift 2 ;; - -f|--fit) fit=1; shift ;; -h|--help) omarchy osd --help; exit 0 ;; *) echo "Unknown OSD option: $1" >&2; exit 1 ;; esac @@ -37,7 +35,6 @@ payload=$(jq -cn \ --arg progressText "$progress_text" \ --arg max "$max" \ --arg duration "$duration" \ - --argjson fit "$fit" \ - '{icon:$icon,message:$message,value:$value,progressText:$progressText,max:$max,duration:$duration,fit:$fit}') + '{icon:$icon,message:$message,value:$value,progressText:$progressText,max:$max,duration:$duration}') omarchy-shell -q osd show "$payload" diff --git a/bin/omarchy-system-logout b/bin/omarchy-system-logout index 4451db9f..cb0fd660 100755 --- a/bin/omarchy-system-logout +++ b/bin/omarchy-system-logout @@ -6,7 +6,7 @@ nohup bash -c "sleep 2 && uwsm stop" >/dev/null 2>&1 & -omarchy-osd -i logout -m "Logging out…" -d 5000 --fit +omarchy-osd -i logout -m "Logging out" -d 5000 # Now close all windows omarchy-hyprland-window-close-all diff --git a/bin/omarchy-system-reboot b/bin/omarchy-system-reboot index c01c3cb2..4d2b6c86 100755 --- a/bin/omarchy-system-reboot +++ b/bin/omarchy-system-reboot @@ -8,7 +8,7 @@ # scope cannot terminate it before it runs. systemd-run --user --collect --quiet --on-active="2s" --timer-property=AccuracySec=100ms systemctl reboot --no-wall || exit 1 -omarchy-osd -i reboot -m "Rebooting…" -d 5000 --fit +omarchy-osd -i reboot -m "Rebooting" -d 5000 omarchy-state clear re*-required diff --git a/bin/omarchy-system-shutdown b/bin/omarchy-system-shutdown index e4021e60..0143760e 100755 --- a/bin/omarchy-system-shutdown +++ b/bin/omarchy-system-shutdown @@ -8,7 +8,7 @@ # scope cannot terminate it before it runs. systemd-run --user --collect --quiet --on-active="2s" --timer-property=AccuracySec=100ms systemctl poweroff --no-wall || exit 1 -omarchy-osd -i shutdown -m "Shutting down…" -d 5000 --fit +omarchy-osd -i shutdown -m "Shutting down" -d 5000 omarchy-state clear re*-required diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index 2cd33bef..39f32e2f 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -17,24 +17,45 @@ Item { property int maxValue: 100 property bool hasProgress: true property int duration: 1200 - property bool fit: false - readonly property int cardWidth: Style.space(269) - readonly property int mediaCardWidth: Math.round(cardWidth * 1.5) - readonly property int messageWidth: Style.space(190) - readonly property int mediaMessageWidth: messageWidth + mediaCardWidth - cardWidth + readonly property bool mediaOsd: iconKey.indexOf("media") === 0 || iconKey.indexOf("player") === 0 - readonly property bool textOnlyOsd: root.fit && !root.hasProgress && !root.mediaOsd - readonly property int fitMessageWidth: root.message === "" ? 0 : Math.round(messageMetrics.boundingRect.width) + Style.space(4) - readonly property int fitCardWidth: root.message === "" || root.fitMessageWidth === 0 - ? card.borderLeft + Style.space(16) + Style.space(28) + Style.space(16) + card.borderRight - : card.borderLeft + Style.space(16) + Style.space(28) + Style.space(16) + root.fitMessageWidth + Style.space(16) + card.borderRight + + // The card is built out of measured columns instead of fixed widths, so it + // keeps exactly `pad` between border and content on every side whatever + // glyph or message it carries. Messages grow with their text up to + // `maxMessageWidth` and elide beyond it. + readonly property int pad: Style.space(16) + readonly property int gap: Style.space(16) + // A glyph next to a message reads airier than it measures: the icon outline + // and the letterforms both fall away from their ink extremes, so the space + // between them opens up well past the nominal gap. Text takes two thirds of + // it; the progress bar's hard edge keeps the full gap. + readonly property int messageGap: Math.round(root.gap * 2 / 3) + readonly property int barWidth: Style.space(142) + readonly property int maxMessageWidth: root.mediaOsd ? Style.space(325) : Style.space(190) + + // Nerd Font glyphs draw well outside their monospace cell, so the icon + // column is measured by ink rather than by advance width. Progress OSDs pin + // it to the widest glyph the model can return, so the bar doesn't shift when + // volume crosses an icon threshold. + readonly property int iconInkWidth: Math.ceil(iconMetrics.tightBoundingRect.width) + readonly property int iconWidth: root.hasProgress + ? Math.max(root.iconInkWidth, Math.ceil(widestIconMetrics.tightBoundingRect.width)) + : root.iconInkWidth + // Same idea for the readout: it is as wide as the longest percentage so the + // digits don't jitter between 9% and 100%. + readonly property int valueWidth: Math.ceil(Math.max(valueMetrics.advanceWidth, messageMetrics.advanceWidth)) + readonly property int messageWidth: Math.min(Math.ceil(messageMetrics.advanceWidth), root.maxMessageWidth) + readonly property int contentWidth: root.hasProgress + ? root.iconWidth + root.gap + root.barWidth + root.gap + root.valueWidth + : (root.message === "" ? root.iconWidth : root.iconWidth + root.messageGap + root.messageWidth) function iconFor(name, percent) { return OsdModel.iconFor(name, percent) } - function show(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration, rawFit) { - var next = OsdModel.stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration, rawFit) + function show(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) { + var next = OsdModel.stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) iconKey = next.iconKey maxValue = next.maxValue hasProgress = next.hasProgress @@ -42,7 +63,6 @@ Item { message = next.message icon = next.icon duration = next.duration - fit = next.fit opened = true if (duration > 0) hideTimer.restart() else hideTimer.stop() @@ -51,7 +71,7 @@ Item { function open(payloadJson) { try { var p = JSON.parse(payloadJson || "{}") - show(p.icon || "", p.message || "", p.value === undefined ? "" : String(p.value), p.max === undefined ? "100" : String(p.max), p.progressText || "", p.duration === undefined ? "1200" : String(p.duration), p.fit === undefined ? false : p.fit) + show(p.icon || "", p.message || "", p.value === undefined ? "" : String(p.value), p.max === undefined ? "100" : String(p.max), p.progressText || "", p.duration === undefined ? "1200" : String(p.duration)) } catch (e) {} } @@ -71,6 +91,25 @@ Item { text: root.message } + TextMetrics { + id: valueMetrics + font: messageMetrics.font + text: "100%" + } + + TextMetrics { + id: iconMetrics + font.family: Style.font.family + font.pixelSize: Style.font.displayLarge + text: root.icon + } + + TextMetrics { + id: widestIconMetrics + font: iconMetrics.font + text: OsdModel.widestIcon + } + IpcHandler { target: "osd" function show(payloadJson: string): string { @@ -97,8 +136,8 @@ Item { BorderSurface { id: card - width: root.textOnlyOsd ? root.fitCardWidth : (root.mediaOsd ? root.mediaCardWidth : root.cardWidth) - height: Math.max(Style.space(68), Style.font.displayLarge + Style.spacing.panelGap) + width: card.borderLeft + root.pad + root.contentWidth + root.pad + card.borderRight + height: card.borderTop + root.pad + Style.font.displayLarge + root.pad + card.borderBottom anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: Style.space(67) @@ -109,23 +148,27 @@ Item { Row { anchors.fill: parent - anchors.topMargin: card.borderTop - anchors.rightMargin: card.borderRight + Style.space(16) - anchors.bottomMargin: card.borderBottom - anchors.leftMargin: card.borderLeft + Style.space(16) - spacing: Style.space(16) - Text { - width: Style.space(28) - anchors.verticalCenter: parent.verticalCenter - horizontalAlignment: Text.AlignHCenter - text: root.icon - font.family: Style.font.family - font.pixelSize: Style.font.displayLarge - color: Color.popups.text + anchors.topMargin: card.borderTop + root.pad + anchors.rightMargin: card.borderRight + root.pad + anchors.bottomMargin: card.borderBottom + root.pad + anchors.leftMargin: card.borderLeft + root.pad + spacing: root.hasProgress ? root.gap : root.messageGap + Item { + width: root.iconWidth + height: parent.height + Text { + // Sit the glyph's ink flush in the column, centered when the + // column is wider than this particular glyph. + x: Math.round((root.iconWidth - root.iconInkWidth) / 2 - iconMetrics.tightBoundingRect.x) + anchors.verticalCenter: parent.verticalCenter + text: root.icon + font: iconMetrics.font + color: Color.popups.text + } } Rectangle { visible: root.hasProgress - width: visible ? Style.space(142) : 0 + width: root.barWidth height: Math.max(Style.space(6), Style.spacing.sm) anchors.verticalCenter: parent.verticalCenter color: Util.alpha(Color.popups.text, 0.45) @@ -136,16 +179,17 @@ Item { } } Text { - width: root.textOnlyOsd ? root.fitMessageWidth : (root.hasProgress ? Style.space(41) : (root.mediaOsd ? root.mediaMessageWidth : root.messageWidth)) + visible: root.message !== "" + width: root.hasProgress ? root.valueWidth : root.messageWidth + // The readout hugs the card edge so a short percentage doesn't leave + // a hole in the padding; the slack lands in the gap after the bar. + horizontalAlignment: root.hasProgress ? Text.AlignRight : Text.AlignLeft anchors.verticalCenter: parent.verticalCenter text: root.message - font.family: Style.font.family - font.bold: true - font.pixelSize: Style.font.title + font: messageMetrics.font color: Color.popups.text - elide: root.textOnlyOsd ? Text.ElideNone : Text.ElideRight + elide: Text.ElideRight maximumLineCount: 1 - clip: !root.textOnlyOsd } } } diff --git a/shell/plugins/osd/OsdModel.js b/shell/plugins/osd/OsdModel.js index fda2eede..a29ca81b 100644 --- a/shell/plugins/osd/OsdModel.js +++ b/shell/plugins/osd/OsdModel.js @@ -2,6 +2,10 @@ function clamp(value, min, max) { return Math.max(min, Math.min(max, value)) } +// The widest glyph `iconFor` can return. The progress OSD sizes its icon +// column to it so the bar keeps its place as the icon changes. +var widestIcon = "" + function iconFor(name, percent) { var n = String(name || "").toLowerCase() if (n === "volume-muted" || n === "volume-mute" || n === "muted" || n === "mute") return "" @@ -30,14 +34,13 @@ function iconFor(name, percent) { return "" } -function stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration, rawFit) { +function stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) { var maxValue = Math.max(1, parseInt(rawMax || "100", 10)) var parsedValue = parseInt(rawValue || "0", 10) var hasProgress = rawValue !== "" && !isNaN(parsedValue) && rawMessage === "" var value = hasProgress ? clamp(parsedValue, 0, maxValue) : 0 var percent = hasProgress ? Math.round(value * 100 / maxValue) : -1 var parsedDuration = parseInt(rawDuration || "1200", 10) - var fit = rawFit === true || rawFit === 1 || rawFit === "1" || rawFit === "true" return { iconKey: String(iconName || "").toLowerCase(), @@ -46,13 +49,13 @@ function stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, r value: value, message: String(rawMessage || (hasProgress ? (rawProgressText || percent + "%") : "")), icon: iconFor(iconName, percent), - duration: isNaN(parsedDuration) ? 1200 : Math.max(0, parsedDuration), - fit: fit + duration: isNaN(parsedDuration) ? 1200 : Math.max(0, parsedDuration) } } if (typeof module !== "undefined") { module.exports = { + widestIcon: widestIcon, iconFor: iconFor, stateForShow: stateForShow } diff --git a/test/shell.d/osd-test.sh b/test/shell.d/osd-test.sh index d8d1587d..033f085e 100644 --- a/test/shell.d/osd-test.sh +++ b/test/shell.d/osd-test.sh @@ -11,6 +11,7 @@ assertEqual(osd.iconFor('', 0), osd.iconFor('muted', 50), 'osd falls back to mut assertEqual(osd.iconFor('volume-high', 1), osd.iconFor('', 100), 'osd maps high volume aliases') assertEqual(osd.iconFor('logout', 50), '󰍃', 'osd maps logout icon') assertEqual(osd.iconFor('custom-symbol', 50), 'custom-symbol', 'osd preserves unknown explicit icons') +assertEqual(osd.widestIcon, osd.iconFor('volume-high', 100), 'osd sizes the icon column to a glyph it can show') assertDeepEqual( osd.stateForShow('volume', '', '75', '100', '', '800'), @@ -21,8 +22,7 @@ assertDeepEqual( value: 75, message: '75%', icon: osd.iconFor('volume', 75), - duration: 800, - fit: false + duration: 800 }, 'osd builds progress state' ) @@ -36,24 +36,8 @@ assertDeepEqual( value: 0, message: 'Paused', icon: osd.iconFor('media-pause', -1), - duration: 1200, - fit: false + duration: 1200 }, 'osd builds message state' ) - -assertDeepEqual( - osd.stateForShow('shutdown', 'Shutting down…', '', '100', '', '5000', '1'), - { - iconKey: 'shutdown', - maxValue: 100, - hasProgress: false, - value: 0, - message: 'Shutting down…', - icon: osd.iconFor('shutdown', -1), - duration: 5000, - fit: true - }, - 'osd parses fit flag' -) JS