1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-08 04:09:15 -04:00

feat(Spotlight): Add a New Lightweight Spotlight style launcher option

This commit is contained in:
purian23
2026-05-16 17:34:56 -04:00
parent 05c7a77c8b
commit 9f2ae6241e
11 changed files with 1496 additions and 8 deletions
+3
View File
@@ -258,6 +258,8 @@ Singleton {
onFrameLauncherEmergeSideChanged: saveSettings()
property bool frameLauncherArcExtender: false
onFrameLauncherArcExtenderChanged: saveSettings()
property bool frameUseSpotlightLauncher: false
onFrameUseSpotlightLauncherChanged: saveSettings()
readonly property string frameModalEmergeSide: frameLauncherEmergeSide === "top" ? "bottom" : "top"
property string frameMode: "connected"
onFrameModeChanged: saveSettings()
@@ -447,6 +449,7 @@ Singleton {
property bool dankLauncherV2UnloadOnClose: false
property bool dankLauncherV2IncludeFilesInAll: false
property bool dankLauncherV2IncludeFoldersInAll: false
property string launcherStyle: "full"
property string _legacyWeatherLocation: "New York, NY"
property string _legacyWeatherCoordinates: "40.7128,-74.0060"
@@ -213,6 +213,7 @@ var SPEC = {
dankLauncherV2UnloadOnClose: { def: false },
dankLauncherV2IncludeFilesInAll: { def: false },
dankLauncherV2IncludeFoldersInAll: { def: false },
launcherStyle: { def: "full" },
useAutoLocation: { def: false },
weatherEnabled: { def: true },
@@ -572,6 +573,7 @@ var SPEC = {
frameCloseGaps: { def: true },
frameLauncherEmergeSide: { def: "bottom" },
frameLauncherArcExtender: { def: false },
frameUseSpotlightLauncher: { def: false },
frameMode: { def: "connected" }
};
@@ -61,7 +61,8 @@ Item {
impl.item.toggleWithMode(mode);
}
readonly property var _desiredBackend: SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp
readonly property bool useSpotlightBackend: SettingsData.connectedFrameModeActive ? SettingsData.frameUseSpotlightLauncher : SettingsData.launcherStyle === "spotlight"
readonly property var _desiredBackend: useSpotlightBackend ? spotlightComp : (SettingsData.connectedFrameModeActive ? connectedComp : standaloneComp)
property var _resolvedBackend: null
Component.onCompleted: _resolvedBackend = _desiredBackend
@@ -71,6 +72,12 @@ Item {
function onConnectedFrameModeActiveChanged() {
root._maybeResolveBackend();
}
function onFrameUseSpotlightLauncherChanged() {
root._maybeResolveBackend();
}
function onLauncherStyleChanged() {
root._maybeResolveBackend();
}
}
// Defer Loader source-component swap until impl is fully closed; avoids
@@ -100,6 +107,11 @@ Item {
DankLauncherV2ModalConnected {}
}
Component {
id: spotlightComp
DankLauncherV2ModalSpotlight {}
}
function _wireBackend(it) {
if (!it)
return;
@@ -0,0 +1,461 @@
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
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 int _openDuration: 80
readonly property int _closeDuration: 70
readonly property int _motionDuration: 90
// Connected frame mode clamps the centered surface inside frame insets.
readonly property bool frameConnected: SettingsData.connectedFrameModeActive && !!effectiveScreen && SettingsData.isScreenInPreferences(effectiveScreen, SettingsData.frameScreenPreferences)
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 - _contentImplicitH);
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;
const targetQuery = query || (SettingsData.rememberLastQuery ? (SessionData.launcherLastQuery || "") : "");
const targetMode = mode || SessionData.launcherLastMode || "all";
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;
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
color: "transparent"
WlrLayershell.namespace: "dms:spotlight:clickcatcher"
WlrLayershell.layer: WlrLayershell.Top
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: {
switch (Quickshell.env("DMS_MODAL_LAYER")) {
case "overlay":
return WlrLayershell.Overlay;
default:
return WlrLayershell.Top;
}
}
WlrLayershell.exclusiveZone: -1
WlrLayershell.keyboardFocus: keyboardActive ? (root.useHyprlandFocusGrab ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) : WlrKeyboardFocus.None
anchors {
top: true
left: true
}
WlrLayershell.margins {
left: root.windowX
top: root.windowY
right: 0
bottom: 0
}
implicitWidth: root.windowWidth
implicitHeight: root.windowHeight
mask: Region {
item: inputMask
}
Rectangle {
id: inputMask
visible: false
color: "transparent"
x: modalContainer.x
y: modalContainer.y + modalContainer.slideOffset
width: root.alignedWidth
height: root._contentImplicitH
}
Item {
id: modalContainer
x: root.contentX
y: root.contentY
width: root.alignedWidth
height: root._animatedContentH
visible: _renderActive
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.onEscapePressed: event => {
root.hide();
event.accepted = true;
}
}
}
}
}
}
@@ -13,6 +13,7 @@ Popup {
property var controller: null
property var searchField: null
property var parentHandler: null
property bool allowEditActions: true
signal hideRequested
signal editAppRequested(var app)
@@ -112,12 +113,14 @@ Popup {
text: I18n.tr("Hide App"),
action: hideCurrentApp
});
items.push({
type: "item",
icon: "edit",
text: I18n.tr("Edit App"),
action: editCurrentApp
});
if (allowEditActions) {
items.push({
type: "item",
icon: "edit",
text: I18n.tr("Edit App"),
action: editCurrentApp
});
}
}
if (item?.actions && item.actions.length > 0) {
@@ -0,0 +1,475 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
FocusScope {
id: root
property var parentModal: null
property alias searchField: searchInput
property alias controller: searchController
readonly property bool _hasQuery: searchInput.text.length > 0
readonly property real _searchBarH: 56
readonly property real _surfaceInset: BlurService.enabled ? (_hasQuery ? Theme.spacingS : Theme.spacingXS) : 0
readonly property real _searchAreaH: _searchBarH + _surfaceInset * 2
readonly property real _statusH: 92
readonly property real _rowH: 64
readonly property real _maxResultsH: Math.min(430, (parentModal?.screenHeight ?? 900) * 0.55)
readonly property var _resultRows: _buildRows()
readonly property real _resultsContentH: _resultRows.length > 0 ? _resultRows.length * _rowH : _statusH
readonly property real _resultsH: _hasQuery ? Math.min(_resultsContentH, _maxResultsH) : 0
readonly property int _fastDuration: 90
readonly property int _resizeDuration: 110
implicitHeight: _searchAreaH + (_resultsH > 0 ? 1 + _resultsH : 0)
function resetScroll() {
resultsList.resetScroll();
}
function _buildRows() {
const flat = searchController.flatModel || [];
const sections = searchController.sections || [];
const rows = [];
for (let i = 0; i < flat.length; i++) {
const entry = flat[i];
if (!entry || entry.isHeader || !entry.item)
continue;
const section = sections[entry.sectionIndex] || null;
rows.push({
"_rowId": entry.item.id || (entry.sectionId + ":" + entry.indexInSection + ":" + i),
"item": entry.item,
"flatIndex": i,
"sectionTitle": section?.title || "",
"sectionIcon": section?.icon || ""
});
}
return rows;
}
function _focusSearch() {
searchInput.forceActiveFocus();
searchInput.cursorPosition = searchInput.text.length;
}
function _showContextMenu(item, sceneX, sceneY, fromKeyboard) {
if (!item || !contextMenu.hasContextMenuActions(item))
return;
const localPos = root.mapFromItem(null, sceneX, sceneY);
contextMenu.show(localPos.x, localPos.y, item, fromKeyboard);
}
function _handleKey(event) {
const hasCtrl = event.modifiers & Qt.ControlModifier;
const hasAlt = event.modifiers & Qt.AltModifier;
switch (event.key) {
case Qt.Key_Escape:
if (searchController.clearPluginFilter()) {
event.accepted = true;
return;
}
root.parentModal?.hide();
event.accepted = true;
return;
case Qt.Key_Backspace:
if (searchInput.text.length === 0) {
if (searchController.clearPluginFilter()) {
event.accepted = true;
return;
}
if (searchController.autoSwitchedToFiles) {
searchController.restorePreviousMode();
event.accepted = true;
return;
}
}
event.accepted = false;
return;
case Qt.Key_Down:
searchController.selectNext();
event.accepted = true;
return;
case Qt.Key_Up:
searchController.selectPrevious();
event.accepted = true;
return;
case Qt.Key_PageDown:
searchController.selectPageDown(7);
event.accepted = true;
return;
case Qt.Key_PageUp:
searchController.selectPageUp(7);
event.accepted = true;
return;
case Qt.Key_J:
if (hasCtrl) {
searchController.selectNext();
event.accepted = true;
return;
}
break;
case Qt.Key_K:
if (hasCtrl) {
searchController.selectPrevious();
event.accepted = true;
return;
}
break;
case Qt.Key_Tab:
if (_hasQuery)
_cycleCategory(false);
event.accepted = true;
return;
case Qt.Key_Backtab:
if (_hasQuery)
_cycleCategory(true);
event.accepted = true;
return;
case Qt.Key_Return:
case Qt.Key_Enter:
if (event.modifiers & Qt.ShiftModifier) {
searchController.pasteSelected();
} else {
searchController.executeSelected();
}
event.accepted = true;
return;
case Qt.Key_Menu:
case Qt.Key_F10:
if (contextMenu.hasContextMenuActions(searchController.selectedItem)) {
const scenePos = resultsList.getSelectedItemPosition();
_showContextMenu(searchController.selectedItem, scenePos.x, scenePos.y, true);
event.accepted = true;
return;
}
break;
case Qt.Key_1:
if (hasCtrl || hasAlt) {
searchController.setMode("all");
event.accepted = true;
return;
}
break;
case Qt.Key_2:
if (hasCtrl || hasAlt) {
searchController.setMode("apps");
event.accepted = true;
return;
}
break;
case Qt.Key_3:
if (hasCtrl || hasAlt) {
searchController.setMode("files");
event.accepted = true;
return;
}
break;
case Qt.Key_4:
if (hasCtrl || hasAlt) {
searchController.setMode("plugins");
event.accepted = true;
return;
}
break;
case Qt.Key_Slash:
if (event.modifiers === Qt.NoModifier && searchInput.text.length === 0) {
searchController.setMode("files", true);
event.accepted = true;
return;
}
break;
}
event.accepted = false;
}
Controller {
id: searchController
active: root.parentModal ? (root.parentModal.spotlightOpen || root.parentModal.isClosing) : true
viewModeContext: "spotlight"
onItemExecuted: {
root.parentModal?.hide();
if (SettingsData.spotlightCloseNiriOverview && NiriService.inOverview)
NiriService.toggleOverview();
}
}
LauncherContextMenu {
id: contextMenu
parent: root
controller: searchController
searchField: searchInput
parentHandler: root
allowEditActions: false
}
Connections {
target: searchController
function onModeChanged(mode) {
if (searchController.autoSwitchedToFiles)
return;
SessionData.setLauncherLastMode(mode);
}
function onSearchQueryRequested(query) {
searchInput.text = query;
root._focusSearch();
}
}
Item {
id: searchBarItem
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: root._searchAreaH
Rectangle {
id: searchBarSurface
anchors.fill: parent
anchors.margins: root._surfaceInset
radius: height / 2
color: Theme.withAlpha(root._hasQuery ? Theme.surfaceContainerHigh : Theme.surfaceContainer, root._hasQuery ? Theme.popupTransparency : Math.max(0.68, Theme.popupTransparency * 0.9))
border.color: BlurService.enabled && !root._hasQuery ? Theme.withAlpha(Theme.outline, 0.08) : "transparent"
border.width: BlurService.enabled && !root._hasQuery ? 1 : 0
Behavior on color {
ColorAnimation {
duration: root._fastDuration
easing.type: Theme.standardEasing
}
}
Rectangle {
id: leadingWell
width: 36
height: 36
radius: height / 2
anchors.left: parent.left
anchors.leftMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
color: searchInput.activeFocus ? Theme.primaryContainer : Theme.surfaceContainer
DankIcon {
anchors.centerIn: parent
name: searchController.activePluginId ? "extension" : searchController.searchMode === "files" ? "folder" : "search"
size: 20
color: searchInput.activeFocus ? Theme.primary : Theme.surfaceVariantText
}
}
Row {
id: rightControls
anchors.right: parent.right
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
Row {
id: categoryRow
spacing: Theme.spacingXS
visible: root._hasQuery
anchors.verticalCenter: parent.verticalCenter
Repeater {
model: root._categoryModel
delegate: Item {
id: categoryChip
required property var modelData
required property int index
readonly property bool isSelected: root._isCategorySelected(modelData)
width: chipLabel.implicitWidth + Theme.spacingM * 2
height: 26
anchors.verticalCenter: parent.verticalCenter
Rectangle {
anchors.fill: parent
radius: height / 2
color: categoryChip.isSelected ? Theme.primary : chipArea.containsMouse ? Theme.surfaceHover : Theme.surfaceVariantAlpha
Behavior on color {
ColorAnimation {
duration: root._fastDuration
easing.type: Theme.standardEasing
}
}
StyledText {
id: chipLabel
anchors.centerIn: parent
text: categoryChip.modelData.label
font.pixelSize: Theme.fontSizeSmall
font.weight: categoryChip.isSelected ? Font.Medium : Font.Normal
color: categoryChip.isSelected ? Theme.primaryText : Theme.surfaceVariantText
}
}
MouseArea {
id: chipArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root._selectCategory(categoryChip.index)
}
}
}
}
DankActionButton {
id: clearButton
anchors.verticalCenter: parent.verticalCenter
iconName: "close"
iconSize: 16
visible: searchInput.text.length > 0
onClicked: {
searchInput.text = "";
searchController.reset();
root._focusSearch();
}
}
}
Text {
anchors.left: leadingWell.right
anchors.leftMargin: Theme.spacingM
anchors.right: rightControls.left
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
text: I18n.tr("Spotlight Search")
font.pixelSize: 18
font.weight: Font.Medium
color: Theme.outlineButton
visible: searchInput.text.length === 0
clip: true
}
TextInput {
id: searchInput
anchors.left: leadingWell.right
anchors.leftMargin: Theme.spacingM
anchors.right: rightControls.left
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
font.pixelSize: 18
font.weight: Font.Medium
color: Theme.surfaceText
selectionColor: Theme.primary
selectedTextColor: Theme.primaryText
clip: true
focus: true
onTextChanged: {
if (text.length > 0) {
searchController.setSearchQuery(text);
} else {
searchController.reset();
}
}
Keys.onPressed: event => root._handleKey(event)
}
}
}
Rectangle {
anchors.top: searchBarItem.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: root._surfaceInset
anchors.rightMargin: root._surfaceInset
height: 1
color: Theme.outlineMedium
opacity: root._resultsH > 0 ? 0.55 : 0
Behavior on opacity {
NumberAnimation {
duration: root._fastDuration
easing.type: Theme.standardEasing
}
}
}
Item {
id: resultsContainer
anchors.top: searchBarItem.bottom
anchors.topMargin: 1
anchors.left: parent.left
anchors.right: parent.right
clip: true
height: root._resultsH
Behavior on height {
NumberAnimation {
duration: root._resizeDuration
easing.type: Easing.BezierSpline
easing.bezierCurve: [0.2, 0.0, 0.0, 1.0, 1.0, 1.0]
}
}
SpotlightResultsList {
id: resultsList
anchors.fill: parent
controller: searchController
hasQuery: root._hasQuery
rows: root._resultRows
onItemRightClicked: (index, item, sceneX, sceneY) => {
root._showContextMenu(item, sceneX, sceneY, false);
}
}
}
readonly property var _categoryModel: [
{
"label": I18n.tr("All"),
"mode": "all"
},
{
"label": I18n.tr("Apps"),
"mode": "apps"
},
{
"label": I18n.tr("Files"),
"mode": "files"
},
{
"label": I18n.tr("Plugins"),
"mode": "plugins"
}
]
function _isCategorySelected(cat) {
return searchController.searchMode === cat.mode;
}
function _cycleCategory(reverse) {
let idx = 0;
for (let i = 0; i < _categoryModel.length; i++) {
if (_isCategorySelected(_categoryModel[i])) {
idx = i;
break;
}
}
idx = reverse ? (idx - 1 + _categoryModel.length) % _categoryModel.length : (idx + 1) % _categoryModel.length;
_selectCategory(idx);
}
function _selectCategory(index) {
const cat = _categoryModel[index];
if (!cat)
return;
searchController.setMode(cat.mode, false);
if (root._hasQuery)
searchController.setSearchQuery(searchInput.text);
root._focusSearch();
}
}
@@ -0,0 +1,299 @@
pragma ComponentBehavior: Bound
import QtQuick
import qs.Common
import qs.Services
import qs.Widgets
import "../../Common/htmlElide.js" as HtmlElide
Rectangle {
id: root
property var item: null
property string sectionTitle: ""
property string sectionIcon: ""
property bool isSelected: false
property var controller: null
property int flatIndex: -1
property bool isHovered: itemArea.containsMouse || quickToggleArea.containsMouse
signal clicked
signal rightClicked(real mouseX, real mouseY)
readonly property string iconValue: {
if (!item)
return "";
switch (item.iconType) {
case "material":
case "nerd":
return "material:" + (item.icon || "apps");
case "unicode":
return "unicode:" + (item.icon || "");
case "composite":
return item.iconFull || "";
case "image":
default:
return item.icon || "";
}
}
readonly property string previewSource: {
const data = item?.data;
const raw = data?.imageUrl || data?.imagePath || (data?.path && isImageFile(data.path) ? data.path : "");
if (!raw)
return "";
if (raw.startsWith("http://") || raw.startsWith("https://") || raw.startsWith("file://"))
return raw;
if (raw.startsWith("/"))
return "file://" + raw;
return raw;
}
readonly property bool hasMediaPreview: previewSource.length > 0
readonly property bool previewAnimated: previewSource.toLowerCase().indexOf(".gif") >= 0
readonly property string typeLabel: {
if (!item)
return "";
switch (item.type) {
case "plugin_browse":
return I18n.tr("Browse");
case "plugin":
return I18n.tr("Plugin");
case "file":
return item.data?.is_dir ? I18n.tr("Folder") : I18n.tr("File");
default:
return "";
}
}
width: parent?.width ?? 200
height: 64
radius: Theme.cornerRadius
color: root.isSelected ? Theme.primaryPressed : root.isHovered ? Theme.primaryHoverLight : "transparent"
Behavior on color {
ColorAnimation {
duration: 90
easing.type: Theme.standardEasing
}
}
DankRipple {
id: rippleLayer
rippleColor: Theme.surfaceText
cornerRadius: root.radius
}
MouseArea {
id: itemArea
z: 2
anchors.fill: parent
anchors.rightMargin: root.item?.type === "plugin_browse" ? 38 : 0
acceptedButtons: Qt.LeftButton | Qt.RightButton
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onPositionChanged: {
if (root.controller)
root.controller.keyboardNavigationActive = false;
}
onPressed: mouse => {
if (mouse.button === Qt.LeftButton)
rippleLayer.trigger(mouse.x, mouse.y);
}
onClicked: mouse => {
if (mouse.button === Qt.RightButton) {
const scenePos = mapToItem(null, mouse.x, mouse.y);
root.rightClicked(scenePos.x, scenePos.y);
} else {
root.clicked();
}
}
}
Rectangle {
id: iconWell
width: 40
height: 40
radius: Theme.cornerRadius
anchors.left: parent.left
anchors.leftMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
color: root.isSelected ? Theme.primaryContainer : Theme.surfaceContainerHigh
border.color: Theme.withAlpha(root.isSelected ? Theme.primary : Theme.outline, root.isSelected ? 0.28 : 0.12)
border.width: 1
AppIconRenderer {
anchors.centerIn: parent
width: 30
height: 30
iconValue: root.iconValue
iconSize: 30
fallbackText: (root.item?.name?.length > 0) ? root.item.name.charAt(0).toUpperCase() : "?"
materialIconSizeAdjustment: 10
}
}
Column {
id: textColumn
anchors.left: iconWell.right
anchors.leftMargin: Theme.spacingM
anchors.right: previewFrame.visible ? previewFrame.left : metaRow.left
anchors.rightMargin: Theme.spacingM
anchors.verticalCenter: parent.verticalCenter
spacing: 2
StyledText {
id: nameText
width: parent.width
text: root.item?._hName ?? root.item?.name ?? ""
textFormat: root.item?._hRich ? Text.RichText : Text.PlainText
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
maximumLineCount: 1
elide: Text.ElideRight
horizontalAlignment: Text.AlignLeft
}
TextMetrics {
id: subProbe
font.pixelSize: Theme.fontSizeSmall
font.family: Theme.fontFamily
elide: Qt.ElideRight
elideWidth: textColumn.width
text: root.item?._hRich ? HtmlElide.stripHtmlTags(root.item?._hSub ?? "") : ""
}
readonly property int _richBudget: {
if (!subProbe.text)
return 0;
const elided = subProbe.elidedText;
return elided.endsWith("\u2026") ? elided.length - 1 : elided.length;
}
StyledText {
width: parent.width
text: root.item?._hRich ? HtmlElide.elideRichText(root.item._hSub ?? "", textColumn._richBudget) : (root.item?.subtitle ?? root.sectionTitle)
textFormat: root.item?._hRich ? Text.RichText : Text.PlainText
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
maximumLineCount: 1
elide: Text.ElideRight
visible: text.length > 0
horizontalAlignment: Text.AlignLeft
}
}
Row {
id: metaRow
anchors.right: parent.right
anchors.rightMargin: Theme.spacingS
anchors.verticalCenter: parent.verticalCenter
spacing: Theme.spacingXS
visible: childrenRect.width > 0
Rectangle {
visible: root.typeLabel.length > 0
width: typeText.implicitWidth + Theme.spacingS * 2
height: 22
radius: height / 2
anchors.verticalCenter: parent.verticalCenter
color: Theme.surfaceVariantAlpha
StyledText {
id: typeText
anchors.centerIn: parent
text: root.typeLabel
font.pixelSize: Theme.fontSizeSmall - 1
color: Theme.surfaceVariantText
}
}
Rectangle {
visible: root.item?.type === "plugin_browse"
width: 28
height: 28
radius: height / 2
anchors.verticalCenter: parent.verticalCenter
color: quickToggleArea.containsMouse ? Theme.surfaceHover : "transparent"
readonly property bool isAllowed: {
if (root.item?.type !== "plugin_browse")
return false;
const pluginId = root.item?.data?.pluginId;
if (!pluginId)
return false;
SettingsData.launcherPluginVisibility;
return SettingsData.getPluginAllowWithoutTrigger(pluginId);
}
DankIcon {
anchors.centerIn: parent
name: parent.isAllowed ? "visibility" : "visibility_off"
size: 17
color: parent.isAllowed ? Theme.primary : Theme.surfaceVariantText
}
MouseArea {
id: quickToggleArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
const pluginId = root.item?.data?.pluginId;
if (!pluginId)
return;
SettingsData.setPluginAllowWithoutTrigger(pluginId, !parent.isAllowed);
}
}
}
SourceBadge {
visible: root.item?.type === "app"
anchors.verticalCenter: parent.verticalCenter
source: root.item?.type === "app" ? (root.item.source || "") : ""
glyphSize: 14
}
}
Rectangle {
id: previewFrame
visible: root.hasMediaPreview
width: 64
height: 44
radius: Theme.cornerRadius
anchors.right: metaRow.left
anchors.rightMargin: metaRow.visible ? Theme.spacingS : 0
anchors.verticalCenter: parent.verticalCenter
clip: true
color: Theme.surfaceContainerHigh
border.color: Theme.withAlpha(Theme.outline, 0.16)
border.width: 1
Image {
anchors.fill: parent
source: root.previewSource
asynchronous: true
fillMode: Image.PreserveAspectCrop
visible: !root.previewAnimated
}
AnimatedImage {
anchors.fill: parent
source: root.previewSource
fillMode: Image.PreserveAspectCrop
playing: visible
visible: root.previewAnimated
}
}
function isImageFile(path) {
if (!path)
return false;
const ext = path.split(".").pop().toLowerCase();
return ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "jxl", "avif", "heif", "exr"].indexOf(ext) >= 0;
}
}
@@ -0,0 +1,183 @@
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Common
import qs.Services
import qs.Widgets
Item {
id: root
property var controller: null
property bool hasQuery: false
property var rows: []
signal itemRightClicked(int index, var item, real mouseX, real mouseY)
function resetScroll() {
mainListView.contentY = mainListView.originY;
}
function ensureVisible(flatIndex) {
if (!controller || flatIndex < 0)
return;
for (let i = 0; i < rows.length; i++) {
if ((rows[i]?.flatIndex ?? -1) === flatIndex) {
mainListView.positionViewAtIndex(i, ListView.Contain);
return;
}
}
}
function getSelectedItemPosition() {
const fallback = mapToItem(null, width / 2, Math.min(height / 2, 56));
if (!controller || controller.selectedFlatIndex < 0)
return fallback;
for (let i = 0; i < rows.length; i++) {
if ((rows[i]?.flatIndex ?? -1) === controller.selectedFlatIndex) {
const rowY = i * mainListView.rowHeight - mainListView.contentY + mainListView.originY;
return mapToItem(null, width / 2, Math.max(28, Math.min(height - 28, rowY + mainListView.rowHeight / 2)));
}
}
return fallback;
}
Connections {
target: root.controller
function onSelectedFlatIndexChanged() {
if (root.controller?.keyboardNavigationActive)
Qt.callLater(() => root.ensureVisible(root.controller.selectedFlatIndex));
}
}
DankListView {
id: mainListView
anchors.fill: parent
clip: true
visible: root.rows.length > 0
readonly property int rowHeight: 64
model: ScriptModel {
values: root.rows
objectProp: "_rowId"
}
add: null
remove: null
displaced: null
move: null
delegate: Item {
id: delegateRoot
required property var modelData
required property int index
width: mainListView.width
height: mainListView.rowHeight
SpotlightResultRow {
anchors.fill: parent
anchors.leftMargin: Theme.spacingS
anchors.rightMargin: Theme.spacingS
anchors.topMargin: 3
anchors.bottomMargin: 3
item: delegateRoot.modelData?.item ?? null
sectionTitle: delegateRoot.modelData?.sectionTitle ?? ""
sectionIcon: delegateRoot.modelData?.sectionIcon ?? ""
flatIndex: delegateRoot.modelData?.flatIndex ?? -1
controller: root.controller
isSelected: (delegateRoot.modelData?.flatIndex ?? -1) === root.controller?.selectedFlatIndex
onClicked: {
if (root.controller && delegateRoot.modelData?.item)
root.controller.executeItem(delegateRoot.modelData.item);
}
onRightClicked: (mouseX, mouseY) => {
root.itemRightClicked(delegateRoot.modelData?.flatIndex ?? -1, delegateRoot.modelData?.item ?? null, mouseX, mouseY);
}
}
}
}
Item {
anchors.fill: parent
visible: root.hasQuery && root.rows.length === 0
Row {
anchors.centerIn: parent
spacing: Theme.spacingM
Rectangle {
width: 40
height: 40
radius: Theme.cornerRadius
anchors.verticalCenter: parent.verticalCenter
color: Theme.surfaceContainerHigh
DankIcon {
anchors.centerIn: parent
name: root.controller?.isSearching || root.controller?.isFileSearching ? "search" : statusIcon()
size: 22
color: Theme.surfaceVariantText
function statusIcon() {
const mode = root.controller?.searchMode ?? "all";
if (mode === "files")
return "folder_open";
if (mode === "plugins")
return "extension";
if (mode === "apps")
return "apps";
return "search_off";
}
}
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: Math.min(420, root.width - 88)
spacing: 2
StyledText {
width: parent.width
text: statusTitle()
font.pixelSize: Theme.fontSizeMedium
font.weight: Font.Medium
color: Theme.surfaceText
elide: Text.ElideRight
function statusTitle() {
if (root.controller?.isSearching || root.controller?.isFileSearching)
return I18n.tr("Searching");
if ((root.controller?.searchMode ?? "") === "files" && !DSearchService.dsearchAvailable)
return I18n.tr("File search unavailable");
if ((root.controller?.searchMode ?? "") === "files" && (root.controller?.searchQuery?.length ?? 0) < 2)
return I18n.tr("Keep typing");
return I18n.tr("No results");
}
}
StyledText {
width: parent.width
text: statusSubtitle()
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
maximumLineCount: 2
wrapMode: Text.WordWrap
elide: Text.ElideRight
function statusSubtitle() {
if ((root.controller?.searchMode ?? "") === "files" && !DSearchService.dsearchAvailable)
return I18n.tr("Install dsearch to search files.");
if ((root.controller?.searchMode ?? "") === "files" && (root.controller?.searchQuery?.length ?? 0) < 2)
return I18n.tr("Type at least 2 characters to search files.");
return I18n.tr("Try a different search or switch filters.");
}
}
}
}
}
}
@@ -205,7 +205,9 @@ FocusScope {
visible: active
focus: active
sourceComponent: LauncherTab {}
sourceComponent: LauncherTab {
parentModal: root.parentModal
}
onActiveChanged: {
if (active && item)
+9
View File
@@ -308,6 +308,15 @@ Item {
onToggled: checked => SettingsData.set("frameCloseGaps", !checked)
}
SettingsToggleRow {
settingKey: "frameUseSpotlightLauncher"
tags: ["frame", "connected", "launcher", "spotlight", "search", "minimal"]
text: I18n.tr("Use Spotlight Launcher")
description: I18n.tr("Use the centered minimal launcher instead of the connected V2 launcher")
checked: SettingsData.frameUseSpotlightLauncher
onToggled: checked => SettingsData.set("frameUseSpotlightLauncher", checked)
}
SettingsButtonGroupRow {
settingKey: "frameLauncherEmergeSide"
tags: ["frame", "connected", "launcher", "modal", "emerge", "direction", "bottom", "top"]
@@ -8,6 +8,8 @@ import qs.Modules.Settings.Widgets
Item {
id: root
property var parentModal: null
FileBrowserModal {
id: logoFileBrowser
browserTitle: I18n.tr("Select Launcher Logo")
@@ -30,6 +32,43 @@ Item {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.spacingXL
SettingsCard {
width: parent.width
iconName: "search"
title: I18n.tr("Launcher Style")
settingKey: "launcherStyle"
SettingsControlledByFrame {
visible: SettingsData.connectedFrameModeActive
parentModal: root.parentModal
settingLabel: I18n.tr("Launcher Style")
reason: I18n.tr("Managed by Frame Mode")
}
StyledText {
width: parent.width
visible: !SettingsData.connectedFrameModeActive
text: SettingsData.launcherStyle === "spotlight" ? I18n.tr("Minimal Spotlight-style bar: appears instantly at the top of the screen and expands as you type.") : I18n.tr("Full-featured launcher with mode tabs, grid view, and action panel.")
font.pixelSize: Theme.fontSizeSmall
color: Theme.surfaceVariantText
wrapMode: Text.WordWrap
}
SettingsButtonGroupRow {
visible: !SettingsData.connectedFrameModeActive
settingKey: "launcherStyleSelector"
tags: ["launcher", "style", "spotlight", "full", "minimal"]
text: I18n.tr("Style")
model: [I18n.tr("Full"), I18n.tr("Spotlight")]
currentIndex: SettingsData.launcherStyle === "spotlight" ? 1 : 0
onSelectionChanged: (index, selected) => {
if (!selected)
return;
SettingsData.set("launcherStyle", index === 1 ? "spotlight" : "full");
}
}
}
SettingsCard {
width: parent.width
iconName: "apps"