Files
Modus/modules/switcher.py
T

405 lines
14 KiB
Python

import json
import gi
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 fabric.widgets.wayland import WaylandWindow as Window
from gi.repository import Gdk, Glace
import config.data as data
from utils.functions import is_special_workspace
from utils.icon_resolver import IconResolver
from utils.occlusion import get_screen_dimensions
gi.require_version("Glace", "0.1")
class ApplicationSwitcher(Window):
def __init__(self, **kwargs):
super().__init__(
name="application-switcher",
title="modus-switcher",
layer="top",
anchor="center",
exclusivity="auto",
keyboard_mode="exclusive",
visible=False, # Start hidden until explicitly shown
**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
# Initialize Glace manager for window previews
self._manager = Glace.Manager()
# Calculate preview size based on screen ratio
# Formula: screen_ratio = a:b, width = x, height = (x*b)/a
screen_width, screen_height = get_screen_dimensions()
preview_width = 150 # Base width
preview_height = int((preview_width * screen_height) / screen_width)
self.preview_size = [preview_width, preview_height]
self.glace_clients = {} # Map window addresses to Glace clients
self.window_previews = {} # Map window addresses to preview images
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=12,
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)
# Connect to Glace manager signals to track clients
self._manager.connect("client-added", self._on_glace_client_added)
self._manager.connect("client-removed", self._on_glace_client_removed)
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 _on_glace_client_added(self, _, client):
"""Handle when a Glace client is added"""
try:
# Map the client by its window address for later lookup
# We'll need to match this with Hyprland window data
client_id = client.get_id()
self.glace_clients[client_id] = client
except Exception as e:
print(f"Error adding Glace client: {e}")
def _on_glace_client_removed(self, _, client):
"""Handle when a Glace client is removed"""
try:
client_id = client.get_id()
if client_id in self.glace_clients:
del self.glace_clients[client_id]
except Exception as e:
print(f"Error removing Glace client: {e}")
def _find_glace_client_for_window(self, window):
"""Find the corresponding Glace client for a Hyprland window"""
try:
window_class = window.get("class", "").lower()
window_title = window.get("title", "")
# Try to match by app_id/class and title
for _, client in self.glace_clients.items():
try:
client_app_id = client.get_app_id()
client_title = client.get_title()
if (
client_app_id
and client_app_id.lower() == window_class
and client_title
and client_title == window_title
):
return client
except Exception:
continue
# Fallback: try to match by class only
for _, client in self.glace_clients.items():
try:
client_app_id = client.get_app_id()
if client_app_id and client_app_id.lower() == window_class:
return client
except Exception:
continue
except Exception as e:
print(f"Error finding Glace client: {e}")
return None
def create_preview_for_window(self, window):
"""Create a preview image for a specific window"""
glace_client = self._find_glace_client_for_window(window)
# Create a placeholder image first
preview_image = Image()
if glace_client:
def capture_callback(pbuf, _):
try:
scaled_pixbuf = pbuf.scale_simple(
self.preview_size[0],
self.preview_size[1],
2, # GdkPixbuf.InterpType.BILINEAR
)
preview_image.set_from_pixbuf(scaled_pixbuf)
except Exception as e:
print(f"Error setting preview image: {e}")
try:
self._manager.capture_client(
client=glace_client,
overlay_cursor=False,
callback=capture_callback,
user_data=None,
)
except Exception as e:
print(f"Error capturing client preview: {e}")
# Fallback to icon if preview fails
self._set_fallback_icon(preview_image, window)
else:
# Use icon as fallback if no Glace client found
self._set_fallback_icon(preview_image, window)
return preview_image
def _set_fallback_icon(self, image_widget, window):
"""Set a fallback icon when preview is not available"""
class_name = window.get("class", "").lower()
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
)
image_widget.set_from_pixbuf(icon_img)
def _is_special_workspace(self, client):
return is_special_workspace(client)
def update_windows(self) -> None:
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"))
# Filter out hidden windows and optionally special workspace windows
filtered_windows = []
for c in clients:
if c.get("hidden", False):
continue
# Skip clients in special workspaces if the setting is enabled
if data.DOCK_HIDE_SPECIAL_WORKSPACE_APPS and self._is_special_workspace(
c
):
continue
filtered_windows.append(c)
self.windows = filtered_windows
active_data = self.conn.send_command("j/activewindow").reply
active_window = (
json.loads(active_data.decode("utf-8")) if active_data else None
)
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
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):
title = window.get("title", "")
# Create preview image for this window
preview_image = self.create_preview_for_window(window)
button_content = Box(
name="switcher-button",
orientation="v",
spacing=4,
h_align="center",
v_align="center",
children=[
Box(
name="switcher-preview-box",
style_classes=["window-basic", "sleek-border"],
children=[preview_image],
h_align="center",
v_align="center",
),
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,
)
current_row.add(event_box)
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_key_press(self, _, 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
if keyval == Gdk.KEY_Right or keyval == Gdk.KEY_l:
self.current_index = (self.current_index + 1) % len(self.windows)
self.update_selection()
return True
if keyval == Gdk.KEY_Left or keyval == Gdk.KEY_h:
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, _, 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:
command = f"/dispatch focuswindow address:{address}"
self.conn.send_command(command)
except Exception as e:
print(f"Failed to focus window: {e}")
def grab_keyboard(self):
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):
try:
display = Gdk.Display.get_default()
seat = display.get_default_seat()
seat.ungrab()
except Exception as e:
print(f"Failed to ungrab keyboard: {e}")