✨ feat(ui): add application switcher with keyboard navigation and mouse interaction
This commit is contained in:
4 files changed
+324
No files matched your search
@@ -5,6 +5,7 @@
|
||||
@import url("./styles/launcher.css");
|
||||
@import url("./styles/indicators.css");
|
||||
@import url("./styles/workspaces.css");
|
||||
@import url("./styles/switcher.css");
|
||||
|
||||
* {
|
||||
all: unset;
|
||||
|
||||
@@ -10,6 +10,7 @@ from config.data import APP_NAME, APP_NAME_CAP, CONFIG_FILE
|
||||
from modules.corners import Corners
|
||||
from modules.dock.main import Dock
|
||||
from modules.launcher.main import Launcher
|
||||
from modules.switcher import ApplicationSwitcher
|
||||
|
||||
gi.require_version("GLib", "2.0")
|
||||
|
||||
@@ -44,6 +45,7 @@ if __name__ == "__main__":
|
||||
corners = Corners()
|
||||
dock = Dock()
|
||||
launcher = Launcher()
|
||||
switcher = ApplicationSwitcher()
|
||||
|
||||
# Set corners visibility based on config
|
||||
corners_visible = config.get("corners_visible", True)
|
||||
@@ -61,6 +63,7 @@ if __name__ == "__main__":
|
||||
dock,
|
||||
corners,
|
||||
launcher,
|
||||
switcher,
|
||||
) # Make sure corners is added to the app
|
||||
|
||||
def set_css():
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import config.data as data
|
||||
from fabric.hyprland.widgets import get_hyprland_connection
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.eventbox import EventBox
|
||||
from fabric.widgets.image import Image
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import Gdk
|
||||
from utils.icon_resolver import IconResolver
|
||||
from utils.wayland import WaylandWindow as Window
|
||||
|
||||
|
||||
class ApplicationSwitcher(Window):
|
||||
"""
|
||||
A application switcher implementation for Hyprland that provides a grid-based application switching interface.
|
||||
Supports keyboard navigation, mouse interaction, and application activation.
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(
|
||||
name="application-switcher",
|
||||
layer="top",
|
||||
anchor="center",
|
||||
exclusivity="normal",
|
||||
keyboard_mode="on-demand",
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
self.conn = get_hyprland_connection()
|
||||
self.icon_resolver = IconResolver()
|
||||
self.windows = []
|
||||
self.current_index = 0
|
||||
self.tab_pressed = False
|
||||
self.items_per_row = data.WINDOW_SWITCHER_ITEMS_PER_ROW
|
||||
self.icon_size = 64
|
||||
|
||||
# Create a container box that expands
|
||||
container = Box(
|
||||
name="application-switcher-container",
|
||||
orientation="v",
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
expand=True,
|
||||
)
|
||||
self.add(container)
|
||||
|
||||
self.view = Box(
|
||||
name="application-switcher-view",
|
||||
orientation="v",
|
||||
spacing=0,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
container.add(self.view)
|
||||
self.connect("key-press-event", self.on_key_press)
|
||||
self.connect("key-release-event", self.on_key_release)
|
||||
self.show_all()
|
||||
self.hide()
|
||||
|
||||
def show_switcher(self) -> None:
|
||||
self.update_windows()
|
||||
if not self.windows:
|
||||
return
|
||||
|
||||
self.show()
|
||||
self.grab_keyboard()
|
||||
self.tab_pressed = False
|
||||
|
||||
def hide_switcher(self) -> None:
|
||||
self.hide()
|
||||
self.ungrab_keyboard()
|
||||
|
||||
def update_windows(self) -> None:
|
||||
# Clear existing windows
|
||||
for child in self.view.get_children():
|
||||
self.view.remove(child)
|
||||
|
||||
try:
|
||||
clients_data = self.conn.send_command("j/clients").reply
|
||||
if not clients_data:
|
||||
return
|
||||
clients = json.loads(clients_data.decode("utf-8"))
|
||||
self.windows = [c for c in clients if not c.get("hidden", False)]
|
||||
|
||||
active_data = self.conn.send_command("j/activewindow").reply
|
||||
active_window = (
|
||||
json.loads(active_data.decode("utf-8")) if active_data else None
|
||||
)
|
||||
|
||||
# Set current index to active window
|
||||
self.current_index = 0
|
||||
if active_window:
|
||||
for i, window in enumerate(self.windows):
|
||||
if window.get("address") == active_window.get("address"):
|
||||
self.current_index = i
|
||||
break
|
||||
|
||||
# Create window grid
|
||||
current_row = Box(
|
||||
name="window-row",
|
||||
orientation="h",
|
||||
spacing=12,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.view.add(current_row)
|
||||
|
||||
for i, window in enumerate(self.windows):
|
||||
class_name = window.get("class", "").lower()
|
||||
title = window.get("title", "")
|
||||
|
||||
# Get icon
|
||||
icon_img = self.icon_resolver.get_icon_pixbuf(
|
||||
class_name, self.icon_size
|
||||
)
|
||||
if not icon_img:
|
||||
icon_img = self.icon_resolver.get_icon_pixbuf(
|
||||
"application-x-executable-symbolic", self.icon_size
|
||||
)
|
||||
|
||||
button_content = Box(
|
||||
name="switcher-button",
|
||||
orientation="v",
|
||||
spacing=4,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
children=[
|
||||
Image(pixbuf=icon_img),
|
||||
Label(
|
||||
label=title[:15] + "..." if len(title) > 15 else title,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
max_width_chars=15,
|
||||
ellipsize="end",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
event_box = EventBox(
|
||||
name="window-button",
|
||||
style_classes=["active"] if i == self.current_index else None,
|
||||
child=button_content,
|
||||
)
|
||||
event_box.connect("button-press-event", self.on_button_clicked, i)
|
||||
current_row.add(event_box)
|
||||
|
||||
# Create new row if needed
|
||||
if (i + 1) % self.items_per_row == 0 and i + 1 < len(self.windows):
|
||||
current_row = Box(
|
||||
name="window-row",
|
||||
orientation="h",
|
||||
spacing=12,
|
||||
h_align="center",
|
||||
v_align="center",
|
||||
)
|
||||
self.view.add(current_row)
|
||||
|
||||
self.view.show_all()
|
||||
self.update_selection()
|
||||
except Exception as e:
|
||||
print(f"Failed to update windows: {e}")
|
||||
|
||||
def on_button_clicked(self, widget, event, index):
|
||||
self.current_index = index
|
||||
self.update_selection()
|
||||
if event.type == Gdk.EventType.DOUBLE_BUTTON_PRESS:
|
||||
self.activate_selected()
|
||||
self.hide_switcher()
|
||||
return True
|
||||
|
||||
def on_key_press(self, widget, event):
|
||||
keyval = event.keyval
|
||||
state = event.state
|
||||
alt_pressed = bool(state & Gdk.ModifierType.MOD1_MASK)
|
||||
|
||||
if not self.windows:
|
||||
return False
|
||||
|
||||
if keyval == Gdk.KEY_Escape:
|
||||
self.hide_switcher()
|
||||
return True
|
||||
|
||||
if keyval == Gdk.KEY_Tab:
|
||||
if not self.tab_pressed or alt_pressed:
|
||||
self.current_index = (self.current_index + 1) % len(self.windows)
|
||||
self.update_selection()
|
||||
self.tab_pressed = True
|
||||
return True
|
||||
|
||||
if keyval == Gdk.KEY_ISO_Left_Tab or (
|
||||
keyval == Gdk.KEY_Tab and (state & Gdk.ModifierType.SHIFT_MASK)
|
||||
):
|
||||
self.current_index = (self.current_index - 1) % len(self.windows)
|
||||
self.update_selection()
|
||||
return True
|
||||
|
||||
if keyval == Gdk.KEY_Return:
|
||||
self.activate_selected()
|
||||
self.hide_switcher()
|
||||
return True
|
||||
|
||||
# Arrow keys
|
||||
if keyval == Gdk.KEY_Right:
|
||||
self.current_index = (self.current_index + 1) % len(self.windows)
|
||||
self.update_selection()
|
||||
return True
|
||||
|
||||
if keyval == Gdk.KEY_Left:
|
||||
self.current_index = (self.current_index - 1) % len(self.windows)
|
||||
self.update_selection()
|
||||
return True
|
||||
|
||||
if keyval == Gdk.KEY_Down:
|
||||
next_index = self.current_index + self.items_per_row
|
||||
if next_index < len(self.windows):
|
||||
self.current_index = next_index
|
||||
self.update_selection()
|
||||
return True
|
||||
|
||||
if keyval == Gdk.KEY_Up:
|
||||
next_index = self.current_index - self.items_per_row
|
||||
if next_index >= 0:
|
||||
self.current_index = next_index
|
||||
self.update_selection()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def on_key_release(self, widget, event):
|
||||
keyval = event.keyval
|
||||
|
||||
if keyval in (Gdk.KEY_Alt_L, Gdk.KEY_Alt_R):
|
||||
self.activate_selected()
|
||||
self.hide_switcher()
|
||||
return True
|
||||
|
||||
if keyval == Gdk.KEY_Tab:
|
||||
self.tab_pressed = False
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def update_selection(self):
|
||||
for row in self.view.get_children():
|
||||
for i, child in enumerate(row.get_children()):
|
||||
index = self.view.get_children().index(row) * self.items_per_row + i
|
||||
if index == self.current_index:
|
||||
child.add_style_class("active")
|
||||
else:
|
||||
child.remove_style_class("active")
|
||||
|
||||
def activate_selected(self):
|
||||
if not self.windows or self.current_index >= len(self.windows):
|
||||
return
|
||||
|
||||
window = self.windows[self.current_index]
|
||||
address = window.get("address")
|
||||
if address:
|
||||
try:
|
||||
cmd = ["hyprctl", "dispatch", "focuswindow", f"address:{address}"]
|
||||
subprocess.run(cmd, capture_output=True, text=True, timeout=2)
|
||||
except Exception as e:
|
||||
print(f"Failed to focus window: {e}")
|
||||
|
||||
def grab_keyboard(self):
|
||||
"""Grab keyboard focus."""
|
||||
try:
|
||||
display = Gdk.Display.get_default()
|
||||
seat = display.get_default_seat()
|
||||
window = self.get_window()
|
||||
seat.grab(window, Gdk.SeatCapabilities.KEYBOARD, False, None, None, None)
|
||||
except Exception as e:
|
||||
print(f"Failed to grab keyboard: {e}")
|
||||
|
||||
def ungrab_keyboard(self):
|
||||
"""Release keyboard focus."""
|
||||
try:
|
||||
display = Gdk.Display.get_default()
|
||||
seat = display.get_default_seat()
|
||||
seat.ungrab()
|
||||
except Exception as e:
|
||||
print(f"Failed to ungrab keyboard: {e}")
|
||||
@@ -0,0 +1,36 @@
|
||||
#application-switcher {
|
||||
background-color: var(--shadow);
|
||||
}
|
||||
|
||||
#application-switcher-view {
|
||||
min-height: 120px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#switcher-button {
|
||||
padding: 4px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
#window-button {
|
||||
background-color: var(--surface);
|
||||
}
|
||||
|
||||
#switcher-button:hover {
|
||||
background-color: var(--surface);
|
||||
}
|
||||
|
||||
#window-button.active {
|
||||
background-color: var(--surface-bright);
|
||||
border: 3px solid var(--surface-bright);
|
||||
}
|
||||
|
||||
#switcher-button label {
|
||||
color: var(--foreground);
|
||||
font-size: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
#window-row {
|
||||
padding: 18px;
|
||||
}
|
||||
Reference in new issue
Block a user