diff --git a/dux-qml-common b/dux-qml-common index abd63f1d3..d2d3c2275 160000 --- a/dux-qml-common +++ b/dux-qml-common @@ -1 +1 @@ -Subproject commit abd63f1d329cbffd33ad0d28420944353bbf50ed +Subproject commit d2d3c22750d54a3715fce05688632ca2fabd2daf diff --git a/flake.lock b/flake.lock index a057dd530..6470ecb87 100644 --- a/flake.lock +++ b/flake.lock @@ -3,11 +3,11 @@ "dank-qml-common": { "flake": false, "locked": { - "lastModified": 1792696287, - "narHash": "sha256-htrNweCLuw+3TQA2Vm7b4ETCuypIx85Vb3jt6k/DdBw=", + "lastModified": 1792748104, + "narHash": "sha256-GmN+sj6kPFADdJsKjFu4Xr+w8+/1wPyDZ9dAlKRHvrc=", "owner": "duxlabs-dev", "repo": "dux-qml-common", - "rev": "abd63f1d329cbffd33ad0d28420944353bbf50ed", + "rev": "d2d3c22750d54a3715fce05688632ca2fabd2daf", "type": "github" }, "original": { diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 2696f3c37..20f980744 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -2845,6 +2845,9 @@ Singleton { id: "dot" + Date.now(), name: I18n.tr("Dot", "bar layout: free-floating dot that opens island activities"), enabled: true, + // A hidden or auto-hiding base bar would switch off the dot's OSD and notifications, and bar IPC refuses dot configs. + visible: true, + autoHide: false, island: false, dot: true, screenPreferences: ["all"], diff --git a/quickshell/Common/settings/BarWidgetDefaults.js b/quickshell/Common/settings/BarWidgetDefaults.js index 20d41caff..c54a1a951 100644 --- a/quickshell/Common/settings/BarWidgetDefaults.js +++ b/quickshell/Common/settings/BarWidgetDefaults.js @@ -8,6 +8,9 @@ var ISLAND_DEFAULTS = { islandPlacement: "edge", islandFreeSize: 48, islandFreeIcon: "blur_on", + islandFreeIconMode: "icon", + islandFreeIconPath: "", + islandFreeIconColor: "primary", islandFreeEdgeMargin: 2, islandFreeIdleDelay: 2500, islandFreeIdleOpacity: 0.45, diff --git a/quickshell/Modules/DIsland/Activities/DotCompact.qml b/quickshell/Modules/DIsland/Activities/DotCompact.qml index 2aa5420ca..7eb0d2a9b 100644 --- a/quickshell/Modules/DIsland/Activities/DotCompact.qml +++ b/quickshell/Modules/DIsland/Activities/DotCompact.qml @@ -3,20 +3,52 @@ pragma ComponentBehavior: Bound import QtQuick import qs.Common import qs.DCommon.Widgets +import qs.Widgets Item { id: root required property var controller property string iconName: "blur_on" + property string iconMode: "icon" + property string iconPath: "" + property string iconColor: "primary" readonly property real diameter: Math.min(width, height) + readonly property real faceSize: Math.max(Theme.fontSizeSmall, Math.round(root.diameter * 0.46)) + readonly property color symbolColor: { + switch (root.iconColor) { + case "": + case "primary": + return Theme.primary; + case "surface": + return Theme.surfaceText; + default: + return root.iconColor; + } + } + Loader { + id: logoLoader + + anchors.centerIn: parent + active: root.iconMode !== "icon" + + sourceComponent: LauncherLogo { + mode: root.iconMode + size: root.faceSize + colorOverride: root.iconColor + customPath: root.iconPath + } + } + + // A logo mode with nothing to show (unknown compositor, empty custom path) falls back to the symbol. DIcon { anchors.centerIn: parent + visible: !(logoLoader.item as LauncherLogo)?.resolvedMode name: root.iconName - size: Math.max(Theme.fontSizeSmall, Math.round(root.diameter * 0.46)) - color: Theme.primary + size: root.faceSize + color: root.symbolColor } Rectangle { diff --git a/quickshell/Modules/DIsland/DIslandSurface.qml b/quickshell/Modules/DIsland/DIslandSurface.qml index d009766fc..567c7ffc5 100644 --- a/quickshell/Modules/DIsland/DIslandSurface.qml +++ b/quickshell/Modules/DIsland/DIslandSurface.qml @@ -31,8 +31,9 @@ Item { property string requestedWindow: "" property bool freeMode: false property bool anchorSnaps: false - property real anchorX: 0 - property real anchorY: 0 + property point anchor + // Velocity the next applyTarget seeds the spring with; a released drag flings from it. + property var flingSeed: null property real freeMargin: 8 // Measured from the band centre, not the band start. property real anchorAlong: 0 @@ -220,8 +221,8 @@ Item { } if (!root.freeMode) return target; - const x = root.clampFree(root.anchorX - target.width / 2, target.width, root.width); - const y = root.clampFree(root.anchorY - target.height / 2, target.height, root.height); + const x = root.clampFree(root.anchor.x - target.width / 2, target.width, root.width); + const y = root.clampFree(root.anchor.y - target.height / 2, target.height, root.height); return Object.assign({}, target, { "offsetAlong": root.isVertical ? y + target.height / 2 - root.height / 2 : x + target.width / 2 - root.width / 2, "offsetCross": root.isVertical ? x : y @@ -238,7 +239,9 @@ Item { fadeCompactCross = root.descriptorCross(controller.compactTarget); if (motion.running) root.unionMotionStartBounds(); - motion.setTarget(root.resolveTarget(controller.targetDescriptor), seedVelocity); + const seed = seedVelocity ?? root.flingSeed; + root.flingSeed = null; + motion.setTarget(root.resolveTarget(controller.targetDescriptor), seed); if (!motion.running) root.controller.releaseIdleVisuals(); } @@ -261,8 +264,7 @@ Item { root.applyTarget(); } - onAnchorXChanged: root.syncAnchor() - onAnchorYChanged: root.syncAnchor() + onAnchorChanged: root.syncAnchor() onAnchorAlongChanged: root.syncAnchor() onAlongExtentChanged: root.syncAnchor() onBandAlongStartChanged: root.syncAnchor() diff --git a/quickshell/Modules/DIsland/IslandBarHost.qml b/quickshell/Modules/DIsland/IslandBarHost.qml index 48b0e9b9b..600ad86c2 100644 --- a/quickshell/Modules/DIsland/IslandBarHost.qml +++ b/quickshell/Modules/DIsland/IslandBarHost.qml @@ -39,8 +39,7 @@ Item { property var centerSectionRect: null property var trailingSectionRect: null readonly property int hostLayer: root.hostWindow?.dBarLayer ?? WlrLayer.Top - property real anchorX: 0 - property real anchorY: 0 + property point anchor property bool anchorSnaps: false property real freeScale: 1 property real freeOpacity: 1 @@ -60,6 +59,7 @@ Item { readonly property real currentVisualY: surface.currentVisualY readonly property real currentVisualWidth: surface.currentVisualWidth readonly property real currentVisualHeight: surface.currentVisualHeight + readonly property real currentSurfaceRadius: surface.currentSurfaceRadius readonly property color surfaceColor: surface.surfaceColor readonly property real surfaceOpacity: surface.surfaceOpacity readonly property bool inputSuspended: controller.inputSuspended @@ -68,7 +68,9 @@ Item { // Held from expand until the collapse spring settles, so compact-to-compact morphs never resize the bar window. property bool sheetOut: false readonly property bool surfaceFits: (root.isVertical ? root.windowWidth : root.windowHeight) >= root.hostThickness - 1 - readonly property bool hostReady: !root.embedded || root.frameHosted || root.surfaceFits + // A free host lifts to the whole output for the sheet; the expand waits for that size so no frame of the morph is clipped. + property bool hostLifted: true + readonly property bool hostReady: root.freeMode ? root.hostLifted : (!root.embedded || root.frameHosted || root.surfaceFits) // Layer surfaces glitch when they resize under a moving sheet: edit room waits for a frame at the grown size, and the grown size outlives the shrink spring. readonly property bool surfaceResizes: !root.freeMode && !root.frameHosted readonly property int editSurfaceHeight: controller.editSurfaceHeight @@ -433,7 +435,8 @@ Item { homeBrightnessDisplay: SettingsData.islandLevelDisplay(root.barConfig, "islandHomeBrightnessDisplay") batteryStyle: root.setting("islandBatteryStyle") mediaClockVisible: root.setting("islandMediaClockVisible") - mediaAutoSwitch: root.setting("islandMediaAutoSwitch") + // Home and media share the dot face; see mediaFaceToggleAvailable. + mediaAutoSwitch: !root.dotMode && root.setting("islandMediaAutoSwitch") launcherCycleEnabled: SettingsData.launcherStyle === "island" dashboardAvailableWidth: Math.max(0, (root.screen?.width ?? root.referenceScreenWidth) - (root.isVertical ? root.crossInsetStart + root.crossInsetEnd + root.outerGap + root.nearInset : root.alongInset) - Theme.spacingL * 2) dashboardAvailableHeight: Math.max(0, (root.screen?.height ?? root.referenceScreenHeight) - (root.isVertical ? root.alongInset : root.crossInsetStart + root.crossInsetEnd + root.outerGap + root.nearInset) - Theme.spacingL * 2) @@ -493,8 +496,7 @@ Item { hostOriginX: root.hostOriginX hostOriginY: root.hostOriginY freeMode: root.freeMode - anchorX: root.anchorX - anchorY: root.anchorY + anchor: root.anchor anchorSnaps: root.anchorSnaps anchorAlong: root.anchorAlong nearInset: root.nearInset @@ -534,6 +536,9 @@ Item { DotCompact { controller: controller iconName: root.setting("islandFreeIcon") + iconMode: root.setting("islandFreeIconMode") + iconPath: root.setting("islandFreeIconPath") + iconColor: root.setting("islandFreeIconColor") } } diff --git a/quickshell/Modules/DIsland/IslandContentHost.qml b/quickshell/Modules/DIsland/IslandContentHost.qml index 9f30e3c89..b404af48c 100644 --- a/quickshell/Modules/DIsland/IslandContentHost.qml +++ b/quickshell/Modules/DIsland/IslandContentHost.qml @@ -189,10 +189,11 @@ Item { component CompactFace: Loader { required property string activity required property Component face - readonly property var target: root.resolveTarget(root.controller.compactTargetFor(activity)) + // A free face only centres in the island, so it skips the anchor-resolved slot every drag event would rebuild. + readonly property var target: root.freeMode ? root.controller.compactTargetFor(activity) : root.resolveTarget(root.controller.compactTargetFor(activity)) readonly property bool isVertical: root.controller.isVertical - readonly property real alongPos: !active ? 0 : Math.round(root.slotAlong(target)) - Math.round(isVertical ? root.islandY : root.islandX) - readonly property real crossPos: !active ? 0 : Math.round(root.slotCross(target)) - Math.round(isVertical ? root.islandX : root.islandY) + readonly property real alongPos: !active || root.freeMode ? 0 : Math.round(root.slotAlong(target)) - Math.round(isVertical ? root.islandY : root.islandX) + readonly property real crossPos: !active || root.freeMode ? 0 : Math.round(root.slotCross(target)) - Math.round(isVertical ? root.islandX : root.islandY) x: root.freeMode ? Math.round((parent.width - width) / 2) : (isVertical ? crossPos : alongPos) y: root.freeMode ? Math.round((parent.height - height) / 2) : (isVertical ? alongPos : crossPos) @@ -209,11 +210,13 @@ Item { component ExpandedFace: Loader { required property string activity readonly property var target: root.controller.expandedTargetFor(activity) - readonly property var slot: root.resolveTarget(target) + // A hidden face stays put, so a kept-alive sheet is not re-laid out on every move of the pill. + readonly property bool placed: active && visible + readonly property var slot: placed ? root.resolveTarget(target) : target readonly property bool isVertical: root.controller.isVertical readonly property real inset: root.controller.farEdge ? 0 : root.expandedInset - readonly property real alongPos: !active ? 0 : Math.round(root.slotAlong(slot)) - Math.round(isVertical ? root.islandY : root.islandX) - readonly property real crossPos: !active ? 0 : Math.round(root.slotCross(slot)) - Math.round(isVertical ? root.islandX : root.islandY) + inset + readonly property real alongPos: !placed ? 0 : Math.round(root.slotAlong(slot)) - Math.round(isVertical ? root.islandY : root.islandX) + readonly property real crossPos: !placed ? 0 : Math.round(root.slotCross(slot)) - Math.round(isVertical ? root.islandX : root.islandY) + inset x: isVertical ? crossPos : alongPos y: isVertical ? alongPos : crossPos diff --git a/quickshell/Modules/DIsland/IslandController.qml b/quickshell/Modules/DIsland/IslandController.qml index ef55efa0b..f6a462704 100644 --- a/quickshell/Modules/DIsland/IslandController.qml +++ b/quickshell/Modules/DIsland/IslandController.qml @@ -618,7 +618,8 @@ QtObject { return mediaAvailable && mediaPreferred ? "media" : "home"; } - readonly property bool mediaFaceToggleAvailable: !expanded && mediaAvailable && (activeActivity === "home" || activeActivity === "media") + // A dot draws the same face for home and media, so a toggle would only change what the next click opens. + readonly property bool mediaFaceToggleAvailable: !dotMode && !expanded && mediaAvailable && (activeActivity === "home" || activeActivity === "media") function toggleMediaFace() { if (!mediaFaceToggleAvailable) diff --git a/quickshell/Modules/DIsland/IslandFreeDragArea.qml b/quickshell/Modules/DIsland/IslandFreeDragArea.qml index f0f8cae5a..39e811a1f 100644 --- a/quickshell/Modules/DIsland/IslandFreeDragArea.qml +++ b/quickshell/Modules/DIsland/IslandFreeDragArea.qml @@ -10,52 +10,126 @@ DragHandler { readonly property real maxFlingSpeed: 4000 readonly property real flingProjection: 0.09 - property real startAnchorX: 0 - property real startAnchorY: 0 + // Single events are too noisy on high-rate mice, so the fling reads the stroke's last stretch. + readonly property int velocityWindow: 60 + property point startAnchor property point startPointer + property point startOrigin + property point lastPointer property bool tracking: false - property real velocityX: 0 - property real velocityY: 0 + property bool movePending: false + property bool flingPending: false + property point releaseVelocity + property var samples: [] + readonly property bool hostLifted: root.hostWindow.lifted + onHostLiftedChanged: { + if (root.hostLifted && root.flingPending) + root.fling(); + } + + // Scene positions are surface-local; events just after the lift can still carry the pre-lift origin, so the reading that continues the stroke wins. function pointerPosition() { - return root.surface.mapToGlobal(root.centroid.position.x, root.centroid.position.y); + const p = root.centroid.scenePosition; + const current = Qt.point(p.x + root.hostWindow.frameLeft, p.y + root.hostWindow.frameTop); + if (!root.tracking) + return current; + const previous = Qt.point(p.x + root.startOrigin.x, p.y + root.startOrigin.y); + const last = root.lastPointer; + return Math.hypot(current.x - last.x, current.y - last.y) <= Math.hypot(previous.x - last.x, previous.y - last.y) ? current : previous; + } + + function anchorFor(pointer) { + return Qt.point(root.startAnchor.x + pointer.x - root.startPointer.x, root.startAnchor.y + pointer.y - root.startPointer.y); } function rememberPress() { - root.startAnchorX = root.surface.currentVisualX + root.surface.currentVisualWidth / 2; - root.startAnchorY = root.surface.currentVisualY + root.surface.currentVisualHeight / 2; + root.startAnchor = Qt.point(root.surface.currentVisualX + root.surface.currentVisualWidth / 2, root.surface.currentVisualY + root.surface.currentVisualHeight / 2); + root.startOrigin = Qt.point(root.hostWindow.frameLeft, root.hostWindow.frameTop); root.startPointer = root.pointerPosition(); - root.velocityX = 0; - root.velocityY = 0; + root.lastPointer = root.startPointer; + root.samples = []; root.tracking = true; } - function beginDrag() { - if (!root.tracking) - root.rememberPress(); - root.hostWindow.dragging = true; - root.hostWindow.wake(); - root.updateDrag(); + function recordPointer() { + const now = Date.now(); + root.lastPointer = root.pointerPosition(); + while (root.samples.length > 1 && now - root.samples[0].time > root.velocityWindow) + root.samples.shift(); + root.samples.push({ + "time": now, + "x": root.lastPointer.x, + "y": root.lastPointer.y + }); + root.movePending = true; } - function updateDrag() { - const point = root.pointerPosition(); - root.velocityX = Math.max(-root.maxFlingSpeed, Math.min(root.maxFlingSpeed, root.centroid.velocity.x)); - root.velocityY = Math.max(-root.maxFlingSpeed, Math.min(root.maxFlingSpeed, root.centroid.velocity.y)); - root.hostWindow.anchorX = root.startAnchorX + point.x - root.startPointer.x; - root.hostWindow.anchorY = root.startAnchorY + point.y - root.startPointer.y; + function flingVelocity() { + const now = Date.now(); + const recent = root.samples.filter(sample => now - sample.time <= root.velocityWindow); + if (recent.length < 2) + return Qt.point(0, 0); + const first = recent[0]; + const last = recent[recent.length - 1]; + const seconds = (last.time - first.time) / 1000; + if (seconds < 0.008) + return Qt.point(0, 0); + const clamp = value => Math.max(-root.maxFlingSpeed, Math.min(root.maxFlingSpeed, value)); + return Qt.point(clamp((last.x - first.x) / seconds), clamp((last.y - first.y) / seconds)); + } + + // Re-based at drag start: a press just after a settle can read the old origin, and a flying dot has moved since the press. + function beginDrag() { + root.flingPending = false; + root.tracking = false; + root.rememberPress(); + root.hostWindow.dragging = true; + root.hostWindow.wake(); + root.recordPointer(); + } + + function applyMove() { + root.movePending = false; + root.hostWindow.anchor = root.anchorFor(root.lastPointer); } function finishDrag() { - root.hostWindow.dragging = false; - root.hostWindow.anchorX = root.hostWindow.clampAnchorX(root.hostWindow.anchorX + root.velocityX * root.flingProjection); - root.hostWindow.anchorY = root.hostWindow.clampAnchorY(root.hostWindow.anchorY + root.velocityY * root.flingProjection); - root.surface.applyTarget({ - "offsetAlong": root.surface.isVertical ? root.velocityY : root.velocityX, - "offsetCross": root.surface.isVertical ? root.velocityX : root.velocityY - }); - root.hostWindow.storeAnchor(); - root.hostWindow.wake(); + const host = root.hostWindow; + root.releaseVelocity = root.flingVelocity(); + root.movePending = false; + root.tracking = false; + // Raised before dragging drops, so the lift holds through the fling. + host.flinging = true; + host.dragging = false; + // A release before the lift lands waits for it, or the fling would spring out of the old buffer. + if (host.lifted) + root.fling(); + else + root.flingPending = true; + } + + function fling() { + const host = root.hostWindow; + root.flingPending = false; + // An expand requested mid-drag disables this handler; the sheet must not open with the release velocity. + if (root.surface.controller.expanded) { + host.flinging = false; + host.wake(); + return; + } + const velocity = root.releaseVelocity; + const anchor = root.anchorFor(root.lastPointer); + root.surface.flingSeed = { + "offsetAlong": root.surface.isVertical ? velocity.y : velocity.x, + "offsetCross": root.surface.isVertical ? velocity.x : velocity.y + }; + host.anchor = Qt.point(host.clampAnchorX(anchor.x + velocity.x * root.flingProjection), host.clampAnchorY(anchor.y + velocity.y * root.flingProjection)); + // An unchanged anchor never reaches applyTarget, so the seed is spent here. + if (root.surface.flingSeed) + root.surface.applyTarget(); + host.flinging = root.surface.motionRunning; + host.wake(); } parent: root.surface @@ -76,6 +150,12 @@ DragHandler { if (!root.tracking) root.rememberPress(); if (active) - root.updateDrag(); + root.recordPointer(); + } + + // One anchor write per frame however fast the mouse polls, and none before the lift lands. + property FrameAnimation moveFrame: FrameAnimation { + running: root.active && root.movePending && root.hostWindow.lifted + onTriggered: root.applyMove() } } diff --git a/quickshell/Modules/DIsland/IslandFreeHostWindow.qml b/quickshell/Modules/DIsland/IslandFreeHostWindow.qml index ecd3fd131..3b20a228b 100644 --- a/quickshell/Modules/DIsland/IslandFreeHostWindow.qml +++ b/quickshell/Modules/DIsland/IslandFreeHostWindow.qml @@ -28,22 +28,43 @@ PanelWindow { readonly property real screenHeight: root.screen?.height ?? 1080 readonly property string positionKey: IslandHostRegistry.key(root.screen?.name, root.barId) readonly property var storedAnchor: SessionData.islandFreePositions[root.positionKey] ?? null - readonly property real storedAnchorX: root.clampAnchorX((typeof root.storedAnchor?.x === "number" ? root.storedAnchor.x : 0.5) * root.screenWidth) - readonly property real storedAnchorY: root.clampAnchorY((typeof root.storedAnchor?.y === "number" ? root.storedAnchor.y : 0.5) * root.screenHeight) + readonly property point storedPoint: Qt.point(root.clampAnchorX((typeof root.storedAnchor?.x === "number" ? root.storedAnchor.x : 0.5) * root.screenWidth), root.clampAnchorY((typeof root.storedAnchor?.y === "number" ? root.storedAnchor.y : 0.5) * root.screenHeight)) + readonly property real anchorX: root.anchor.x + readonly property real anchorY: root.anchor.y + // Sized from the visual's size alone, so a fractional spring position never resizes the surface. readonly property int surfaceX: Math.floor(body.currentVisualX - root.hitPadding - body.shadowPadding) readonly property int surfaceY: Math.floor(body.currentVisualY - root.hitPadding - body.shadowPadding) + readonly property int surfaceWidth: Math.ceil(body.currentVisualWidth + 2 * (root.hitPadding + body.shadowPadding)) + 1 + readonly property int surfaceHeight: Math.ceil(body.currentVisualHeight + 2 * (root.hitPadding + body.shadowPadding)) + 1 + + // Gestures and morphs run on a surface lifted to the whole output: per-frame margin commits skew pointer coordinates and relayout Hyprland. + property bool lifting: false + property bool liftForced: false + property point liftOrigin + // True from the unlift until the tight configure lands: a re-grab in that window still reads the output size. + property bool sizeStale: false + readonly property bool outputSized: root.width >= root.screenWidth - 2 && root.height >= root.screenHeight - 2 + // Negative far margins grow the surface in place; the origin flips only once the buffer spans the output. + readonly property bool lifted: root.lifting && (root.liftForced || (!root.sizeStale && root.outputSized)) + readonly property int frameLeft: root.lifted ? 0 : root.lifting ? root.liftOrigin.x : root.surfaceX + readonly property int frameTop: root.lifted ? 0 : root.lifting ? root.liftOrigin.y : root.surfaceY + readonly property int frameRight: root.lifting ? -root.frameLeft : Math.round(root.screenWidth) - root.surfaceX - root.surfaceWidth + readonly property int frameBottom: root.lifting ? -root.frameTop : Math.round(root.screenHeight) - root.surfaceY - root.surfaceHeight readonly property bool expanded: body.expanded readonly property bool pointerInside: body.islandController.pointerInside readonly property string activeActivity: body.islandController.activeActivity - // An idle-shrunk nub keeps its full-size hit area; the drag area shares it or ring clicks would be lost. - readonly property real hitPadding: root.idle ? Math.max(0, (root.dotSize - root.dotSize * root.idleScale) / 2) : 0 + readonly property bool motionRunning: body.motionRunning + // A shrunk nub keeps the full dot's hit area and surface, so idling and waking never resize the window. + readonly property real hitPadding: root.dot ? Math.max(0, (root.dotSize - body.currentVisualWidth) / 2) : 0 // Blur regions are not antialiased and a small circle shows the jagged rim, so blur waits for the morph. readonly property bool blurWanted: !root.dot || Math.min(body.currentVisualWidth, body.currentVisualHeight) > root.dotSize * 1.5 - property real anchorX: 0 - property real anchorY: 0 + property point anchor property bool dragging: false + property bool flinging: false + // sheetOut spans the expand through the collapse spring, so the whole morph draws into one static buffer. + readonly property bool liftWanted: root.dragging || root.flinging || body.sheetOut property bool idle: false function anchorLimit(size, extent) { @@ -64,16 +85,24 @@ PanelWindow { } function storeAnchor() { - SessionData.setIslandFreePosition(root.positionKey, root.anchorX / root.screenWidth, root.anchorY / root.screenHeight); + SessionData.setIslandFreePosition(root.positionKey, root.anchor.x / root.screenWidth, root.anchor.y / root.screenHeight); } function moveTo(x, y) { - root.anchorX = root.clampAnchorX(x); - root.anchorY = root.clampAnchorY(y); + root.anchor = Qt.point(root.clampAnchorX(x), root.clampAnchorY(y)); root.storeAnchor(); root.wake(); } + function beginLift() { + if (root.lifting) + return; + root.liftOrigin = Qt.point(root.frameLeft, root.frameTop); + root.liftForced = false; + root.lifting = true; + liftTimeout.restart(); + } + function wake() { root.idle = false; if (root.idleDelay > 0) @@ -90,17 +119,37 @@ PanelWindow { return body.containsGlobalPoint(gx, gy, padding); } - onStoredAnchorXChanged: { - if (!root.dragging) - root.anchorX = root.storedAnchorX; + onStoredPointChanged: { + if (!root.liftWanted) + root.anchor = root.storedPoint; } - onStoredAnchorYChanged: { - if (!root.dragging) - root.anchorY = root.storedAnchorY; + // Stored once the fling settles; a save mid-gesture stalls the fling's first frame. + onLiftWantedChanged: { + if (root.liftWanted) { + root.beginLift(); + return; + } + root.sizeStale = root.outputSized; + root.lifting = false; + liftTimeout.stop(); + root.storeAnchor(); + } + onOutputSizedChanged: { + if (!root.outputSized) + root.sizeStale = false; + } + onLiftedChanged: { + if (root.lifted) + liftTimeout.stop(); + } + onMotionRunningChanged: { + if (!root.motionRunning) + root.flinging = false; } onPointerInsideChanged: root.wake() onExpandedChanged: root.wake() onActiveActivityChanged: root.wake() + onIdleDelayChanged: root.wake() color: "transparent" exclusiveZone: -1 @@ -122,16 +171,19 @@ PanelWindow { radius: body.currentSurfaceRadius > 0 ? Math.ceil(body.currentSurfaceRadius) + 1 : 0 } + // All four edges are anchored so the margins alone place and size the surface, letting a lift grow it past the output. anchors { top: true + bottom: true left: true + right: true } WlrLayershell.margins { - left: root.surfaceX - top: root.surfaceY + left: root.frameLeft + top: root.frameTop + right: root.frameRight + bottom: root.frameBottom } - implicitWidth: Math.ceil(body.currentVisualX + body.currentVisualWidth + root.hitPadding + body.shadowPadding) - root.surfaceX - implicitHeight: Math.ceil(body.currentVisualY + body.currentVisualHeight + root.hitPadding + body.shadowPadding) - root.surfaceY Component.onCompleted: { root.wake(); @@ -150,24 +202,46 @@ PanelWindow { Timer { id: idleTimer - interval: Math.max(1000, root.idleDelay) + interval: root.idleDelay onTriggered: { - if (root.expanded || root.pointerInside || root.dragging || body.islandController.transientActive) + if (root.expanded || root.pointerInside || hitHover.hovered || root.liftWanted || body.islandController.transientActive) return; root.idle = true; } } - mask: Region { + // A compositor that never sizes the lifted surface would leave every drag frozen. + Timer { + id: liftTimeout + + interval: 250 + onTriggered: root.liftForced = true + } + + // A quick stroke outruns the dot's rectangle between frames and the pointer leave ends the drag, so a drag takes input everywhere. + mask: root.dragging && root.lifted ? null : hitRegion + Region { + id: hitRegion + item: body.inputSuspended ? null : hitTarget } Item { id: hitTarget - anchors.fill: parent - anchors.margins: body.shadowPadding + + x: body.currentVisualX - root.hitPadding - root.frameLeft + y: body.currentVisualY - root.hitPadding - root.frameTop + width: body.currentVisualWidth + 2 * root.hitPadding + height: body.currentVisualHeight + 2 * root.hitPadding + + // The idle ring around a shrunk nub takes clicks and drags, so hovering it wakes the dot too. + HoverHandler { + id: hitHover + + onHoveredChanged: root.wake() + } } - // Keep the animated buffer tight; the unmoving input-only window catches outside clicks. + // Catches outside clicks; the lifted surface's own mask stays the island rectangle. PanelWindow { id: dismissWindow screen: root.screen @@ -182,14 +256,17 @@ PanelWindow { left: true right: true } + // The frozen spring envelope, so this full-output surface is not re-committed every frame of a morph. mask: Region { width: dismissWindow.width height: dismissWindow.height Region { - x: root.surfaceX + hitTarget.x - y: root.surfaceY + hitTarget.y - width: hitTarget.width - height: hitTarget.height + readonly property Item envelope: body.inputMaskItem + + x: dismissWindow.visible ? envelope.x : 0 + y: dismissWindow.visible ? envelope.y : 0 + width: dismissWindow.visible ? envelope.width : 0 + height: dismissWindow.visible ? envelope.height : 0 intersection: Intersection.Subtract } } @@ -212,8 +289,8 @@ PanelWindow { IslandBarHost { id: body - x: -root.surfaceX - y: -root.surfaceY + x: -root.frameLeft + y: -root.frameTop width: root.screenWidth height: root.screenHeight originOffsetX: x @@ -221,9 +298,9 @@ PanelWindow { barConfig: root.barConfig screen: root.screen hostWindow: root + hostLifted: root.lifted barId: root.barId - anchorX: root.anchorX - anchorY: root.anchorY + anchor: root.anchor anchorSnaps: root.dragging freeScale: root.idle ? root.idleScale : 1 freeOpacity: root.idle ? root.idleOpacity : 1 diff --git a/quickshell/Modules/Settings/ColorDropdownRow.qml b/quickshell/Modules/Settings/ColorDropdownRow.qml index 64a541b81..5fc78ea0a 100644 --- a/quickshell/Modules/Settings/ColorDropdownRow.qml +++ b/quickshell/Modules/Settings/ColorDropdownRow.qml @@ -153,6 +153,7 @@ Column { anchors.leftMargin: SettingsMetrics.rowPaddingH anchors.rightMargin: SettingsMetrics.rowPaddingH spacing: Theme.spacingM + opacity: root.enabled ? 1 : SettingsMetrics.disabledOpacity DColorSwatch { width: Theme.avatarSize diff --git a/quickshell/Modules/Settings/DBarTab.qml b/quickshell/Modules/Settings/DBarTab.qml index c8edc580e..165cec330 100644 --- a/quickshell/Modules/Settings/DBarTab.qml +++ b/quickshell/Modules/Settings/DBarTab.qml @@ -173,7 +173,7 @@ Item { maximum: 64 step: 1 value: bar.islandSetting("islandFreeEdgeMargin") - onSliderValueChanged: value => bar.apply("islandFreeEdgeMargin", value) + onSliderDragFinished: value => bar.apply("islandFreeEdgeMargin", value) } SettingsSliderRow { @@ -188,7 +188,7 @@ Item { maximum: 48 step: 1 value: bar.islandSetting("islandOuterGap") - onSliderValueChanged: value => bar.apply("islandOuterGap", value) + onSliderDragFinished: value => bar.apply("islandOuterGap", value) } SettingsSliderRow { @@ -203,7 +203,7 @@ Item { maximum: 600 step: 1 value: bar.islandSetting("islandAlongOffset") - onSliderValueChanged: value => bar.apply("islandAlongOffset", value) + onSliderDragFinished: value => bar.apply("islandAlongOffset", value) } SettingsSliderRow { @@ -219,7 +219,7 @@ Item { step: 1 value: bar.islandSetting("islandReserveThickness") enabled: !bar.islandSetting("islandFloating") - onSliderValueChanged: value => bar.apply("islandReserveThickness", value) + onSliderDragFinished: value => bar.apply("islandReserveThickness", value) } } @@ -279,7 +279,7 @@ Item { }); bar.notifyHorizontalBarChange(); } - onSliderValueChanged: newValue => SettingsData.updateBarConfig(bar.selectedBarId, { + onSliderDragFinished: newValue => SettingsData.updateBarConfig(bar.selectedBarId, { autoHideDelay: newValue }) } @@ -457,7 +457,7 @@ Item { minimum: 0 maximum: 1000 unit: "ms" - onSliderValueChanged: newValue => { + onSliderDragFinished: newValue => { SettingsData.updateBarConfig(bar.selectedBarId, { hoverPopoutDelay: newValue }); diff --git a/quickshell/Modules/Settings/DDotTab.qml b/quickshell/Modules/Settings/DDotTab.qml index ac5946de2..67a9a6c45 100644 --- a/quickshell/Modules/Settings/DDotTab.qml +++ b/quickshell/Modules/Settings/DDotTab.qml @@ -1,5 +1,9 @@ import QtQuick +import Quickshell import qs.Common +import qs.DCommon.Widgets +import qs.Modals.FileBrowser +import qs.Modules.DIsland import qs.Services import qs.Modules.Settings.Widgets @@ -15,6 +19,32 @@ Item { barId: SettingsData.dotBarConfig?.id ?? "" } + readonly property bool highContrast: dot.setting("islandHighContrast") + readonly property bool paletteTinted: ["bright", "dim"].includes(dot.setting("islandPalette")) + readonly property var iconModes: ["icon", "os", "dank", "compositor", "custom"] + readonly property string iconMode: dot.setting("islandFreeIconMode") + readonly property string iconColor: dot.setting("islandFreeIconColor") + readonly property bool iconColorCustom: iconColor !== "" && iconColor !== "primary" && iconColor !== "surface" + readonly property string compositorLabel: CompositorService.displayName || I18n.tr("Compositor") + + // A screen with no stored entry already sits at the default centre. + function resetDotPosition() { + for (const screen of Quickshell.screens) { + const key = IslandHostRegistry.key(screen.name, root.dot.barId); + if (SessionData.islandFreePositions[key]) + SessionData.setIslandFreePosition(key, 0.5, 0.5); + } + } + + FileBrowserModal { + id: iconFileBrowser + + browserTitle: I18n.tr("Select Dot Icon") + bucket: "logo" + filters: ["*.svg", "*.png", "*.jpg", "*.jpeg", "*.webp"] + onAccepted: paths => root.dot.apply("islandFreeIconPath", paths[0]) + } + SettingsPage { SettingsCard { settingKey: "dotSize" @@ -33,11 +63,93 @@ Item { maximum: 160 step: 1 value: dot.setting("islandFreeSize") - onSliderValueChanged: value => dot.apply("islandFreeSize", value) + onSliderDragFinished: finalValue => dot.apply("islandFreeSize", finalValue) + } + + SettingsButtonGroupRow { + settingKey: "dotIcon" + tags: ["dot", "icon", "symbol", "logo", "face"] + resetStore: root.dot + resetKeys: ["islandFreeIconMode"] + text: I18n.tr("Icon") + buttonPadding: Theme.spacingS + minButtonWidth: 44 + textSize: Theme.fontSizeSmall + model: [I18n.tr("Symbol"), I18n.tr("OS Logo"), "Dux", root.compositorLabel, I18n.tr("Custom")] + values: root.iconModes + value: root.iconMode + fallbackValue: "icon" + onValueSelected: value => root.dot.apply("islandFreeIconMode", value) + } + + SettingsRow { + settingKey: "dotIconSymbol" + tags: ["dot", "icon", "symbol", "glyph"] + resetStore: root.dot + resetKeys: ["islandFreeIcon"] + title: I18n.tr("Symbol") + visible: root.iconMode === "icon" + + DIconPicker { + anchors.verticalCenter: parent.verticalCenter + currentIcon: root.dot.setting("islandFreeIcon") + onIconSelected: iconName => root.dot.apply("islandFreeIcon", iconName) + } + } + + SettingsTextFieldRow { + settingKey: "dotIconImage" + tags: ["dot", "icon", "image", "custom", "file", "logo"] + resetStore: root.dot + resetKeys: ["islandFreeIconPath"] + text: I18n.tr("Custom") + visible: root.iconMode === "custom" + placeholderText: I18n.tr("Select an image file...") + value: root.dot.setting("islandFreeIconPath") + onEditingFinished: value => root.dot.apply("islandFreeIconPath", value.trim()) + + actions: DActionButton { + iconName: "folder_open" + Accessible.name: I18n.tr("Select Dot Icon") + onClicked: iconFileBrowser.open() + } + } + + ColorDropdownRow { + settingKey: "dotIconColor" + tags: ["dot", "icon", "color", "tint", "logo"] + resetStore: root.dot + resetKeys: ["islandFreeIconColor"] + text: I18n.tr("Color override") + options: [({ + "value": "", + "label": I18n.tr("Default") + }), ({ + "value": "primary", + "label": I18n.tr("Primary") + }), ({ + "value": "surface", + "label": I18n.tr("Surface", "color option") + }), ({ + "value": "custom", + "label": I18n.tr("Custom") + })] + currentMode: root.iconColorCustom ? "custom" : root.iconColor + customColor: root.iconColorCustom ? root.iconColor : Theme.primary + pickerTitle: I18n.tr("Color override") + onModeSelected: mode => { + if (mode !== "custom") { + root.dot.apply("islandFreeIconColor", mode); + return; + } + if (!root.iconColorCustom) + root.dot.apply("islandFreeIconColor", Theme.primary.toString()); + } + onCustomColorSelected: selectedColor => root.dot.apply("islandFreeIconColor", selectedColor.toString()) } SettingsSliderRow { - settingKey: "islandFreeEdgeMargin" + settingKey: "dotEdgeMargin" tags: ["dot", "edge", "margin", "gap"] resetStore: dot resetKeys: ["islandFreeEdgeMargin"] @@ -47,7 +159,7 @@ Item { maximum: 64 step: 1 value: dot.setting("islandFreeEdgeMargin") - onSliderValueChanged: value => dot.apply("islandFreeEdgeMargin", value) + onSliderDragFinished: finalValue => dot.apply("islandFreeEdgeMargin", finalValue) } SettingsSliderRow { @@ -57,11 +169,12 @@ Item { resetKeys: ["islandFreeIdleDelay"] text: I18n.tr("Idle delay", "island settings: time before the dot fades, 0 disables idling") unit: "ms" + minimumLabel: I18n.tr("Off") minimum: 0 maximum: 30000 step: 500 value: dot.setting("islandFreeIdleDelay") - onSliderValueChanged: value => dot.apply("islandFreeIdleDelay", value) + onSliderDragFinished: finalValue => dot.apply("islandFreeIdleDelay", finalValue) } SettingsSliderRow { @@ -76,7 +189,7 @@ Item { step: 1 value: Math.round(dot.setting("islandFreeIdleScale") * 100) enabled: dot.setting("islandFreeIdleDelay") > 0 - onSliderValueChanged: value => dot.apply("islandFreeIdleScale", value / 100) + onSliderDragFinished: finalValue => dot.apply("islandFreeIdleScale", finalValue / 100) } SettingsSliderRow { @@ -91,7 +204,16 @@ Item { step: 1 value: Math.round(dot.setting("islandFreeIdleOpacity") * 100) enabled: dot.setting("islandFreeIdleDelay") > 0 - onSliderValueChanged: value => dot.apply("islandFreeIdleOpacity", value / 100) + onSliderDragFinished: finalValue => dot.apply("islandFreeIdleOpacity", finalValue / 100) + } + + SettingsRow { + settingKey: "dotResetPosition" + tags: ["dot", "position", "center", "reset", "move"] + iconName: "drag_pan" + title: I18n.tr("Reset Position") + clickable: true + onClicked: root.resetDotPosition() } } @@ -107,6 +229,7 @@ Item { resetStore: dot resetKeys: ["surfaceColor", "surfaceCustomColor"] text: I18n.tr("Background") + enabled: !root.highContrast && !root.paletteTinted defaultColor: Theme.hostSurface currentMode: dot.config?.surfaceColor ?? "default" customColor: dot.config?.surfaceCustomColor ?? SettingsData.barConfigDefault("surfaceCustomColor") @@ -121,6 +244,7 @@ Item { resetStore: dot resetKeys: ["transparency"] text: I18n.tr("Opacity") + enabled: !root.highContrast minimum: 0 maximum: 100 step: 1 @@ -139,6 +263,7 @@ Item { text: I18n.tr("Palette", "island settings: surface tone choice") model: [I18n.tr("Default", "island settings: default surface tone"), I18n.tr("Bright", "island settings: bright surface tone"), I18n.tr("Dim", "island settings: dim surface tone")] values: root.paletteValues + enabled: !root.highContrast value: dot.setting("islandPalette") fallbackValue: "default" onValueSelected: value => dot.apply("islandPalette", value) @@ -147,6 +272,8 @@ Item { SettingsToggleRow { settingKey: "dotHighContrast" tags: ["dot", "contrast", "accessibility", "outline"] + resetStore: root.dot + resetKeys: ["islandHighContrast"] text: I18n.tr("High contrast", "island settings: high contrast toggle") checked: dot.setting("islandHighContrast") onToggled: checked => dot.apply("islandHighContrast", checked) diff --git a/quickshell/Modules/Settings/Widgets/IslandBehaviorCard.qml b/quickshell/Modules/Settings/Widgets/IslandBehaviorCard.qml index 25cbf44e6..cd299cfdd 100644 --- a/quickshell/Modules/Settings/Widgets/IslandBehaviorCard.qml +++ b/quickshell/Modules/Settings/Widgets/IslandBehaviorCard.qml @@ -156,7 +156,7 @@ SettingsCard { step: 10 value: root.store.setting("islandHoverOpenDelay") visible: root.docked && root.store.setting("islandInteractionMode") !== "click" - onSliderValueChanged: value => root.store.apply("islandHoverOpenDelay", value) + onSliderDragFinished: value => root.store.apply("islandHoverOpenDelay", value) } SettingsSliderRow { @@ -171,7 +171,7 @@ SettingsCard { step: 10 value: root.store.setting("islandHoverCloseDelay") visible: root.docked && root.store.setting("islandInteractionMode") !== "click" - onSliderValueChanged: value => root.store.apply("islandHoverCloseDelay", value) + onSliderDragFinished: value => root.store.apply("islandHoverCloseDelay", value) } SettingsToggleRow { diff --git a/quickshell/Modules/Settings/Widgets/IslandMotionCard.qml b/quickshell/Modules/Settings/Widgets/IslandMotionCard.qml index aa19e8a8b..1717766bb 100644 --- a/quickshell/Modules/Settings/Widgets/IslandMotionCard.qml +++ b/quickshell/Modules/Settings/Widgets/IslandMotionCard.qml @@ -24,7 +24,7 @@ SettingsCard { step: 10 value: Math.round(root.store.setting("islandSpringStiffness")) enabled: !SettingsData.reduceMotion - onSliderValueChanged: value => root.store.apply("islandSpringStiffness", value) + onSliderDragFinished: finalValue => root.store.apply("islandSpringStiffness", finalValue) } SettingsSliderRow { @@ -39,7 +39,7 @@ SettingsCard { step: 1 value: Math.round(root.store.setting("islandSpringDamping")) enabled: !SettingsData.reduceMotion - onSliderValueChanged: value => root.store.apply("islandSpringDamping", value) + onSliderDragFinished: finalValue => root.store.apply("islandSpringDamping", finalValue) } SettingsSliderRow { @@ -55,6 +55,6 @@ SettingsCard { decimals: 2 value: Math.round(root.store.setting("islandSpringMass") * 100) enabled: !SettingsData.reduceMotion - onSliderValueChanged: value => root.store.apply("islandSpringMass", value / 100) + onSliderDragFinished: finalValue => root.store.apply("islandSpringMass", finalValue / 100) } } diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index e57af0cfe..7a1dcf2a7 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -17443,6 +17443,29 @@ "parentLabel": "Bar", "conditionKey": "dotEnabled" }, + { + "section": "dotIconColor", + "label": "Color override", + "tabIndex": 65, + "category": "Dot", + "keywords": [ + "bar", + "color", + "colour", + "dot", + "hue", + "icon", + "logo", + "override", + "panel", + "statusbar", + "taskbar", + "tint", + "topbar" + ], + "parentLabel": "Bar", + "conditionKey": "dotEnabled" + }, { "section": "dotRouteControlCenter", "label": "Control Center", @@ -17465,6 +17488,28 @@ "parentLabel": "Bar", "conditionKey": "dotEnabled" }, + { + "section": "dotIconImage", + "label": "Custom", + "tabIndex": 65, + "category": "Dot", + "keywords": [ + "bar", + "custom", + "dot", + "file", + "icon", + "image", + "logo", + "panel", + "statusbar", + "taskbar", + "topbar" + ], + "parentLabel": "Bar", + "icon": "folder_open", + "conditionKey": "dotEnabled" + }, { "section": "dotRouteDash", "label": "Dashboard", @@ -17533,6 +17578,25 @@ "parentLabel": "Bar", "description": "A floating companion that works alongside any bar layout" }, + { + "section": "dotEdgeMargin", + "label": "Edge margin", + "tabIndex": 65, + "category": "Dot", + "keywords": [ + "bar", + "dot", + "edge", + "gap", + "margin", + "panel", + "statusbar", + "taskbar", + "topbar" + ], + "parentLabel": "Bar", + "conditionKey": "dotEnabled" + }, { "section": "dotNotificationExpand", "label": "Expand by default", @@ -17598,6 +17662,26 @@ "parentLabel": "Bar", "conditionKey": "dotEnabled" }, + { + "section": "dotIcon", + "label": "Icon", + "tabIndex": 65, + "category": "Dot", + "keywords": [ + "bar", + "dot", + "face", + "icon", + "logo", + "panel", + "statusbar", + "symbol", + "taskbar", + "topbar" + ], + "parentLabel": "Bar", + "conditionKey": "dotEnabled" + }, { "section": "islandFreeIdleDelay", "label": "Idle delay", @@ -17883,6 +17967,27 @@ "parentLabel": "Bar", "conditionKey": "dotEnabled" }, + { + "section": "dotResetPosition", + "label": "Reset Position", + "tabIndex": 65, + "category": "Dot", + "keywords": [ + "bar", + "center", + "dot", + "move", + "panel", + "position", + "reset", + "statusbar", + "taskbar", + "topbar" + ], + "parentLabel": "Bar", + "icon": "drag_pan", + "conditionKey": "dotEnabled" + }, { "section": "dotSharedRouting", "label": "Shared shortcuts", @@ -18025,6 +18130,25 @@ "icon": "palette", "conditionKey": "dotEnabled" }, + { + "section": "dotIconSymbol", + "label": "Symbol", + "tabIndex": 65, + "category": "Dot", + "keywords": [ + "bar", + "dot", + "glyph", + "icon", + "panel", + "statusbar", + "symbol", + "taskbar", + "topbar" + ], + "parentLabel": "Bar", + "conditionKey": "dotEnabled" + }, { "section": "dotSystemOsd", "label": "Use standard OSDs",