Files
Modus/modules/switcher.py
T

406 lines
14 KiB
Python

import json
import gi
from gi.repository import Gdk, Glace
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 utils.icon_resolver import IconResolver
from utils.occlusion import get_screen_dimensions
from utils.functions import is_special_workspace
from widgets.wayland import WaylandWindow as Window
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}")