refactor(dot): major performance & stability improvements

- Hyprland & Mango users will benefit most while niri gains solid QOL updates
- New icon picker in dot settings
- Dragging, flinging and opening the dot now run on one static surface so they stay smooth on every compositor

Port 2.0
This commit is contained in:
purian23 committed 2026-10-23 05:38:46 -04:00
1 parent ba7b3620a8
commit 0cd03e0180
17 files changed
+565 -107

No files matched your search

Generated
+3 -3
View File
@@ -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": {
+3
View File
@@ -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"],
@@ -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,
@@ -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 {
@@ -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()
+11 -6
View File
@@ -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")
}
}
@@ -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
@@ -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)
+111 -31
View File
@@ -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()
}
}
@@ -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
@@ -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
+6 -6
View File
@@ -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
});
+133 -6
View File
@@ -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)
@@ -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 {
@@ -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)
}
}
@@ -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",