diff --git a/quickshell/Common/settings/DockConfig.js b/quickshell/Common/settings/DockConfig.js index 21b492128..b526e3b67 100644 --- a/quickshell/Common/settings/DockConfig.js +++ b/quickshell/Common/settings/DockConfig.js @@ -1,6 +1,8 @@ .pragma library var EDGES = ["top", "bottom", "left", "right"]; +var MAGNIFICATION_PROFILES = ["parabolic", "cosine", "gaussian"]; +var DEFAULT_MAGNIFICATION_PROFILE = "parabolic"; function clone(value) { return JSON.parse(JSON.stringify(value)); @@ -52,6 +54,9 @@ function create(id, name) { showTrash: false, trashFileManager: "default", trashCustomCommand: "", + magnification: false, + magnificationScale: 130, + magnificationProfile: DEFAULT_MAGNIFICATION_PROFILE, order: [], widgets: [ { @@ -104,6 +109,8 @@ function normalize(configs) { result[key] = bounded(result[key], 1, 0, 1); for (const key of ["maxVisibleApps", "maxVisibleRunningApps"]) result[key] = Math.floor(bounded(result[key], 0, 0, 100)); + result.magnificationScale = bounded(result.magnificationScale, defaults.magnificationScale, 105, 160); + result.magnificationProfile = MAGNIFICATION_PROFILES.includes(result.magnificationProfile) ? result.magnificationProfile : defaults.magnificationProfile; for (const key of Object.keys(defaults)) { if (typeof defaults[key] === "boolean") result[key] = result[key] === true; @@ -385,3 +392,25 @@ function indicatorLane(config) { function effectiveThickness(config) { return config.iconSize + config.spacing * 2; } + +// Proximity magnification influence radius per profile curve. +function magnificationRadius(baseSize, profile) { + const size = baseSize > 0 ? baseSize : 42; + if (profile === "parabolic") + return size * 1.8; + if (profile === "gaussian") + return size * 2.2; + return size * 2.5; +} + +// Magnification scale decay factor (1.0 at dist=0, tapering to 0.0 at dist>=radius). +function magnificationFactor(dist, radius, profile) { + if (dist >= radius) + return 0; + const u = dist / radius; + if (profile === "cosine") + return 0.5 * (1 + Math.cos(Math.PI * u)); + if (profile === "gaussian") + return (Math.exp(-3 * u * u) - Math.exp(-3)) / (1 - Math.exp(-3)); + return 1 - (u * u); +} diff --git a/quickshell/Modules/DankBar/Widgets/AppsDock.qml b/quickshell/Modules/DankBar/Widgets/AppsDock.qml index e391afe93..14a21b3ed 100644 --- a/quickshell/Modules/DankBar/Widgets/AppsDock.qml +++ b/quickshell/Modules/DankBar/Widgets/AppsDock.qml @@ -42,6 +42,9 @@ BasePill { activeColorMode: appOption("appsDockActiveColorMode", "primary"), enlargeOnHover: appOption("appsDockEnlargeOnHover", false), enlargePercentage: appOption("appsDockEnlargePercentage", 125), + magnification: dockHosted && (surfaceContext?.config?.magnification ?? false), + magnificationScale: dockHosted ? (surfaceContext?.config?.magnificationScale ?? 130) : 130, + magnificationProfile: dockHosted ? (surfaceContext?.config?.magnificationProfile ?? "parabolic") : "parabolic", iconSizePercentage: appOption("appsDockIconSizePercentage", 100) }); } diff --git a/quickshell/Modules/Dock/DockAppButton.qml b/quickshell/Modules/Dock/DockAppButton.qml index 336d41474..fc7623558 100644 --- a/quickshell/Modules/Dock/DockAppButton.qml +++ b/quickshell/Modules/Dock/DockAppButton.qml @@ -435,7 +435,7 @@ Item { y: !root.isVertical && !root.indicatorAtFarEdge ? root.indicatorLane : 0 width: parent.width - (root.isVertical ? root.indicatorLane : 0) height: parent.height - (root.isVertical ? 0 : root.indicatorLane) - scale: root.options.enlargeOnHover && root.isHovered ? (root.options.enlargePercentage ?? 125) / 100 : 1 + scale: !root.options?.magnification && root.options?.enlargeOnHover && root.isHovered ? (root.options?.enlargePercentage ?? 125) / 100 : 1 AppIconRenderer { id: coreIcon diff --git a/quickshell/Modules/Dock/DockLauncherButton.qml b/quickshell/Modules/Dock/DockLauncherButton.qml index 73d92c4f6..1d7cf711a 100644 --- a/quickshell/Modules/Dock/DockLauncherButton.qml +++ b/quickshell/Modules/Dock/DockLauncherButton.qml @@ -58,7 +58,7 @@ Item { anchors.verticalCenterOffset: root.isVertical ? 0 : root.laneOffset width: actualIconSize height: actualIconSize - scale: root.options.enlargeOnHover && root.isHovered ? (root.options.enlargePercentage ?? 125) / 100 : 1 + scale: !root.options?.magnification && root.options?.enlargeOnHover && root.isHovered ? (root.options?.enlargePercentage ?? 125) / 100 : 1 LauncherLogo { anchors.centerIn: parent diff --git a/quickshell/Modules/Dock/DockTrashButton.qml b/quickshell/Modules/Dock/DockTrashButton.qml index 50c0ee7a6..e8f6c36dc 100644 --- a/quickshell/Modules/Dock/DockTrashButton.qml +++ b/quickshell/Modules/Dock/DockTrashButton.qml @@ -71,7 +71,7 @@ Item { anchors.verticalCenterOffset: root.isVertical ? 0 : root.laneOffset width: actualIconSize - 4 height: actualIconSize - 4 - scale: root.options.enlargeOnHover && root.isHovered ? (root.options.enlargePercentage ?? 125) / 100 : 1 + scale: !root.options?.magnification && root.options?.enlargeOnHover && root.isHovered ? (root.options?.enlargePercentage ?? 125) / 100 : 1 readonly property string iconPath: Paths.resolveIconPath(TrashService.isEmpty ? "user-trash" : "user-trash-full") diff --git a/quickshell/Modules/Settings/DockAppearanceTab.qml b/quickshell/Modules/Settings/DockAppearanceTab.qml index d5697f0f6..cc1182ede 100644 --- a/quickshell/Modules/Settings/DockAppearanceTab.qml +++ b/quickshell/Modules/Settings/DockAppearanceTab.qml @@ -75,6 +75,65 @@ Item { } } + SettingsToggleCard { + width: parent.width + visible: dock.hasConfig + iconName: "zoom_in" + settingKey: "dockMagnification" + tags: ["dock", "zoom", "magnification", "hover", "scale", "fisheye"] + resetStore: dock + resetKeys: ["magnification"] + title: I18n.tr("Magnification", "dock setting: enlarge icons near the cursor") + checked: dock.config?.magnification ?? false + onToggled: checked => dock.setOption("magnification", checked) + + SettingsSliderRow { + settingKey: "dockMagnificationScale" + tags: ["dock", "zoom", "magnification", "scale"] + resetStore: dock + resetKeys: ["magnificationScale"] + text: I18n.tr("Scale") + value: dock.config?.magnificationScale ?? 130 + minimum: 105 + maximum: 160 + step: 5 + unit: "%" + onSliderValueChanged: value => dock.setOption("magnificationScale", value) + } + + SettingsDropdownRow { + settingKey: "dockMagnificationProfile" + tags: ["dock", "zoom", "magnification", "animation", "profile", "style", "curve"] + resetStore: dock + resetKeys: ["magnificationProfile"] + text: I18n.tr("Animation style", "dock setting: magnification curve profile") + currentValue: { + const profile = dock.config?.magnificationProfile ?? "parabolic"; + switch (profile) { + case "cosine": + return I18n.tr("Cosine", "magnification animation profile"); + case "gaussian": + return I18n.tr("Gaussian", "magnification animation profile"); + default: + return I18n.tr("Parabolic", "magnification animation profile"); + } + } + options: [ + I18n.tr("Parabolic", "magnification animation profile"), + I18n.tr("Cosine", "magnification animation profile"), + I18n.tr("Gaussian", "magnification animation profile") + ] + onValueChanged: value => { + if (value === I18n.tr("Cosine", "magnification animation profile")) + dock.setOption("magnificationProfile", "cosine"); + else if (value === I18n.tr("Gaussian", "magnification animation profile")) + dock.setOption("magnificationProfile", "gaussian"); + else + dock.setOption("magnificationProfile", "parabolic"); + } + } + } + SettingsControlledBy { visible: dock.hasConfig && !dock.connectedFrameModeActive target: "surfaces" diff --git a/quickshell/Modules/SurfaceWidgets/SurfaceStrip.qml b/quickshell/Modules/SurfaceWidgets/SurfaceStrip.qml index de289bc45..7f0288815 100644 --- a/quickshell/Modules/SurfaceWidgets/SurfaceStrip.qml +++ b/quickshell/Modules/SurfaceWidgets/SurfaceStrip.qml @@ -115,6 +115,46 @@ FocusScope { cancelDrag(); } readonly property bool vertical: surfaceContext.isVertical + readonly property bool magnificationEnabled: (root.surfaceContext.config?.magnification ?? false) && !root.surfaceContext.editMode + readonly property string magnificationProfile: root.surfaceContext.config?.magnificationProfile ?? "parabolic" + readonly property real maxMagnification: Math.max(1.05, Math.min(2.0, (root.surfaceContext.config?.magnificationScale ?? 130) / 100)) + readonly property real baseSlotSize: { + const baseSize = root.vertical ? root.width : root.height; + return baseSize > 0 ? baseSize : 42; + } + readonly property real influenceRadius: DockConfig.magnificationRadius(baseSlotSize, magnificationProfile) + readonly property int itemTransformOrigin: { + const edge = root.surfaceContext.config?.position; + switch (edge) { + case SettingsData.Position.Top: + return Item.Top; + case SettingsData.Position.Bottom: + return Item.Bottom; + case SettingsData.Position.Left: + return Item.Left; + case SettingsData.Position.Right: + return Item.Right; + default: + return Item.Center; + } + } + readonly property real hoverCursorX: stripHoverHandler.point.position.x + scroll.contentX - (root.vertical ? root.crossOverflow : 0) + readonly property real hoverCursorY: stripHoverHandler.point.position.y + scroll.contentY - (root.vertical ? 0 : root.crossOverflow) + readonly property bool hoverActive: stripHoverHandler.hovered && !root.dragActive + property real magnificationProgress: 0.0 + Binding { + target: root + property: "magnificationProgress" + value: (root.hoverActive && root.magnificationEnabled) ? 1.0 : 0.0 + restoreMode: Binding.RestoreNone + } + Behavior on magnificationProgress { + enabled: !SettingsData.reduceMotion && Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None + NumberAnimation { + duration: root.hoverActive ? Theme.shorterDuration : Theme.shortDuration + easing.type: root.hoverActive ? Easing.OutCubic : Easing.InCubic + } + } readonly property var sizes: { layoutRevision; const sizes = []; @@ -215,12 +255,18 @@ FocusScope { y: root.vertical ? 0 : -root.crossOverflow width: root.width + (root.vertical ? root.crossOverflow * 2 : 0) height: root.height + (root.vertical ? 0 : root.crossOverflow * 2) - clip: true + clip: root.contentLength > root.availableSize contentWidth: root.vertical ? width : root.contentLength contentHeight: root.vertical ? root.contentLength : height flickableDirection: root.vertical ? Flickable.VerticalFlick : Flickable.HorizontalFlick boundsBehavior: Flickable.StopAtBounds interactive: !root.dragActive && root.contentLength > root.availableSize + + HoverHandler { + id: stripHoverHandler + enabled: root.magnificationEnabled + } + Repeater { id: repeater model: ScriptModel { @@ -239,7 +285,16 @@ FocusScope { readonly property bool unitDragging: root.draggingUnit === unitId readonly property bool animatesShift: root.dragActive && !dragging && !unitDragging && !SettingsData.reduceMotion && Theme.currentAnimationSpeed !== SettingsData.AnimationSpeed.None readonly property real layoutPosition: unitDragging ? root.positionAt(index) + root.dragOffset : (root.previewPositions[index] ?? 0) - z: dragging || unitDragging ? 1 : 0 + readonly property real targetScale: { + if (!root.magnificationEnabled || root.magnificationProgress <= 0.001 || slot.flexible) + return 1.0; + const center = root.vertical ? (slot.y + slot.height / 2) : (slot.x + slot.width / 2); + const cursor = root.vertical ? root.hoverCursorY : root.hoverCursorX; + const dist = Math.abs(center - cursor); + const factor = DockConfig.magnificationFactor(dist, root.influenceRadius, root.magnificationProfile); + return 1.0 + (root.maxMagnification - 1.0) * factor * root.magnificationProgress; + } + z: dragging || unitDragging ? 100 : (targetScale > 1.01 ? Math.round(targetScale * 10) : 0) Binding { target: slot @@ -316,39 +371,48 @@ FocusScope { slot.widgetItem?.forceActiveFocus(); event.accepted = true; } - Loader { - id: appLoader + Item { + id: visualContent anchors.centerIn: parent - active: slot.modelData.widgetId === "application" && root.applicationStrip !== null - sourceComponent: ApplicationItem { - strip: root.applicationStrip - modelData: slot.modelData.appData - index: slot.modelData.appIndex - } - onLoaded: updateLayout.schedule() - } + width: parent.width + height: parent.height + scale: slot.targetScale + transformOrigin: root.itemTransformOrigin - SurfaceWidgetHost { - id: loader - anchors.centerIn: parent - surfaceContext: root.surfaceContext - widgetId: slot.modelData.widgetId - widgetData: slot.modelData - spacerSize: slot.modelData.size ?? 20 - instanceId: slot.modelData.id - occurrenceOrder: slot.index - components: root.components - axis: root.surfaceContext.axis - isInColumn: root.vertical - parentScreen: root.surfaceContext.screen - barConfig: root.surfaceContext.config - barThickness: root.surfaceContext.thickness - widgetThickness: root.surfaceContext.widgetThickness - barSpacing: root.spacing - section: "center" - sectionAvailablePrimarySize: root.availableSize - blurBarWindow: root.surfaceContext.host - onContentItemReady: updateLayout.schedule() + Loader { + id: appLoader + anchors.centerIn: parent + active: slot.modelData.widgetId === "application" && root.applicationStrip !== null + sourceComponent: ApplicationItem { + strip: root.applicationStrip + modelData: slot.modelData.appData + index: slot.modelData.appIndex + } + onLoaded: updateLayout.schedule() + } + + SurfaceWidgetHost { + id: loader + anchors.centerIn: parent + surfaceContext: root.surfaceContext + widgetId: slot.modelData.widgetId + widgetData: slot.modelData + spacerSize: slot.modelData.size ?? 20 + instanceId: slot.modelData.id + occurrenceOrder: slot.index + components: root.components + axis: root.surfaceContext.axis + isInColumn: root.vertical + parentScreen: root.surfaceContext.screen + barConfig: root.surfaceContext.config + barThickness: root.surfaceContext.thickness + widgetThickness: root.surfaceContext.widgetThickness + barSpacing: root.spacing + section: "center" + sectionAvailablePrimarySize: root.availableSize + blurBarWindow: root.surfaceContext.host + onContentItemReady: updateLayout.schedule() + } } Rectangle { anchors.fill: parent diff --git a/quickshell/tests/dock-units.test.mjs b/quickshell/tests/dock-units.test.mjs index 22e03a8d4..b7b46437f 100644 --- a/quickshell/tests/dock-units.test.mjs +++ b/quickshell/tests/dock-units.test.mjs @@ -100,3 +100,25 @@ test("a dock shown only in the overview resolves onto an edge no enabled dock ho const enabled = { ...config.create("second", "Second"), enabled: true }; assert.equal(config.resolveEdge([overviewOnly, enabled], screen, [screen], "bottom"), enabled); }); + +test("magnification radius and falloff factor behave predictably per profile", () => { + assert.equal(config.magnificationRadius(40, "parabolic"), 40 * 1.8); + assert.equal(config.magnificationRadius(40, "gaussian"), 40 * 2.2); + assert.equal(config.magnificationRadius(40, "cosine"), 40 * 2.5); + + // At dist 0, all profiles yield factor 1 + assert.equal(config.magnificationFactor(0, 100, "parabolic"), 1); + assert.equal(config.magnificationFactor(0, 100, "cosine"), 1); + assert.equal(config.magnificationFactor(0, 100, "gaussian"), 1); + + // At dist >= radius, all profiles yield factor 0 + assert.equal(config.magnificationFactor(100, 100, "parabolic"), 0); + assert.equal(config.magnificationFactor(120, 100, "cosine"), 0); + assert.equal(config.magnificationFactor(100, 100, "gaussian"), 0); + + // Monotonic decay between 0 and radius + const pMid = config.magnificationFactor(50, 100, "parabolic"); + assert.equal(pMid, 0.75); // 1 - 0.5^2 + const cMid = config.magnificationFactor(50, 100, "cosine"); + assert.equal(Math.round(cMid * 1000), 500); // 0.5 * (1 + cos(pi/2)) = 0.5 +}); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 6ad53f710..bf37c9d01 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -15864,6 +15864,30 @@ "parentLabel": "Dock", "icon": "widgets" }, + { + "section": "dockMagnificationProfile", + "label": "Animation style", + "tabIndex": 58, + "category": "Appearance", + "keywords": [ + "animate", + "animation", + "animations", + "appearance", + "curve", + "dock", + "launcher bar", + "magnification", + "motion", + "panel", + "profile", + "style", + "taskbar", + "transition", + "zoom" + ], + "parentLabel": "Dock" + }, { "section": "dockBorder", "label": "Border", @@ -15918,6 +15942,26 @@ ], "parentLabel": "Dock" }, + { + "section": "dockMagnification", + "label": "Magnification", + "tabIndex": 58, + "category": "Appearance", + "keywords": [ + "appearance", + "dock", + "fisheye", + "hover", + "launcher bar", + "magnification", + "panel", + "scale", + "taskbar", + "zoom" + ], + "parentLabel": "Dock", + "icon": "zoom_in" + }, { "section": "dockMargin", "label": "Margin", @@ -15951,6 +15995,23 @@ ], "parentLabel": "Dock" }, + { + "section": "dockMagnificationScale", + "label": "Scale", + "tabIndex": 58, + "category": "Appearance", + "keywords": [ + "appearance", + "dock", + "launcher bar", + "magnification", + "panel", + "scale", + "taskbar", + "zoom" + ], + "parentLabel": "Dock" + }, { "section": "dockSizing", "label": "Size",