From f918c67cc95bd0aa636aebb6158f4aa47d9db440 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Mon, 16 Jun 2025 14:23:36 +0545 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(ui):=20add=20application=20swi?= =?UTF-8?q?tcher=20with=20keyboard=20navigation=20and=20mouse=20interactio?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.css | 1 + main.py | 3 + modules/switcher.py | 284 ++++++++++++++++++++++++++++++++++++++++++++ styles/switcher.css | 36 ++++++ 4 files changed, 324 insertions(+) create mode 100644 modules/switcher.py create mode 100644 styles/switcher.css diff --git a/main.css b/main.css index bfa3dfe..45f5310 100644 --- a/main.css +++ b/main.css @@ -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; diff --git a/main.py b/main.py index ce7dc32..d2f4af0 100644 --- a/main.py +++ b/main.py @@ -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(): diff --git a/modules/switcher.py b/modules/switcher.py new file mode 100644 index 0000000..86d2897 --- /dev/null +++ b/modules/switcher.py @@ -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}") diff --git a/styles/switcher.css b/styles/switcher.css new file mode 100644 index 0000000..d2f9849 --- /dev/null +++ b/styles/switcher.css @@ -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; +}