diff --git a/shell/Commons/Style.qml b/shell/Commons/Style.qml index 81792792..a4026972 100644 --- a/shell/Commons/Style.qml +++ b/shell/Commons/Style.qml @@ -353,6 +353,7 @@ QtObject { readonly property int iconSlot: root.barToken("icon-slot", 27) readonly property int iconCanvas: root.barToken("icon-canvas", 16) readonly property int iconFont: root.barToken("icon-font", 13) + readonly property int iconSize: Math.min(iconCanvas, root.barToken("icon-size", 12)) readonly property int statusSlot: root.barToken("status-slot", 21) } @@ -424,7 +425,9 @@ QtObject { } else if (section === "bar") { if (key === "scale-with-font") { nextBarScaleWithFont = boolToken(raw, nextBarScaleWithFont) - } else if (key === "size-horizontal" || key === "size-vertical" || key === "notch-height") { + } else if (key === "size-horizontal" || key === "size-vertical" || key === "notch-height" + || key === "icon-slot" || key === "icon-canvas" || key === "icon-font" + || key === "icon-size" || key === "status-slot") { var b = parseInt(raw, 10) if (isFinite(b)) barOut[key] = b } diff --git a/shell/Ui/BarIconButton.qml b/shell/Ui/BarIconButton.qml index 32214bb4..30b41070 100644 --- a/shell/Ui/BarIconButton.qml +++ b/shell/Ui/BarIconButton.qml @@ -8,52 +8,90 @@ WidgetButton { property Component iconComponent: null property real slotSize: Style.bar.iconSlot property real opticalSize: Style.bar.iconCanvas + property real iconSize: Math.min(Style.bar.iconSize, opticalSize) + readonly property real visualIconSize: Style.bar.iconSize + readonly property real canvasSize: Math.max(visualIconSize, opticalSize) + property string labelText: "" property bool debugOpticalBounds: Quickshell.env("OMARCHY_DEBUG_BAR_ICONS") === "1" - // Measured against the slot, which the open-panel underline centers on. - readonly property real opticalCenterErrorX: glyph.visible ? opticalCanvas.x + glyph.paintedCenterX - root.width / 2 : 0 - readonly property real glyphPaintedWidth: glyph.visible ? glyph.tightWidth : 0 + // A label shifts the icon within the centered group. + readonly property real opticalCenterErrorX: + content.x + opticalCanvas.x + (glyph.visible ? glyph.paintedCenterX : customIcon.width / 2 + customIcon.centerErrorX) - root.width / 2 + - (label.visible ? (label.implicitWidth + Style.space(4)) / 2 : 0) + readonly property real opticalCenterErrorY: + content.y + (glyph.visible ? glyph.paintedCenterY : customIcon.height / 2 + customIcon.centerErrorY) - root.height / 2 + readonly property real glyphPaintedWidth: glyph.visible ? glyph.paintedWidth : 0 + readonly property real glyphPaintedHeight: glyph.visible ? glyph.paintedHeight : 0 readonly property real glyphBaselineY: glyph.visible ? glyph.baselineY : 0 readonly property int glyphFontSize: glyph.visible ? glyph.renderedFontSize : 0 + readonly property real componentPaintedWidth: customIcon.visible ? customIcon.paintedWidth : 0 + readonly property real componentPaintedHeight: customIcon.visible ? customIcon.paintedHeight : 0 labelVisible: false - hasVisualContent: text !== "" || iconComponent !== null + hasVisualContent: text !== "" || iconComponent !== null || labelText !== "" fontSize: Style.bar.iconFont fixedWidth: vertical ? -1 : slotSize fixedHeight: vertical ? slotSize : -1 Item { - id: opticalCanvas - // Placed exactly, not anchored: centerIn snaps an even canvas in an odd slot - // to a whole pixel, pulling every glyph off the open-panel underline. + id: content + // Exact coordinates preserve the center of odd-width slots. x: (root.width - width) / 2 y: (root.height - height) / 2 - width: root.opticalSize - height: root.opticalSize + width: root.canvasSize + (label.visible ? label.implicitWidth + Style.space(4) : 0) + height: root.canvasSize - OpticalGlyph { - id: glyph - anchors.fill: parent - visible: root.iconComponent === null - text: root.text - fontFamily: root.fontFamily - fontSize: root.fontSize + Text { + id: label + visible: root.labelText !== "" + textFormat: Text.PlainText + text: root.labelText + font.family: root.fontFamily + font.pixelSize: root.fontSize color: root.active && root.useActiveColor ? root.activeColor : root.foreground - rotation: root.textRotation - debugBounds: root.debugOpticalBounds + y: (parent.height - height) / 2 + + (labelMetrics.descent - labelMetrics.ascent + labelMetrics.tightBoundingRect("0").height) / 2 + renderType: Text.NativeRendering } - Loader { - anchors.fill: parent - visible: root.iconComponent !== null - sourceComponent: root.iconComponent + FontMetrics { + id: labelMetrics + font: label.font } - Rectangle { - visible: root.debugOpticalBounds && root.iconComponent !== null - anchors.fill: parent - color: "transparent" - border.width: 1 - border.color: "#4488ff" + Item { + id: opticalCanvas + x: label.visible ? label.implicitWidth + Style.space(4) : 0 + width: root.canvasSize + height: root.canvasSize + + OpticalGlyph { + id: glyph + anchors.fill: parent + visible: root.iconComponent === null + text: root.text + fontFamily: root.fontFamily + fontSize: root.fontSize + paintedSize: root.visualIconSize + color: root.active && root.useActiveColor ? root.activeColor : root.foreground + rotation: root.textRotation + debugBounds: root.debugOpticalBounds + } + + BarIconContent { + id: customIcon + anchors.fill: parent + visible: root.iconComponent !== null + sourceComponent: root.iconComponent + sourceSize: root.opticalSize + } + + Rectangle { + visible: root.debugOpticalBounds && root.iconComponent !== null + anchors.fill: parent + color: "transparent" + border.width: 1 + border.color: "#4488ff" + } } } diff --git a/shell/Ui/BarIconContent.qml b/shell/Ui/BarIconContent.qml new file mode 100644 index 00000000..61bd4b71 --- /dev/null +++ b/shell/Ui/BarIconContent.qml @@ -0,0 +1,38 @@ +import QtQuick +import qs.Commons + +Item { + id: root + + property Component sourceComponent: null + property real sourceSize: Style.bar.iconCanvas + readonly property rect sourceBounds: bounds.mappedBounds + readonly property real contentScale: sourceBounds.width > 0 && sourceBounds.height > 0 + ? Style.bar.iconSize / Math.max(sourceBounds.width, sourceBounds.height) : 1 + readonly property real paintedWidth: sourceBounds.width * contentScale + readonly property real paintedHeight: sourceBounds.height * contentScale + readonly property real centerErrorX: content.x + (sourceBounds.x + sourceBounds.width / 2) * contentScale - width / 2 + readonly property real centerErrorY: content.y + (sourceBounds.y + sourceBounds.height / 2) * contentScale - height / 2 + + Loader { + id: content + sourceComponent: root.sourceComponent + onLoaded: { + // Keep declared dimensions. Empty wrapper items receive a source canvas. + if (!(item instanceof Image)) { + if (item.width <= 0) item.width = Qt.binding(function() { return root.sourceSize }) + if (item.height <= 0) item.height = Qt.binding(function() { return root.sourceSize }) + } + } + transformOrigin: Item.TopLeft + scale: root.contentScale + x: root.width / 2 - (root.sourceBounds.x + root.sourceBounds.width / 2) * scale + y: root.height / 2 - (root.sourceBounds.y + root.sourceBounds.height / 2) * scale + } + + IconContentBounds { + id: bounds + sourceItem: content.item + boundsParent: content + } +} diff --git a/shell/Ui/BarIndicator.qml b/shell/Ui/BarIndicator.qml index 73f7480f..07c70b52 100644 --- a/shell/Ui/BarIndicator.qml +++ b/shell/Ui/BarIndicator.qml @@ -46,7 +46,6 @@ BarIconButton { useActiveColor: false maintainIndicatorReveal: indicatorBlock === "inactive" revealHost: indicatorHost - fontSize: Style.font.caption horizontalMargin: 5 verticalPadding: 5 fixedWidth: vertical ? -1 : Style.bar.statusSlot diff --git a/shell/Ui/IconContentBounds.qml b/shell/Ui/IconContentBounds.qml new file mode 100644 index 00000000..1c41e6f0 --- /dev/null +++ b/shell/Ui/IconContentBounds.qml @@ -0,0 +1,115 @@ +import QtQuick +import QtQuick.Shapes + +Item { + id: root + + property Item sourceItem: null + property Item boundsParent: null + property rect mappedBounds: Qt.rect(0, 0, 0, 0) + visible: false + + readonly property bool textItem: sourceItem instanceof Text + readonly property bool imageItem: sourceItem instanceof Image + readonly property bool boxItem: sourceItem instanceof Rectangle + || sourceItem instanceof Shape || sourceItem instanceof Canvas || sourceItem instanceof ShaderEffect + + function unionBounds(a, b) { + if (b.width <= 0 || b.height <= 0) return a + if (a.width <= 0 || a.height <= 0) return b + var x = Math.min(a.x, b.x) + var y = Math.min(a.y, b.y) + return Qt.rect(x, y, Math.max(a.x + a.width, b.x + b.width) - x, + Math.max(a.y + a.height, b.y + b.height) - y) + } + + readonly property rect paintedBounds: { + var item = sourceItem + var bounds = Qt.rect(0, 0, 0, 0) + if (!item || !item.visible || item.opacity <= 0) return bounds + if (textItem) { + var ink = textMetrics.tightBoundingRect + bounds = Qt.rect(ink.x, item.baselineOffset + ink.y, ink.width, ink.height) + } else if (imageItem) { + var w = item.paintedWidth + var h = item.paintedHeight + var x = item.horizontalAlignment === Image.AlignLeft ? 0 + : (item.horizontalAlignment === Image.AlignRight ? item.width - w : (item.width - w) / 2) + var y = item.verticalAlignment === Image.AlignTop ? 0 + : (item.verticalAlignment === Image.AlignBottom ? item.height - h : (item.height - h) / 2) + if (item.fillMode === Image.PreserveAspectFit) { + bounds = Qt.rect(x, y, w, h) + } else if (item.fillMode === Image.Pad) { + var left = Math.max(0, x) + var top = Math.max(0, y) + bounds = Qt.rect(left, top, Math.max(0, Math.min(item.width, x + w) - left), + Math.max(0, Math.min(item.height, y + h) - top)) + } else { + bounds = Qt.rect(0, 0, item.width, item.height) + } + } else if (boxItem) { + bounds = Qt.rect(0, 0, item.width, item.height) + } + for (var i = 0; i < childrenBounds.count; i++) { + var child = childrenBounds.itemAt(i) + if (child) bounds = unionBounds(bounds, child.mappedBounds) + } + if (item.clip && bounds.width > 0 && bounds.height > 0) { + var left = Math.max(0, bounds.x) + var top = Math.max(0, bounds.y) + bounds = Qt.rect(left, top, + Math.max(0, Math.min(item.width, bounds.x + bounds.width) - left), + Math.max(0, Math.min(item.height, bounds.y + bounds.height) - top)) + } + return bounds + } + + function updateMappedBounds() { + var b = paintedBounds + mappedBounds = sourceItem && boundsParent && b.width > 0 && b.height > 0 + ? sourceItem.mapToItem(boundsParent, b.x, b.y, b.width, b.height) + : b + } + + function configureSource() { + if (sourceItem instanceof Text) sourceItem.renderType = Text.CurveRendering + updateMappedBounds() + } + + onPaintedBoundsChanged: updateMappedBounds() + onSourceItemChanged: configureSource() + onBoundsParentChanged: updateMappedBounds() + Component.onCompleted: updateMappedBounds() + + TextMetrics { + id: textMetrics + font: root.textItem ? root.sourceItem.font : Qt.font({ pixelSize: 12 }) + text: root.textItem ? root.sourceItem.text : "" + } + + Connections { + target: root.sourceItem + function onXChanged() { root.updateMappedBounds() } + function onYChanged() { root.updateMappedBounds() } + function onWidthChanged() { root.updateMappedBounds() } + function onHeightChanged() { root.updateMappedBounds() } + function onScaleChanged() { root.updateMappedBounds() } + function onRotationChanged() { root.updateMappedBounds() } + function onTransformOriginChanged() { root.updateMappedBounds() } + } + + Repeater { + id: childrenBounds + model: root.sourceItem ? root.sourceItem.children : [] + delegate: Loader { + id: childLoader + required property var modelData + readonly property rect mappedBounds: item ? item.mappedBounds : Qt.rect(0, 0, 0, 0) + source: Qt.resolvedUrl("IconContentBounds.qml") + onLoaded: { + item.sourceItem = Qt.binding(function() { return childLoader.modelData }) + item.boundsParent = Qt.binding(function() { return root.sourceItem }) + } + } + } +} diff --git a/shell/Ui/OpticalGlyph.qml b/shell/Ui/OpticalGlyph.qml index d3751c51..ea2fdeba 100644 --- a/shell/Ui/OpticalGlyph.qml +++ b/shell/Ui/OpticalGlyph.qml @@ -8,14 +8,22 @@ Item { property string text: "" property string fontFamily: Style.font.family property real fontSize: Style.font.body + property real paintedSize: 0 property color color: Commons.Color.foreground property bool debugBounds: false readonly property int renderedFontSize: Math.max(1, Math.round(fontSize)) readonly property real tightWidth: Math.max(1, glyphMetrics.tightBoundingRect.width) + readonly property real tightHeight: Math.max(1, glyphMetrics.tightBoundingRect.height) + readonly property real paintedScale: paintedSize > 0 + ? Math.min(paintedSize, width, height) / Math.max(tightWidth, tightHeight) + : 1 + readonly property real paintedWidth: tightWidth * paintedScale + readonly property real paintedHeight: tightHeight * paintedScale readonly property real horizontalCorrection: glyph.implicitWidth / 2 - (glyphMetrics.tightBoundingRect.x + tightWidth / 2) - readonly property real paintedCenterX: glyph.x + glyphMetrics.tightBoundingRect.x + tightWidth / 2 - readonly property real baselineY: glyph.y + glyph.baselineOffset + readonly property real paintedCenterX: glyph.x + (glyphMetrics.tightBoundingRect.x + tightWidth / 2) * paintedScale + readonly property real paintedCenterY: glyph.y + (glyph.baselineOffset + glyphMetrics.tightBoundingRect.y + tightHeight / 2) * paintedScale + readonly property real baselineY: glyph.y + glyph.baselineOffset * paintedScale TextMetrics { id: glyphMetrics @@ -27,17 +35,18 @@ Item { Text { id: glyph textFormat: Text.PlainText - // Keep the shared line box and baseline intact. Correcting only the - // horizontal painted bounds avoids per-glyph vertical drift. - // The correction is fractional, so x is set exactly: centerIn would snap it - // to a whole pixel and undo it. y still snaps, keeping the baseline crisp. - x: (root.width - width) / 2 + root.horizontalCorrection - y: Math.round((root.height - height) / 2) + // Bar icons fit their painted bounds. Text labels retain their line box. + x: root.width / 2 - (glyphMetrics.tightBoundingRect.x + root.tightWidth / 2) * root.paintedScale + y: root.paintedSize > 0 + ? root.height / 2 - (baselineOffset + glyphMetrics.tightBoundingRect.y + root.tightHeight / 2) * root.paintedScale + : Math.round((root.height - height) / 2) text: root.text color: root.color font.family: root.fontFamily font.pixelSize: root.renderedFontSize - renderType: Text.NativeRendering + transformOrigin: Item.TopLeft + scale: root.paintedScale + renderType: root.paintedSize > 0 ? Text.CurveRendering : Text.NativeRendering } Rectangle { diff --git a/shell/Ui/WidgetButton.qml b/shell/Ui/WidgetButton.qml index 9267f1d5..c27e21aa 100644 --- a/shell/Ui/WidgetButton.qml +++ b/shell/Ui/WidgetButton.qml @@ -65,11 +65,31 @@ Item { // Width of the painted label, for bar chrome that wants to line up with the // text rather than with the slot it sits in. Zero on icon-only buttons. readonly property real labelWidth: label.visible ? label.implicitWidth : 0 + readonly property bool iconLabel: labelVisible && isIconGlyph(text) + readonly property real iconPaintedWidth: iconLabel && iconLoader.item ? iconLoader.item.paintedWidth : 0 + readonly property real iconPaintedHeight: iconLabel && iconLoader.item ? iconLoader.item.paintedHeight : 0 + readonly property real iconCenterErrorX: iconLabel && iconLoader.item + ? iconLoader.x + iconLoader.item.paintedCenterX - width / 2 : 0 + readonly property real iconCenterErrorY: iconLabel && iconLoader.item + ? iconLoader.y + iconLoader.item.paintedCenterY - height / 2 : 0 + + function isIconGlyph(value) { + var text = String(value || "") + if (!text) return false + var code = text.codePointAt(0) + if (text.length !== (code > 0xffff ? 2 : 1)) return false + return (code >= 0xe000 && code <= 0xf8ff) + || (code >= 0xf0000 && code <= 0xffffd) + || (code >= 0x100000 && code <= 0x10fffd) + || (code >= 0x2190 && code <= 0x2bff) + } visible: hasVisualContent || keepSpace opacity: !hasVisualContent || concealed ? 0 : (dimmed ? 0.45 : 1) - implicitWidth: fixedWidth > 0 ? fixedWidth : (vertical ? barSize : Math.max(12, label.implicitWidth + scaledHorizontalMargin * 2)) - implicitHeight: fixedHeight > 0 ? fixedHeight : (vertical ? Math.max(12, label.implicitHeight + scaledVerticalPadding * 2) : barSize) + implicitWidth: fixedWidth > 0 ? fixedWidth + : (vertical ? barSize : (iconLabel ? Style.bar.iconSlot : Math.max(12, label.implicitWidth + scaledHorizontalMargin * 2))) + implicitHeight: fixedHeight > 0 ? fixedHeight + : (vertical ? (iconLabel ? Style.bar.iconSlot : Math.max(12, label.implicitHeight + scaledVerticalPadding * 2)) : barSize) Behavior on opacity { NumberAnimation { duration: Style.duration(140); easing.type: Easing.OutCubic } @@ -78,7 +98,7 @@ Item { Text { id: label textFormat: Text.PlainText - visible: root.labelVisible + visible: root.labelVisible && !root.iconLabel anchors.centerIn: parent // Centering the line box puts the figures above the bar's middle, since // the box keeps room for descenders. Center the figure height instead, so @@ -106,6 +126,25 @@ Item { font: label.font } + Loader { + id: iconLoader + active: root.iconLabel + width: Style.bar.iconCanvas + height: Style.bar.iconCanvas + x: (root.width - width) / 2 + y: (root.height - height) / 2 + sourceComponent: Component { + OpticalGlyph { + text: root.text + fontFamily: root.fontFamily + fontSize: root.fontSize + paintedSize: Style.bar.iconSize + color: root.active && root.useActiveColor ? root.activeColor : root.foreground + rotation: root.textRotation + } + } + } + MouseArea { id: mouseArea anchors.fill: parent diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index 9061ad6a..90f50678 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -2,6 +2,7 @@ module qs.Ui BarIndicator 1.0 BarIndicator.qml BarIconButton 1.0 BarIconButton.qml +BarIconContent 1.0 BarIconContent.qml BarWidget 1.0 BarWidget.qml BackgroundMedia 1.0 BackgroundMedia.qml BorderOverlay 1.0 BorderOverlay.qml @@ -11,6 +12,7 @@ ButtonGroup 1.0 ButtonGroup.qml ConfirmDialog 1.0 ConfirmDialog.qml CursorSurface 1.0 CursorSurface.qml Dropdown 1.0 Dropdown.qml +IconContentBounds 1.0 IconContentBounds.qml KeyboardPanel 1.0 KeyboardPanel.qml MultiSelect 1.0 MultiSelect.qml NumberField 1.0 NumberField.qml diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index 300648bc..c41caba3 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -78,6 +78,16 @@ The `omarchy.indicators` widget loads individual bar indicators from `indicators All widgets work in `top`, `bottom`, `left`, and `right` positions. Popups anchor on the side opposite the bar edge, sliding into the workspace. Vertical bars use 28px width; widgets that show text fall back to compact icon-only forms (e.g. `media` hides its scrolling label). +## Icon geometry + +Use `BarIconButton` from `qs.Ui` for bar icons. It fits the visible glyph bounds to `Style.bar.iconSize` and centers them on both axes. It preserves each glyph's aspect ratio. Set `labelText` for an adjacent text label, such as the battery percentage. + +The `[bar]` section of a theme's `shell.toml` supplies the shared dimensions. `icon-size` sets the visual target, with a default of 12 logical pixels. `icon-canvas` sets the canvas, with a default of 16 logical pixels. `icon-slot` and `status-slot` set the button extent. These dimensions follow the font scale unless `scale-with-font` is false. + +Custom `iconComponent` content also fits the shared target. Its source dimensions can be 15px or another size. The bar measures its glyph, image, or vector bounds and applies one scale without changing its aspect ratio. A source `iconSize` override does not change the rendered target. Image padding remains part of an application's tray asset. + +`WidgetButton` also uses the shared renderer for a single icon glyph. This covers custom command widgets and existing plugins that use `WidgetButton` with a different `fontSize`. Text labels retain their text size. Custom widgets must use `WidgetButton` or `BarIconButton` for this icon contract. + ## Custom user modules The schema accepts arbitrary module ids that you provide. Set `type` to `command` for shell-driven output or `qml` for a custom QML widget. Both still go under `bar.layout.
` in `shell.json`. diff --git a/shell/plugins/bar/widgets/SystemUpdate.qml b/shell/plugins/bar/widgets/SystemUpdate.qml index 2baf6b0d..b509323a 100644 --- a/shell/plugins/bar/widgets/SystemUpdate.qml +++ b/shell/plugins/bar/widgets/SystemUpdate.qml @@ -58,7 +58,6 @@ BarWidget { bar: root.bar text: "\uf021" slotSize: Style.bar.statusSlot - fontSize: Style.font.caption tooltipText: "Pending Omarchy Updates" onPressed: root.runUpdate() } diff --git a/shell/plugins/bar/widgets/Tray.qml b/shell/plugins/bar/widgets/Tray.qml index 628b3c2f..0a49b76d 100644 --- a/shell/plugins/bar/widgets/Tray.qml +++ b/shell/plugins/bar/widgets/Tray.qml @@ -810,9 +810,10 @@ BarWidget { } TrayIcon { - anchors.centerIn: parent - width: Style.space(12) - height: Style.space(12) + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + width: Style.bar.iconSize + height: Style.bar.iconSize icon: trayItemRoot.modelData.icon } diff --git a/shell/plugins/menu/BarWidget.qml b/shell/plugins/menu/BarWidget.qml index 61e5e804..8ade7d50 100644 --- a/shell/plugins/menu/BarWidget.qml +++ b/shell/plugins/menu/BarWidget.qml @@ -8,14 +8,12 @@ BarWidget { implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight - WidgetButton { + BarIconButton { id: button anchors.fill: parent bar: root.bar text: "\ue900" fontFamily: "omarchy" - centerFigures: false - horizontalMargin: 7.5 onPressed: function(button) { if (!root.bar) return if (button === Qt.RightButton) root.bar.run("xdg-terminal-exec") diff --git a/shell/plugins/panels/dropbox/DropboxIcon.qml b/shell/plugins/panels/dropbox/DropboxIcon.qml index 488346bc..5f0d411d 100644 --- a/shell/plugins/panels/dropbox/DropboxIcon.qml +++ b/shell/plugins/panels/dropbox/DropboxIcon.qml @@ -7,11 +7,13 @@ Item { id: root property real iconSize: Style.font.icon + readonly property real aspectRatio: 1.18 + property real artworkScale: 0.95 property color color: Commons.Color.foreground - width: iconSize * 1.18 + width: iconSize * aspectRatio height: iconSize - implicitWidth: iconSize * 1.18 + implicitWidth: iconSize * aspectRatio implicitHeight: iconSize Shape { @@ -19,7 +21,7 @@ Item { antialiasing: true layer.enabled: true layer.samples: 4 - scale: 0.95 + scale: root.artworkScale Tile { cx: root.width * 0.25; cy: root.height * 0.188 } Tile { cx: root.width * 0.75; cy: root.height * 0.188 } diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index 583bfa83..4fd44f84 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -175,8 +175,10 @@ Panel { iconComponent: Component { Item { DropboxIcon { - anchors.centerIn: parent - iconSize: Style.space(12) + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + iconSize: button.iconSize / aspectRatio + artworkScale: 1 color: root.barIconColor opacity: dropbox.active ? 1.0 : 0.6 } diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index 6a69fb26..0e60ed7f 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -278,9 +278,8 @@ Panel { id: button anchors.fill: parent bar: root.bar - text: root.showPercentage && !vertical - ? Math.round(root.batteryFraction * 100) + "% " + root.batteryIcon() - : root.batteryIcon() + text: root.batteryIcon() + labelText: root.showPercentage && !vertical ? Math.round(root.batteryFraction * 100) + "%" : "" slotSize: Style.bar.iconSlot * (root.showPercentage && !vertical ? 2 : 1) tooltipText: "" onPressed: function(b) { diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index ae8a323b..21954587 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -385,8 +385,9 @@ Panel { iconComponent: Component { Item { TailscaleIcon { - anchors.centerIn: parent - iconSize: Style.space(11) + x: (parent.width - width) / 2 + y: (parent.height - height) / 2 + iconSize: button.iconSize color: root.barIconColor badgeColor: root.urgent crossed: !tailscale.active && !tailscale.needsLogin diff --git a/test/shell.d/bar-icon-geometry-test.sh b/test/shell.d/bar-icon-geometry-test.sh index 626bf405..b5c6fecf 100644 --- a/test/shell.d/bar-icon-geometry-test.sh +++ b/test/shell.d/bar-icon-geometry-test.sh @@ -16,6 +16,8 @@ trap 'rm -rf "$test_tmp"' EXIT ln -s "$ROOT/shell/Ui" "$test_tmp/Ui" ln -s "$ROOT/shell/Commons" "$test_tmp/Commons" +ln -s "$ROOT/shell/plugins/panels/dropbox/DropboxIcon.qml" "$test_tmp/DropboxIcon.qml" +ln -s "$ROOT/shell/plugins/panels/tailscale/TailscaleIcon.qml" "$test_tmp/TailscaleIcon.qml" cat >"$test_tmp/shell.qml" <<'QML' import QtQuick @@ -25,12 +27,30 @@ import qs.Ui ShellRoot { id: root + property real customWidth: 15 + property bool completed: false + property bool testsStarted: false + property int customRenderType: -1 + readonly property bool imageReady: customImage.componentPaintedWidth > 0 && croppedImage.componentPaintedWidth > 0 + readonly property string imageSource: "data:image/svg+xml;base64," + Qt.btoa(''.split("").map(function(c) { return c.charCodeAt(0) })) function fail(message) { console.log("RESULT fail " + message) Qt.quit() } + function checkPaintedBounds(icon, name) { + if (Math.abs(icon.opticalCenterErrorX) > 0.05 || Math.abs(icon.opticalCenterErrorY) > 0.05) { + fail(name + " painted bounds are off center: " + icon.opticalCenterErrorX + ", " + icon.opticalCenterErrorY) + return false + } + if (Math.abs(Math.max(icon.glyphPaintedWidth, icon.glyphPaintedHeight) - Style.bar.iconSize) > 0.05) { + fail(name + " painted size is " + icon.glyphPaintedWidth + " × " + icon.glyphPaintedHeight) + return false + } + return true + } + function checkIcon(icon, name) { if (icon.implicitWidth !== Style.bar.iconSlot) { fail(name + " slot width is " + icon.implicitWidth) @@ -40,68 +60,144 @@ ShellRoot { fail(name + " optical canvas is " + icon.opticalSize) return false } - // The open-panel underline centers on the slot, so the glyph must too. - if (Math.abs(icon.opticalCenterErrorX) > 0.05) { - fail(name + " painted bounds are off the slot's center by " + icon.opticalCenterErrorX) - return false - } if (icon.glyphFontSize !== Style.bar.iconFont) { fail(name + " font size is " + icon.glyphFontSize) return false } + return checkPaintedBounds(icon, name) + } + + function checkGeometry() { + if (!checkIcon(bluetooth, "bluetooth")) return false + if (!checkIcon(network, "network")) return false + if (!checkIcon(audio, "audio")) return false + if (!checkIcon(monitor, "monitor")) return false + if (!checkIcon(power, "power")) return false + if (!checkIcon(elsewhen, "elsewhen")) return false + if (!checkIcon(menu, "menu")) return false + var sourceRatio = sourceMetrics.tightBoundingRect.width / sourceMetrics.tightBoundingRect.height + if (Math.abs(bluetooth.glyphPaintedWidth / bluetooth.glyphPaintedHeight - sourceRatio) > 0.01) { + fail("the glyph's aspect ratio changes") + return false + } + if (!checkPaintedBounds(smallFont, "small source font")) return false + if (!checkPaintedBounds(largeFont, "large source font")) return false + if (!checkPaintedBounds(customSize, "15px size override")) return false + if (!checkPaintedBounds(smallCanvas, "small source canvas")) return false + if (!legacyIcon.iconLabel + || Math.abs(Math.max(legacyIcon.iconPaintedWidth, legacyIcon.iconPaintedHeight) - Style.bar.iconSize) > 0.05 + || Math.abs(legacyIcon.iconCenterErrorX) > 0.05 || Math.abs(legacyIcon.iconCenterErrorY) > 0.05) { + fail("a custom WidgetButton does not fit: " + legacyIcon.iconLabel + " " + + legacyIcon.iconPaintedWidth + " × " + legacyIcon.iconPaintedHeight + " " + + legacyIcon.iconCenterErrorX + ", " + legacyIcon.iconCenterErrorY) + return false + } + if (textLabel.iconLabel || textLabel.fontSize !== 15) { + fail("icon normalization changes an ordinary text label") + return false + } + if (customRenderType !== Text.CurveRendering) { + fail("custom glyphs do not use the scalable renderer") + return false + } + var customIcons = [customBox, nestedBox, customGlyph, rotatedBox, customImage, croppedImage] + for (var i = 0; i < customIcons.length; i++) { + var custom = customIcons[i] + if (Math.abs(Math.max(custom.componentPaintedWidth, custom.componentPaintedHeight) - Style.bar.iconSize) > 0.05 + || Math.abs(custom.opticalCenterErrorX) > 0.05 || Math.abs(custom.opticalCenterErrorY) > 0.05) { + fail("custom component " + i + " does not fit: " + custom.componentPaintedWidth + " × " + + custom.componentPaintedHeight + " " + custom.opticalCenterErrorX + ", " + custom.opticalCenterErrorY) + return false + } + } + if (Math.abs(customBox.componentPaintedWidth / customBox.componentPaintedHeight - root.customWidth / 9) > 0.01 + || Math.abs(rotatedBox.componentPaintedWidth / rotatedBox.componentPaintedHeight - 9 / 15) > 0.01 + || Math.abs(customImage.componentPaintedWidth / customImage.componentPaintedHeight - 15 / 9) > 0.01 + || Math.abs(croppedImage.componentPaintedWidth / croppedImage.componentPaintedHeight - 1) > 0.01) { + fail("a custom component's aspect ratio changes") + return false + } + if (!checkPaintedBounds(labelledPower, "battery percentage")) return false + if (Math.abs(labelledPower.glyphPaintedWidth - power.glyphPaintedWidth) > 0.05 + || Math.abs(labelledPower.glyphPaintedHeight - power.glyphPaintedHeight) > 0.05) { + fail("the battery percentage changes the icon's size") + return false + } + if (vector.implicitWidth !== Style.bar.iconSlot || vector.opticalSize !== Style.bar.iconCanvas) { + fail("vector icon does not share glyph geometry") + return false + } + if (verticalIcon.implicitWidth !== Style.bar.sizeVertical || verticalIcon.implicitHeight !== Style.bar.iconSlot) { + fail("vertical icon does not use the shared slot") + return false + } + if (!checkPaintedBounds(verticalIcon, "vertical icon")) return false + if (!checkPaintedBounds(horizontalIndicator, "horizontal indicator")) return false + if (!checkPaintedBounds(verticalIndicator, "vertical indicator")) return false + if (verticalIndicator.implicitWidth !== Style.bar.sizeVertical || verticalIndicator.implicitHeight >= Style.bar.iconSlot) { + fail("vertical indicator does not retain compact spacing") + return false + } + if (horizontalIndicator.implicitWidth >= Style.bar.iconSlot) { + fail("horizontal indicator does not retain compact spacing") + return false + } + if (horizontalIndicatorPair.implicitWidth >= Style.bar.iconSlot * 2 + || verticalIndicatorPair.implicitHeight >= Style.bar.iconSlot * 2) { + fail("indicator groups do not retain compact internal spacing") + return false + } + if (compactStatusIcon.implicitWidth !== Style.bar.statusSlot + || compactVerticalStatusIcon.implicitHeight !== Style.bar.statusSlot) { + fail("compact status icons do not use the shared status slot") + return false + } + if (!checkPaintedBounds(compactStatusIcon, "compact status icon")) return false + if (!checkPaintedBounds(compactVerticalStatusIcon, "compact vertical status icon")) return false + if (!checkPaintedBounds(rotatedIcon, "rotated icon")) return false + if (Math.abs(Math.max(dropbox.width, dropbox.height) - Style.bar.iconSize) > 0.05 + || Math.abs(Math.max(tailscale.width, tailscale.height) - Style.bar.iconSize) > 0.05) { + fail("vector icons do not use the shared visual size") + return false + } return true } - Component.onCompleted: Qt.callLater(function() { - if (!checkIcon(bluetooth, "bluetooth")) return - if (!checkIcon(network, "network")) return - if (!checkIcon(audio, "audio")) return - if (!checkIcon(monitor, "monitor")) return - if (!checkIcon(power, "power")) return - if (!checkIcon(elsewhen, "elsewhen")) return - var baseline = bluetooth.glyphBaselineY - if (network.glyphBaselineY !== baseline || audio.glyphBaselineY !== baseline - || monitor.glyphBaselineY !== baseline || power.glyphBaselineY !== baseline) { - fail("glyph baselines do not match") - return - } - if (vector.implicitWidth !== Style.bar.iconSlot || vector.opticalSize !== Style.bar.iconCanvas) { - fail("vector icon does not share glyph geometry") - return - } - if (verticalIcon.implicitWidth !== Style.bar.sizeVertical || verticalIcon.implicitHeight !== Style.bar.iconSlot) { - fail("vertical icon does not use the shared slot") - return - } - if (verticalIndicator.implicitWidth !== Style.bar.sizeVertical || verticalIndicator.implicitHeight >= Style.bar.iconSlot) { - fail("vertical indicator does not retain compact spacing") - return - } - if (verticalIndicator.glyphFontSize !== Style.font.caption) { - fail("indicator does not use the secondary icon scale") - return - } - if (Math.abs(verticalIndicator.opticalCenterErrorX) > 0.05) { - fail("vertical indicator is not optically centered") - return - } - if (horizontalIndicator.implicitWidth >= Style.bar.iconSlot) { - fail("horizontal indicator does not retain compact spacing") - return - } - if (horizontalIndicatorPair.implicitWidth >= Style.bar.iconSlot * 2 - || verticalIndicatorPair.implicitHeight >= Style.bar.iconSlot * 2) { - fail("indicator groups do not retain compact internal spacing") - return - } - if (compactStatusIcon.implicitWidth !== Style.bar.statusSlot - || compactVerticalStatusIcon.implicitHeight !== Style.bar.statusSlot) { - fail("compact status icons do not use the shared status slot") - return - } - console.log("RESULT pass") - Qt.quit() - }) + function runGeometryTests() { + if (testsStarted) return + testsStarted = true + if (!checkGeometry()) return + var labelBaseline = plainLabel.baselineY + root.customWidth = 23 + Style.applyShellValues({ "font.base-size": "18", "bar.icon-size": "14", "bar.icon-canvas": "19" }) + Qt.callLater(function() { + if (!checkGeometry()) return + if (Style.bar.iconSize !== 21 || Style.bar.iconCanvas !== 29) { + fail("theme icon dimensions do not follow the font scale") + return + } + if (plainLabel.baselineY !== labelBaseline) { + fail("bar icon settings change a text label's baseline") + return + } + Style.applyShellValues({ "font.base-size": "18", "bar.scale-with-font": "false", "bar.icon-size": "20" }) + Qt.callLater(function() { + if (!checkGeometry()) return + if (Style.bar.iconSize !== Style.bar.iconCanvas) { + fail("oversized icons do not fit their canvas") + return + } + console.log("RESULT pass") + Qt.quit() + }) + }) + } + + onImageReadyChanged: if (imageReady && completed) Qt.callLater(runGeometryTests) + Component.onCompleted: { + completed = true + if (imageReady) Qt.callLater(runGeometryTests) + } QtObject { id: testBar @@ -137,6 +233,79 @@ ShellRoot { BarIconButton { id: monitor; bar: testBar; text: "󰍹" } BarIconButton { id: power; bar: testBar; text: "󰁹" } BarIconButton { id: elsewhen; bar: testBar; text: "󰇧" } + TextMetrics { + id: sourceMetrics + font.family: testBar.fontFamily + font.pixelSize: Style.bar.iconFont + text: bluetooth.text + } + BarIconButton { id: menu; bar: testBar; text: "\ue900"; fontFamily: "omarchy" } + BarIconButton { id: smallFont; bar: testBar; text: "\uf053"; fontSize: 8 } + BarIconButton { id: largeFont; bar: testBar; text: "󰍹"; fontSize: 24 } + BarIconButton { id: customSize; bar: testBar; text: "󰍹"; iconSize: 15 } + BarIconButton { id: smallCanvas; bar: testBar; text: "󰍹"; opticalSize: 8 } + WidgetButton { id: legacyIcon; bar: testBar; text: "󰍹"; fontSize: 15 } + WidgetButton { id: textLabel; bar: testBar; text: "GPU"; fontSize: 15 } + BarIconButton { + id: customBox + bar: testBar + iconSize: 15 + iconComponent: Component { Rectangle { width: root.customWidth; height: 9; color: "white" } } + } + BarIconButton { + id: nestedBox + bar: testBar + iconComponent: Component { + Item { + Rectangle { x: 1; y: 5; width: 15; height: 9; color: "white" } + } + } + } + BarIconButton { + id: customGlyph + bar: testBar + iconComponent: Component { + Text { + textFormat: Text.PlainText + text: "\ue900" + font.family: "omarchy" + font.pixelSize: 15 + onRenderTypeChanged: root.customRenderType = renderType + } + } + } + BarIconButton { + id: rotatedBox + bar: testBar + iconComponent: Component { Rectangle { width: 15; height: 9; rotation: 90; color: "white" } } + } + BarIconButton { + id: customImage + bar: testBar + iconComponent: Component { + Image { + fillMode: Image.PreserveAspectFit + source: root.imageSource + } + } + } + BarIconButton { + id: croppedImage + bar: testBar + iconComponent: Component { + Image { + width: 15 + height: 15 + fillMode: Image.PreserveAspectCrop + source: root.imageSource + } + } + } + BarIconButton { id: labelledPower; bar: testBar; text: "󰁹"; labelText: "100%"; slotSize: Style.bar.iconSlot * 2 } + BarIconButton { id: rotatedIcon; bar: verticalBar; text: "\uf053"; textRotation: 90 } + OpticalGlyph { id: plainLabel; width: 30; height: 20; text: "12"; fontSize: 12 } + DropboxIcon { id: dropbox; iconSize: Style.bar.iconSize / aspectRatio; artworkScale: 1 } + TailscaleIcon { id: tailscale; iconSize: Style.bar.iconSize } BarIconButton { id: vector bar: testBar @@ -168,7 +337,7 @@ output=$(timeout 15 env \ if ! grep -q 'RESULT pass' <<<"$output"; then printf '%s\n' "$output" >&2 - fail "bar icons share slot and baseline geometry" + fail "bar icons share visual size and center geometry" fi -pass "bar icons share slot and baseline geometry" +pass "bar icons share visual size and center geometry"