diff --git a/src/core/bar.py b/src/core/bar.py index 775b276b7..328c441f2 100644 --- a/src/core/bar.py +++ b/src/core/bar.py @@ -2,7 +2,7 @@ from PyQt6.QtCore import QEvent, QRect, Qt, QTimer, pyqtSignal from PyQt6.QtGui import QScreen -from PyQt6.QtWidgets import QFrame, QGridLayout, QHBoxLayout, QWidget +from PyQt6.QtWidgets import QBoxLayout, QFrame, QGridLayout, QHBoxLayout, QLabel, QVBoxLayout, QWidget from core.bar_helper import ( AppBarManager, @@ -68,11 +68,16 @@ def __init__( self._auto_width_manager = None self._cli_manager = None self._target_screen = bar_screen + self._fullscreen_app_bar_suspended = False + self._is_vertical = self._alignment["position"] in ("left", "right") self.screen_name = self._target_screen.name() - self.app_bar_edge = ( - app_bar.AppBarEdge.Top if self._alignment["position"] == "top" else app_bar.AppBarEdge.Bottom - ) + self.app_bar_edge = { + "top": app_bar.AppBarEdge.Top, + "bottom": app_bar.AppBarEdge.Bottom, + "left": app_bar.AppBarEdge.Left, + "right": app_bar.AppBarEdge.Right, + }[self._alignment["position"]] self.setWindowTitle(APP_BAR_TITLE) self.setStyleSheet(stylesheet) @@ -82,7 +87,19 @@ def __init__( self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) self._bar_frame = QFrame(self) - self._bar_frame.setProperty("class", f"bar {self.config.class_name}") + self._bar_frame.setProperty( + "class", + " ".join( + [ + "bar", + self.config.class_name, + "bar-vertical" if self._is_vertical else "bar-horizontal", + f"bar-{self._alignment['position']}", + ] + ), + ) + self._bar_frame.setProperty("orientation", "vertical" if self._is_vertical else "horizontal") + self._bar_frame.setProperty("edge", self._alignment["position"]) if IMPORT_APP_BAR_MANAGER_SUCCESSFUL: self.app_bar_manager = app_bar.Win32AppBar() @@ -165,15 +182,27 @@ def on_geometry_changed(self, geo: QRect) -> None: if self._is_auto_width and self._auto_width_manager: QTimer.singleShot(0, self._auto_width_manager.sync) - def update_app_bar(self) -> None: + def update_app_bar(self, reserve_space_override: bool | None = None) -> None: if self.app_bar_manager: # Always register AppBar for notifications, but only reserve space when windows_app_bar is true - reserve_space = self._window_flags["windows_app_bar"] + reserve_space = ( + self._window_flags["windows_app_bar"] if reserve_space_override is None else reserve_space_override + ) scale_screen_height = self._target_screen.devicePixelRatio() > 1.0 self.app_bar_manager.create_appbar( self.winId().__int__(), self.app_bar_edge, - self._dimensions["height"] + self._padding["top"] + self._padding["bottom"], + ( + self._resolve_dimension(self._dimensions["width"], self._target_screen.geometry().width()) + + self._padding["left"] + + self._padding["right"] + ) + if self._is_vertical + else ( + self._resolve_dimension(self._dimensions["height"], self._target_screen.geometry().height()) + + self._padding["top"] + + self._padding["bottom"] + ), self._target_screen, scale_screen_height, self._bar_name, @@ -189,35 +218,56 @@ def bar_pos(self, bar_w: int, bar_h: int, screen_w: int, screen_h: int) -> tuple screen_x = self._target_screen.geometry().x() screen_y = self._target_screen.geometry().y() - if self._align == "center" or self._alignment.get("center", False): - available_x = screen_x + self._padding["left"] - available_width = screen_w - self._padding["left"] - self._padding["right"] - if bar_w >= available_width: - x = available_x + if self._is_vertical: + if self._alignment["position"] == "right": + x = int(screen_x + screen_w - bar_w - self._padding["right"]) else: - x = int(available_x + (available_width - bar_w) / 2) - elif self._align == "right": - x = int(screen_x + screen_w - bar_w - self._padding["right"]) - min_x = screen_x + self._padding["left"] - if x < min_x: - x = min_x + x = int(screen_x + self._padding["left"]) + + if self._align == "center" or self._alignment.get("center", False): + available_y = screen_y + self._padding["top"] + available_height = screen_h - self._padding["top"] - self._padding["bottom"] + if bar_h >= available_height: + y = available_y + else: + y = int(available_y + (available_height - bar_h) / 2) + elif self._align == "right": + y = int(screen_y + screen_h - bar_h - self._padding["bottom"]) + min_y = screen_y + self._padding["top"] + if y < min_y: + y = min_y + else: + y = int(screen_y + self._padding["top"]) + max_y = screen_y + screen_h - bar_h - self._padding["bottom"] + if y > max_y: + y = max_y else: - x = int(screen_x + self._padding["left"]) - max_x = screen_x + screen_w - bar_w - self._padding["right"] - if x > max_x: - x = max_x + if self._align == "center" or self._alignment.get("center", False): + available_x = screen_x + self._padding["left"] + available_width = screen_w - self._padding["left"] - self._padding["right"] + if bar_w >= available_width: + x = available_x + else: + x = int(available_x + (available_width - bar_w) / 2) + elif self._align == "right": + x = int(screen_x + screen_w - bar_w - self._padding["right"]) + min_x = screen_x + self._padding["left"] + if x < min_x: + x = min_x + else: + x = int(screen_x + self._padding["left"]) + max_x = screen_x + screen_w - bar_w - self._padding["right"] + if x > max_x: + x = max_x - if self._alignment["position"] == "bottom": - y = int(screen_y + screen_h - bar_h - self._padding["bottom"]) - else: - y = int(screen_y + self._padding["top"]) + if self._alignment["position"] == "bottom": + y = int(screen_y + screen_h - bar_h - self._padding["bottom"]) + else: + y = int(screen_y + self._padding["top"]) return x, y def position_bar(self, init=False) -> None: - bar_width = self._dimensions["width"] - bar_height = self._dimensions["height"] - screen_width = self._target_screen.geometry().width() screen_height = self._target_screen.geometry().height() @@ -226,29 +276,106 @@ def position_bar(self, init=False) -> None: bar_width = self._auto_width_manager.update() if self._auto_width_manager else 0 else: bar_width = 0 + else: + bar_width = self._resolve_dimension(self._dimensions["width"], screen_width) - elif is_valid_percentage_str(str(self._dimensions["width"])): - percent = percent_to_float(self._dimensions["width"]) - bar_width = int(screen_width * percent) + bar_height = self._resolve_dimension(self._dimensions["height"], screen_height) - # Ensure bar width does not exceed screen width available_width = screen_width - self._padding["left"] - self._padding["right"] - if bar_width > available_width: - bar_width = available_width + available_height = screen_height - self._padding["top"] - self._padding["bottom"] + bar_width = min(bar_width, available_width) + bar_height = min(bar_height, available_height) bar_x, bar_y = self.bar_pos(bar_width, bar_height, screen_width, screen_height) self.setGeometry(bar_x, bar_y, bar_width, bar_height) self._bar_frame.setGeometry(0, 0, bar_width, bar_height) + def _resolve_dimension(self, value: str | int, available: int) -> int: + if isinstance(value, int): + return value + if value == "auto": + return 0 + if is_valid_percentage_str(str(value)): + return int(available * percent_to_float(value)) + return 0 + + def _format_label_text_for_orientation(self, text: str, label: QLabel) -> str: + if not self._is_vertical: + return text.replace("\n", "") + + class_name = str(label.property("class") or "") + if "icon" in class_name: + return text + + parts = [part.strip() for part in text.splitlines()] + parts = [part for part in parts if part] + if not parts: + return text + + vertical_parts = [] + for part in parts: + vertical_parts.append("\n".join(ch for ch in part if ch != " ")) + return "\n\n".join(vertical_parts) + + def _set_box_layout_direction(self, layout: QBoxLayout | None) -> None: + if layout is None: + return + + direction = QBoxLayout.Direction.TopToBottom if self._is_vertical else QBoxLayout.Direction.LeftToRight + if layout.direction() != direction: + layout.setDirection(direction) + + def _configure_widget_orientation(self, widget: QWidget) -> None: + max_label_width = max( + 12, + self._resolve_dimension(self._dimensions["width"], self._target_screen.geometry().width()) + - self._padding["left"] + - self._padding["right"] + - 10, + ) + self._set_box_layout_direction(getattr(widget, "_widget_container_layout", None)) + self._set_box_layout_direction(getattr(widget, "workspace_container_layout", None)) + + for child in widget.findChildren(QWidget): + self._set_box_layout_direction(getattr(child, "_widget_container_layout", None)) + self._set_box_layout_direction(getattr(child, "button_layout", None)) + + for label in widget.findChildren(QLabel): + class_name = str(label.property("class") or "") + if "label" not in class_name or "icon" in class_name: + continue + + if not hasattr(label, "_yasb_original_set_text"): + label._yasb_original_set_text = label.setText + + def orientation_set_text(text: str, _label=label, _bar=self): + _label._yasb_original_set_text(_bar._format_label_text_for_orientation(text, _label)) + + label.setText = orientation_set_text + + label.setWordWrap(self._is_vertical) + if self._is_vertical: + label.setMaximumWidth(max_label_width) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + else: + label.setMaximumWidth(16777215) + + current_text = label.text() + label._yasb_original_set_text(self._format_label_text_for_orientation(current_text, label)) + def _add_widgets(self, widgets: dict[str, list] = None): bar_layout = QGridLayout() bar_layout.setContentsMargins(0, 0, 0, 0) bar_layout.setSpacing(0) + if self._is_vertical: + bar_layout.setRowStretch(1, 1) + else: + bar_layout.setColumnStretch(1, 1) - for column_num, layout_type in enumerate(["left", "center", "right"]): + for index, layout_type in enumerate(["left", "center", "right"]): config = self.config.layouts.model_dump()[layout_type] - layout = QHBoxLayout() + layout = QVBoxLayout() if self._is_vertical else QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout_container = QFrame() @@ -260,6 +387,7 @@ def _add_widgets(self, widgets: dict[str, list] = None): widget.parent_layout_type = layout_type widget.bar_id = self.bar_id widget.monitor_hwnd = self.monitor_hwnd + self._configure_widget_orientation(widget) layout.addWidget(widget, 0) if config["alignment"] == "left" and config["stretch"]: @@ -273,7 +401,10 @@ def _add_widgets(self, widgets: dict[str, list] = None): layout.addStretch(1) layout_container.setLayout(layout) - bar_layout.addWidget(layout_container, 0, column_num) + if self._is_vertical: + bar_layout.addWidget(layout_container, index, 0) + else: + bar_layout.addWidget(layout_container, 0, index) self._bar_frame.setLayout(bar_layout) diff --git a/src/core/bar_helper.py b/src/core/bar_helper.py index 973d25f91..29ccf517a 100644 --- a/src/core/bar_helper.py +++ b/src/core/bar_helper.py @@ -6,6 +6,7 @@ from datetime import datetime from functools import partial +import win32con import win32gui import win32process from PyQt6.QtCore import ( @@ -38,7 +39,14 @@ from core.utils.win32.bindings import SetWindowPos from core.utils.win32.bindings.user32 import KillTimer, RegisterWindowMessage, SetTimer, user32 from core.utils.win32.structs import MSG -from core.utils.win32.utils import apply_qmenu_style +from core.utils.win32.utils import ( + apply_qmenu_style, + get_monitor_hwnd, + get_monitor_info, + get_window_extended_frame_bounds, + get_window_rect, + is_window_maximized, +) # Register TaskbarCreated message to detect Explorer restarts WM_TASKBARCREATED = RegisterWindowMessage("TaskbarCreated") @@ -133,18 +141,31 @@ def _start_slide(self, show: bool): geo = bar.geometry() self._target_geo = (geo.x(), geo.y(), geo.width(), geo.height()) self._full_height = geo.height() + self._full_width = geo.width() + self._is_vertical = bar._alignment["position"] in ("left", "right") screen_geo = bar.screen().geometry() - is_top = bar._alignment["position"] == "top" - if is_top: - self._edge_y = screen_geo.y() - padding = geo.y() - self._edge_y + if self._is_vertical: + is_left = bar._alignment["position"] == "left" + if is_left: + self._edge_x = screen_geo.x() + padding = geo.x() - self._edge_x + else: + self._edge_x = screen_geo.x() + screen_geo.width() + padding = self._edge_x - geo.x() - self._full_width + total_slide = self._full_width + padding else: - self._edge_y = screen_geo.y() + screen_geo.height() - padding = self._edge_y - geo.y() - self._full_height + is_top = bar._alignment["position"] == "top" + if is_top: + self._edge_y = screen_geo.y() + padding = geo.y() - self._edge_y + else: + self._edge_y = screen_geo.y() + screen_geo.height() + padding = self._edge_y - geo.y() - self._full_height + total_slide = self._full_height + padding - total_slide = self._full_height + padding - self._phase_point = self._full_height / total_slide if total_slide > 0 else 1.0 + visible_span = self._full_width if self._is_vertical else self._full_height + self._phase_point = visible_span / total_slide if total_slide > 0 else 1.0 self._padding = padding if show: @@ -166,24 +187,43 @@ def _start_slide(self, show: bool): def _update_slide(self, value: float): x, y, w, full_h = self._target_geo pp = self._phase_point - is_top = self.bar_widget._alignment["position"] == "top" - - if value <= pp and pp > 0: - t = value / pp - h = max(1, round(full_h * t)) - if is_top: - self.bar_widget.setGeometry(x, self._edge_y, w, h) - self.bar_widget._bar_frame.move(0, h - full_h) + if self._is_vertical: + is_left = self.bar_widget._alignment["position"] == "left" + if value <= pp and pp > 0: + t = value / pp + width = max(1, round(w * t)) + if is_left: + self.bar_widget.setGeometry(self._edge_x, y, width, full_h) + self.bar_widget._bar_frame.move(width - w, 0) + else: + self.bar_widget.setGeometry(self._edge_x - width, y, width, full_h) + self.bar_widget._bar_frame.move(0, 0) else: - self.bar_widget.setGeometry(x, self._edge_y - h, w, h) + t = (value - pp) / (1.0 - pp) if pp < 1.0 else 1.0 + if is_left: + self.bar_widget.setGeometry(round(self._edge_x + self._padding * t), y, w, full_h) + else: + self.bar_widget.setGeometry(round(self._edge_x - w - self._padding * t), y, w, full_h) self.bar_widget._bar_frame.move(0, 0) else: - t = (value - pp) / (1.0 - pp) if pp < 1.0 else 1.0 - if is_top: - self.bar_widget.setGeometry(x, round(self._edge_y + self._padding * t), w, full_h) + is_top = self.bar_widget._alignment["position"] == "top" + + if value <= pp and pp > 0: + t = value / pp + h = max(1, round(full_h * t)) + if is_top: + self.bar_widget.setGeometry(x, self._edge_y, w, h) + self.bar_widget._bar_frame.move(0, h - full_h) + else: + self.bar_widget.setGeometry(x, self._edge_y - h, w, h) + self.bar_widget._bar_frame.move(0, 0) else: - self.bar_widget.setGeometry(x, round(self._edge_y - full_h - self._padding * t), w, full_h) - self.bar_widget._bar_frame.move(0, 0) + t = (value - pp) / (1.0 - pp) if pp < 1.0 else 1.0 + if is_top: + self.bar_widget.setGeometry(x, round(self._edge_y + self._padding * t), w, full_h) + else: + self.bar_widget.setGeometry(x, round(self._edge_y - full_h - self._padding * t), w, full_h) + self.bar_widget._bar_frame.move(0, 0) def _on_show_finished(self): if self._target_geo: @@ -302,13 +342,27 @@ def setup_detection_zone(self): self._detection_zone.setGeometry( screen_geometry.x(), screen_geometry.y(), screen_geometry.width(), self._detection_zone_height ) - else: + elif alignment["position"] == "bottom": self._detection_zone.setGeometry( screen_geometry.x(), screen_geometry.y() + screen_geometry.height() - self._detection_zone_height, screen_geometry.width(), self._detection_zone_height, ) + elif alignment["position"] == "left": + self._detection_zone.setGeometry( + screen_geometry.x(), + screen_geometry.y(), + self._detection_zone_height, + screen_geometry.height(), + ) + else: + self._detection_zone.setGeometry( + screen_geometry.x() + screen_geometry.width() - self._detection_zone_height, + screen_geometry.y(), + self._detection_zone_height, + screen_geometry.height(), + ) self._hide_timer.start(self._autohide_delay) @@ -392,9 +446,14 @@ def _is_mouse_in_safe_zone(self, cursor_pos, bar_geometry): if alignment["position"] == "top": bar_top = bar_geometry.y() - screen_geometry.y() return 0 <= screen_y <= bar_top - else: + if alignment["position"] == "bottom": bar_bottom = (bar_geometry.y() + bar_geometry.height()) - screen_geometry.y() return bar_bottom <= screen_y <= screen_geometry.height() + if alignment["position"] == "left": + bar_left = bar_geometry.x() - screen_geometry.x() + return 0 <= screen_x <= bar_left + bar_right = (bar_geometry.x() + bar_geometry.width()) - screen_geometry.x() + return bar_right <= screen_x <= screen_geometry.width() def is_enabled(self): """Check if autohide is enabled""" @@ -618,6 +677,39 @@ def _is_foreground_excluded(self) -> bool: pass return False + def _is_actual_fullscreen_foreground(self) -> bool: + """Reject standard maximized windows so hide_on_fullscreen only reacts to real fullscreen apps.""" + try: + hwnd = win32gui.GetForegroundWindow() + if not hwnd: + return False + + monitor_hwnd = get_monitor_hwnd(hwnd) + if not monitor_hwnd: + return False + + monitor_rect = get_monitor_info(monitor_hwnd)["rect"] + try: + window_rect = get_window_extended_frame_bounds(hwnd) + except Exception: + window_rect = get_window_rect(hwnd) + + style = win32gui.GetWindowLong(hwnd, win32con.GWL_STYLE) + has_standard_frame = bool(style & (win32con.WS_CAPTION | win32con.WS_THICKFRAME)) + + if is_window_maximized(hwnd) and has_standard_frame: + return False + + tolerance = 2 + return ( + abs(window_rect["x"] - monitor_rect["x"]) <= tolerance + and abs(window_rect["y"] - monitor_rect["y"]) <= tolerance + and abs(window_rect["width"] - monitor_rect["width"]) <= tolerance + and abs(window_rect["height"] - monitor_rect["height"]) <= tolerance + ) + except Exception: + return False + def _handle_fullscreen(self, hwnd: int, is_fullscreen_opening: bool): """Handle ABN_FULLSCREENAPP notification for a bar.""" bar_widget = self._bars.get(hwnd) @@ -628,19 +720,44 @@ def _handle_fullscreen(self, hwnd: int, is_fullscreen_opening: bool): if is_fullscreen_opening: if self._is_foreground_excluded(): return + if not self._is_actual_fullscreen_foreground(): + return intended_visible = self._bar_intended_state.get(hwnd, True) should_hide_bar = getattr(bar_widget, "_hide_on_fullscreen", False) + should_reserve_space = getattr(bar_widget, "_window_flags", {}).get("windows_app_bar", False) # We only need to process if hide_on_fullscreen is enabled if not should_hide_bar: return if is_fullscreen_opening: + if should_reserve_space and not getattr(bar_widget, "_fullscreen_app_bar_suspended", False): + try: + SystrayAppBarHelper.execute_without_systray_interference( + lambda: ( + bar_widget.app_bar_manager.remove_appbar(), + bar_widget.update_app_bar(False), + ) + ) + bar_widget._fullscreen_app_bar_suspended = True + except Exception: + logging.exception("Failed to suspend AppBar reservation during fullscreen") if intended_visible: SetWindowPos(hwnd, HWND_BOTTOM, 0, 0, 0, 0, self._swp_flags) self._bar_intended_state[hwnd] = False else: + if should_reserve_space and getattr(bar_widget, "_fullscreen_app_bar_suspended", False): + try: + SystrayAppBarHelper.execute_without_systray_interference( + lambda: ( + bar_widget.app_bar_manager.remove_appbar(), + bar_widget.update_app_bar(True), + ) + ) + bar_widget._fullscreen_app_bar_suspended = False + except Exception: + logging.exception("Failed to restore AppBar reservation after fullscreen") if not intended_visible: SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, self._swp_flags) self._bar_intended_state[hwnd] = True @@ -1094,7 +1211,9 @@ def apply(self, new_width: int) -> None: if new_width < 0: return - bar_height = self.bar_widget._dimensions["height"] + bar_height = self.bar_widget._resolve_dimension( + self.bar_widget._dimensions["height"], self.bar_widget._target_screen.geometry().height() + ) screen_geometry = self.bar_widget._target_screen.geometry() bar_x, bar_y = self.bar_widget.bar_pos( new_width, diff --git a/src/core/utils/win32/app_bar.py b/src/core/utils/win32/app_bar.py index 560bdecb3..4c5d07f11 100644 --- a/src/core/utils/win32/app_bar.py +++ b/src/core/utils/win32/app_bar.py @@ -93,7 +93,7 @@ def create_appbar( self, hwnd: int, edge: AppBarEdge, - app_bar_height: int, + app_bar_size: int, screen: QScreen, scale_screen: bool = False, bar_name: str = None, @@ -112,33 +112,48 @@ def create_appbar( updated_ex_style |= win32con.WS_EX_TOPMOST windll.user32.SetWindowLongPtrW(hwnd, win32con.GWL_EXSTYLE, updated_ex_style) - self.position_bar(app_bar_height, screen, scale_screen, bar_name) + self.position_bar(app_bar_size, screen, scale_screen, bar_name) # Only reserve screen space if requested windows_app_bar: true if reserve_space: self.set_position() def position_bar( - self, app_bar_height: int, screen: QScreen, scale_screen: bool = False, bar_name: str = None + self, app_bar_size: int, screen: QScreen, scale_screen: bool = False, bar_name: str = None ) -> None: geometry = screen.geometry() - bar_height = int(app_bar_height * screen.devicePixelRatio()) - screen_height = int(geometry.height() * screen.devicePixelRatio() if scale_screen else geometry.height()) - - self.app_bar_data.rc.left = geometry.x() - self.app_bar_data.rc.right = geometry.x() + geometry.width() + # Keep AppBar reservation in the same logical coordinate space as the Qt bar geometry. + # Multiplying the reserved size by devicePixelRatio again on high-DPI displays makes + # the workspace gap larger than the visible bar. + bar_size = int(app_bar_size) + screen_width = geometry.width() + screen_height = geometry.height() if self.app_bar_data.uEdge == AppBarEdge.Top: - self.app_bar_data.rc.top = screen.geometry().y() - self.app_bar_data.rc.bottom = screen.geometry().y() + bar_height + self.app_bar_data.rc.left = geometry.x() + self.app_bar_data.rc.right = geometry.x() + geometry.width() + self.app_bar_data.rc.top = geometry.y() + self.app_bar_data.rc.bottom = geometry.y() + bar_size + elif self.app_bar_data.uEdge == AppBarEdge.Bottom: + self.app_bar_data.rc.left = geometry.x() + self.app_bar_data.rc.right = geometry.x() + geometry.width() + self.app_bar_data.rc.top = geometry.y() + screen_height - bar_size + self.app_bar_data.rc.bottom = geometry.y() + screen_height + elif self.app_bar_data.uEdge == AppBarEdge.Left: + self.app_bar_data.rc.left = geometry.x() + self.app_bar_data.rc.right = geometry.x() + bar_size + self.app_bar_data.rc.top = geometry.y() + self.app_bar_data.rc.bottom = geometry.y() + geometry.height() else: - self.app_bar_data.rc.top = screen.geometry().y() + screen_height - bar_height - self.app_bar_data.rc.bottom = screen.geometry().y() + screen_height + self.app_bar_data.rc.left = geometry.x() + screen_width - bar_size + self.app_bar_data.rc.right = geometry.x() + screen_width + self.app_bar_data.rc.top = geometry.y() + self.app_bar_data.rc.bottom = geometry.y() + geometry.height() bar_info = f"Bar {bar_name}" if bar_name else "Bar" logging.debug( - "%s Created on Screen: %s [Bar Height: %spx, DPI Scale: %s]", + "%s Created on Screen: %s [Bar Size: %spx, DPI Scale: %s]", bar_info, screen.name(), - app_bar_height, + app_bar_size, screen.devicePixelRatio(), ) diff --git a/src/core/validation/bar.py b/src/core/validation/bar.py index 6d08d793a..8f308ce9d 100644 --- a/src/core/validation/bar.py +++ b/src/core/validation/bar.py @@ -6,7 +6,7 @@ class BarAlignment(CustomBaseModel): - position: Literal["top", "bottom"] = "top" + position: Literal["top", "bottom", "left", "right"] = "top" align: Literal["left", "center", "right"] = "center" @@ -34,20 +34,29 @@ class BarWindowFlags(CustomBaseModel): class BarDimensions(CustomBaseModel): width: str | int = "100%" - height: int = Field(default=30, ge=0) + height: str | int = Field(default=30) @field_validator("width") @classmethod def validate_width(cls, v: str | int) -> str | int: + return cls._validate_dimension(v, "Width") + + @field_validator("height") + @classmethod + def validate_height(cls, v: str | int) -> str | int: + return cls._validate_dimension(v, "Height") + + @staticmethod + def _validate_dimension(v: str | int, label: str) -> str | int: if isinstance(v, int): if v < 0: - raise ValueError("Width must be non-negative") + raise ValueError(f"{label} must be non-negative") return v if v == "auto": return v if v.endswith("%") and v[:-1].isdigit(): return v - raise ValueError("Width must be an integer, 'auto', or a percentage string (e.g. '100%')") + raise ValueError(f"{label} must be an integer, 'auto', or a percentage string (e.g. '100%')") class BarPadding(CustomBaseModel): diff --git a/src/core/widgets/glazewm/workspaces.py b/src/core/widgets/glazewm/workspaces.py index ff5b68e62..22ee7c4e8 100644 --- a/src/core/widgets/glazewm/workspaces.py +++ b/src/core/widgets/glazewm/workspaces.py @@ -17,6 +17,102 @@ logger = logging.getLogger("glazewm_workspaces") +WORKSPACE_CATEGORY_DEFAULTS = { + "1": "WEB", + "2": "CHAT", + "3": "CODE", + "4": "DOCS", + "5": "NOTE", + "6": "TERM", + "7": "EMU", + "8": "MISC", + "9": "MISC", +} + + +def _default_workspace_label(workspace_name: str) -> str: + return WORKSPACE_CATEGORY_DEFAULTS.get(workspace_name, "MISC") + + +def _pick_relevant_workspace_window(workspace: Workspace) -> Window | None: + windows = list(workspace.windows or []) + if not windows: + return None + + focused_window = next((window for window in windows if window.has_focus), None) + if focused_window is not None: + return focused_window + + for window_id in workspace.child_focus_order: + match = next((window for window in windows if window.id == window_id), None) + if match is not None: + return match + + return windows[0] + + +def _score_workspace_window(window: Window) -> dict[str, int]: + proc = (window.process_name or "").lower() + cls = (window.class_name or "").lower() + title = window.title or "" + text = f"{proc} {cls} {title}".lower() + scores = { + "WEB": 0, + "CHAT": 0, + "CODE": 0, + "DOCS": 0, + "NOTE": 0, + "TERM": 0, + "FILE": 0, + "EMU": 0, + "MISC": 1, + } + + if proc in {"weixin", "wechatappex", "telegram", "discord"} or "微信" in text: + scores["CHAT"] += 100 + + if proc in {"typedown", "notepad", "notepad++", "obsidian"}: + scores["NOTE"] += 95 + + if proc in {"windowsterminal", "openconsole", "cmd", "pwsh"} or cls == "cascadia_hosting_window_class": + scores["TERM"] += 70 + + if proc in {"code", "codium", "devenv", "idea64", "pycharm64", "rider64", "figma", "blender"}: + scores["CODE"] += 100 + + if proc in {"python", "node", "codex", "codex-command-runner", "figma_agent", "blender-mcp"}: + scores["CODE"] += 75 + + if proc in {"explorer", "dopus", "dopusrt", "everything", "photos"} or cls in {"cabinetwclass", "dopus.lister"}: + scores["FILE"] += 100 + + if proc in {"brave", "chrome", "msedge", "firefox"}: + scores["WEB"] += 70 + + if re.search(r"mumu|emulator|vmware|virtualbox|wegame|leagueclient", proc) or re.search( + r"emulator|模拟器|league of legends", + text, + ): + scores["EMU"] += 100 + + if re.search(r"知网|paper|论文|pdf|arxiv|scholar|docs|document|文档|阅读|read", text): + scores["DOCS"] += 95 + + if re.search(r"github|gitlab|stack overflow|stackoverflow|terminal|powershell|codex|repo|代码|code", text): + scores["CODE"] += 80 + + if re.search(r"youtube music|spotify|music", text): + scores["WEB"] += 15 + + if proc in {"windowsterminal", "openconsole"} and re.search(r"nvim|vim|cursor|claude|project|yasb", text): + scores["CODE"] += 65 + + return scores + + +def _resolve_workspace_label(workspace: Workspace) -> str: + return workspace.display_name or workspace.name + class WorkspaceStatus(StrEnum): EMPTY = auto() @@ -150,6 +246,7 @@ def __init__( self.status = WorkspaceStatus.EMPTY self.workspace_window_count = 0 self.windows = windows + self.child_focus_order: list[str] = [] self.setSizePolicy(QSizePolicy.Policy.Fixed, self.sizePolicy().verticalPolicy()) @@ -244,10 +341,18 @@ def _update_status(self): self.status = WorkspaceStatus.POPULATED if is_populated else WorkspaceStatus.EMPTY def _get_all_windows_in_workspace(self) -> list[Window]: - windows = self.windows or [] + windows = list(self.windows or []) if self.config.app_icons.hide_floating: - return [window for window in windows if not window.is_floating] + windows = [window for window in windows if not window.is_floating] + + focus_order = {window_id: index for index, window_id in enumerate(self.child_focus_order)} + windows.sort( + key=lambda window: ( + 0 if window.has_focus else 1, + focus_order.get(window.id, len(focus_order)), + ) + ) return windows def _get_all_icons_in_workspace(self) -> dict[int, QPixmap | None]: @@ -379,6 +484,7 @@ def __init__(self, config: GlazewmWorkspacesConfig): or self.config.app_icons.enabled_active or self.config.app_icons.enabled_focused ) + self._is_normalizing_workspaces = False @override def showEvent(self, a0: QShowEvent | None): @@ -406,12 +512,16 @@ def _update_workspaces(self, message: list[Monitor]): if workspace.focus: global_focused_ws = workspace.name + if self._normalize_workspace_names(list(all_workspaces.values())): + return + if self.config.monitor_exclusive: workspace_source = {workspace.name: workspace for workspace in current_mon.workspaces} else: workspace_source = {workspace.name: workspace for workspace in all_workspaces.values()} for workspace in workspace_source.values(): + resolved_display_name = _resolve_workspace_label(workspace) if (btn := self.workspaces.get(workspace.name)) is None: if self.workspace_app_icons_enabled: btn = self.workspaces[workspace.name] = GlazewmWorkspaceButtonWithIcons( @@ -419,7 +529,7 @@ def _update_workspaces(self, message: list[Monitor]): self.glazewm_client, parent_widget=self, config=self.config, - display_name=workspace.display_name, + display_name=resolved_display_name, windows=workspace.windows, ) else: @@ -427,17 +537,18 @@ def _update_workspaces(self, message: list[Monitor]): workspace.name, self.glazewm_client, config=self.config, - display_name=workspace.display_name, + display_name=resolved_display_name, ) btn.monitor_exclusive = self.config.monitor_exclusive btn.workspace_name = workspace.name - btn.display_name = workspace.display_name + btn.display_name = resolved_display_name btn.workspace_window_count = workspace.num_windows btn.is_displayed = workspace.is_displayed btn.is_focused = btn.workspace_name == global_focused_ws if global_focused_ws else workspace.focus if self.workspace_app_icons_enabled: btn.windows = workspace.windows + btn.child_focus_order = workspace.child_focus_order for i, ws_name in enumerate(sorted(self.workspaces.keys(), key=natural_sort_key)): if self.workspace_container_layout.indexOf(self.workspaces[ws_name]) != i: @@ -451,34 +562,43 @@ def _update_workspaces(self, message: list[Monitor]): if is_current_ipc_workspace: workspace = workspace_source[btn.workspace_name] - btn.display_name = workspace.display_name + btn.display_name = _resolve_workspace_label(workspace) btn.workspace_window_count = workspace.num_windows btn.is_displayed = workspace.is_displayed btn.is_focused = btn.workspace_name == global_focused_ws if global_focused_ws else workspace.focus if self.workspace_app_icons_enabled: btn.windows = workspace.windows + btn.child_focus_order = workspace.child_focus_order else: workspace = all_workspaces.get(btn.workspace_name) + if workspace is not None: + btn.display_name = _resolve_workspace_label(workspace) btn.is_displayed = False btn.workspace_window_count = 0 btn.is_focused = False if self.workspace_app_icons_enabled: btn.windows = [] + btn.child_focus_order = [] is_focused = btn.is_focused + btn.update_button() + hide_empty = self.config.hide_empty_workspaces and btn.status == WorkspaceStatus.EMPTY if not self.config.monitor_exclusive: - if is_current_ipc_workspace or is_focused: + if (is_current_ipc_workspace or is_focused) and not hide_empty: btn.setHidden(False) else: btn.setHidden(True) else: - if is_current_ipc_workspace: + if is_current_ipc_workspace and not hide_empty: btn.setHidden(False) else: btn.setHidden(True) - btn.update_button() + def _normalize_workspace_names(self, workspaces: list[Workspace]) -> bool: + # Keep naming authoritative in GlazeWM config / external labeler. + # A second rename loop inside YASB can fight with pause/resume and game window transitions. + return False def _get_active_workspace( self, diff --git a/src/core/widgets/services/glazewm/client.py b/src/core/widgets/services/glazewm/client.py index 479c96792..81fa98a4f 100644 --- a/src/core/widgets/services/glazewm/client.py +++ b/src/core/widgets/services/glazewm/client.py @@ -20,6 +20,7 @@ class Window: process_name: str display_state: str is_floating: bool + has_focus: bool = False @dataclass @@ -30,6 +31,7 @@ class Workspace: is_displayed: bool = False num_windows: int = 0 windows: list[Window] = field(default_factory=list) + child_focus_order: list[str] = field(default_factory=list) @dataclass @@ -90,6 +92,11 @@ def __init__( def activate_workspace(self, workspace_name: str): self._websocket.sendTextMessage(f"command focus --workspace {workspace_name}") + def update_workspace_name(self, workspace_name: str, new_name: str): + self._websocket.sendTextMessage( + f"command update-workspace-config --workspace {workspace_name} --name {new_name}" + ) + def toggle_tiling_direction(self): self._websocket.sendTextMessage("command toggle-tiling-direction") @@ -185,6 +192,7 @@ def _process_workspaces(self, data: list[dict[str, Any]]) -> list[Monitor]: focus=child.get("hasFocus", False), num_windows=len(child.get("children", [])), windows=self._read_windows(child), + child_focus_order=child.get("childFocusOrder", []) or [], ) for child in mon.get("children", []) if child.get("type") == "workspace" @@ -220,6 +228,7 @@ def _read_windows(self, parent): process_name=child.get("processName"), display_state=child.get("displayState"), is_floating=child.get("state").get("type") == "floating", + has_focus=child.get("hasFocus", False), ) ) elif child.get("type") == "split": diff --git a/src/core/widgets/yasb/power_menu.py b/src/core/widgets/yasb/power_menu.py index 8eeb43d4f..5e8d33d77 100644 --- a/src/core/widgets/yasb/power_menu.py +++ b/src/core/widgets/yasb/power_menu.py @@ -408,18 +408,15 @@ def __init__( buttons_layout.setSpacing(0) buttons_layout.setContentsMargins(0, 0, 0, 0) - row_layouts = [] - for i, (button_name, button_info) in enumerate(buttons.items()): + action_buttons = [(name, info) for name, info in buttons.items() if name != "cancel"] + cancel_button = buttons.get("cancel") + self.primary_button_count = len(action_buttons) + self.cancel_button_index = -1 + + def build_button(button_name, button_info): icon, label = button_info action = getattr(self.power_operations, button_name, self.power_operations.cancel) - if i % button_row == 0: - row = QHBoxLayout() - row.setSpacing(0) - row.setContentsMargins(0, 0, 0, 0) - row_layouts.append(row) - buttons_layout.addLayout(row) - button = QPushButton(self) button.setProperty("class", f"button {button_name.replace('_', '-')}") btn_layout = QVBoxLayout(button) @@ -440,9 +437,30 @@ def __init__( text_label.setAlignment(Qt.AlignmentFlag.AlignCenter) btn_layout.addWidget(text_label) - row_layouts[-1].addWidget(button) button.clicked.connect(action) button.installEventFilter(self) + return button + + row_layouts = [] + for i, (button_name, button_info) in enumerate(action_buttons): + if i % button_row == 0: + row = QHBoxLayout() + row.setSpacing(0) + row.setContentsMargins(0, 0, 0, 0) + row_layouts.append(row) + buttons_layout.addLayout(row) + + row_layouts[-1].addWidget(build_button(button_name, button_info)) + + if cancel_button: + cancel_row = QHBoxLayout() + cancel_row.setSpacing(0) + cancel_row.setContentsMargins(0, 22, 0, 0) + cancel_row.addStretch(1) + cancel_row.addWidget(build_button("cancel", cancel_button)) + cancel_row.addStretch(1) + buttons_layout.addLayout(cancel_row) + self.cancel_button_index = len(self.buttons_list) - 1 main_layout.addWidget(buttons_frame) self.setLayout(main_layout) @@ -543,6 +561,14 @@ def navigate_focus(self, step): new_index = (current + 1) % total_buttons elif step == -1: # Left new_index = (current - 1) % total_buttons + elif self.cancel_button_index != -1 and step in (self.button_row, -self.button_row): + if current == self.cancel_button_index: + if step == -self.button_row and self.primary_button_count > 0: + new_index = min(self.primary_button_count // 2, self.primary_button_count - 1) + else: + new_index = 0 + else: + new_index = self.cancel_button_index elif step == self.button_row or step == -self.button_row: # Up/Down - vertical movement # Calculate the current row and column current_row = current // self.button_row