feat(dock): add customizable proximity icon magnification (#3625)

* feat(dock): add proximity icon magnification with customizable curves

* fix(dock): restrict proximity magnification to dock and add translator context
This commit is contained in:
Huỳnh Thiện Lộc
2026-09-29 17:07:22 -04:00
committed by GitHub
parent 34e0ed2e90
commit ea70863fd7
9 changed files with 274 additions and 36 deletions
+29
View File
@@ -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);
}
@@ -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)
});
}
+1 -1
View File
@@ -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
@@ -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
+1 -1
View File
@@ -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")
@@ -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"
@@ -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
+22
View File
@@ -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
});
@@ -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",