1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-08 04:09:15 -04:00
Files
DankMaterialShell/quickshell/Modals/DankLauncherV2/DankLauncherV2ModalSpotlight.qml
T
bbedward 8c20f448ed control center: improve drag handling
misc: fix layer shell enum usage
2026-06-01 13:16:47 -04:00

497 lines
18 KiB
QML

import QtQuick
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
readonly property var log: Log.scoped("DankLauncherV2ModalSpotlight")
property var modalHandle: root
property bool triggerUsesOverlayLayer: false
visible: false
property bool spotlightOpen: false
property bool keyboardActive: false
property bool contentVisible: false
property var spotlightContent: contentLoader.item
property bool openedFromOverview: false
property bool isClosing: false
property bool _pendingInitialize: false
property string _pendingQuery: ""
property string _pendingMode: ""
readonly property bool useHyprlandFocusGrab: CompositorService.useHyprlandFocusGrab
readonly property var effectiveScreen: launcherWindow.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 bool useBackgroundDarken: !SettingsData.frameEnabled && SettingsData.modalDarkenBackground
readonly property bool usesOverlayLayer: useBackgroundDarken || SettingsData.launcherUseOverlayLayer || triggerUsesOverlayLayer
readonly property var effectiveLauncherLayer: LayerShell.fromEnv("DMS_MODAL_LAYER", root.usesOverlayLayer ? WlrLayer.Overlay : WlrLayer.Top, {
"allow": ["top", "overlay"],
"invalidLayer": WlrLayer.Top,
"label": "modals",
"error": true
})
readonly property int _openDuration: 50
readonly property int _closeDuration: 40
readonly property int _motionDuration: 60
// Connected frame mode clamps the centered surface inside frame insets.
readonly property bool frameConnected: CompositorService.usesConnectedFrameChromeForScreen(effectiveScreen)
function _frameEdgeInset(side) {
if (!effectiveScreen || !frameConnected)
return 0;
return SettingsData.frameEdgeInsetForSide(effectiveScreen, side);
}
// Fixed 680px width, centered horizontally (respecting frame insets)
readonly property int modalWidth: Math.min(680, screenWidth - 80)
readonly property real modalX: {
const insetL = _frameEdgeInset("left");
const insetR = _frameEdgeInset("right");
const usable = Math.max(0, screenWidth - insetL - insetR);
return insetL + Math.max(0, (usable - modalWidth) / 2);
}
// Keep the search bar centered; results expand downward unless the screen edge clamps it.
readonly property real modalY: {
const insetT = _frameEdgeInset("top");
const insetB = _frameEdgeInset("bottom");
const searchBarH = 56;
const usableH = Math.max(searchBarH, screenHeight - insetT - insetB);
const preferred = insetT + Math.max(0, usableH * 0.33 - searchBarH / 2);
const maxY = Math.max(insetT, screenHeight - insetB - 56);
return Math.max(insetT, Math.min(preferred, maxY));
}
// Dynamic height from content
readonly property real _contentImplicitH: contentLoader.item?.implicitHeight ?? 56
readonly property int modalHeight: _contentImplicitH
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 alignedX: Theme.snap(modalX, dpr)
readonly property real alignedY: Theme.snap(modalY, dpr)
// Extra headroom above the window for the slide-in animation
readonly property real _animHeadroom: 16
readonly property real windowX: Math.max(0, Theme.snap(alignedX - shadowPad, dpr))
readonly property real windowY: Math.max(0, Theme.snap(alignedY - shadowPad - _animHeadroom, dpr))
readonly property real contentX: Theme.snap(alignedX - windowX, dpr)
readonly property real contentY: Theme.snap(alignedY - windowY, dpr)
readonly property real windowWidth: alignedWidth + contentX + shadowPad
readonly property real _animatedContentH: Theme.snap(_contentImplicitH, dpr)
readonly property real windowHeight: _animatedContentH + contentY + shadowPad + _animHeadroom
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
signal dialogClosed
function _ensureContentLoadedAndInitialize(query, mode) {
_pendingQuery = query || "";
_pendingMode = mode || "";
_pendingInitialize = true;
contentVisible = true;
contentLoader.active = true;
if (spotlightContent) {
_initializeContent(_pendingQuery, _pendingMode);
_pendingInitialize = false;
}
}
function _initializeContent(query, mode) {
if (!spotlightContent)
return;
contentVisible = true;
spotlightContent.closeTransientUi?.();
const targetQuery = query || (SettingsData.rememberLastQuery ? (SessionData.launcherLastQuery || "") : "");
const targetMode = mode || SessionData.getLauncherRestoreMode();
if (spotlightContent.searchField) {
spotlightContent.searchField.text = targetQuery;
}
if (spotlightContent.controller) {
spotlightContent.controller.reset();
spotlightContent.controller.searchMode = targetMode;
spotlightContent.controller.historyIndex = -1;
if (targetQuery.length > 0)
spotlightContent.controller.setSearchQuery(targetQuery);
}
if (spotlightContent.resetScroll) {
spotlightContent.resetScroll();
}
if (spotlightContent.searchField) {
spotlightContent.searchField.forceActiveFocus();
spotlightContent.searchField.cursorPosition = spotlightContent.searchField.text.length;
}
}
function _finishShow(query, mode) {
spotlightOpen = true;
isClosing = false;
openedFromOverview = false;
keyboardActive = true;
ModalManager.openModal(modalHandle);
if (useHyprlandFocusGrab)
focusGrab.active = true;
_ensureContentLoadedAndInitialize(query || "", mode || "");
}
function _openCommon(query, mode) {
closeCleanupTimer.stop();
const focusedScreen = CompositorService.getFocusedScreen();
if (focusedScreen && launcherWindow.screen !== focusedScreen) {
spotlightOpen = false;
isClosing = false;
launcherWindow.screen = focusedScreen;
Qt.callLater(() => root._finishShow(query, mode));
return;
}
_finishShow(query, mode);
}
function show() {
_openCommon("", "");
}
function showWithQuery(query) {
_openCommon(query, "");
}
function showWithMode(mode) {
_openCommon("", mode);
}
function hide() {
if (!spotlightOpen)
return;
spotlightContent?.closeTransientUi?.();
openedFromOverview = false;
isClosing = true;
contentVisible = false;
keyboardActive = false;
spotlightOpen = false;
focusGrab.active = false;
ModalManager.closeModal(modalHandle);
closeCleanupTimer.start();
}
function toggle() {
spotlightOpen ? hide() : show();
}
function toggleWithMode(mode) {
spotlightOpen ? hide() : showWithMode(mode);
}
function toggleWithQuery(query) {
spotlightOpen ? hide() : showWithQuery(query);
}
Timer {
id: closeCleanupTimer
interval: root._motionDuration + 30
repeat: false
onTriggered: {
isClosing = false;
dialogClosed();
}
}
HyprlandFocusGrab {
id: focusGrab
windows: [launcherWindow]
active: false
onCleared: {
if (spotlightOpen)
hide();
}
}
Connections {
target: ModalManager
function onCloseAllModalsExcept(excludedModal) {
if (excludedModal !== modalHandle && spotlightOpen)
hide();
}
}
Connections {
target: Quickshell
function onScreensChanged() {
if (Quickshell.screens.length === 0)
return;
const screenName = launcherWindow.screen?.name;
if (screenName) {
for (let i = 0; i < Quickshell.screens.length; i++) {
if (Quickshell.screens[i].name === screenName)
return;
}
}
if (spotlightOpen)
hide();
const newScreen = CompositorService.getFocusedScreen() ?? Quickshell.screens[0];
if (newScreen)
launcherWindow.screen = newScreen;
}
}
// Background click catcher
PanelWindow {
id: clickCatcher
screen: launcherWindow.screen
visible: (spotlightOpen || isClosing) && !root.useBackgroundDarken
color: "transparent"
WlrLayershell.namespace: "dms:spotlight:clickcatcher"
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
anchors {
top: true
bottom: true
left: true
right: true
}
mask: Region {
item: bgMask
Region {
item: bgHole
intersection: Intersection.Subtract
}
}
Item {
id: bgMask
visible: false
anchors.fill: parent
}
Rectangle {
id: bgHole
visible: false
color: "transparent"
x: root.windowX
y: root.windowY
width: root.windowWidth
height: root.windowHeight
}
MouseArea {
anchors.fill: parent
enabled: spotlightOpen
onClicked: root.hide()
}
}
// Launcher window
PanelWindow {
id: launcherWindow
visible: spotlightOpen || isClosing
color: "transparent"
exclusionMode: ExclusionMode.Ignore
WindowBlur {
targetWindow: launcherWindow
readonly property real op: Math.max(0, Math.min(1, (modalContainer.opacity - 0.06) * 2))
blurX: modalContainer.x
blurY: modalContainer.y + modalContainer.slideOffset
blurWidth: contentVisible ? root.alignedWidth * op : 0
blurHeight: contentVisible ? root._contentImplicitH * op : 0
blurRadius: root.cornerRadius
}
WlrLayershell.namespace: "dms:spotlight"
WlrLayershell.layer: root.effectiveLauncherLayer
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: PopoutManager.screenshotActive ? WlrKeyboardFocus.None : (keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None)
anchors {
top: true
left: true
right: root.useBackgroundDarken
bottom: root.useBackgroundDarken
}
WlrLayershell.margins {
left: root.useBackgroundDarken ? 0 : root.windowX
top: root.useBackgroundDarken ? 0 : root.windowY
right: 0
bottom: 0
}
implicitWidth: root.useBackgroundDarken ? 0 : root.windowWidth
implicitHeight: root.useBackgroundDarken ? 0 : root.windowHeight
mask: Region {
item: inputMask
}
Rectangle {
id: inputMask
visible: false
color: "transparent"
x: root.useBackgroundDarken ? 0 : modalContainer.x
y: root.useBackgroundDarken ? 0 : modalContainer.y + modalContainer.slideOffset
width: root.useBackgroundDarken ? launcherWindow.width : root.alignedWidth
height: root.useBackgroundDarken ? launcherWindow.height : root._contentImplicitH
}
MouseArea {
anchors.fill: parent
enabled: root.useBackgroundDarken && spotlightOpen
z: -2
onClicked: root.hide()
}
Rectangle {
id: backgroundDarken
anchors.fill: parent
color: "black"
opacity: contentVisible && root.useBackgroundDarken ? 0.5 : 0
visible: (spotlightOpen || isClosing) && (root.useBackgroundDarken || opacity > 0)
z: -3
Behavior on opacity {
NumberAnimation {
duration: contentVisible ? root._openDuration : root._closeDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: contentVisible ? [0.0, 0.0, 0.2, 1.0, 1.0, 1.0] : [0.4, 0.0, 1.0, 1.0, 1.0, 1.0]
}
}
}
Item {
id: modalContainer
x: root.useBackgroundDarken ? root.alignedX : root.contentX
y: root.useBackgroundDarken ? root.alignedY : root.contentY
width: root.alignedWidth
height: root._animatedContentH
visible: _renderActive
z: 0
property bool _renderActive: contentVisible
property real slideOffset: contentVisible ? 0 : -root._animHeadroom
opacity: contentVisible ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: contentVisible ? root._openDuration : root._closeDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: contentVisible ? [0.0, 0.0, 0.2, 1.0, 1.0, 1.0] : [0.4, 0.0, 1.0, 1.0, 1.0, 1.0]
onRunningChanged: {
if (!running && !root.contentVisible)
modalContainer._renderActive = false;
}
}
}
Behavior on slideOffset {
NumberAnimation {
duration: root._motionDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: contentVisible ? [0.2, 0.0, 0.0, 1.0, 1.0, 1.0] : [0.4, 0.0, 1.0, 1.0, 1.0, 1.0]
}
}
Connections {
target: root
function onContentVisibleChanged() {
if (root.contentVisible)
modalContainer._renderActive = true;
}
}
ElevationShadow {
anchors.fill: contentWrapper
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"
}
Item {
id: contentWrapper
x: 0
y: modalContainer.slideOffset
width: parent.width
height: root._animatedContentH
MouseArea {
anchors.fill: parent
onPressed: mouse => mouse.accepted = true
}
FocusScope {
anchors.fill: parent
focus: keyboardActive
Loader {
id: contentLoader
anchors.fill: parent
active: root.spotlightOpen || root.isClosing || root.contentVisible || root._pendingInitialize
asynchronous: false
sourceComponent: SpotlightLauncherContent {
focus: true
parentModal: root
}
onLoaded: {
if (root._pendingInitialize) {
root._initializeContent(root._pendingQuery, root._pendingMode);
root._pendingInitialize = false;
}
}
}
Keys.onPressed: event => root.spotlightContent?.activeContextMenu?.handleKey(event)
Keys.onEscapePressed: event => {
root.spotlightContent?.activeContextMenu?.handleKey(event);
if (!event.accepted)
root.hide();
event.accepted = true;
}
}
}
}
}
}