From ee23423a157c98c2f74d51d52a4153ab3ff37e9d Mon Sep 17 00:00:00 2001 From: thearturca Date: Thu, 7 Dec 2023 22:03:31 +0300 Subject: [PATCH 1/2] feat(komorebi-preview-workspace): add preview workspace behavior on mouse over event When you over your mouse on some of komorebi workspace button you will see whats going on in that workspace. And when mouse leaves the button you should get back to workspace where you was before mouse over. Delay between preview is 500ms. Also add option to komorebi_workspaces widget. You should set "preview_workspace: true" to enable this feature --- src/core/event_enums.py | 3 + src/core/utils/komorebi/client.py | 37 +++++++- .../validation/widgets/komorebi/workspaces.py | 9 +- src/core/widgets/komorebi/workspaces.py | 93 ++++++++++++++++++- 4 files changed, 130 insertions(+), 12 deletions(-) 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 From 7712f68240954e91f0b70d02b4ee04e142bc38c9 Mon Sep 17 00:00:00 2001 From: thearturca Date: Thu, 7 Dec 2023 22:13:54 +0300 Subject: [PATCH 2/2] docs: add preview_workspace option to config.yml --- src/config.yaml | 4 ++++ 1 file changed, 4 insertions(+) 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"