The menu, emoji picker, clipboard, OSD, reminder flow and Wi-Fi QR code mapped a fresh surface on every open. Qt drew its first frames before Hyprland sent the surface's fractional scale: the pixel ratio stepped 2, 1, then 1.6, so on a 1.6 display each overlay showed blurry for ~350ms before going sharp. A new OverlayWindow keeps the surface. Hidden, it parks as a 1x1, input-less layer below windows, off the overlay layer so it never blocks direct scanout. Showing only resizes and raises it, so the scale is already settled. Content stays hidden until the surface has grown, so no 1x1 frame is stretched across the screen, and the window follows the focused monitor each time it is shown. The image picker's own fullscreen parking moves onto it too. Each parked overlay keeps a Qt window alive: at rest the shell holds ~40 MiB more RSS and ~20 MiB more GPU memory. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
7.3 KiB
QML
203 lines
7.3 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import Quickshell.Wayland
|
|
import qs.Commons
|
|
import qs.Ui
|
|
import "OsdModel.js" as OsdModel
|
|
|
|
Item {
|
|
id: root
|
|
|
|
property bool opened: false
|
|
property string icon: ""
|
|
property string message: ""
|
|
property string iconKey: ""
|
|
property int value: 0
|
|
property int maxValue: 100
|
|
property bool hasProgress: true
|
|
property int duration: 1200
|
|
|
|
readonly property bool mediaOsd: iconKey.indexOf("media") === 0 || iconKey.indexOf("player") === 0
|
|
|
|
// 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) {
|
|
var next = OsdModel.stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration)
|
|
// Update before opening so a fresh OSD starts at its new value; only
|
|
// subsequent updates while it remains open animate the progress bar.
|
|
iconKey = next.iconKey
|
|
maxValue = next.maxValue
|
|
hasProgress = next.hasProgress
|
|
value = next.value
|
|
message = next.message
|
|
icon = next.icon
|
|
duration = next.duration
|
|
opened = true
|
|
if (duration > 0) hideTimer.restart()
|
|
else hideTimer.stop()
|
|
}
|
|
|
|
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))
|
|
} catch (e) {}
|
|
}
|
|
|
|
function close() { opened = false }
|
|
|
|
Timer {
|
|
id: hideTimer
|
|
interval: root.duration
|
|
onTriggered: root.opened = false
|
|
}
|
|
|
|
TextMetrics {
|
|
id: messageMetrics
|
|
font.family: Style.font.family
|
|
font.bold: true
|
|
font.pixelSize: Style.font.title
|
|
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 {
|
|
root.open(payloadJson)
|
|
return "ok"
|
|
}
|
|
function close(): string { root.close(); return "ok" }
|
|
function state(): string { return root.opened ? "open" : "closed" }
|
|
function ping(): string { return "ok" }
|
|
}
|
|
|
|
OverlayWindow {
|
|
id: panel
|
|
shown: root.opened
|
|
shownKeyboardFocus: WlrKeyboardFocus.None
|
|
WlrLayershell.namespace: "omarchy-osd"
|
|
// Visual-only surface: keep the layer-shell input region empty so the OSD
|
|
// never blocks clicks to the desktop below it.
|
|
mask: Region {}
|
|
|
|
BorderSurface {
|
|
id: card
|
|
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)
|
|
color: Util.alpha(Color.background, 0.97)
|
|
borderSpec: Border.surfaceSpec("popups", "border", Color.popups.border, Math.max(1, Style.space(2)))
|
|
radius: Style.cornerRadius
|
|
opacity: root.opened ? 1 : 0
|
|
|
|
Row {
|
|
anchors.fill: parent
|
|
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 {
|
|
textFormat: Text.PlainText
|
|
// 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: root.barWidth
|
|
height: Math.max(Style.space(6), Style.spacing.sm)
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
color: Util.alpha(Color.popups.text, 0.45)
|
|
Rectangle {
|
|
height: parent.height
|
|
width: parent.width * (root.hasProgress ? root.value / root.maxValue : 0)
|
|
color: Color.accent
|
|
|
|
Behavior on width {
|
|
enabled: root.opened
|
|
NumberAnimation { duration: 140; easing.type: Easing.OutCubic }
|
|
}
|
|
}
|
|
}
|
|
Text {
|
|
textFormat: Text.PlainText
|
|
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: messageMetrics.font
|
|
color: Color.popups.text
|
|
elide: Text.ElideRight
|
|
maximumLineCount: 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|