mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-04-03 20:32:07 -04:00
586 lines
22 KiB
QML
586 lines
22 KiB
QML
import QtQuick
|
|
import QtQuick.Effects
|
|
import Quickshell
|
|
import Quickshell.Wayland
|
|
import Quickshell.Hyprland
|
|
import qs.Common
|
|
import qs.Services
|
|
|
|
Item {
|
|
id: root
|
|
|
|
visible: false
|
|
|
|
property bool spotlightOpen: false
|
|
property bool keyboardActive: false
|
|
property bool contentVisible: false
|
|
readonly property bool launcherMotionVisible: Theme.isDirectionalEffect ? spotlightOpen : _motionActive
|
|
property var spotlightContent: launcherContentLoader.item
|
|
property bool openedFromOverview: false
|
|
property bool isClosing: false
|
|
property bool _windowEnabled: true
|
|
property bool _pendingInitialize: false
|
|
property string _pendingQuery: ""
|
|
property string _pendingMode: ""
|
|
readonly property bool unloadContentOnClose: SettingsData.dankLauncherV2UnloadOnClose
|
|
|
|
// Animation state — matches DankPopout/DankModal pattern
|
|
property bool animationsEnabled: true
|
|
property bool _motionActive: false
|
|
property real _frozenMotionX: 0
|
|
property real _frozenMotionY: 0
|
|
|
|
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
|
|
readonly property var effectiveScreen: contentWindow.screen
|
|
readonly property real screenWidth: effectiveScreen?.width ?? 1920
|
|
readonly property real screenHeight: effectiveScreen?.height ?? 1080
|
|
readonly property real dpr: effectiveScreen ? CompositorService.getScreenScale(effectiveScreen) : 1
|
|
|
|
readonly property int baseWidth: {
|
|
switch (SettingsData.dankLauncherV2Size) {
|
|
case "micro":
|
|
return 500;
|
|
case "medium":
|
|
return 720;
|
|
case "large":
|
|
return 860;
|
|
default:
|
|
return 620;
|
|
}
|
|
}
|
|
readonly property int baseHeight: {
|
|
switch (SettingsData.dankLauncherV2Size) {
|
|
case "micro":
|
|
return 480;
|
|
case "medium":
|
|
return 720;
|
|
case "large":
|
|
return 860;
|
|
default:
|
|
return 600;
|
|
}
|
|
}
|
|
readonly property int modalWidth: Math.min(baseWidth, screenWidth - 100)
|
|
readonly property int modalHeight: Math.min(baseHeight, screenHeight - 100)
|
|
readonly property real modalX: (screenWidth - modalWidth) / 2
|
|
readonly property real modalY: (screenHeight - modalHeight) / 2
|
|
|
|
readonly property color backgroundColor: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency)
|
|
readonly property real cornerRadius: Theme.cornerRadius
|
|
readonly property color borderColor: {
|
|
if (!SettingsData.dankLauncherV2BorderEnabled)
|
|
return Theme.outlineMedium;
|
|
switch (SettingsData.dankLauncherV2BorderColor) {
|
|
case "primary":
|
|
return Theme.primary;
|
|
case "secondary":
|
|
return Theme.secondary;
|
|
case "outline":
|
|
return Theme.outline;
|
|
case "surfaceText":
|
|
return Theme.surfaceText;
|
|
default:
|
|
return Theme.primary;
|
|
}
|
|
}
|
|
readonly property int borderWidth: SettingsData.dankLauncherV2BorderEnabled ? SettingsData.dankLauncherV2BorderThickness : 0
|
|
|
|
// Shadow padding for the content window (render padding only, no motion padding)
|
|
readonly property var shadowLevel: Theme.elevationLevel3
|
|
readonly property real shadowFallbackOffset: 6
|
|
readonly property real shadowRenderPadding: (Theme.elevationEnabled && SettingsData.modalElevationEnabled) ? Theme.elevationRenderPadding(shadowLevel, Theme.elevationLightDirection, shadowFallbackOffset, 8, 16) : 0
|
|
readonly property real shadowPad: Theme.snap(shadowRenderPadding, dpr)
|
|
readonly property real alignedWidth: Theme.px(modalWidth, dpr)
|
|
readonly property real alignedHeight: Theme.px(modalHeight, dpr)
|
|
readonly property real alignedX: Theme.snap(modalX, dpr)
|
|
readonly property real alignedY: Theme.snap(modalY, dpr)
|
|
|
|
// For directional/depth: window extends from screen top (content slides within)
|
|
// For standard: small window tightly around the modal + shadow padding
|
|
readonly property bool _needsExtendedWindow: Theme.isDirectionalEffect || Theme.isDepthEffect
|
|
// Content window geometry
|
|
readonly property real _cwMarginLeft: Theme.snap(alignedX - shadowPad, dpr)
|
|
readonly property real _cwMarginTop: _needsExtendedWindow ? 0 : Theme.snap(alignedY - shadowPad, dpr)
|
|
readonly property real _cwWidth: alignedWidth + shadowPad * 2
|
|
readonly property real _cwHeight: {
|
|
if (Theme.isDirectionalEffect)
|
|
return screenHeight + shadowPad;
|
|
if (Theme.isDepthEffect)
|
|
return alignedY + alignedHeight + shadowPad;
|
|
return alignedHeight + shadowPad * 2;
|
|
}
|
|
// Where the content container sits inside the content window
|
|
readonly property real _ccX: shadowPad
|
|
readonly property real _ccY: _needsExtendedWindow ? alignedY : shadowPad
|
|
|
|
signal dialogClosed
|
|
|
|
function _ensureContentLoadedAndInitialize(query, mode) {
|
|
_pendingQuery = query || "";
|
|
_pendingMode = mode || "";
|
|
_pendingInitialize = true;
|
|
contentVisible = true;
|
|
launcherContentLoader.active = true;
|
|
|
|
if (spotlightContent) {
|
|
_initializeAndShow(_pendingQuery, _pendingMode);
|
|
_pendingInitialize = false;
|
|
}
|
|
}
|
|
|
|
function _initializeAndShow(query, mode) {
|
|
if (!spotlightContent)
|
|
return;
|
|
contentVisible = true;
|
|
// NOTE: forceActiveFocus() is deliberately NOT called here.
|
|
// It is deferred to after animation starts to avoid compositor IPC stalls.
|
|
|
|
if (spotlightContent.searchField) {
|
|
spotlightContent.searchField.text = query;
|
|
}
|
|
if (spotlightContent.controller) {
|
|
var targetMode = mode || SessionData.launcherLastMode || "all";
|
|
spotlightContent.controller.searchMode = targetMode;
|
|
spotlightContent.controller.activePluginId = "";
|
|
spotlightContent.controller.activePluginName = "";
|
|
spotlightContent.controller.pluginFilter = "";
|
|
spotlightContent.controller.fileSearchType = "all";
|
|
spotlightContent.controller.fileSearchExt = "";
|
|
spotlightContent.controller.fileSearchFolder = "";
|
|
spotlightContent.controller.fileSearchSort = "score";
|
|
spotlightContent.controller.collapsedSections = {};
|
|
spotlightContent.controller.selectedFlatIndex = 0;
|
|
spotlightContent.controller.selectedItem = null;
|
|
if (query) {
|
|
spotlightContent.controller.setSearchQuery(query);
|
|
} else {
|
|
spotlightContent.controller.searchQuery = "";
|
|
spotlightContent.controller.performSearch();
|
|
}
|
|
}
|
|
if (spotlightContent.resetScroll) {
|
|
spotlightContent.resetScroll();
|
|
}
|
|
if (spotlightContent.actionPanel) {
|
|
spotlightContent.actionPanel.hide();
|
|
}
|
|
}
|
|
|
|
function _openCommon(query, mode) {
|
|
closeCleanupTimer.stop();
|
|
isClosing = false;
|
|
openedFromOverview = false;
|
|
|
|
// Disable animations so the snap is instant
|
|
animationsEnabled = false;
|
|
|
|
// Freeze the collapsed offsets (they depend on height which could change)
|
|
_frozenMotionX = contentContainer ? contentContainer.collapsedMotionX : 0;
|
|
_frozenMotionY = contentContainer ? contentContainer.collapsedMotionY : (Theme.isDirectionalEffect ? Math.max(root.screenHeight - root._ccY + root.shadowPad, Theme.effectAnimOffset * 1.1) : -Theme.effectAnimOffset);
|
|
|
|
var focusedScreen = CompositorService.getFocusedScreen();
|
|
if (focusedScreen) {
|
|
backgroundWindow.screen = focusedScreen;
|
|
contentWindow.screen = focusedScreen;
|
|
}
|
|
|
|
// _motionActive = false ensures motionX/Y snap to frozen collapsed position
|
|
_motionActive = false;
|
|
|
|
// Make windows visible but do NOT request keyboard focus yet
|
|
ModalManager.openModal(root);
|
|
spotlightOpen = true;
|
|
backgroundWindow.visible = true;
|
|
contentWindow.visible = true;
|
|
if (useHyprlandFocusGrab)
|
|
focusGrab.active = true;
|
|
|
|
// Load content and initialize (but no forceActiveFocus — that's deferred)
|
|
_ensureContentLoadedAndInitialize(query || "", mode || "");
|
|
|
|
// Frame 1: enable animations and trigger enter motion
|
|
Qt.callLater(() => {
|
|
root.animationsEnabled = true;
|
|
root._motionActive = true;
|
|
|
|
// Frame 2: request keyboard focus + activate search field
|
|
// Double-deferred to avoid compositor IPC competing with animation frames
|
|
Qt.callLater(() => {
|
|
root.keyboardActive = true;
|
|
if (root.spotlightContent && root.spotlightContent.searchField)
|
|
root.spotlightContent.searchField.forceActiveFocus();
|
|
});
|
|
});
|
|
}
|
|
|
|
function show() {
|
|
_openCommon("", "");
|
|
}
|
|
|
|
function showWithQuery(query) {
|
|
_openCommon(query, "");
|
|
}
|
|
|
|
function hide() {
|
|
if (!spotlightOpen)
|
|
return;
|
|
openedFromOverview = false;
|
|
isClosing = true;
|
|
// For directional effects, defer contentVisible=false so content stays rendered during exit slide
|
|
if (!Theme.isDirectionalEffect)
|
|
contentVisible = false;
|
|
|
|
// Trigger exit animation — Behaviors will animate motionX/Y to frozen collapsed position
|
|
_motionActive = false;
|
|
|
|
keyboardActive = false;
|
|
spotlightOpen = false;
|
|
focusGrab.active = false;
|
|
ModalManager.closeModal(root);
|
|
closeCleanupTimer.start();
|
|
}
|
|
|
|
function toggle() {
|
|
spotlightOpen ? hide() : show();
|
|
}
|
|
|
|
function showWithMode(mode) {
|
|
_openCommon("", mode);
|
|
}
|
|
|
|
function toggleWithMode(mode) {
|
|
if (spotlightOpen) {
|
|
hide();
|
|
} else {
|
|
showWithMode(mode);
|
|
}
|
|
}
|
|
|
|
function toggleWithQuery(query) {
|
|
if (spotlightOpen) {
|
|
hide();
|
|
} else {
|
|
showWithQuery(query);
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: closeCleanupTimer
|
|
interval: Theme.variantCloseInterval(Theme.modalAnimationDuration)
|
|
repeat: false
|
|
onTriggered: {
|
|
isClosing = false;
|
|
contentVisible = false;
|
|
contentWindow.visible = false;
|
|
backgroundWindow.visible = false;
|
|
if (root.unloadContentOnClose)
|
|
launcherContentLoader.active = false;
|
|
dialogClosed();
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: spotlightContent?.controller ?? null
|
|
function onModeChanged(mode) {
|
|
if (spotlightContent.controller.autoSwitchedToFiles)
|
|
return;
|
|
SessionData.setLauncherLastMode(mode);
|
|
}
|
|
}
|
|
|
|
HyprlandFocusGrab {
|
|
id: focusGrab
|
|
windows: [contentWindow]
|
|
active: false
|
|
|
|
onCleared: {
|
|
if (spotlightOpen) {
|
|
hide();
|
|
}
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: ModalManager
|
|
function onCloseAllModalsExcept(excludedModal) {
|
|
if (excludedModal !== root && spotlightOpen) {
|
|
hide();
|
|
}
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: Quickshell
|
|
function onScreensChanged() {
|
|
if (Quickshell.screens.length === 0)
|
|
return;
|
|
|
|
const screen = contentWindow.screen;
|
|
const screenName = screen?.name;
|
|
|
|
let needsReset = !screen || !screenName;
|
|
if (!needsReset) {
|
|
needsReset = true;
|
|
for (let i = 0; i < Quickshell.screens.length; i++) {
|
|
if (Quickshell.screens[i].name === screenName) {
|
|
needsReset = false;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!needsReset)
|
|
return;
|
|
|
|
const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0];
|
|
if (!newScreen)
|
|
return;
|
|
|
|
root._windowEnabled = false;
|
|
backgroundWindow.screen = newScreen;
|
|
contentWindow.screen = newScreen;
|
|
Qt.callLater(() => {
|
|
root._windowEnabled = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Background window: fullscreen, handles darkening + click-to-dismiss ──
|
|
PanelWindow {
|
|
id: backgroundWindow
|
|
visible: false
|
|
color: "transparent"
|
|
|
|
WlrLayershell.namespace: "dms:spotlight:bg"
|
|
WlrLayershell.layer: WlrLayershell.Top
|
|
WlrLayershell.exclusiveZone: -1
|
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
|
|
|
|
anchors {
|
|
top: true
|
|
bottom: true
|
|
left: true
|
|
right: true
|
|
}
|
|
|
|
mask: Region {
|
|
item: (spotlightOpen || isClosing) ? bgFullScreenMask : null
|
|
}
|
|
|
|
Item {
|
|
id: bgFullScreenMask
|
|
anchors.fill: parent
|
|
}
|
|
|
|
Rectangle {
|
|
id: backgroundDarken
|
|
anchors.fill: parent
|
|
color: "black"
|
|
opacity: launcherMotionVisible && SettingsData.modalDarkenBackground ? 0.5 : 0
|
|
visible: launcherMotionVisible || opacity > 0
|
|
|
|
Behavior on opacity {
|
|
enabled: root.animationsEnabled && !Theme.isDirectionalEffect
|
|
DankAnim {
|
|
duration: Math.round(Theme.variantDuration(Theme.modalAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
|
|
easing.bezierCurve: launcherMotionVisible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
enabled: spotlightOpen
|
|
onClicked: root.hide()
|
|
}
|
|
}
|
|
|
|
// ── Content window: SMALL, positioned with margins — only renders the modal area ──
|
|
PanelWindow {
|
|
id: contentWindow
|
|
visible: false
|
|
color: "transparent"
|
|
|
|
WlrLayershell.namespace: "dms:spotlight"
|
|
WlrLayershell.layer: {
|
|
switch (Quickshell.env("DMS_MODAL_LAYER")) {
|
|
case "bottom":
|
|
console.error("DankLauncherV2Modal: 'bottom' layer is not valid for modals. Defaulting to 'top' layer.");
|
|
return WlrLayershell.Top;
|
|
case "background":
|
|
console.error("DankLauncherV2Modal: 'background' layer is not valid for modals. Defaulting to 'top' layer.");
|
|
return WlrLayershell.Top;
|
|
case "overlay":
|
|
return WlrLayershell.Overlay;
|
|
default:
|
|
return WlrLayershell.Top;
|
|
}
|
|
}
|
|
WlrLayershell.exclusiveZone: -1
|
|
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
|
|
|
|
anchors {
|
|
left: true
|
|
top: true
|
|
}
|
|
|
|
WlrLayershell.margins {
|
|
left: root._cwMarginLeft
|
|
top: root._cwMarginTop
|
|
}
|
|
|
|
implicitWidth: root._cwWidth
|
|
implicitHeight: root._cwHeight
|
|
|
|
mask: Region {
|
|
item: contentInputMask
|
|
}
|
|
|
|
Item {
|
|
id: contentInputMask
|
|
visible: false
|
|
x: contentContainer.x + contentWrapper.x
|
|
y: contentContainer.y + contentWrapper.y
|
|
width: root.alignedWidth
|
|
height: root.alignedHeight
|
|
}
|
|
|
|
Item {
|
|
id: contentContainer
|
|
|
|
// For directional/depth: contentContainer is at alignedY from window top (window starts at screen top)
|
|
// For standard: contentContainer is at shadowPad from window top (window starts near modal)
|
|
x: root._ccX
|
|
y: root._ccY
|
|
width: root.alignedWidth
|
|
height: root.alignedHeight
|
|
|
|
readonly property bool directionalEffect: Theme.isDirectionalEffect
|
|
readonly property bool depthEffect: Theme.isDepthEffect
|
|
readonly property real collapsedMotionX: depthEffect ? Theme.effectAnimOffset * 0.25 : 0
|
|
readonly property real collapsedMotionY: {
|
|
if (directionalEffect)
|
|
return Math.max(root.screenHeight - root._ccY + root.shadowPad, Theme.effectAnimOffset * 1.1);
|
|
if (depthEffect)
|
|
return -Math.max(Theme.effectAnimOffset * 0.85, 34);
|
|
return 0;
|
|
}
|
|
|
|
// animX/animY are Behavior-animated — DankPopout pattern
|
|
property real animX: 0
|
|
property real animY: 0
|
|
property real scaleValue: Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed
|
|
|
|
Component.onCompleted: {
|
|
animX = Theme.snap(root._motionActive ? 0 : collapsedMotionX, root.dpr);
|
|
animY = Theme.snap(root._motionActive ? 0 : collapsedMotionY, root.dpr);
|
|
scaleValue = root._motionActive ? 1.0 : (Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed);
|
|
}
|
|
|
|
Connections {
|
|
target: root
|
|
function on_MotionActiveChanged() {
|
|
contentContainer.animX = Theme.snap(root._motionActive ? 0 : root._frozenMotionX, root.dpr);
|
|
contentContainer.animY = Theme.snap(root._motionActive ? 0 : root._frozenMotionY, root.dpr);
|
|
contentContainer.scaleValue = root._motionActive ? 1.0 : (Theme.isDirectionalEffect ? 1 : Theme.effectScaleCollapsed);
|
|
}
|
|
}
|
|
|
|
Behavior on animX {
|
|
enabled: root.animationsEnabled
|
|
DankAnim {
|
|
duration: Theme.variantDuration(Theme.modalAnimationDuration, root._motionActive)
|
|
easing.bezierCurve: root._motionActive ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
|
|
}
|
|
}
|
|
|
|
Behavior on animY {
|
|
enabled: root.animationsEnabled
|
|
DankAnim {
|
|
duration: Theme.variantDuration(Theme.modalAnimationDuration, root._motionActive)
|
|
easing.bezierCurve: root._motionActive ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
|
|
}
|
|
}
|
|
|
|
Behavior on scaleValue {
|
|
enabled: root.animationsEnabled && !Theme.isDirectionalEffect
|
|
DankAnim {
|
|
duration: Theme.variantDuration(Theme.modalAnimationDuration, root._motionActive)
|
|
easing.bezierCurve: root._motionActive ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
|
|
}
|
|
}
|
|
|
|
// Shadow mirrors contentWrapper position/scale/opacity
|
|
ElevationShadow {
|
|
id: launcherShadowLayer
|
|
width: parent.width
|
|
height: parent.height
|
|
opacity: contentWrapper.opacity
|
|
scale: contentWrapper.scale
|
|
x: contentWrapper.x
|
|
y: contentWrapper.y
|
|
level: root.shadowLevel
|
|
fallbackOffset: root.shadowFallbackOffset
|
|
targetColor: root.backgroundColor
|
|
borderColor: root.borderColor
|
|
borderWidth: root.borderWidth
|
|
targetRadius: root.cornerRadius
|
|
shadowEnabled: Theme.elevationEnabled && SettingsData.modalElevationEnabled && Quickshell.env("DMS_DISABLE_LAYER") !== "true" && Quickshell.env("DMS_DISABLE_LAYER") !== "1"
|
|
}
|
|
|
|
// contentWrapper moves inside static contentContainer — DankPopout pattern
|
|
Item {
|
|
id: contentWrapper
|
|
width: parent.width
|
|
height: parent.height
|
|
opacity: Theme.isDirectionalEffect ? 1 : (launcherMotionVisible ? 1 : 0)
|
|
visible: opacity > 0
|
|
scale: contentContainer.scaleValue
|
|
x: Theme.snap(contentContainer.animX + (parent.width - width) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
|
|
y: Theme.snap(contentContainer.animY + (parent.height - height) * (1 - contentContainer.scaleValue) * 0.5, root.dpr)
|
|
|
|
Behavior on opacity {
|
|
enabled: root.animationsEnabled && !Theme.isDirectionalEffect
|
|
DankAnim {
|
|
duration: Math.round(Theme.variantDuration(Theme.modalAnimationDuration, launcherMotionVisible) * Theme.variantOpacityDurationScale)
|
|
easing.bezierCurve: launcherMotionVisible ? Theme.variantModalEnterCurve : Theme.variantModalExitCurve
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
onPressed: mouse => mouse.accepted = true
|
|
}
|
|
|
|
FocusScope {
|
|
anchors.fill: parent
|
|
focus: keyboardActive
|
|
|
|
Loader {
|
|
id: launcherContentLoader
|
|
anchors.fill: parent
|
|
active: !root.unloadContentOnClose || root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize
|
|
asynchronous: false
|
|
sourceComponent: LauncherContent {
|
|
focus: true
|
|
parentModal: root
|
|
}
|
|
|
|
onLoaded: {
|
|
if (root._pendingInitialize) {
|
|
root._initializeAndShow(root._pendingQuery, root._pendingMode);
|
|
root._pendingInitialize = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
Keys.onEscapePressed: event => {
|
|
root.hide();
|
|
event.accepted = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|