mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-16 16:24:00 -04:00
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:
@@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user