From 5142d576f5ba36e1b1b1a1ebc18592dd478cd60b Mon Sep 17 00:00:00 2001 From: Mario Mohar <76663003+Mario-Mohar@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:13:09 +0200 Subject: [PATCH] fix(bar): centre widget content on whole pixels (#3263) * fix(bar): keep widget content on whole pixels Bar widgets centre their content inside a box of widgetThickness - horizontalPadding * 2, and Qt rounds the offset of a centred anchor to whole pixels. When the content and that box have different parity the exact offset falls on a half pixel, Qt rounds it away from zero and the content renders half a pixel above the centre. Widgets drawn from rectangles, such as the workspace switcher, compute their own snapped height and stay exactly centred, which is why they look right next to everything else in #3074. With the stock configuration this is the normal case: the bar is 40 px, the box is 14 px and barIconSize returns round((40 / 48) * 18) = 15. Two sizes now round to whole pairs of pixels. The icon size, so that it matches the parity of the box, and widgetThickness, because the box inherits its parity from there (the padding is doubled, so it never changes it). Going through every innerPadding from 0 to 16, nine of the seventeen configurations currently put the icon on a half pixel and none of them do afterwards. The 48 px default bar is untouched, the sizes that change do so by one pixel. Measured in a nested Hyprland at scale 1, ink centroid of the widget content relative to the centre of its pill, before and after: launcher -0.50 +0.00 weather -0.81 -0.52 clipboard -1.49 -0.93 notifications +0.03 +0.46 battery -0.63 -0.49 bluetooth -0.53 -0.03 Every icon moves down by half a pixel. What remains matches the ink asymmetry of the individual glyphs, measured separately at an exact position, and is not a placement error. Two things this does not cover. On a fractional or HiDPI scale horizontalPadding can land on a half pixel, which puts the box back between the grid lines, and a text only widget such as the clock has a line box of 15 px at the default font size, which cannot sit exactly on the centre of a 28 px pill no matter how it is rounded. Closes #3074 * fix(bar): give bar text a line box that can be centred A widget without an icon is centred as one line box, and the same parity rule applies to it as to the icon in the commit before this one. At fontSizeSmall the line box of the bar font is 15 px tall, so on a 28 px pill it cannot sit on the centre no matter which way the offset is rounded, and the clock reads about half a pixel high next to everything else. One step up the box is 16 px and it lands on the grid. Only the branch the standard bar uses changes. The 0.9 branch below a scale of 0.75 already comes out at 14 px, and the fontSizeMedium branch above 1.25 is left alone because there the next even box is two steps away, which would be a bigger change than this is worth. Same session and same measurement as the previous commit, ink centroid of the widget content relative to the centre of its pill: clock and date -0.58 +0.05 battery -0.63 -0.05 weather -0.82 -0.28 The bar font goes from 12 px to 13 px, which is the visible part of this and the reason it is a separate commit. It can be dropped without touching the icon fix. * bar: snap widget thickness to even device pixels and pick text sizes by line box parity * bar: snap bar thickness to even so the pill centres on whole pixels * bar: share the bar thickness formula so dock, osd and popouts stay in step * dock: read the bar clearance at the compositor screen scale --------- Co-authored-by: bbedward --- quickshell/Common/SettingsData.qml | 4 +- quickshell/Common/Theme.qml | 42 +++++++++++++++++-- quickshell/Modals/PowerMenuModal.qml | 4 +- quickshell/Modules/DankBar/DankBarBody.qml | 4 +- .../Modules/DankBar/Widgets/AppsDock.qml | 4 +- .../Modules/DankBar/Widgets/RunningApps.qml | 6 +-- .../DankIsland/IslandSatelliteHost.qml | 4 +- quickshell/Modules/Dock/DockBody.qml | 4 +- quickshell/Widgets/DankOSD.qml | 3 +- quickshell/Widgets/DankPopoutConnected.qml | 3 +- quickshell/Widgets/DankPopoutStandalone.qml | 3 +- 11 files changed, 53 insertions(+), 28 deletions(-) diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 3619aca81..291e8342f 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -2305,7 +2305,7 @@ Singleton { continue; const otherSpacing = other.spacing !== undefined ? other.spacing : (defaultBar?.spacing ?? 4); const otherPadding = other.innerPadding !== undefined ? other.innerPadding : (defaultBar?.innerPadding ?? 4); - const otherThickness = Math.max(26 + otherPadding * 0.6, Theme.barHeight - 4 - (8 - otherPadding)) + otherSpacing; + const otherThickness = Theme.barThickness(otherPadding, CompositorService.getScreenScale(screen)) + otherSpacing; const useAutoGaps = other.popupGapsAuto !== undefined ? other.popupGapsAuto : (defaultBar?.popupGapsAuto ?? true); const manualGap = other.popupGapsManual !== undefined ? other.popupGapsManual : (defaultBar?.popupGapsManual ?? 4); @@ -2381,7 +2381,7 @@ Singleton { continue; const otherSpacing = other.spacing !== undefined ? other.spacing : (defaultBar?.spacing ?? 4); const otherPadding = other.innerPadding !== undefined ? other.innerPadding : (defaultBar?.innerPadding ?? 4); - const otherThickness = Math.max(26 + otherPadding * 0.6, Theme.barHeight - 4 - (8 - otherPadding)) + otherSpacing + wingSize; + const otherThickness = Theme.barThickness(otherPadding, CompositorService.getScreenScale(screen)) + otherSpacing + wingSize; const otherBottomGap = isConnected ? 0 : (other.bottomGap !== undefined ? other.bottomGap : (defaultBar?.bottomGap ?? 0)); switch (other.position) { diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index a9450049e..bf7c67b86 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -1517,7 +1517,7 @@ Singleton { const size = (maximizeIcon ?? false) ? iconSizeLarge : iconSize; const s = iconScale !== undefined ? iconScale : 1.0; - return Math.round((barThickness / 48) * (size + defaultOffset) * s); + return 2 * Math.round((barThickness / 48) * (size + defaultOffset) * s / 2); } function barTextSize(barThickness, fontScale, maximizeText) { @@ -1525,10 +1525,30 @@ Singleton { const dankBarScale = fontScale !== undefined ? fontScale : 1.0; const maxScale = (maximizeText ?? false) ? 1.5 : 1.0; if (scale <= 0.75) - return Math.round(fontSizeSmall * 0.9 * dankBarScale * maxScale); + return evenLineBoxSize(Math.round(fontSizeSmall * 0.9 * dankBarScale * maxScale)); if (scale >= 1.25) - return Math.round(fontSizeMedium * dankBarScale * maxScale); - return Math.round(fontSizeSmall * dankBarScale * maxScale); + return evenLineBoxSize(Math.round(fontSizeMedium * dankBarScale * maxScale)); + return evenLineBoxSize(Math.round(fontSizeSmall * dankBarScale * maxScale)); + } + + FontMetrics { + id: lineBoxProbe + } + + // Text.implicitHeight is ceil(FontMetrics.height); probe.font is written whole and read via boundingRect() so bindings capture no dependency on the probe + function lineBoxHeight(pixelSize) { + lineBoxProbe.font = Qt.font({ + family: fontFamily, + weight: fontWeight, + pixelSize: pixelSize + }); + return Math.ceil(lineBoxProbe.boundingRect("0").height); + } + + function evenLineBoxSize(pixelSize) { + if (lineBoxHeight(pixelSize) % 2 === 0) + return pixelSize; + return lineBoxHeight(pixelSize + 1) % 2 === 0 ? pixelSize + 1 : pixelSize; } function getBatteryIcon(level, isCharging, batteryAvailable) { @@ -2080,11 +2100,25 @@ Singleton { return Math.round(value * s) / s; } + // Qt rounds a centred anchor offset to whole pixels, so a box must share its content's parity + function snapEven(value, dpr) { + const s = dpr || 1; + return 2 * Math.round(value * s / 2) / s; + } + function px(value, dpr) { const s = dpr || 1; return Math.round(value * s) / s; } + function barWidgetThickness(innerPadding, dpr) { + return snapEven(Math.max(20, 26 + innerPadding * 0.6), dpr); + } + + function barThickness(innerPadding, dpr) { + return snapEven(Math.max(barWidgetThickness(innerPadding, dpr) + innerPadding + 4, barHeight - 4 - (8 - innerPadding)), dpr); + } + function hairline(dpr) { return 1 / (dpr || 1); } diff --git a/quickshell/Modals/PowerMenuModal.qml b/quickshell/Modals/PowerMenuModal.qml index 45fe255ae..474c5898b 100644 --- a/quickshell/Modals/PowerMenuModal.qml +++ b/quickshell/Modals/PowerMenuModal.qml @@ -1,6 +1,7 @@ import QtQuick import qs.Common import qs.Modals.Common +import qs.Services import qs.Modules.PowerMenu DankModal { @@ -38,8 +39,7 @@ DankModal { customPosition: { if (parentBounds.width > 0) { const bar = SettingsData.getPrimaryBarConfig(); - const barPadding = bar?.innerPadding ?? 4; - const effectiveBarThickness = Math.max(26 + barPadding * 0.6 + barPadding + 4, Theme.barHeight - 4 - (8 - barPadding)); + const effectiveBarThickness = Theme.barThickness(bar?.innerPadding ?? 4, CompositorService.getScreenScale(parentScreen)); const barExclusionZone = effectiveBarThickness + (bar?.spacing ?? 4) + (bar?.bottomGap ?? 0); const barPosition = bar?.position ?? SettingsData.Position.Top; const screenW = parentScreen?.width ?? 1920; diff --git a/quickshell/Modules/DankBar/DankBarBody.qml b/quickshell/Modules/DankBar/DankBarBody.qml index 15266749a..28b74b088 100644 --- a/quickshell/Modules/DankBar/DankBarBody.qml +++ b/quickshell/Modules/DankBar/DankBarBody.qml @@ -602,7 +602,7 @@ Item { } readonly property int notificationCount: NotificationService.notifications.length - readonly property real effectiveBarThickness: (FrameTransitionState.effectiveFrameEnabled && usesFrameBarChrome) ? SettingsData.frameBarSize : Theme.snap(Math.max(barWindow.widgetThickness + (barConfig?.innerPadding ?? 4) + 4, Theme.barHeight - 4 - (8 - (barConfig?.innerPadding ?? 4))), _dpr) + readonly property real effectiveBarThickness: (FrameTransitionState.effectiveFrameEnabled && usesFrameBarChrome) ? SettingsData.frameBarSize : Theme.barThickness(barConfig?.innerPadding ?? 4, _dpr) readonly property real effectiveBarLengthPadding: { if ((FrameTransitionState.effectiveFrameEnabled && usesFrameBarChrome) || (flattenForMaximizedWindow && hasMaximizedToplevel)) return 0; @@ -611,7 +611,7 @@ Item { return length > 0 ? Math.min(pad, Math.max(0, length / 2 - effectiveSpacing)) : pad; } readonly property bool effectiveOpenOnOverview: (FrameTransitionState.effectiveFrameEnabled && usesFrameBarChrome) ? SettingsData.frameShowOnOverview : (barConfig?.openOnOverview ?? false) - readonly property real widgetThickness: Theme.snap(Math.max(20, 26 + (barConfig?.innerPadding ?? 4) * 0.6), _dpr) + readonly property real widgetThickness: Theme.barWidgetThickness(barConfig?.innerPadding ?? 4, _dpr) readonly property bool hasAdjacentTopBar: { if (barConfig?.autoHide ?? false) diff --git a/quickshell/Modules/DankBar/Widgets/AppsDock.qml b/quickshell/Modules/DankBar/Widgets/AppsDock.qml index 830572143..d7fa6fc33 100644 --- a/quickshell/Modules/DankBar/Widgets/AppsDock.qml +++ b/quickshell/Modules/DankBar/Widgets/AppsDock.qml @@ -39,9 +39,7 @@ BasePill { if (barThickness > 0 && barSpacing > 0) { return barThickness + barSpacing; } - const innerPadding = barConfig?.innerPadding ?? 4; - const spacing = barConfig?.spacing ?? 4; - return Math.max(26 + innerPadding * 0.6, Theme.barHeight - 4 - (8 - innerPadding)) + spacing; + return Theme.barThickness(barConfig?.innerPadding ?? 4, CompositorService.getScreenScale(parentScreen)) + (barConfig?.spacing ?? 4); } readonly property var barBounds: { diff --git a/quickshell/Modules/DankBar/Widgets/RunningApps.qml b/quickshell/Modules/DankBar/Widgets/RunningApps.qml index 92e0fc91d..22f044831 100644 --- a/quickshell/Modules/DankBar/Widgets/RunningApps.qml +++ b/quickshell/Modules/DankBar/Widgets/RunningApps.qml @@ -34,9 +34,7 @@ BasePill { if (barThickness > 0 && barSpacing > 0) { return barThickness + barSpacing; } - const innerPadding = barConfig?.innerPadding ?? 4; - const spacing = barConfig?.spacing ?? 4; - return Math.max(26 + innerPadding * 0.6, Theme.barHeight - 4 - (8 - innerPadding)) + spacing; + return Theme.barThickness(barConfig?.innerPadding ?? 4, CompositorService.getScreenScale(parentScreen)) + (barConfig?.spacing ?? 4); } readonly property var barBounds: { @@ -784,7 +782,7 @@ BasePill { if (triggerBarThickness > 0 && triggerBarSpacing > 0) { return triggerBarThickness + triggerBarSpacing; } - return Math.max(26 + (barConfig?.innerPadding ?? 4) * 0.6, Theme.barHeight - 4 - (8 - (barConfig?.innerPadding ?? 4))) + (barConfig?.spacing ?? 4); + return Theme.barThickness(barConfig?.innerPadding ?? 4, CompositorService.getScreenScale(contextMenuWindow.screen)) + (barConfig?.spacing ?? 4); } property var barBounds: { diff --git a/quickshell/Modules/DankIsland/IslandSatelliteHost.qml b/quickshell/Modules/DankIsland/IslandSatelliteHost.qml index e42b620be..6741b8a05 100644 --- a/quickshell/Modules/DankIsland/IslandSatelliteHost.qml +++ b/quickshell/Modules/DankIsland/IslandSatelliteHost.qml @@ -35,8 +35,8 @@ Item { readonly property string scrollYBehavior: satelliteConfig?.scrollYBehavior ?? "workspace" readonly property real screenScale: CompositorService.getScreenScale(root.targetScreen) readonly property real innerPadding: root.satelliteConfig?.innerPadding ?? 4 - readonly property real widgetThickness: Theme.snap(Math.max(20, 26 + root.innerPadding * 0.6), root.screenScale) - readonly property real barThickness: Theme.snap(Math.max(root.widgetThickness + root.innerPadding + 4, Theme.barHeight - 4 - (8 - root.innerPadding)), root.screenScale) + readonly property real widgetThickness: Theme.barWidgetThickness(root.innerPadding, root.screenScale) + readonly property real barThickness: Theme.barThickness(root.innerPadding, root.screenScale) readonly property real satelliteSpacing: root.satelliteConfig?.spacing ?? 4 readonly property bool edgeAligned: SettingsData.dankIslandSatellitePosition === "edges" readonly property real edgeBaseMargin: Math.max(Theme.spacingXS, root.innerPadding * 0.8) diff --git a/quickshell/Modules/Dock/DockBody.qml b/quickshell/Modules/Dock/DockBody.qml index 77bdf3d91..f410a553b 100644 --- a/quickshell/Modules/Dock/DockBody.qml +++ b/quickshell/Modules/Dock/DockBody.qml @@ -44,9 +44,7 @@ Item { function getBarHeight(barConfig) { if (!barConfig) return 0; - const innerPadding = barConfig.innerPadding ?? 4; - const widgetThickness = Math.max(20, 26 + innerPadding * 0.6); - const barThickness = Math.max(widgetThickness + innerPadding + 4, Theme.barHeight - 4 - (8 - innerPadding)); + const barThickness = Theme.barThickness(barConfig.innerPadding ?? 4, CompositorService.getScreenScale(dock.screen)); const spacing = barConfig.spacing ?? 4; const bottomGap = barConfig.bottomGap ?? 0; return barThickness + spacing + bottomGap; diff --git a/quickshell/Widgets/DankOSD.qml b/quickshell/Widgets/DankOSD.qml index 7fbfa9c99..ea2e91ce7 100644 --- a/quickshell/Widgets/DankOSD.qml +++ b/quickshell/Widgets/DankOSD.qml @@ -140,8 +140,7 @@ PanelWindow { if (!SettingsData.barConfigCoversScreen(bc, screen)) continue; const innerPadding = bc.innerPadding ?? (defaultBar?.innerPadding ?? 4); - const widgetThickness = Math.max(20, 26 + innerPadding * 0.6); - const thickness = Math.max(widgetThickness + innerPadding + 4, Theme.barHeight - 4 - (8 - innerPadding)); + const thickness = Theme.barThickness(innerPadding, dpr); const spacing = bc.spacing ?? (defaultBar?.spacing ?? 4); const bottomGap = bc.bottomGap ?? (defaultBar?.bottomGap ?? 0); const offset = thickness + spacing + bottomGap; diff --git a/quickshell/Widgets/DankPopoutConnected.qml b/quickshell/Widgets/DankPopoutConnected.qml index 441a1945c..90389265c 100644 --- a/quickshell/Widgets/DankPopoutConnected.qml +++ b/quickshell/Widgets/DankPopoutConnected.qml @@ -72,8 +72,7 @@ Item { readonly property real effectiveBarThickness: { if (root.usesConnectedSurfaceChrome) return Math.max(0, storedBarThickness); - const padding = storedBarConfig ? (storedBarConfig.innerPadding !== undefined ? storedBarConfig.innerPadding : 4) : 4; - return Math.max(26 + padding * 0.6, Theme.barHeight - 4 - (8 - padding)) + storedBarSpacing; + return Theme.barThickness(storedBarConfig?.innerPadding ?? 4, dpr) + storedBarSpacing; } readonly property var barBounds: { diff --git a/quickshell/Widgets/DankPopoutStandalone.qml b/quickshell/Widgets/DankPopoutStandalone.qml index 8e4e76c5c..5653a57bb 100644 --- a/quickshell/Widgets/DankPopoutStandalone.qml +++ b/quickshell/Widgets/DankPopoutStandalone.qml @@ -104,8 +104,7 @@ Item { } readonly property real effectiveBarThickness: { - const padding = storedBarConfig ? (storedBarConfig.innerPadding !== undefined ? storedBarConfig.innerPadding : 4) : 4; - return Math.max(26 + padding * 0.6, Theme.barHeight - 4 - (8 - padding)) + storedBarSpacing; + return Theme.barThickness(storedBarConfig?.innerPadding ?? 4, dpr) + storedBarSpacing; } readonly property var barBounds: {