From 20e1f0e0bb79cc796035e38558ea0aa09df7f93a Mon Sep 17 00:00:00 2001 From: end-4 <97237370+end-4@users.noreply.github.com> Date: Wed, 12 Nov 2025 00:09:22 +0100 Subject: [PATCH] taskbar: window previews --- .../ii/assets/icons/fluent/dismiss.svg | 1 + .../ii/modules/waffle/bar/AppButton.qml | 13 +- .../ii/modules/waffle/bar/AppIcon.qml | 8 +- .../ii/modules/waffle/bar/BarButton.qml | 55 +------ .../ii/modules/waffle/bar/TaskAppButton.qml | 18 ++- .../ii/modules/waffle/bar/TaskPreview.qml | 128 +++++++++++++++++ .../ii/modules/waffle/bar/Tasks.qml | 19 ++- .../modules/waffle/bar/WaffleBarContent.qml | 1 - .../ii/modules/waffle/bar/WindowPreview.qml | 135 ++++++++++++++++++ .../modules/waffle/looks/AcrylicRectangle.qml | 63 ++++++++ .../ii/modules/waffle/looks/Looks.qml | 25 ++-- .../quickshell/ii/services/AppSearch.qml | 2 +- 12 files changed, 390 insertions(+), 78 deletions(-) create mode 100644 dots/.config/quickshell/ii/assets/icons/fluent/dismiss.svg create mode 100644 dots/.config/quickshell/ii/modules/waffle/bar/TaskPreview.qml create mode 100644 dots/.config/quickshell/ii/modules/waffle/bar/WindowPreview.qml create mode 100644 dots/.config/quickshell/ii/modules/waffle/looks/AcrylicRectangle.qml diff --git a/dots/.config/quickshell/ii/assets/icons/fluent/dismiss.svg b/dots/.config/quickshell/ii/assets/icons/fluent/dismiss.svg new file mode 100644 index 000000000..3cb3656dc --- /dev/null +++ b/dots/.config/quickshell/ii/assets/icons/fluent/dismiss.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/dots/.config/quickshell/ii/modules/waffle/bar/AppButton.qml b/dots/.config/quickshell/ii/modules/waffle/bar/AppButton.qml index 017d8f931..90d0cc007 100644 --- a/dots/.config/quickshell/ii/modules/waffle/bar/AppButton.qml +++ b/dots/.config/quickshell/ii/modules/waffle/bar/AppButton.qml @@ -10,8 +10,16 @@ BarButton { required property string iconName property bool separateLightDark: false + leftInset: 2 + rightInset: 2 implicitWidth: height - topInset - bottomInset + leftInset + rightInset + onDownChanged: { + scaleAnim.duration = root.down ? 150 : 200 + scaleAnim.easing.bezierCurve = root.down ? Looks.transition.easing.bezierCurve.easeIn : Looks.transition.easing.bezierCurve.easeOut + contentItem.scale = root.down ? 5/6 : 1 // If/When we do dragging, the scale is 1.25 + } + contentItem: Item { id: contentItem anchors.centerIn: root.background @@ -19,12 +27,10 @@ BarButton { implicitHeight: iconWidget.implicitHeight implicitWidth: iconWidget.implicitWidth - scale: root.down ? 5/6 : 1 // If/When we do dragging, the scale is 1.25 Behavior on scale { NumberAnimation { - duration: 90 + id: scaleAnim easing.type: Easing.BezierSpline - easing.bezierCurve: root.down ? Looks.transition.easing.bezierCurve.easeIn : Looks.transition.easing.bezierCurve.easeOut } } @@ -32,6 +38,7 @@ BarButton { id: iconWidget anchors.centerIn: parent iconName: root.iconName + separateLightDark: root.separateLightDark } } } diff --git a/dots/.config/quickshell/ii/modules/waffle/bar/AppIcon.qml b/dots/.config/quickshell/ii/modules/waffle/bar/AppIcon.qml index 02c1da144..fc5c75426 100644 --- a/dots/.config/quickshell/ii/modules/waffle/bar/AppIcon.qml +++ b/dots/.config/quickshell/ii/modules/waffle/bar/AppIcon.qml @@ -5,11 +5,13 @@ import qs.modules.common import qs.modules.waffle.looks Kirigami.Icon { - id: iconWidget + id: root required property string iconName + property bool separateLightDark: false - implicitWidth: 26 - implicitHeight: 26 + property real implicitSize: 26 + implicitWidth: implicitSize + implicitHeight: implicitSize roundToIconSize: false source: `${Looks.iconsPath}/${root.iconName}${!root.separateLightDark ? "" : Looks.dark ? "-dark" : "-light"}.svg` fallback: root.iconName diff --git a/dots/.config/quickshell/ii/modules/waffle/bar/BarButton.qml b/dots/.config/quickshell/ii/modules/waffle/bar/BarButton.qml index 20929b1e6..52e5164aa 100644 --- a/dots/.config/quickshell/ii/modules/waffle/bar/BarButton.qml +++ b/dots/.config/quickshell/ii/modules/waffle/bar/BarButton.qml @@ -11,17 +11,9 @@ Button { Layout.fillHeight: true topInset: 4 bottomInset: 4 - - property color borderColor: ColorUtils.transparentize(Looks.colors.bg1Border, ((root.hovered && !root.down) || root.checked) ? Looks.fluentContentTransparency : 1) - Behavior on borderColor { - animation: Looks.transition.color.createObject(this) - } - onBorderColorChanged: { - borderCanvas.requestPaint(); - } - background: Rectangle { - id: background + background: AcrylicRectangle { + shiny: ((root.hovered && !root.down) || root.checked) color: { if (root.down) { return Looks.colors.bg1Active @@ -31,48 +23,5 @@ Button { return ColorUtils.transparentize(Looks.colors.bg1) } } - radius: Looks.radius.medium - Behavior on color { - animation: Looks.transition.color.createObject(this) - } - - // Top 1px border with color - Canvas { - id: borderCanvas - anchors.fill: parent - onPaint: { - var ctx = getContext("2d"); - ctx.clearRect(0, 0, width, height); - - var borderColor = root.borderColor; - - var r = background.radius; - var fadeLength = Math.max(1, r); - var fadeLengthPercent = fadeLength / width; - - // Compute normalized stops - var leftFadeStop = fadeLengthPercent; - var rightFadeStop = 1 - fadeLengthPercent; - - var grad = ctx.createLinearGradient(0, 0, width, 0); - grad.addColorStop(0, Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0)); - grad.addColorStop(leftFadeStop, borderColor); - grad.addColorStop(rightFadeStop, borderColor); - grad.addColorStop(1, Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0)); - - ctx.strokeStyle = grad; - ctx.lineWidth = 1; - - ctx.beginPath(); - ctx.moveTo(r, 0.5); - ctx.lineTo(width - r, 0.5); - // Top-right curve - ctx.arcTo(width, 0.5, width, r + 0.5, r); - // Top-left curve - ctx.moveTo(width - r, 0.5); - ctx.arcTo(0, 0.5, 0, r + 0.5, r); - ctx.stroke(); - } - } } } diff --git a/dots/.config/quickshell/ii/modules/waffle/bar/TaskAppButton.qml b/dots/.config/quickshell/ii/modules/waffle/bar/TaskAppButton.qml index 241796f0f..7363b0387 100644 --- a/dots/.config/quickshell/ii/modules/waffle/bar/TaskAppButton.qml +++ b/dots/.config/quickshell/ii/modules/waffle/bar/TaskAppButton.qml @@ -3,15 +3,23 @@ import QtQuick.Layouts import qs.services import qs.modules.common import qs.modules.waffle.looks +import Quickshell AppButton { id: root - required property var toplevel - readonly property bool isSeparator: toplevel.appId === "SEPARATOR" - readonly property var desktopEntry: DesktopEntries.heuristicLookup(toplevel.appId) + required property var appEntry + readonly property bool isSeparator: appEntry.appId === "SEPARATOR" + readonly property var desktopEntry: DesktopEntries.heuristicLookup(appEntry.appId) - Layout.fillHeight: true + signal hoverPreviewRequested() - iconName: toplevel.appId + iconName: AppSearch.guessIcon(appEntry.appId) + Timer { + running: root.hovered + interval: 250 + onTriggered: { + root.hoverPreviewRequested() + } + } } diff --git a/dots/.config/quickshell/ii/modules/waffle/bar/TaskPreview.qml b/dots/.config/quickshell/ii/modules/waffle/bar/TaskPreview.qml new file mode 100644 index 000000000..3c03563a3 --- /dev/null +++ b/dots/.config/quickshell/ii/modules/waffle/bar/TaskPreview.qml @@ -0,0 +1,128 @@ +import QtQuick +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.waffle.looks +import Quickshell + +PopupWindow { + id: root + + ///////////////////// Properties //////////////////// + required property bool tasksHovered + property var appEntry + property Item anchorItem + + //////////////////// Functions //////////////////// + function close() { + marginBehavior.enabled = false; + root.visible = false; + } + + function open() { + marginBehavior.enabled = true; + root.visible = true; + } + + function show(appEntry: var, button: Item) { + root.appEntry = appEntry; + root.anchorItem = button; + root.anchor.updateAnchor(); + root.open(); + } + + ///////////////////// Internals ///////////////////// + readonly property bool bottom: Config.options.waffles.bar.bottom + property real visualMargin: 12 + property alias ambientShadowWidth: ambientShadow.border.width + + visible: false + color: "transparent" + implicitWidth: contentItem.implicitWidth + ambientShadowWidth + (visualMargin * 2) + implicitHeight: contentItem.implicitHeight + ambientShadowWidth + (visualMargin * 2) + anchor { + adjustment: PopupAdjustment.Slide + item: root.anchorItem + gravity: bottom ? Edges.Top : Edges.Bottom + edges: bottom ? Edges.Top : Edges.Bottom + } + + Timer { + interval: 250 + running: root.visible && !hoverChecker.containsMouse && !root.tasksHovered + onTriggered: { + root.close(); + } + } + + // Content + MouseArea { + id: hoverChecker + anchors.fill: parent + hoverEnabled: true + + // Shadow + Rectangle { + id: ambientShadow + anchors { + fill: contentItem + margins: -border.width + } + border.color: ColorUtils.transparentize(Looks.colors.bg0Border, Looks.contentTransparency) + border.width: 1 + color: "transparent" + radius: Looks.radius.large + border.width + } + + Rectangle { + id: contentItem + property real sourceEdgeMargin: root.visible ? (root.ambientShadowWidth + root.visualMargin) : -root.implicitHeight + Behavior on sourceEdgeMargin { + id: marginBehavior + animation: Looks.transition.enter.createObject(this) + } + anchors { + left: parent.left + right: parent.right + top: root.bottom ? undefined : parent.top + bottom: root.bottom ? parent.bottom : undefined + margins: root.ambientShadowWidth + root.visualMargin + // Opening anim + bottomMargin: root.bottom ? sourceEdgeMargin : (root.ambientShadowWidth + root.visualMargin) + topMargin: root.bottom ? (root.ambientShadowWidth + root.visualMargin) : sourceEdgeMargin + } + color: Looks.colors.bg1 + radius: Looks.radius.large + + layer.enabled: true + layer.effect: OpacityMask { + maskSource: Rectangle { + width: contentItem.width + height: contentItem.height + radius: contentItem.radius + } + } + + // Testing + implicitHeight: Math.min(158, windowsRow.implicitHeight) + implicitWidth: windowsRow.implicitWidth + + RowLayout { + id: windowsRow + anchors.fill: parent + + Repeater { + model: ScriptModel { + values: root.appEntry?.toplevels ?? [] + } + delegate: WindowPreview { + required property var modelData + toplevel: modelData + } + } + } + } + } +} diff --git a/dots/.config/quickshell/ii/modules/waffle/bar/Tasks.qml b/dots/.config/quickshell/ii/modules/waffle/bar/Tasks.qml index ba8944999..eac963722 100644 --- a/dots/.config/quickshell/ii/modules/waffle/bar/Tasks.qml +++ b/dots/.config/quickshell/ii/modules/waffle/bar/Tasks.qml @@ -5,30 +5,41 @@ import qs.services import qs.modules.common import qs.modules.waffle.looks -Item { +MouseArea { id: root Layout.fillHeight: true implicitHeight: row.implicitHeight implicitWidth: row.implicitWidth + hoverEnabled: true // Apps row RowLayout { id: row anchors.fill: parent - spacing: 4 + spacing: 0 Repeater { + // TODO: Include only apps (and windows) in current workspace only model: ScriptModel { objectProp: "appId" values: TaskbarApps.apps.filter(app => app.appId !== "SEPARATOR") } delegate: TaskAppButton { required property var modelData - toplevel: modelData + appEntry: modelData + + onHoverPreviewRequested: { + previewPopup.show(appEntry, this) + } } } } - // TODO: Previews popup + // Previews popup + TaskPreview { + id: previewPopup + tasksHovered: root.containsMouse + anchor.window: root.QsWindow.window + } } diff --git a/dots/.config/quickshell/ii/modules/waffle/bar/WaffleBarContent.qml b/dots/.config/quickshell/ii/modules/waffle/bar/WaffleBarContent.qml index 1a9763616..5228abb5c 100644 --- a/dots/.config/quickshell/ii/modules/waffle/bar/WaffleBarContent.qml +++ b/dots/.config/quickshell/ii/modules/waffle/bar/WaffleBarContent.qml @@ -36,7 +36,6 @@ Rectangle { BarGroupRow { id: appsRow - spacing: 4 anchors.left: undefined anchors.horizontalCenter: parent.horizontalCenter diff --git a/dots/.config/quickshell/ii/modules/waffle/bar/WindowPreview.qml b/dots/.config/quickshell/ii/modules/waffle/bar/WindowPreview.qml new file mode 100644 index 000000000..b1944c350 --- /dev/null +++ b/dots/.config/quickshell/ii/modules/waffle/bar/WindowPreview.qml @@ -0,0 +1,135 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Qt5Compat.GraphicalEffects +import qs.services +import qs.modules.common +import qs.modules.common.functions +import qs.modules.waffle.looks +import Quickshell +import Quickshell.Wayland + +Button { + id: root + + required property var toplevel + property real previewWidthConstraint: 200 + property real previewHeightConstraint: 110 + padding: 5 + Layout.fillHeight: true + + onClicked: { + root.toplevel.activate(); // TODO: make this work with those who disable focus on activate because telegram is abusive + } + + background: Rectangle { + id: background + radius: Looks.radius.medium + color: root.down ? Looks.colors.bg2Active : (root.hovered ? Looks.colors.bg2Hover : ColorUtils.transparentize(Looks.colors.bg2)) + Behavior on color { + animation: Looks.transition.color.createObject(this) + } + } + + contentItem: ColumnLayout { + id: contentItem + anchors.fill: parent + anchors.margins: root.padding + spacing: 5 + + RowLayout { + Layout.fillWidth: true + Layout.fillHeight: false + spacing: 8 + + AppIcon { + id: appIcon + Layout.leftMargin: Looks.radius.large - root.padding + 2 + Layout.alignment: Qt.AlignVCenter + iconName: AppSearch.guessIcon(root.toplevel.appId) + implicitSize: 16 + } + + Item { + id: appTitleContainer + Layout.fillWidth: true + Layout.fillHeight: true + implicitHeight: closeButton.implicitHeight // Enforce height, because closeButton doesn't contribute when it's invisible + WText { + id: appTitleText + anchors.fill: parent + text: root.toplevel.title + elide: Text.ElideRight + font.pixelSize: Looks.font.pixelSize.large + font.weight: Looks.font.weight.thin + color: Looks.colors.fg1 + } + } + + CloseButton { + id: closeButton + } + } + + Item { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.margins: Looks.radius.large - root.padding + Layout.topMargin: 0 + implicitWidth: Math.max(screencopyView.implicitWidth, 80) + implicitHeight: screencopyView.implicitHeight + + ScreencopyView { + id: screencopyView + anchors.centerIn: parent + captureSource: root.toplevel + live: true + paintCursor: true + constraintSize: Qt.size(root.previewWidthConstraint, root.previewHeightConstraint) + } + } + } + + component CloseButton: Button { + id: reusableCloseButton + visible: root.hovered + Layout.leftMargin: 4 + implicitHeight: 30 + implicitWidth: 30 + onClicked: { + root.toplevel.close(); + } + + Rectangle { + z: 0 + color: "transparent" + anchors.fill: closeButtonBg + anchors.margins: -1 + opacity: closeButtonBg.opacity + border.width: 1 + radius: closeButtonBg.radius + 1 + border.color: Looks.colors.bg2Border + } + + background: Rectangle { + id: closeButtonBg + z: 1 + opacity: reusableCloseButton.hovered ? 1 : 0 + radius: Looks.radius.large - root.padding + color: reusableCloseButton.pressed ? Looks.colors.dangerActive : Looks.colors.danger + Behavior on opacity { + animation: Looks.transition.opacity.createObject(this) + } + Behavior on color { + animation: Looks.transition.color.createObject(this) + } + } + + contentItem: FluentIcon { + z: 2 + anchors.centerIn: parent + icon: "dismiss" + implicitSize: 10 + } + } +} diff --git a/dots/.config/quickshell/ii/modules/waffle/looks/AcrylicRectangle.qml b/dots/.config/quickshell/ii/modules/waffle/looks/AcrylicRectangle.qml new file mode 100644 index 000000000..3720d6186 --- /dev/null +++ b/dots/.config/quickshell/ii/modules/waffle/looks/AcrylicRectangle.qml @@ -0,0 +1,63 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import qs.modules.common +import qs.modules.common.functions +import qs.modules.waffle.looks + +Rectangle { + id: root + + property bool shiny: true // Top border + property color borderColor: ColorUtils.transparentize(Looks.colors.bg1Border, shiny ? Looks.contentTransparency : 1) + color: Looks.colors.bg1Hover + radius: Looks.radius.medium + Behavior on color { + animation: Looks.transition.color.createObject(this) + } + Behavior on borderColor { + animation: Looks.transition.color.createObject(this) + } + onBorderColorChanged: { + borderCanvas.requestPaint(); + } + + // Top 1px border with color + Canvas { + id: borderCanvas + anchors.fill: parent + onPaint: { + var ctx = getContext("2d"); + ctx.clearRect(0, 0, width, height); + + var borderColor = root.borderColor; + + var r = root.radius; + var fadeLength = Math.max(1, r); + var fadeLengthPercent = fadeLength / width; + + // Compute normalized stops + var leftFadeStop = fadeLengthPercent; + var rightFadeStop = 1 - fadeLengthPercent; + + var grad = ctx.createLinearGradient(0, 0, width, 0); + grad.addColorStop(0, Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0)); + grad.addColorStop(leftFadeStop, borderColor); + grad.addColorStop(rightFadeStop, borderColor); + grad.addColorStop(1, Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0)); + + ctx.strokeStyle = grad; + ctx.lineWidth = 1; + + ctx.beginPath(); + ctx.moveTo(r, 0.5); + ctx.lineTo(width - r, 0.5); + // Top-right curve + ctx.arcTo(width, 0.5, width, r + 0.5, r); + // Top-left curve + ctx.moveTo(width - r, 0.5); + ctx.arcTo(0, 0.5, 0, r + 0.5, r); + ctx.stroke(); + } + } +} diff --git a/dots/.config/quickshell/ii/modules/waffle/looks/Looks.qml b/dots/.config/quickshell/ii/modules/waffle/looks/Looks.qml index ea225f093..df7df8187 100644 --- a/dots/.config/quickshell/ii/modules/waffle/looks/Looks.qml +++ b/dots/.config/quickshell/ii/modules/waffle/looks/Looks.qml @@ -15,17 +15,24 @@ Singleton { property string iconsPath: `${Directories.assetsPath}/icons/fluent` property bool dark: Appearance.m3colors.darkmode - property real fluentBackgroundTransparency: 0.17 - property real fluentContentTransparency: 0.3 + property real backgroundTransparency: 0.17 + property real contentTransparency: 0.25 colors: QtObject { id: colors property color bg0: root.dark ? "#1C1C1C" : "#EEEEEE" property color bg0Border: root.dark ? "#404040" : "#BEBEBE" - property color bg1: root.dark ? "#2E2E2E" : "#F7F7F7" + property color bg1: root.dark ? "#2C2C2C" : "#F7F7F7" property color bg1Hover: root.dark ? "#292929" : "#F7F7F7" property color bg1Active: root.dark ? "#252525" : "#F3F3F3" property color bg1Border: root.dark ? "#333333" : "#E9E9E9" + property color bg2: root.dark ? "#313131" : "#FBFBFB" + property color bg2Hover: root.dark ? "#383838" : "#FDFDFD" + property color bg2Active: root.dark ? "#333333" : "#FDFDFD" + property color bg2Border: root.dark ? "#464646" : "#EEEEEE" property color fg: root.dark ? "#FFFFFF" : "#000000" + property color fg1: root.dark ? "#D1D1D1" : "#626262" + property color danger: "#C42B1C" + property color dangerActive: "#B62D1F" property color brand: Appearance.m3colors.m3primary } @@ -44,12 +51,14 @@ Singleton { property string ui: "Noto Sans" } property QtObject weight: QtObject { // Noto is not Segoe, so we might use slightly different weights + property int thin: Font.Normal property int regular: Font.Medium property int strong: Font.DemiBold property int stronger: Font.Bold } property QtObject pixelSize: QtObject { property real normal: 11 + property real large: 15 } } @@ -57,15 +66,15 @@ Singleton { id: transition property QtObject easing: QtObject { property QtObject bezierCurve: QtObject { - readonly property list easeInOut: [0.42,0.00,0.58,1.00] - readonly property list easeIn: [0,1,1,1] - readonly property list easeOut: [1,0,1,1] + readonly property list easeInOut: [0.42,0.00,0.58,1.00,1,1] + readonly property list easeIn: [0,1,1,1,1,1] + readonly property list easeOut: [1,0,1,1,1,1] } } property Component color: Component { ColorAnimation { - duration: 80 + duration: 120 easing.type: Easing.BezierSpline easing.bezierCurve: transition.easing.bezierCurve.easeIn } @@ -73,7 +82,7 @@ Singleton { property Component opacity: Component { NumberAnimation{ - duration: 80 + duration: 120 easing.type: Easing.BezierSpline easing.bezierCurve: transition.easing.bezierCurve.easeIn } diff --git a/dots/.config/quickshell/ii/services/AppSearch.qml b/dots/.config/quickshell/ii/services/AppSearch.qml index 196e1bed3..7d8c375a6 100644 --- a/dots/.config/quickshell/ii/services/AppSearch.qml +++ b/dots/.config/quickshell/ii/services/AppSearch.qml @@ -151,6 +151,6 @@ Singleton { // Give up - return str; + return "application-x-executable"; } }