diff --git a/AUTHORS b/AUTHORS index fae6d05..8f868ed 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,10 @@ David Stygstra Generalized the "move-to-center" command into the "move-to-*" family of commands. +Greg Marthews + Added the "cascade" and "tile-all" commands. Fixed "move-to-center" + positioning and "workspace-go-*" / "show-desktop" behaviour. + Fábio C. Barrionuevo da Luz Fixed some permissioning warts in the install procedure. diff --git a/ChangeLog b/ChangeLog index 5488475..e5b9d8c 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,16 @@ -0.5.0 (git HEAD): -- Add a releasing section to the Developer's Guide +0.4.2: +- Add cascade command to arrange windows in diagonal stair-step pattern +- Add tile-all command to tile all workspace windows in optimal grid layout +- Fix move-to-center positioning (incorrect from_gravity for CENTER gravity) +- Fix windowless commands (show-desktop, workspace-go-*) breaking after + first invocation +- Fix GravityLayout treating explicit x=0.0 as unset (falsy check) +- Add Rectangle.center_in() utility method +- Add _calculate_optimal_grid() for grid dimension calculation +- (Thanks to Greg Marthews for the above contributions) + +0.4.1: +- Add support for HiDPI monitors where GDK's pixel units don't necessarily 0.4.1: - Add support for HiDPI monitors where GDK's pixel units don't necessarily diff --git a/docs/authors/index.rst b/docs/authors/index.rst index 5ad823b..cb75ab0 100644 --- a/docs/authors/index.rst +++ b/docs/authors/index.rst @@ -29,6 +29,10 @@ Thanks go out to the following people: Generalized the ``move-to-center`` command into the ``move-to-*`` family of commands. +Greg Marthews + Added the ``cascade`` and ``tile-all`` commands. Fixed ``move-to-center`` + positioning and ``workspace-go-*`` / ``show-desktop`` behaviour. + `Fábio C. Barrionuevo da Luz`_ Fixed some permissioning warts in the install procedure. diff --git a/quicktile/VERSION b/quicktile/VERSION index 8f0916f..2b7c5ae 100644 --- a/quicktile/VERSION +++ b/quicktile/VERSION @@ -1 +1 @@ -0.5.0 +0.4.2 diff --git a/quicktile/commands.py b/quicktile/commands.py index 6808c7b..4e9c1a9 100644 --- a/quicktile/commands.py +++ b/quicktile/commands.py @@ -11,7 +11,7 @@ # pylint: disable=unsubscriptable-object,invalid-sequence-index # pylint: disable=wrong-import-order -import logging, time +import logging, math, time from functools import wraps from Xlib import Xatom @@ -120,6 +120,9 @@ def add(self, name: str, *p_args: Any, **p_kwargs: Any def decorate(func: CommandCB) -> CommandCB: """Closure used to allow decorator to take arguments""" + # Extract windowless before wrapper so it's stable across calls + windowless = p_kwargs.pop('windowless', False) + @wraps(func) # pylint: disable=missing-docstring,keyword-arg-before-vararg def wrapper(winman: WindowManager, @@ -134,11 +137,6 @@ def wrapper(winman: WindowManager, state.update(self.extra_state) state["cmd_name"] = name - # FIXME: Refactor to avoid this hack - windowless = p_kwargs.get('windowless', False) - if 'windowless' in p_kwargs: - del p_kwargs['windowless'] - # Bail out early on None or things like the desktop window if not (windowless or self.get_window_meta( window, state, winman)): @@ -387,14 +385,15 @@ def move_to_position(winman: WindowManager, monitor_rect = state['monitor_geom'] win_rect = Rectangle(*win.get_geometry()) - # Build a target rectangle - # TODO: Think about ways to refactor scaling for better maintainability - target = Rectangle( - x=gravity.value[0] * monitor_rect.width, - y=gravity.value[1] * monitor_rect.height, - width=win_rect.width, - height=win_rect.height - ).from_gravity(gravity).from_relative(monitor_rect) + if gravity == Gravity.CENTER: + target = win_rect.center_in(monitor_rect) + else: + target = Rectangle( + x=gravity.value[0] * monitor_rect.width, + y=gravity.value[1] * monitor_rect.height, + width=win_rect.width, + height=win_rect.height + ).from_gravity(gravity).from_relative(monitor_rect) # Push it out from under any panels logging.debug("Clipping rectangle %r\n\tto usable region %r", @@ -586,4 +585,235 @@ def workspace_send_window( win.move_to_workspace(target) + +@commands.add('cascade') +def cascade_windows( + winman: WindowManager, + win: Wnck.Window, + state: Dict[str, Any] +) -> None: + """Cascade all visible windows on the current workspace. + + Arranges windows in a diagonal stair-step pattern starting from the + top-left of the usable screen area. Each window is offset by the + CascadeOffset value (default 25 pixels) from the previous one. + + :param winman: The WindowManager instance. + :param win: The currently active window (used to determine workspace). + :param state: Command state dictionary. + """ + curr_workspace = win.get_workspace() + if not curr_workspace: + logging.debug("Cannot cascade: no current workspace") + return + + # Get all relevant windows on the current workspace + windows = list(winman.get_relevant_windows(curr_workspace)) + + if not windows: + logging.debug("No windows to cascade") + return + + # Sort windows so active window comes last (appears on top) + active = winman.screen.get_active_window() + windows.sort(key=lambda w: 1 if w == active else 0) + + # Get monitor geometry from state (respects panels) + monitor_geom = state.get('monitor_geom') + if not monitor_geom: + logging.debug("No monitor geometry available") + return + + config = state.get('config') + offset = config.getint('general', 'CascadeOffset') if config else 25 + + logging.debug("Cascading %d windows starting at %r", + len(windows), monitor_geom) + + for i, window in enumerate(windows): + # Get current window geometry + geom = window.get_geometry() + win_rect = Rectangle(*geom) + + # Calculate cascade position + new_x = monitor_geom.x + (i * offset) + new_y = monitor_geom.y + (i * offset) + + # Create target rectangle (preserve original size) + target = Rectangle(new_x, new_y, win_rect.width, win_rect.height) + + # Make sure window is visible (de-minimize if needed) + if window.is_minimized(): + window.unminimize(Gdk.CURRENT_TIME) + + # Unshade if shaded + if window.is_shaded(): + window.unshade() + + logging.debug("Cascading window %r to %r", window, target) + + # Reposition window (unmaximize first if maximized) + if window.is_maximized(): + window.unmaximize() + + winman.reposition(window, target) + + +@commands.add('tile-all') +def tile_all_windows( + winman: WindowManager, + win: Wnck.Window, + state: Dict[str, Any] +) -> None: + """Tile all visible windows in a grid layout. + + Resizes and positions windows to fill the usable screen area in an + optimal grid arrangement. Grid dimensions are calculated based on + window count and screen aspect ratio. + + :param winman: The WindowManager instance. + :param win: The currently active window (used to determine workspace). + :param state: Command state dictionary. + """ + curr_workspace = win.get_workspace() + if not curr_workspace: + logging.debug("Cannot tile: no current workspace") + return + + # Get all relevant windows on the current workspace + windows = list(winman.get_relevant_windows(curr_workspace)) + + if not windows: + logging.debug("No windows to tile") + return + + n_windows = len(windows) + + # Get monitor geometry from state + monitor_geom = state.get('monitor_geom') + if not monitor_geom: + logging.debug("No monitor geometry available") + return + + logging.debug("Tiling %d windows in monitor %r", + n_windows, monitor_geom) + + # Calculate optimal grid dimensions + n_cols, n_rows = _calculate_optimal_grid( + n_windows, monitor_geom.width, monitor_geom.height + ) + + # Calculate base cell dimensions + cell_width = monitor_geom.width // n_cols + cell_height = monitor_geom.height // n_rows + + # Position and resize each window + for i, window in enumerate(windows): + col = i % n_cols + row = i // n_cols + + # Calculate position + x = monitor_geom.x + (col * cell_width) + y = monitor_geom.y + (row * cell_height) + + # Handle last row (may have fewer windows) + width = cell_width + if row == n_rows - 1: + remaining = n_windows - (row * n_cols) + if remaining < n_cols and remaining > 0: + # Expand cells in last row to fill remaining width + width = monitor_geom.width // remaining + x = monitor_geom.x + ((i % n_cols) * width) + + height = cell_height + + # Create target rectangle + target = Rectangle(x, y, width, height) + + # Make sure window is visible + if window.is_minimized(): + window.unminimize(Gdk.CURRENT_TIME) + + if window.is_shaded(): + window.unshade() + + logging.debug("Tiling window %r to %r", window, target) + + # Unmaximize if maximized, then reposition + if window.is_maximized(): + window.unmaximize() + + winman.reposition(window, target) + + +def _calculate_optimal_grid(n: int, width: int, height: int) -> Tuple[int, int]: + """Calculate optimal grid dimensions for n windows. + + Strategy: Minimize difference from screen aspect ratio while ensuring + all windows fit (cols * rows >= n). + + :param n: Number of windows. + :param width: Screen width. + :param height: Screen height. + :returns: Tuple of (columns, rows). + """ + if n <= 1: + return (1, 1) + + screen_ratio = width / height + + best_cols, best_rows = n, 1 + best_diff = float('inf') + + for cols in range(1, n + 1): + rows = math.ceil(n / cols) + cell_ratio = (width / cols) / (height / rows) + diff = abs(cell_ratio - screen_ratio) + + if diff < best_diff: + best_diff = diff + best_cols, best_rows = cols, rows + + return (int(best_cols), int(best_rows)) + + +@commands.add('grid-overlay') +def grid_overlay( + winman: WindowManager, + win: Wnck.Window, + state: Dict[str, Any] +) -> None: + """Show a grid overlay to visually select window position. + + Allows clicking two corners or using keyboard to select a grid area. + The active window will be resized to fill the selected area. + + :param winman: The WindowManager instance. + :param win: The currently active window. + :param state: Command state dictionary. + """ + from .overlay import GridOverlay + + # Get monitor geometry + monitor_id, monitor_geom = winman.get_monitor(win) + if not monitor_geom: + logging.debug("No monitor geometry available") + return + + # Get grid dimensions from config + config = state.get('config') + rows = config.getint('general', 'GridRows') if config else 3 + cols = config.getint('general', 'GridCols') if config else 3 + + logging.debug("Showing grid overlay: %dx%d on monitor %d", + cols, rows, monitor_id) + logging.debug("Monitor geometry: %s", monitor_geom) + + # Create and show overlay + overlay = GridOverlay(winman, monitor_geom, rows, cols) + logging.debug("Overlay created, calling show_overlay()") + overlay.show_overlay() + logging.debug("show_overlay() returned") + + # vim: set sw=4 sts=4 expandtab : diff --git a/quicktile/config.py b/quicktile/config.py index 4351bcb..5950268 100644 --- a/quicktile/config.py +++ b/quicktile/config.py @@ -24,6 +24,9 @@ 'ColumnCount': 3, 'MarginX_Percent': 0, 'MarginY_Percent': 0, + 'GridRows': 3, + 'GridCols': 3, + 'CascadeOffset': 25, }, 'keys': { "KP_Enter": "monitor-switch", @@ -49,6 +52,7 @@ "V": "vertical-maximize", "H": "horizontal-maximize", "C": "move-to-center", + "Return": "grid-overlay", } } diff --git a/quicktile/layout.py b/quicktile/layout.py index 977f3ad..8f98442 100644 --- a/quicktile/layout.py +++ b/quicktile/layout.py @@ -159,8 +159,8 @@ def __call__(self, :class:`quicktile.util.Rectangle`. """ - x = x or self.GRAVITIES[gravity].value[0] - y = y or self.GRAVITIES[gravity].value[1] + x = self.GRAVITIES[gravity].value[0] if x is None else x + y = self.GRAVITIES[gravity].value[1] if y is None else y offset_x = width * self.GRAVITIES[gravity].value[0] offset_y = height * self.GRAVITIES[gravity].value[1] diff --git a/quicktile/overlay.py b/quicktile/overlay.py new file mode 100644 index 0000000..2b7e272 --- /dev/null +++ b/quicktile/overlay.py @@ -0,0 +1,355 @@ +"""Grid overlay for visual window tiling""" + +__author__ = "Greg" +__license__ = "GNU GPL 2.0 or later" + +import logging +import time + +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('Gdk', '3.0') + +from gi.repository import Gtk, Gdk, GLib + +from .util import Rectangle + +log = logging.getLogger(__name__) + + +class GridOverlay(Gtk.Window): + """A centered grid overlay dialog for visual window tiling. + + Uses POPUP window type to avoid WM focus stealing issues. + """ + + def __init__(self, winman, monitor_geom, rows=3, cols=3): + super().__init__() + self.winman = winman + self.monitor_geom = Rectangle(*monitor_geom) + self.rows = rows + self.cols = cols + + self.first_corner = None + self.second_corner = None + self.selection_active = False + self.target_window = None # Store the window to reposition + + self._setup_window() + self._setup_events() + + def _setup_window(self): + """Configure the overlay window properties.""" + # POPUP type is designed for transient UI like menus/overlays + # It doesn't get decorated and WM treats it specially + self.set_type_hint(Gdk.WindowTypeHint.POPUP_MENU) + self.set_decorated(False) + self.set_keep_above(True) + self.set_accept_focus(True) + self.set_focus_on_map(True) + self.set_app_paintable(True) + + # Size: ~half screen, centered on monitor + overlay_width = int(self.monitor_geom.width * 0.5) + overlay_height = int(self.monitor_geom.height * 0.5) + overlay_x = self.monitor_geom.x + int((self.monitor_geom.width - overlay_width) / 2) + overlay_y = self.monitor_geom.y + int((self.monitor_geom.height - overlay_height) / 2) + + self.set_default_size(overlay_width, overlay_height) + self.move(overlay_x, overlay_y) + + screen = self.get_screen() + visual = screen.get_rgba_visual() + if visual: + self.set_visual(visual) + + def _setup_events(self): + """Connect event handlers.""" + self.connect('draw', self._on_draw) + self.connect('button-press-event', self._on_button_press) + self.connect('key-press-event', self._on_key_press) + self.connect('show', self._on_show) + self.connect('hide', self._on_hide) + self.add_events(Gdk.EventMask.BUTTON_PRESS_MASK | + Gdk.EventMask.BUTTON_RELEASE_MASK | + Gdk.EventMask.KEY_PRESS_MASK) + + def _on_show(self, widget): + """Grab focus when shown.""" + log.debug("Overlay shown") + self.present() + self.grab_focus() + self.grab_add() # Grab all input + gdk_window = self.get_window() + if gdk_window: + gdk_window.focus(Gdk.CURRENT_TIME) + + def _on_hide(self, widget): + """Release grabs on hide.""" + log.debug("Overlay hidden") + self.grab_remove() # Paired with grab_add + + def _on_draw(self, widget, cr): + """Draw the grid and selection rectangle.""" + alloc = self.get_allocation() + width = alloc.width + height = alloc.height + + # Background - semi-transparent dark + cr.set_source_rgba(0.1, 0.1, 0.1, 0.85) + cr.paint() + + # Draw grid lines + cr.set_source_rgba(0.6, 0.6, 1.0, 0.8) + cr.set_line_width(1) + + cell_width = width / self.cols + cell_height = height / self.rows + + # Vertical lines + for col in range(1, self.cols): + x = col * cell_width + cr.move_to(x, 0) + cr.line_to(x, height) + cr.stroke() + + # Horizontal lines + for row in range(1, self.rows): + y = row * cell_height + cr.move_to(0, y) + cr.line_to(width, y) + cr.stroke() + + # Draw selection rectangle if active + if self.first_corner and self.second_corner: + x1, y1 = self.first_corner + x2, y2 = self.second_corner + + if x1 > x2: + x1, x2 = x2, x1 + if y1 > y2: + y1, y2 = y2, y1 + + cr.set_source_rgba(0.2, 0.6, 1.0, 0.3) + cr.rectangle(x1, y1, x2 - x1, y2 - y1) + cr.fill_preserve() + + cr.set_source_rgba(0.2, 0.6, 1.0, 0.8) + cr.set_line_width(2) + cr.stroke() + + def _on_button_press(self, widget, event): + """Handle mouse clicks for cell selection.""" + log.debug("CLICK RECEIVED at %.1f, %.1f button=%d", event.x, event.y, event.button) + + alloc = self.get_allocation() + cell_width = alloc.width / self.cols + cell_height = alloc.height / self.rows + + grid_x = min(int(event.x / cell_width), self.cols - 1) + grid_y = min(int(event.y / cell_height), self.rows - 1) + + pixel_x = grid_x * cell_width + pixel_y = grid_y * cell_height + + log.debug("Grid cell: %d, %d -> pixel: %.1f, %.1f", + grid_x, grid_y, pixel_x, pixel_y) + + if self.first_corner and self.second_corner: + # Reset and start new selection + self.first_corner = (pixel_x, pixel_y) + self.second_corner = None + self.selection_active = True + log.debug("Reset selection, first corner: %d, %d", grid_x, grid_y) + elif not self.first_corner: + # FIRST click - set only first corner + self.first_corner = (pixel_x, pixel_y) + self.second_corner = None # <-- Don't set second corner yet! + self.selection_active = True + log.debug("First corner set: %d, %d", grid_x, grid_y) + else: + # SECOND click - set second corner + self.second_corner = (pixel_x, pixel_y) + log.debug("Second corner set: %d, %d", grid_x, grid_y) + self._apply_selection() + self.queue_draw() + return True + + def _on_key_press(self, widget, event): + """Handle keyboard navigation.""" + key = Gdk.keyval_name(event.keyval) + log.debug("Key press: %s", key) + + if key == 'Escape': + self.target_window = None # Clear saved window + self.hide() + return True + elif key == 'Return' or key == 'KP_Enter': + if self.first_corner and self.second_corner: + self._apply_selection() + return True + elif key in ('Up', 'Down', 'Left', 'Right'): + self._handle_arrow_key(key, event.state) + self.queue_draw() + return True + + return False + + def _handle_arrow_key(self, key, state): + """Move selection using arrow keys.""" + alloc = self.get_allocation() + cell_width = alloc.width / self.cols + cell_height = alloc.height / self.rows + + if not self.first_corner: + self.first_corner = (0, 0) + self.second_corner = (0, 0) + self.selection_active = True + return + + # Get current grid position + if self.second_corner: + cur_x = int(self.second_corner[0] / cell_width) + cur_y = int(self.second_corner[1] / cell_height) + else: + cur_x = int(self.first_corner[0] / cell_width) + cur_y = int(self.first_corner[1] / cell_height) + + if state & Gdk.ModifierType.SHIFT_MASK: + # Move second corner + if key == 'Up' and cur_y > 0: + cur_y -= 1 + elif key == 'Down' and cur_y < self.rows - 1: + cur_y += 1 + elif key == 'Left' and cur_x > 0: + cur_x -= 1 + elif key == 'Right' and cur_x < self.cols - 1: + cur_x += 1 + self.second_corner = (cur_x * cell_width, cur_y * cell_height) + else: + # Move first corner + if key == 'Up' and cur_y > 0: + cur_y -= 1 + elif key == 'Down' and cur_y < self.rows - 1: + cur_y += 1 + elif key == 'Left' and cur_x > 0: + cur_x -= 1 + elif key == 'Right' and cur_x < self.cols - 1: + cur_x += 1 + self.first_corner = (cur_x * cell_width, cur_y * cell_height) + self.second_corner = self.first_corner + + def _apply_selection(self): + """Apply the selected grid area to the active window.""" + log.debug("APPLY_SELECTION CALLED") + if not self.first_corner or not self.second_corner: + log.debug("Missing corner data") + return + + # Use the saved target window, NOT the currently active window (overlay) + window = self.target_window + if not window: + log.debug("No target window saved") + self.hide() + return + if not self.winman.is_relevant(window): + log.debug("Window not relevant: %s", window.get_name()) + self.hide() + return + + log.debug("Target window: %s", window.get_name()) + + # Convert overlay pixel positions to grid cell coordinates + alloc = self.get_allocation() + log.debug("Overlay allocation: %s", alloc) + cell_width = alloc.width / self.cols + cell_height = alloc.height / self.rows + log.debug("Cell size: %.1f x %.1f", cell_width, cell_height) + + grid_x1 = int(self.first_corner[0] / cell_width) + grid_y1 = int(self.first_corner[1] / cell_height) + grid_x2 = int(self.second_corner[0] / cell_width) + grid_y2 = int(self.second_corner[1] / cell_height) + log.debug("Grid cells: (%d,%d) to (%d,%d)", grid_x1, grid_y1, grid_x2, grid_y2) + + # Ensure correct ordering + if grid_x1 > grid_x2: + grid_x1, grid_x2 = grid_x2, grid_x1 + if grid_y1 > grid_y2: + grid_y1, grid_y2 = grid_y2, grid_y1 + + # Map grid cells to actual monitor pixel coordinates + monitor_cell_width = self.monitor_geom.width / self.cols + monitor_cell_height = self.monitor_geom.height / self.rows + log.debug("Monitor cell size: %.1f x %.1f", monitor_cell_width, monitor_cell_height) + + x1 = grid_x1 * monitor_cell_width + self.monitor_geom.x + y1 = grid_y1 * monitor_cell_height + self.monitor_geom.y + x2 = (grid_x2 + 1) * monitor_cell_width + self.monitor_geom.x + y2 = (grid_y2 + 1) * monitor_cell_height + self.monitor_geom.y + + target = Rectangle(x1, y1, x2 - x1, y2 - y1) + log.debug("Target rectangle: %s", target) + + # Order matters: unmaximize/unminimize FIRST before reposition + # WM will queue these requests, 50ms timeout gives time to settle + if window.is_maximized(): + log.debug("Unmaximizing window") + window.unmaximize() + + if window.is_minimized(): + log.debug("Unminimizing window") + window.unminimize(Gdk.CURRENT_TIME) + + if window.is_shaded(): + log.debug("Unshading window") + window.unshade() + + # Ensure window is focused before repositioning + log.debug("Activating window") + window.activate(int(time.time())) + # activate() in _deferred_hide will re-focus after hide + + log.debug("Calling reposition()") + self.winman.reposition(window, target) + log.debug("reposition() complete") + + # Flush X11 buffer to ensure request is sent immediately + dsp = Gdk.Display.get_default() + if dsp: + dsp.flush() + + # Defer hide so WM has time to process the async X11 resize request + # _NET_MOVERESIZE_WINDOW is processed on next event loop iteration + log.debug("Deferring overlay hide (50ms) to allow WM to process resize") + GLib.timeout_add(50, self._deferred_hide, window) + + def show_overlay(self): + """Show the overlay and grab keyboard focus.""" + log.debug("SHOW_OVERLAY CALLED") + + # Save the target window BEFORE overlay grabs focus + self.target_window = self.winman.screen.get_active_window() + if self.target_window: + log.debug("Saved target window: %s", self.target_window.get_name()) + else: + log.debug("No target window found!") + + self.first_corner = None + self.second_corner = None + self.selection_active = False + + log.debug("Calling show_all()") + self.show_all() + log.debug("Calling present()") + self.present() + log.debug("Calling grab_focus()") + self.grab_focus() + log.debug("show_overlay() complete, window visible: %s", self.get_visible()) + + def _deferred_hide(self, window): + """Hide overlay after WM has processed the resize.""" + log.debug("Deferred hide: hiding overlay") + self.hide() + self.target_window = None # Clean up + return False # Don't repeat diff --git a/quicktile/util.py b/quicktile/util.py index f14729c..0ea8c94 100644 --- a/quicktile/util.py +++ b/quicktile/util.py @@ -783,6 +783,21 @@ def to_gdk(self): gdk_rect.height = self.height return gdk_rect + def center_in(self, other: 'Rectangle') -> 'Rectangle': + """Return a copy of ``self`` centered within ``other``. + + .. doctest:: + + >>> monitor = Rectangle(0, 0, 1000, 800) + >>> window = Rectangle(0, 0, 200, 100) + >>> window.center_in(monitor) + Rectangle(x=400, y=350, width=200, height=100) + """ + return Rectangle( + x=other.x + (other.width - self.width) // 2, + y=other.y + (other.height - self.height) // 2, + width=self.width, height=self.height) + # Keep _Rectangle from showing up in automated documentation del _Rectangle diff --git a/quicktile/wm.py b/quicktile/wm.py index 0f55280..6225434 100644 --- a/quicktile/wm.py +++ b/quicktile/wm.py @@ -240,17 +240,33 @@ def get_monitor(self, win: Wnck.Window) -> Tuple[int, Rectangle]: def get_relevant_windows(self, workspace: Wnck.Workspace ) -> Iterable[Wnck.Window]: """Wrapper for :meth:`Wnck.Screen.get_windows` that filters out windows - of type :any:`Wnck.WindowType.DESKTOP` or :any:`Wnck.WindowType.DOCK`. + of type :any:`Wnck.WindowType.DESKTOP` or :any:`Wnck.WindowType.DOCK`, + plus utility windows that should not be rearranged in bulk operations. :param workspace: The virtual desktop to retrieve windows from. """ + excluded_types = [ + Wnck.WindowType.DESKTOP, + Wnck.WindowType.DOCK, + Wnck.WindowType.TOOLBAR, + Wnck.WindowType.MENU, + Wnck.WindowType.UTILITY, + Wnck.WindowType.SPLASH, + ] for window in self.screen.get_windows(): # Skip windows on other virtual desktops for intuitiveness if workspace and not window.is_on_workspace(workspace): logging.debug("Skipping window on other workspace: %r", window) continue - # Don't cycle elements of the desktop + if window.get_window_type() in excluded_types: + logging.debug("Skipping excluded window type: %r", window) + continue + + if window.is_skip_tasklist(): + logging.debug("Skipping skip-tasklist window: %r", window) + continue + if not self.is_relevant(window): continue diff --git a/tests/test_util.py b/tests/test_util.py index 1718782..87068c1 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -539,6 +539,79 @@ def test_two_point_form(self): self.assertEqual(self.rect1.y2, self.rect1.y + self.rect1.height) +class TestRectangle_center_in(unittest.TestCase): + """Tests for Rectangle.center_in""" + + def setUp(self): + self.monitor = Rectangle(0, 0, 1000, 800) + + def test_center_in_basic(self): + """center_in: basic centering within monitor""" + window = Rectangle(0, 0, 200, 100) + result = window.center_in(self.monitor) + self.assertEqual(result, Rectangle(x=400, y=350, width=200, height=100)) + + def test_center_in_with_offset(self): + """center_in: centering when monitor has non-zero origin""" + monitor_offset = Rectangle(1920, 0, 1920, 1080) + window = Rectangle(0, 0, 400, 300) + result = window.center_in(monitor_offset) + self.assertEqual(result, Rectangle(x=2680, y=390, width=400, height=300)) + + def test_center_in_odd_dimensions(self): + """center_in: rounding behavior with odd dimensions""" + monitor = Rectangle(0, 0, 1001, 801) + window = Rectangle(0, 0, 3, 3) + result = window.center_in(monitor) + self.assertEqual(result, Rectangle(x=499, y=399, width=3, height=3)) + + def test_center_in_fullscreen(self): + """center_in: window same size as monitor""" + window = Rectangle(0, 0, 1000, 800) + result = window.center_in(self.monitor) + self.assertEqual(result, Rectangle(x=0, y=0, width=1000, height=800)) + + +class TestCalculateOptimalGrid(unittest.TestCase): + """Tests for _calculate_optimal_grid""" + + def setUp(self): + from quicktile.commands import _calculate_optimal_grid + self.func = _calculate_optimal_grid + + def test_single_window(self): + """_calculate_optimal_grid: single window returns 1x1""" + self.assertEqual(self.func(1, 1920, 1080), (1, 1)) + + def test_two_windows(self): + """_calculate_optimal_grid: two windows""" + cols, rows = self.func(2, 1920, 1080) + self.assertEqual(cols * rows, 2) + self.assertGreaterEqual(cols, 1) + self.assertGreaterEqual(rows, 1) + + def test_four_windows_wide(self): + """_calculate_optimal_grid: four windows on wide screen""" + cols, rows = self.func(4, 1920, 1080) + self.assertEqual(cols * rows, 4) + self.assertLessEqual(cols, 4) + self.assertLessEqual(rows, 4) + + def test_three_windows(self): + """_calculate_optimal_grid: three windows""" + cols, rows = self.func(3, 1920, 1080) + self.assertGreaterEqual(cols * rows, 3) + + def test_aspect_ratio_preserved(self): + """_calculate_optimal_grid: grid cells approximate screen aspect ratio""" + cols, rows = self.func(6, 1920, 1080) + self.assertEqual(cols * rows, 6) + cell_ratio = (1920 / cols) / (1080 / rows) + screen_ratio = 1920 / 1080 + diff = abs(cell_ratio - screen_ratio) + self.assertLess(diff, 1.0) + + class TestUsableRegion(unittest.TestCase): """Tests for my per-monitor ``_NET_WORKAREA`` calculation class"""