Files
Modus/modules/launcher/components/window_switcher.py
T
2025-04-25 06:33:51 +05:45

312 lines
11 KiB
Python

import subprocess
import json
from fabric.widgets.box import Box
from fabric.widgets.label import Label
from fabric.widgets.button import Button
from fabric.widgets.entry import Entry
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.image import Image
from fabric.utils import idle_add, remove_handler, exec_shell_command_async
from utils import IconResolver
from gi.repository import GLib, Gdk, Gtk
import utils.icons as icons
import config.data as data
class WindowSwitcher(Box):
def __init__(self, **kwargs):
super().__init__(
name="window-switcher",
visible=False,
all_visible=False,
**kwargs,
)
self.launcher = kwargs["launcher"]
self.selected_index = -1 # Track the selected item index
self.icon_resolver = IconResolver()
self.viewport = Box(name="viewport", spacing=4, orientation="v")
self.search_entry = Entry(
name="search-entry",
placeholder="Search Windows...",
h_expand=True,
notify_text=lambda entry, *_: self.handle_search(entry.get_text()),
on_activate=lambda entry, *_: self.handle_search(entry.get_text()),
on_key_press_event=self.on_entry_key_press,
)
self.search_entry.props.xalign = 0.5
self.scrolled_window = ScrolledWindow(
name="scrolled-window",
spacing=10,
min_content_size=(450, 305),
max_content_size=(450, 305),
child=self.viewport,
)
self.header_box = Box(
name="header_box",
spacing=10,
orientation="h",
children=[
self.search_entry,
],
)
self.window_switcher_box = Box(
name="window-switcher-box",
spacing=10,
h_expand=True,
orientation="v",
children=[
self.header_box,
self.scrolled_window,
],
)
self.add(self.window_switcher_box)
self.show_all()
def close_switcher(self):
"""Close the window switcher"""
self.viewport.hide()
self.selected_index = -1 # Reset selection
self.launcher.close()
def open_switcher(self):
"""Open the window switcher and refresh windows"""
self.refresh_windows()
self.search_entry.set_text("")
self.search_entry.grab_focus()
self.viewport.show()
def refresh_windows(self):
"""Get Hyprland windows and populate the viewport"""
remove_handler(self._arranger_handler) if hasattr(self, '_arranger_handler') else None
self.viewport.children = []
self.selected_index = -1 # Clear selection when viewport changes
# Get Hyprland windows
windows = self.get_hyprland_windows()
if not windows:
# Create a container box to better center the message
container = Box(
name="no-windows-container",
orientation="v",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True
)
# Show a message if no windows
label = Label(
name="no-windows",
markup=icons.window,
h_align="center",
v_align="center",
)
container.add(label)
self.viewport.add(container)
return
# Add window slots to viewport
for window in windows:
self.viewport.add(self.create_window_slot(window))
def get_hyprland_windows(self):
"""Get list of windows from Hyprland"""
try:
# Get clients info from Hyprland
result = subprocess.run(
["hyprctl", "clients", "-j"],
capture_output=True,
text=True,
check=True
)
clients = json.loads(result.stdout)
# Filter out special windows and sort by workspace
windows = []
for client in clients:
if client.get("class") and not client.get("class").startswith("special"):
windows.append(client)
# Sort windows by workspace number
windows.sort(key=lambda x: int(x.get("workspace", {}).get("id", 0)))
return windows
except Exception as e:
print(f"Error getting Hyprland windows: {e}")
return []
def create_window_slot(self, window):
"""Create a button slot for a window"""
# Get window title or class as fallback
title = window.get("title", window.get("class", "Unknown"))
workspace = window.get("workspace", {}).get("id", 0)
window_class = window.get("class", "")
# Get window icon using IconResolver
icon = self.get_window_icon(window_class)
# Create the button with window info
button = Button(
name="slot-button",
child=Box(
name="window-slot-box",
orientation="h",
spacing=10,
children=[
Image(
name="window-icon",
pixbuf=icon,
h_align="start",
),
Label(
name="workspace-label",
label=f"Workspace {workspace}",
h_align="start",
),
Label(
name="window-title",
label=title,
ellipsization="end",
v_align="center",
h_align="center",
),
],
),
on_clicked=lambda *_: self.focus_window(window.get("address")),
on_key_press_event=lambda btn, event, addr=window.get("address"): self.on_slot_key_press(btn, event, addr),
)
return button
def get_window_icon(self, window_class):
"""Get the appropriate icon for a window class using IconResolver"""
try:
# Try to get icon from IconResolver
icon = self.icon_resolver.get_icon_pixbuf(window_class, size=24)
if icon:
return icon
except Exception as e:
print(f"Error resolving icon for {window_class}: {e}")
# Fallback to default window icon if resolution fails
return icons.window
def focus_window(self, address):
"""Focus a window by its address"""
try:
subprocess.run(
["hyprctl", "dispatch", "focuswindow", f"address:{address}"],
check=True
)
self.close_switcher()
except Exception as e:
print(f"Error focusing window: {e}")
def handle_search(self, query):
"""Handle search input"""
if not query:
self.refresh_windows()
return
# Filter windows based on search query
windows = self.get_hyprland_windows()
filtered_windows = [
window for window in windows
if query.lower() in window.get("title", "").lower() or
query.lower() in window.get("class", "").lower()
]
# Update viewport with filtered results
self.viewport.children = []
self.selected_index = -1
for window in filtered_windows:
self.viewport.add(self.create_window_slot(window))
def on_entry_key_press(self, widget, event):
"""Handle key presses on the search entry"""
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
if self.selected_index != -1:
children = self.viewport.get_children()
if 0 <= self.selected_index < len(children):
children[self.selected_index].clicked()
return True
elif event.keyval == Gdk.KEY_Down:
self.move_selection(1)
return True
elif event.keyval == Gdk.KEY_Up:
self.move_selection(-1)
return True
elif event.keyval == Gdk.KEY_Escape:
self.close_switcher()
return True
return False
def on_slot_key_press(self, button, event, address):
"""Handle key presses on window slots"""
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
self.focus_window(address)
return True
elif event.keyval == Gdk.KEY_Escape:
self.close_switcher()
return True
return False
def move_selection(self, direction):
"""Move the selection up or down"""
children = self.viewport.get_children()
if not children:
return
new_index = self.selected_index + direction
if 0 <= new_index < len(children):
self.update_selection(new_index)
def update_selection(self, new_index):
"""Update the selected item"""
# Unselect current
if self.selected_index != -1 and self.selected_index < len(self.viewport.get_children()):
current_button = self.viewport.get_children()[self.selected_index]
current_button.get_style_context().remove_class("selected")
# Select new
if new_index != -1 and new_index < len(self.viewport.get_children()):
new_button = self.viewport.get_children()[new_index]
new_button.get_style_context().add_class("selected")
self.selected_index = new_index
self.scroll_to_selected(new_button)
else:
self.selected_index = -1
def scroll_to_selected(self, button):
"""Scroll to ensure the selected button is visible"""
def scroll():
adj = self.scrolled_window.get_vadjustment()
alloc = button.get_allocation()
if alloc.height == 0:
return False # Retry if allocation isn't ready
y = alloc.y
height = alloc.height
page_size = adj.get_page_size()
current_value = adj.get_value()
# Calculate visible boundaries
visible_top = current_value
visible_bottom = current_value + page_size
if y < visible_top:
# Item above viewport - align to top
adj.set_value(y)
elif y + height > visible_bottom:
# Item below viewport - align to bottom
new_value = y + height - page_size
adj.set_value(new_value)
# No action if already fully visible
return False
GLib.idle_add(scroll)