406 lines
14 KiB
Python
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}")
|