Skip to content

Commit ba329ac

Browse files
committed
window switcher
1 parent 6b08a96 commit ba329ac

8 files changed

Lines changed: 586 additions & 4 deletions

File tree

dot_files/hypr/hyprland.conf

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,10 @@ bindl = , XF86AudioPrev, exec, playerctl previous
336336
# Screenshot with Gradia (Print Screen key)
337337
bind = , Print, exec, flatpak run be.alexandervanhee.gradia --screenshot=INTERACTIVE
338338

339+
# App Switcher (Super+Tab to switch between windows across all workspaces)
340+
bind = $mainMod, Tab, exec, qs ipc call shell nextWindow
341+
bind = $mainMod SHIFT, Tab, exec, qs ipc call shell prevWindow
342+
339343
##############################
340344
### WINDOWS AND WORKSPACES ###
341345
##############################
@@ -356,3 +360,6 @@ windowrule = nofocus,class:^$,title:^$,xwayland:1,floating:1,fullscreen:0,pinned
356360
layerrule = blur,quickshell
357361
layerrule = ignorealpha 0,quickshell
358362
layerrule = animation slide,quickshell
363+
364+
# App switcher layer rules
365+
layerrule = animation fade,appswitcher

dot_files/quickshell/modules/sidebars/SidebarLeft.qml

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ PanelWindow {
3434

3535
// State for search results
3636
property var searchResults: []
37+
property var allApps: []
3738
property string currentQuery: ""
39+
property bool isSearching: false
3840

3941
// Background
4042
Rectangle {
@@ -94,7 +96,12 @@ PanelWindow {
9496

9597
onTextChanged: {
9698
root.currentQuery = text
97-
queryDebounceTimer.restart()
99+
root.isSearching = text.trim() !== ""
100+
if (root.isSearching) {
101+
queryDebounceTimer.restart()
102+
} else {
103+
searchResults = []
104+
}
98105
}
99106

100107
Keys.onEscapePressed: {
@@ -109,7 +116,8 @@ PanelWindow {
109116
Keys.onUpPressed: appListView.decrementCurrentIndex()
110117
Keys.onReturnPressed: {
111118
if (appListView.currentIndex >= 0 && appListView.currentIndex < appListView.count) {
112-
launchApp(searchResults[appListView.currentIndex])
119+
const apps = root.isSearching ? searchResults : allApps
120+
launchApp(apps[appListView.currentIndex])
113121
}
114122
}
115123
}
@@ -141,7 +149,7 @@ PanelWindow {
141149
clip: true
142150
spacing: 2
143151

144-
model: searchResults
152+
model: root.isSearching ? searchResults : allApps
145153

146154
delegate: MouseArea {
147155
id: appDelegate
@@ -248,7 +256,7 @@ PanelWindow {
248256
Text {
249257
anchors.centerIn: parent
250258
visible: appListView.count === 0
251-
text: searchInput.text ? "No applications found" : "Type to search..."
259+
text: root.isSearching ? "No applications found" : "Loading applications..."
252260
font.family: Common.Appearance.fonts.main
253261
font.pixelSize: Common.Appearance.fontSize.normal
254262
color: Common.Appearance.m3colors.onSurfaceVariant
@@ -263,6 +271,60 @@ PanelWindow {
263271
// Track the query we're waiting for results from
264272
property string activeQueryId: ""
265273

274+
// Load all apps on startup
275+
Component.onCompleted: {
276+
allAppsQuery.running = true
277+
}
278+
279+
// Query to get all applications sorted alphabetically
280+
Process {
281+
id: allAppsQuery
282+
property var pendingApps: []
283+
command: ["bash", "-lc", "datacube-cli query '' --json -m 500 -p applications"]
284+
285+
stdout: SplitParser {
286+
splitMarker: "\n"
287+
onRead: data => {
288+
if (!data || data.trim() === "") return
289+
290+
try {
291+
const item = JSON.parse(data)
292+
const result = {
293+
id: item.id || "",
294+
type: "app",
295+
name: item.text || "",
296+
description: item.subtext || "",
297+
genericName: item.subtext || "",
298+
icon: getIconPath(item.icon),
299+
exec: item.exec || "",
300+
provider: item.provider || "",
301+
score: item.score || 0,
302+
_raw: item
303+
}
304+
allAppsQuery.pendingApps.push(result)
305+
} catch (e) {
306+
console.log("Failed to parse app:", e, data)
307+
}
308+
}
309+
}
310+
311+
onStarted: {
312+
allAppsQuery.pendingApps = []
313+
}
314+
315+
onExited: {
316+
// Sort alphabetically by name
317+
allAppsQuery.pendingApps.sort((a, b) => {
318+
const nameA = (a.name || "").toLowerCase()
319+
const nameB = (b.name || "").toLowerCase()
320+
if (nameA < nameB) return -1
321+
if (nameA > nameB) return 1
322+
return 0
323+
})
324+
root.allApps = allAppsQuery.pendingApps
325+
}
326+
}
327+
266328
// Debounce timer for search queries
267329
Timer {
268330
id: queryDebounceTimer
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
import QtQuick
2+
import QtQuick.Layouts
3+
import Quickshell
4+
import Quickshell.Wayland
5+
6+
import "../common" as Common
7+
import "../../services" as Services
8+
9+
// App switcher overlay - centered on screen
10+
PanelWindow {
11+
id: root
12+
13+
required property var targetScreen
14+
screen: targetScreen
15+
16+
// Center on screen
17+
anchors {
18+
top: true
19+
bottom: true
20+
left: true
21+
right: true
22+
}
23+
24+
color: "transparent"
25+
26+
visible: Services.Windows.switcherActive
27+
28+
WlrLayershell.layer: WlrLayer.Overlay
29+
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
30+
WlrLayershell.namespace: "appswitcher"
31+
32+
// Focus scope for keyboard handling
33+
FocusScope {
34+
id: focusRoot
35+
anchors.fill: parent
36+
focus: true
37+
38+
// Keyboard handling
39+
Keys.onPressed: (event) => {
40+
if (event.key === Qt.Key_Tab) {
41+
if (event.modifiers & Qt.ShiftModifier) {
42+
Services.Windows.prevWindow()
43+
} else {
44+
Services.Windows.nextWindow()
45+
}
46+
event.accepted = true
47+
} else if (event.key === Qt.Key_Escape) {
48+
Services.Windows.cancelSwitcher()
49+
event.accepted = true
50+
} else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
51+
Services.Windows.selectWindow()
52+
event.accepted = true
53+
} else if (event.key === Qt.Key_Left) {
54+
Services.Windows.prevWindow()
55+
event.accepted = true
56+
} else if (event.key === Qt.Key_Right) {
57+
Services.Windows.nextWindow()
58+
event.accepted = true
59+
}
60+
}
61+
62+
Keys.onReleased: (event) => {
63+
// When Super (Meta) is released, select the current window
64+
if (event.key === Qt.Key_Super_L || event.key === Qt.Key_Super_R || event.key === Qt.Key_Meta) {
65+
Services.Windows.selectWindow()
66+
event.accepted = true
67+
}
68+
}
69+
70+
// Dark overlay background
71+
Rectangle {
72+
anchors.fill: parent
73+
color: Qt.rgba(0, 0, 0, 0.5)
74+
75+
MouseArea {
76+
anchors.fill: parent
77+
onClicked: Services.Windows.cancelSwitcher()
78+
}
79+
}
80+
}
81+
82+
// Switcher panel - centered
83+
Rectangle {
84+
id: switcherPanel
85+
anchors.centerIn: parent
86+
87+
// Calculate width based on number of windows
88+
readonly property int itemWidth: 120
89+
readonly property int itemSpacing: Common.Appearance.spacing.medium
90+
readonly property int windowCount: Services.Windows.windows.length
91+
readonly property int contentWidth: windowCount > 0
92+
? (windowCount * itemWidth) + ((windowCount - 1) * itemSpacing)
93+
: 200 // Minimum width when no windows
94+
95+
width: Math.min(parent.width * 0.8, contentWidth + Common.Appearance.spacing.large * 2)
96+
height: 160
97+
radius: Common.Appearance.rounding.large
98+
color: Qt.rgba(
99+
Common.Appearance.m3colors.surface.r,
100+
Common.Appearance.m3colors.surface.g,
101+
Common.Appearance.m3colors.surface.b,
102+
0.95
103+
)
104+
105+
// Window list
106+
ListView {
107+
id: switcherRow
108+
anchors.fill: parent
109+
anchors.margins: Common.Appearance.spacing.medium
110+
orientation: ListView.Horizontal
111+
spacing: switcherPanel.itemSpacing
112+
clip: true
113+
114+
model: Services.Windows.windows
115+
currentIndex: Services.Windows.currentIndex
116+
117+
highlightFollowsCurrentItem: true
118+
highlightMoveDuration: 150
119+
120+
delegate: Item {
121+
id: windowDelegate
122+
required property var modelData
123+
required property int index
124+
125+
width: switcherPanel.itemWidth
126+
height: switcherRow.height
127+
128+
Rectangle {
129+
anchors.fill: parent
130+
radius: Common.Appearance.rounding.medium
131+
color: switcherRow.currentIndex === windowDelegate.index
132+
? Common.Appearance.m3colors.primaryContainer
133+
: (delegateMouse.containsMouse
134+
? Common.Appearance.m3colors.surfaceVariant
135+
: "transparent")
136+
137+
Behavior on color {
138+
ColorAnimation { duration: 100 }
139+
}
140+
}
141+
142+
MouseArea {
143+
id: delegateMouse
144+
anchors.fill: parent
145+
hoverEnabled: true
146+
onClicked: {
147+
Services.Windows.currentIndex = windowDelegate.index
148+
Services.Windows.selectWindow()
149+
}
150+
}
151+
152+
ColumnLayout {
153+
anchors.fill: parent
154+
anchors.margins: Common.Appearance.spacing.small
155+
spacing: Common.Appearance.spacing.tiny
156+
157+
// App icon
158+
Item {
159+
Layout.fillWidth: true
160+
Layout.preferredHeight: 48
161+
Layout.alignment: Qt.AlignHCenter
162+
163+
// Get icon from IconResolver service
164+
property string cachedIcon: modelData.class ? Services.IconResolver.getIcon(modelData.class) : ""
165+
166+
// Primary: datacube cached icon
167+
Image {
168+
id: appIcon
169+
anchors.centerIn: parent
170+
width: 48
171+
height: 48
172+
source: parent.cachedIcon
173+
sourceSize: Qt.size(48, 48)
174+
smooth: true
175+
visible: status === Image.Ready
176+
}
177+
178+
// Fallback letter icon
179+
Rectangle {
180+
anchors.centerIn: parent
181+
width: 48
182+
height: 48
183+
visible: appIcon.status !== Image.Ready
184+
radius: Common.Appearance.rounding.medium
185+
color: Common.Appearance.m3colors.secondaryContainer
186+
187+
Text {
188+
anchors.centerIn: parent
189+
text: modelData.class ? modelData.class.charAt(0).toUpperCase() : "?"
190+
font.pixelSize: 20
191+
font.bold: true
192+
color: Common.Appearance.m3colors.onSecondaryContainer
193+
}
194+
}
195+
}
196+
197+
// Window title
198+
Text {
199+
Layout.fillWidth: true
200+
Layout.fillHeight: true
201+
text: modelData.title || modelData.class || "Window"
202+
font.family: Common.Appearance.fonts.main
203+
font.pixelSize: Common.Appearance.fontSize.small
204+
color: switcherRow.currentIndex === windowDelegate.index
205+
? Common.Appearance.m3colors.onPrimaryContainer
206+
: Common.Appearance.m3colors.onSurface
207+
horizontalAlignment: Text.AlignHCenter
208+
wrapMode: Text.Wrap
209+
maximumLineCount: 2
210+
elide: Text.ElideRight
211+
}
212+
213+
// Workspace indicator
214+
Text {
215+
Layout.fillWidth: true
216+
text: "Workspace " + (modelData.workspace ? modelData.workspace.id : "?")
217+
font.family: Common.Appearance.fonts.main
218+
font.pixelSize: Common.Appearance.fontSize.small - 2
219+
color: Common.Appearance.m3colors.onSurfaceVariant
220+
horizontalAlignment: Text.AlignHCenter
221+
}
222+
}
223+
}
224+
}
225+
}
226+
227+
// Current window title at bottom
228+
Rectangle {
229+
anchors.horizontalCenter: parent.horizontalCenter
230+
anchors.top: switcherPanel.bottom
231+
anchors.topMargin: Common.Appearance.spacing.medium
232+
width: titleText.implicitWidth + Common.Appearance.spacing.large * 2
233+
height: titleText.implicitHeight + Common.Appearance.spacing.medium
234+
radius: Common.Appearance.rounding.medium
235+
color: Qt.rgba(
236+
Common.Appearance.m3colors.surface.r,
237+
Common.Appearance.m3colors.surface.g,
238+
Common.Appearance.m3colors.surface.b,
239+
0.95
240+
)
241+
visible: Services.Windows.windows.length > 0
242+
243+
Text {
244+
id: titleText
245+
anchors.centerIn: parent
246+
text: {
247+
const windows = Services.Windows.windows
248+
const idx = Services.Windows.currentIndex
249+
if (windows.length > 0 && idx < windows.length) {
250+
return windows[idx].title || windows[idx].class || ""
251+
}
252+
return ""
253+
}
254+
font.family: Common.Appearance.fonts.main
255+
font.pixelSize: Common.Appearance.fontSize.normal
256+
color: Common.Appearance.m3colors.onSurface
257+
maximumLineCount: 1
258+
elide: Text.ElideMiddle
259+
}
260+
}
261+
262+
onVisibleChanged: {
263+
if (visible) {
264+
focusRoot.forceActiveFocus()
265+
}
266+
}
267+
}

0 commit comments

Comments
 (0)