diff --git a/src/config.yaml b/src/config.yaml index 8a37f5d..30fee72 100644 --- a/src/config.yaml +++ b/src/config.yaml @@ -292,6 +292,9 @@ bars: # ^ Specifies if the workspace and monitor index values should be zero-indexed. Accepts: boolean # hide_empty_workspaces: False # ^ Specifies if empty workspaces should be shown in the list of komorebi workspaces. Accepts: boolean + # preview_workspace: False + # ^ Specifies mouse over behavior on komorebi workspaces. If true, you can see workspace by mouse over and get back by leaving the button area. Accepts: boolean + # komorebi_active_layout: # type: "komorebi.active_layout.ActiveLayoutWidget" @@ -420,6 +423,7 @@ widgets: label_default_name: "{index}" label_zero_index: false hide_empty_workspaces: false + preview_workspace: false komorebi_active_layout: type: "komorebi.active_layout.ActiveLayoutWidget" diff --git a/src/core/event_enums.py b/src/core/event_enums.py index 7c0ee35..6bf6972 100644 --- a/src/core/event_enums.py +++ b/src/core/event_enums.py @@ -13,6 +13,9 @@ def __contains__(cls, item): class Event(Enum, metaclass=MetaEvent): pass +class WorkspaceButtonEvent(Event): + HoverEnter = "HoverEnter" + HoverLeave = "HoverLeave" class KomorebiEvent(Event): KomorebiConnect = "KomorebiConnect" diff --git a/src/core/utils/komorebi/client.py b/src/core/utils/komorebi/client.py index 4249e3e..8a2961c 100644 --- a/src/core/utils/komorebi/client.py +++ b/src/core/utils/komorebi/client.py @@ -14,12 +14,13 @@ class KomorebiClient: def __init__( self, komorebic_path: str = "komorebic.exe", - timeout_secs: int = 0.5 + timeout_secs: float = 0.5 ): super().__init__() self._timeout_secs = timeout_secs self._komorebic_path = komorebic_path self._previous_poll_offline = False + self._previous_mouse_follows_focus = False def query_state(self) -> Optional[dict]: with suppress(json.JSONDecodeError, subprocess.CalledProcessError, subprocess.TimeoutExpired): @@ -81,8 +82,31 @@ def get_workspace_by_window_hwnd(self, workspaces: list[Optional[dict]], window_ if managed_window['hwnd'] == window_hwnd: return add_index(workspace, i) - def activate_workspace(self, ws_idx: int) -> None: - subprocess.Popen([self._komorebic_path, "focus-workspace", str(ws_idx)], shell=True) + def get_mouse_follows_focus(self, state: dict) -> bool: + return state['mouse_follows_focus'] + + def activate_workspace(self, ws_idx: int, wait: bool = False) -> None: + p = subprocess.Popen([self._komorebic_path, "focus-workspace", str(ws_idx)], shell=True) + + if wait: + p.wait() + + def hide_preview(self, ws_idx: int, stay_on_workspace: bool = False) -> None: + if self._previous_mouse_follows_focus: + self._previous_mouse_follows_focus = False + self.toggle("mouse-follows-focus", True) + + if not stay_on_workspace: + self.activate_workspace(ws_idx, True) + + def preview_workspace(self, ws_idx: int, state: dict) -> None: + is_mff_active = self.get_mouse_follows_focus(state) + + if is_mff_active and not self._previous_mouse_follows_focus: + self._previous_mouse_follows_focus = True + self.toggle("mouse-follows-focus", True) + + self.activate_workspace(ws_idx, True) def next_workspace(self) -> None: try: @@ -119,9 +143,12 @@ def flip_layout(self) -> None: except subprocess.SubprocessError: pass - def toggle(self, toggle_type: str): + def toggle(self, toggle_type: str, wait: bool = False) -> None: try: - subprocess.Popen([self._komorebic_path, f"toggle-{toggle_type}"], shell=True) + p = subprocess.Popen([self._komorebic_path, f"toggle-{toggle_type}"], shell=True) + + if wait: + p.wait() except subprocess.SubprocessError: logging.exception(f"Failed to toggle {toggle_type} for currently active workspace") diff --git a/src/core/validation/widgets/komorebi/workspaces.py b/src/core/validation/widgets/komorebi/workspaces.py index 6c43974..3813262 100644 --- a/src/core/validation/widgets/komorebi/workspaces.py +++ b/src/core/validation/widgets/komorebi/workspaces.py @@ -3,7 +3,8 @@ 'label_workspace_btn': '{index}', 'label_default_name': '', 'label_zero_index': False, - 'hide_empty_workspaces': False + 'hide_empty_workspaces': False, + 'preview_workspace': False } VALIDATION_SCHEMA = { @@ -26,5 +27,9 @@ 'hide_empty_workspaces': { 'type': 'boolean', 'default': DEFAULTS['hide_empty_workspaces'] - } + }, + 'preview_workspace': { + 'type': 'boolean', + 'default': DEFAULTS['preview_workspace'] + }, } diff --git a/src/core/widgets/komorebi/workspaces.py b/src/core/widgets/komorebi/workspaces.py index dd0c47e..f5c7307 100644 --- a/src/core/widgets/komorebi/workspaces.py +++ b/src/core/widgets/komorebi/workspaces.py @@ -1,11 +1,11 @@ import logging from PyQt6.QtWidgets import QPushButton, QWidget, QHBoxLayout, QLabel -from PyQt6.QtCore import pyqtSignal +from PyQt6.QtCore import QObject, pyqtSignal, QTimer, pyqtSlot from typing import Literal from contextlib import suppress from core.utils.win32.utilities import get_monitor_hwnd from core.event_service import EventService -from core.event_enums import KomorebiEvent +from core.event_enums import KomorebiEvent, WorkspaceButtonEvent from core.widgets.base import BaseWidget from core.utils.komorebi.client import KomorebiClient from core.validation.widgets.komorebi.workspaces import VALIDATION_SCHEMA @@ -24,9 +24,17 @@ class WorkspaceButton(QPushButton): - def __init__(self, workspace_index: int, label: str = None): + def __init__(self, workspace_index: int, label: str | None = None, preview_workspace: bool = False): super().__init__() + self._preview_workspace = preview_workspace self.komorebic = KomorebiClient() + + self.hover_enter_debounce = QTimer() + self.hover_enter_debounce.setInterval(500) + self.hover_enter_debounce.setSingleShot(True) + self.hover_enter_debounce.timeout.connect(self.sendHoverEnter) + + self.event_service = EventService() self.workspace_index = workspace_index self.status = WORKSPACE_STATUS_EMPTY self.setProperty("class", "ws-btn") @@ -34,6 +42,8 @@ def __init__(self, workspace_index: int, label: str = None): self.clicked.connect(self.activate_workspace) self.hide() + self.isPressed = False + def update_and_redraw(self, status: WorkspaceStatus): self.status = status self.setProperty("class", f"ws-btn {status.lower()}") @@ -42,14 +52,54 @@ def update_and_redraw(self, status: WorkspaceStatus): def activate_workspace(self): try: self.komorebic.activate_workspace(self.workspace_index) + self.isPressed = True except Exception: logging.exception(f"Failed to focus workspace at index {self.workspace_index}") + def sendHoverEnter(self): + self.event_service.emit_event( + WorkspaceButtonEvent.HoverEnter, + self.workspace_index, + self.parent().parent().parent() + ) + + def sendHoverLeave(self): + self.event_service.emit_event( + WorkspaceButtonEvent.HoverLeave, + self.workspace_index, + self.parent().parent().parent(), + self.isPressed + ) + + def enterEvent(self, event) -> None: + if not self._preview_workspace: + return super().enterEvent(event) + + if self.status is not WORKSPACE_STATUS_ACTIVE: + self.hover_enter_debounce.start() + + return super().enterEvent(event) + + def leaveEvent(self, a0) -> None: + if not self._preview_workspace: + return super().leaveEvent(a0) + + self.hover_enter_debounce.stop() + self.sendHoverLeave() + + if self.isPressed: + self.isPressed = False + + return super().leaveEvent(a0) + + class WorkspaceWidget(BaseWidget): k_signal_connect = pyqtSignal(dict) k_signal_update = pyqtSignal(dict, dict) k_signal_disconnect = pyqtSignal() + b_signal_hover_enter = pyqtSignal(int, QObject) + b_signal_hover_leave = pyqtSignal(int, QObject, bool) validation_schema = VALIDATION_SCHEMA event_listener = KomorebiEventListener @@ -60,8 +110,10 @@ def __init__( label_workspace_btn: str, label_default_name: str, label_zero_index: bool, - hide_empty_workspaces: bool + hide_empty_workspaces: bool, + preview_workspace: bool ): + super().__init__(class_name="komorebi-workspaces") self._event_service = EventService() @@ -75,6 +127,10 @@ def __init__( self._curr_workspace_index = None self._workspace_buttons: list[WorkspaceButton] = [] self._hide_empty_workspaces = hide_empty_workspaces + + self._preview_workspace = preview_workspace + self._prev_workspace_on_mouse_index = None + self._workspace_on_mouse_index = None self._workspace_focus_events = [ KomorebiEvent.CycleFocusWorkspace.value, @@ -123,17 +179,24 @@ def _register_signals_and_events(self): self.k_signal_connect.connect(self._on_komorebi_connect_event) self.k_signal_update.connect(self._on_komorebi_update_event) self.k_signal_disconnect.connect(self._on_komorebi_disconnect_event) + self.b_signal_hover_enter.connect(self._show_workspace_on_mouse) + self.b_signal_hover_leave.connect(self._hide_workspace_on_mouse) self._event_service.register_event(KomorebiEvent.KomorebiConnect, self.k_signal_connect) self._event_service.register_event(KomorebiEvent.KomorebiDisconnect, self.k_signal_disconnect) self._event_service.register_event(KomorebiEvent.KomorebiUpdate, self.k_signal_update) + self._event_service.register_event(WorkspaceButtonEvent.HoverEnter, self.b_signal_hover_enter) + self._event_service.register_event(WorkspaceButtonEvent.HoverLeave, self.b_signal_hover_leave) + def _reset(self): self._komorebi_state = None self._komorebi_screen = None self._komorebi_workspaces = [] self._curr_workspace_index = None self._prev_workspace_index = None + self._prev_workspace_on_mouse_index = None + self._workspace_on_mouse_index = None self._workspace_buttons = [] self._clear_container_layout() @@ -264,7 +327,7 @@ def _try_add_workspace_button(self, workspace_index: int) -> WorkspaceButton: if workspace_index not in workspace_button_indexes: ws_label = self._get_workspace_label(workspace_index) - workspace_btn = WorkspaceButton(workspace_index, ws_label) + workspace_btn = WorkspaceButton(workspace_index, ws_label, self._preview_workspace) self._update_button(workspace_btn) self._workspace_buttons.append(workspace_btn) @@ -283,3 +346,23 @@ def _show_offline_status(self): def _hide_offline_status(self): self._offline_text.hide() self._workspace_container.show() + + @pyqtSlot(int, QObject) + def _show_workspace_on_mouse(self, ws_idx: int, parent) -> None: + if id(self) != id(parent): + return + + self._workspace_on_mouse_index = ws_idx + self._prev_workspace_on_mouse_index = self._curr_workspace_index + self._komorebic.preview_workspace(self._workspace_on_mouse_index, self._komorebi_state) + + + @pyqtSlot(int, QObject, bool) + def _hide_workspace_on_mouse(self, ws_idx: int, parent, is_pressed: bool) -> None: + if id(self) != id(parent): + return + + if self._workspace_on_mouse_index is not None: + self._komorebic.hide_preview(self._prev_workspace_on_mouse_index, is_pressed) + self._prev_workspace_on_mouse_index = None + self._workspace_on_mouse_index = None