diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..12b0a98 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: pip install ruff + - run: ruff check . + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install GTK3 + GObject introspection + pytest + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + gir1.2-gtk-3.0 \ + gir1.2-glib-2.0 \ + gir1.2-gdkpixbuf-2.0 \ + gir1.2-pango-1.0 \ + python3-gi \ + python3-pytest \ + libgtk-layer-shell-dev \ + gir1.2-gtklayershell-0.1 + - name: Run tests + run: python3 -m pytest tests/ -v diff --git a/clipse_gui/app.py b/clipse_gui/app.py index 9865a9d..c6349d9 100644 --- a/clipse_gui/app.py +++ b/clipse_gui/app.py @@ -10,7 +10,18 @@ from .controller import ClipboardHistoryController from .tray_manager import TrayManager -from gi.repository import Gtk, Gio, GLib # noqa: E402 +from gi.repository import Gdk, Gtk, Gio, GLib # noqa: E402 + +# gtk-layer-shell: optional, enables cursor-position launch on Wayland +try: + import gi as _gi + + _gi.require_version("GtkLayerShell", "0.1") + from gi.repository import GtkLayerShell # noqa: E402 + + _HAS_LAYER_SHELL = True +except (ImportError, ValueError): + _HAS_LAYER_SHELL = False log = logging.getLogger(__name__) @@ -65,12 +76,18 @@ def do_activate(self): else: init_w = DEFAULT_WINDOW_WIDTH init_h = DEFAULT_WINDOW_HEIGHT - self.window.set_default_size(init_w, init_h) try: self.window.set_icon_name("edit-copy") except GLib.Error as e: log.warning(f"Could not set window icon name: {e}") + if compact_mode_on: + # Layer-shell ignores set_default_size; use set_size_request + self.window.set_size_request(init_w, init_h) + self._position_at_cursor(init_w, init_h) + else: + self.window.set_default_size(init_w, init_h) + self.window.connect("delete-event", self._on_window_delete) # Show the window immediately so it appears without delay @@ -122,6 +139,150 @@ def _finish_activation(self): self.tray_manager = TrayManager(self) return False + def _position_at_cursor(self, win_w, win_h): + """Position the window at the mouse cursor. + + Wayland: use gtk-layer-shell (anchor top-left, margins = cursor pos). + X11 fallback: standard window.move(). + """ + import os + + is_wayland = "wayland" in os.environ.get("XDG_SESSION_TYPE", "").lower() + + if is_wayland and _HAS_LAYER_SHELL: + cx, cy, mw, mh = self._get_cursor_pos_wayland() + if cx is not None: + # Clamp so the window stays within the monitor + cx = min(cx, max(0, mw - win_w)) + cy = min(cy, max(0, mh - win_h)) + self._position_layer_shell(cx, cy) + elif not is_wayland: + self._position_at_cursor_x11(win_w, win_h) + + def _get_cursor_pos_wayland(self): + """Get cursor position on Wayland, relative to the monitor the cursor is on. + + Returns (cx, cy, monitor_w, monitor_h) or (None, None, 0, 0). + Layer-shell margins are per-output, so we convert global + compositor coordinates to monitor-local offsets. + """ + import shutil + + if shutil.which("hyprctl"): + return self._hyprctl_cursor_pos_local() + + return None, None, 0, 0 + + def _hyprctl_cursor_pos_local(self): + """Query Hyprland for cursor pos and convert to monitor-local coords. + + Returns (local_x, local_y, monitor_w, monitor_h) or (None, None, 0, 0). + """ + import json + import subprocess + + env = None + result = subprocess.run( + ["hyprctl", "cursorpos"], capture_output=True, timeout=1 + ) + if result.returncode != 0: + env = self._fix_hyprland_env() + if env: + result = subprocess.run( + ["hyprctl", "cursorpos"], + capture_output=True, timeout=1, env=env, + ) + if result.returncode != 0: + log.debug( + f"hyprctl cursorpos failed (exit {result.returncode}): " + f"{result.stderr.decode().strip()}" + ) + return None, None, 0, 0 + + try: + raw = result.stdout.decode().strip() + cx, cy = (int(v.strip()) for v in raw.split(",")) + except (ValueError, TypeError) as e: + log.debug(f"hyprctl cursorpos parse error: {e}") + return None, None, 0, 0 + + # Get monitor geometry to convert global → local + mon_result = subprocess.run( + ["hyprctl", "monitors", "-j"], + capture_output=True, timeout=1, env=env, + ) + if mon_result.returncode == 0: + try: + monitors = json.loads(mon_result.stdout.decode()) + for m in monitors: + mx, my = m["x"], m["y"] + mw, mh = m["width"], m["height"] + if mx <= cx < mx + mw and my <= cy < my + mh: + return cx - mx, cy - my, mw, mh + except (json.JSONDecodeError, KeyError) as e: + log.debug(f"hyprctl monitors parse error: {e}") + + return cx, cy, 1920, 1080 + + @staticmethod + def _fix_hyprland_env(): + """Find the newest Hyprland instance socket when the env var is stale.""" + import os + from pathlib import Path + + hypr_dir = Path(f"/run/user/{os.getuid()}/hypr") + if not hypr_dir.is_dir(): + return None + instances = sorted(hypr_dir.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True) + for inst in instances: + sock = inst / ".socket.sock" + if sock.exists(): + env = os.environ.copy() + env["HYPRLAND_INSTANCE_SIGNATURE"] = inst.name + return env + return None + + def _position_layer_shell(self, cx, cy): + """Use gtk-layer-shell to place the window at (cx, cy).""" + GtkLayerShell.init_for_window(self.window) + GtkLayerShell.set_layer(self.window, GtkLayerShell.Layer.TOP) + GtkLayerShell.set_keyboard_mode( + self.window, GtkLayerShell.KeyboardMode.ON_DEMAND + ) + + # Anchor top-left, push to cursor via margins + GtkLayerShell.set_anchor(self.window, GtkLayerShell.Edge.TOP, True) + GtkLayerShell.set_anchor(self.window, GtkLayerShell.Edge.LEFT, True) + GtkLayerShell.set_margin(self.window, GtkLayerShell.Edge.TOP, cy) + GtkLayerShell.set_margin(self.window, GtkLayerShell.Edge.LEFT, cx) + log.debug(f"Layer-shell positioned at cursor ({cx}, {cy})") + + def _position_at_cursor_x11(self, win_w, win_h): + """Use GTK window.move() to position at cursor (X11).""" + display = Gdk.Display.get_default() + if not display: + return + seat = display.get_default_seat() + if not seat: + return + pointer = seat.get_pointer() + if not pointer: + return + screen, cx, cy = pointer.get_position() + + monitor = display.get_monitor_at_point(cx, cy) + if monitor: + geo = monitor.get_geometry() + else: + geo = Gdk.Rectangle() + geo.x, geo.y = 0, 0 + geo.width = screen.get_width() if screen else 1920 + geo.height = screen.get_height() if screen else 1080 + + x = max(geo.x, min(cx, geo.x + geo.width - win_w)) + y = max(geo.y, min(cy, geo.y + geo.height - win_h)) + self.window.move(x, y) + def _restore_window_from_tray(self): """Restore and show the window, even if minimized to tray.""" if self.window: diff --git a/clipse_gui/controller_mixins/keyboard_mixin.py b/clipse_gui/controller_mixins/keyboard_mixin.py index 3b53e36..fbbaabb 100644 --- a/clipse_gui/controller_mixins/keyboard_mixin.py +++ b/clipse_gui/controller_mixins/keyboard_mixin.py @@ -9,287 +9,310 @@ log = logging.getLogger(__name__) +# Keys that insert their character when search entry is focused +_SEARCH_INSERT_KEYS = { + Gdk.KEY_v: "v", + Gdk.KEY_x: "x", + Gdk.KEY_p: "p", + Gdk.KEY_j: "j", + Gdk.KEY_k: "k", + Gdk.KEY_f: "f", + Gdk.KEY_slash: "/", + Gdk.KEY_question: "?", + Gdk.KEY_space: " ", +} + +_PAGE_STEP = 5 + class KeyboardMixin: def on_key_press(self, widget, event): """Handles key presses on the main window.""" keyval = event.keyval - ctrl = event.state & Gdk.ModifierType.CONTROL_MASK - shift = event.state & Gdk.ModifierType.SHIFT_MASK + ctrl = bool(event.state & Gdk.ModifierType.CONTROL_MASK) + shift = bool(event.state & Gdk.ModifierType.SHIFT_MASK) if self.search_entry.has_focus(): - if keyval == Gdk.KEY_Escape: - # Priority order: exit selection mode -> clear search + unfocus - if self.selection_mode: - self.toggle_selection_mode() - return True + return self._handle_search_keys(keyval) - # Clear search text if present - if self.search_entry.get_text(): - self.search_entry.set_text("") - - # Unfocus search entry and focus first list item (deferred) - def unfocus_search(): - if self.list_box: - # Select and focus first row - first_row = self.list_box.get_row_at_index(0) - if first_row: - self.list_box.select_row(first_row) - first_row.grab_focus() - else: - self.list_box.grab_focus() - else: - self.window.grab_focus() - return False - GLib.idle_add(unfocus_search) - return True - - if keyval in [Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Page_Up, Gdk.KEY_Page_Down]: - focusable_elements = self.list_box.get_children() - if not focusable_elements: - return False - - current_focus = self.window.get_focus() - current_index = ( - focusable_elements.index(current_focus) - if current_focus in focusable_elements - else -1 - ) + return self._dispatch_key(keyval, ctrl, shift) - target_index = current_index - if keyval == Gdk.KEY_Down: - target_index = 0 if current_index == -1 else current_index + 1 - elif keyval == Gdk.KEY_Up: - target_index = ( - len(focusable_elements) - 1 - if current_index == -1 - else current_index - 1 - ) - elif keyval == Gdk.KEY_Page_Down: - target_index = ( - 0 - if current_index == -1 - else min(current_index + 5, len(focusable_elements) - 1) - ) - elif keyval == Gdk.KEY_Page_Up: - target_index = ( - len(focusable_elements) - 1 - if current_index == -1 - else max(current_index - 5, 0) - ) - - if 0 <= target_index < len(focusable_elements): - row = focusable_elements[target_index] - self.list_box.select_row(row) - row.grab_focus() - allocation = row.get_allocation() - adj = self.scrolled_window.get_vadjustment() - if adj: - adj.set_value( - min(allocation.y, adj.get_upper() - adj.get_page_size()) - ) - return True + # ── Search-focused key handling ───────────────────────────── - return False - - # When search entry has focus, insert bound keys as text instead of - # triggering their global actions. Let all other keys pass through normally. - key_to_char = { - Gdk.KEY_v: "v", - Gdk.KEY_x: "x", - Gdk.KEY_p: "p", - Gdk.KEY_j: "j", - Gdk.KEY_k: "k", - Gdk.KEY_f: "f", - Gdk.KEY_slash: "/", - Gdk.KEY_question: "?", - Gdk.KEY_space: " ", - } - - if keyval in key_to_char: - # Insert the character into the search entry - char = key_to_char[keyval] - current_text = self.search_entry.get_text() - # Get cursor position (if available) or append to end - if hasattr(self.search_entry, 'get_position'): - pos = self.search_entry.get_position() - new_text = current_text[:pos] + char + current_text[pos:] - self.search_entry.set_text(new_text) - self.search_entry.set_position(pos + 1) - else: - self.search_entry.set_text(current_text + char) - return True # Block the global action - - # Block Tab and Return from triggering actions, but don't insert them - if keyval in (Gdk.KEY_Tab, Gdk.KEY_Return): - return True + def _handle_search_keys(self, keyval): + """Handle keys when search entry is focused.""" + if keyval == Gdk.KEY_Escape: + return self._handle_search_escape() - # Let all other keys pass through normally - return False + if keyval in (Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Page_Up, Gdk.KEY_Page_Down): + return self._navigate_from_search(keyval) - selected_row = self.list_box.get_selected_row() - - if keyval == Gdk.KEY_Return: - if selected_row: - self.on_row_activated(self.list_box, shift and not ENTER_TO_PASTE) - elif self.list_box.get_children(): - first_row = self.list_box.get_row_at_index(0) - if first_row: - self.list_box.select_row(first_row) - first_row.grab_focus() - self.on_row_activated(self.list_box) - else: - self.search_entry.grab_focus() + char = _SEARCH_INSERT_KEYS.get(keyval) + if char: + self._insert_search_char(char) return True - # Navigation Aliases - if keyval == Gdk.KEY_k: - return self.list_box.emit("move-cursor", Gtk.MovementStep.DISPLAY_LINES, -1) - if keyval == Gdk.KEY_j: - return self.list_box.emit("move-cursor", Gtk.MovementStep.DISPLAY_LINES, 1) - - # Actions - if ( - keyval == Gdk.KEY_slash - or keyval == Gdk.KEY_f - and not self.search_entry.has_focus() - ): - # In compact mode the search entry is set_no_show_all(True). Re-allow show. - self.search_entry.set_no_show_all(False) - self.search_entry.show() - - # Defer focus + select: GTK realizes the widget on the next idle tick, - # grab_focus() before realization is silently dropped. - def _focus_search(): - self.search_entry.grab_focus() - self.search_entry.select_region(0, -1) - return False - - GLib.idle_add(_focus_search) + if keyval in (Gdk.KEY_Tab, Gdk.KEY_Return): return True - if keyval == Gdk.KEY_v: - # Toggle selection mode + + return False + + def _handle_search_escape(self): + """Escape while search focused: exit selection → clear + unfocus.""" + if self.selection_mode: self.toggle_selection_mode() return True - if keyval == Gdk.KEY_space: - if self.selection_mode: - # In selection mode, space toggles item selection - self.toggle_item_selection() - return True - elif selected_row: - # If it's a URL and browser-open is enabled, open it - if OPEN_LINKS_WITH_BROWSER and getattr(selected_row, "is_url", False): - url = getattr(selected_row, "website_url", None) or getattr(selected_row, "item_value", "") - if url: - self.open_url_with_gtk(url) - return True - # Otherwise show preview - self.show_item_preview() - return True - if ctrl and keyval == Gdk.KEY_a: - if shift: - # Ctrl+Shift+A: Deselect all - self.deselect_all_items() + + if self.search_entry.get_text(): + self.search_entry.set_text("") + + def unfocus(): + if self.list_box: + first = self.list_box.get_row_at_index(0) + if first: + self.list_box.select_row(first) + first.grab_focus() + else: + self.list_box.grab_focus() else: - # Ctrl+A: Select all - self.select_all_items() + self.window.grab_focus() + return False + + GLib.idle_add(unfocus) + return True + + def _navigate_from_search(self, keyval): + """Arrow / PageUp / PageDown while search is focused.""" + children = self.list_box.get_children() + if not children: + return False + + focus = self.window.get_focus() + idx = children.index(focus) if focus in children else -1 + last = len(children) - 1 + + target = { + Gdk.KEY_Down: 0 if idx == -1 else idx + 1, + Gdk.KEY_Up: last if idx == -1 else idx - 1, + Gdk.KEY_Page_Down: 0 if idx == -1 else min(idx + _PAGE_STEP, last), + Gdk.KEY_Page_Up: last if idx == -1 else max(idx - _PAGE_STEP, 0), + }.get(keyval, idx) + + if 0 <= target <= last: + row = children[target] + self.list_box.select_row(row) + row.grab_focus() + adj = self.scrolled_window.get_vadjustment() + if adj: + alloc = row.get_allocation() + adj.set_value(min(alloc.y, adj.get_upper() - adj.get_page_size())) return True - if ctrl and keyval == Gdk.KEY_x: - # Ctrl+X: Delete selected items - if self.selection_mode and self.selected_indices: - self.delete_selected_items() + + return False + + def _insert_search_char(self, char): + """Insert a character into the search entry at the cursor.""" + text = self.search_entry.get_text() + if hasattr(self.search_entry, "get_position"): + pos = self.search_entry.get_position() + self.search_entry.set_text(text[:pos] + char + text[pos:]) + self.search_entry.set_position(pos + 1) + else: + self.search_entry.set_text(text + char) + + # ── Main key dispatch ─────────────────────────────────────── + + def _dispatch_key(self, keyval, ctrl, shift): + """Look up (ctrl, shift, keyval) in dispatch table and call handler.""" + dispatch = { + # Navigation + (False, False, Gdk.KEY_j): self._nav_down, + (False, False, Gdk.KEY_k): self._nav_up, + # Row activation + (False, False, Gdk.KEY_Return): lambda: self._handle_return(False), + (False, True, Gdk.KEY_Return): lambda: self._handle_return( + not ENTER_TO_PASTE + ), + # Search focus + (False, False, Gdk.KEY_slash): self._focus_search, + (False, False, Gdk.KEY_f): self._focus_search, + # Selection mode + (False, False, Gdk.KEY_v): self.toggle_selection_mode, + (True, False, Gdk.KEY_a): self.select_all_items, + (True, True, Gdk.KEY_a): self.deselect_all_items, + # Item actions + (False, False, Gdk.KEY_space): self._handle_space, + (False, False, Gdk.KEY_p): self._handle_pin, + (False, False, Gdk.KEY_x): self._handle_remove_item, + (False, False, Gdk.KEY_Delete): self._handle_remove_item, + (True, False, Gdk.KEY_x): self._handle_delete_selected, + (False, True, Gdk.KEY_Delete): self._handle_delete_selected, + (True, True, Gdk.KEY_Delete): self._handle_clear_all, + (True, False, Gdk.KEY_d): self._handle_clear_all, + # Help / Settings + (False, False, Gdk.KEY_question): self._show_help, + (False, True, Gdk.KEY_question): self._show_help, + (False, True, Gdk.KEY_slash): self._show_help, + (True, False, Gdk.KEY_comma): self._show_settings, + # Tab / Escape / Quit + (False, False, Gdk.KEY_Tab): self._handle_tab, + (False, False, Gdk.KEY_Escape): self._handle_escape, + (True, False, Gdk.KEY_q): self._handle_quit, + # Zoom + (True, False, Gdk.KEY_plus): self._zoom_in, + (True, False, Gdk.KEY_equal): self._zoom_in, + (True, False, Gdk.KEY_minus): self._zoom_out, + (True, False, Gdk.KEY_0): self._zoom_reset, + } + + handler = dispatch.get((ctrl, shift, keyval)) + if handler: + result = handler() + return result if result is not None else True + return False + + # ── Handler methods ───────────────────────────────────────── + + def _nav_up(self): + return self.list_box.emit("move-cursor", Gtk.MovementStep.DISPLAY_LINES, -1) + + def _nav_down(self): + return self.list_box.emit("move-cursor", Gtk.MovementStep.DISPLAY_LINES, 1) + + def _handle_return(self, with_paste): + """Enter: activate selected row, or select first, or focus search.""" + selected = self.list_box.get_selected_row() + if selected: + self.on_row_activated(self.list_box, with_paste) + elif self.list_box.get_children(): + first = self.list_box.get_row_at_index(0) + if first: + self.list_box.select_row(first) + first.grab_focus() + self.on_row_activated(self.list_box) + else: + self.search_entry.grab_focus() + + def _focus_search(self): + """Show and focus the search entry.""" + self.search_entry.set_no_show_all(False) + self.search_entry.show() + + def _focus(): + self.search_entry.grab_focus() + self.search_entry.select_region(0, -1) + return False + + GLib.idle_add(_focus) + + def _handle_space(self): + """Space: toggle selection, open URL, or show preview.""" + if self.selection_mode: + self.toggle_item_selection() return True - if shift and keyval == Gdk.KEY_Delete: - # Shift+Delete: Delete selected items - if self.selection_mode and self.selected_indices: - self.delete_selected_items() + selected = self.list_box.get_selected_row() + if selected: + if OPEN_LINKS_WITH_BROWSER and getattr(selected, "is_url", False): + url = getattr(selected, "website_url", None) or getattr( + selected, "item_value", "" + ) + if url: + self.open_url_with_gtk(url) + return True + self.show_item_preview() return True - if ctrl and shift and keyval == Gdk.KEY_Delete: - # Ctrl+Shift+Delete: Clear all non-pinned items - self.clear_all_items() + return False + + def _handle_pin(self): + if self.list_box.get_selected_row(): + self.toggle_pin_selected() return True - if ctrl and keyval == Gdk.KEY_d: - # Ctrl+D: Clear all non-pinned items (alternative) - self.clear_all_items() + return False + + def _handle_remove_item(self): + """x / Delete: remove single item (only outside selection mode).""" + if self.list_box.get_selected_row() and not self.selection_mode: + self.remove_selected_item() return True - if keyval == Gdk.KEY_p: - if selected_row: - self.toggle_pin_selected() - return True - if keyval in [Gdk.KEY_x, Gdk.KEY_Delete]: - if selected_row and not self.selection_mode: - self.remove_selected_item() - return True - if keyval == Gdk.KEY_question or (shift and keyval == Gdk.KEY_slash): - show_help_window(self.window, self.on_help_window_close) + return False + + def _handle_delete_selected(self): + """Ctrl+X / Shift+Delete: delete selected items in selection mode.""" + if self.selection_mode and self.selected_indices: + self.delete_selected_items() return True - if ctrl and keyval == Gdk.KEY_comma: - style_defaults = { - "border_radius": 6, - "accent_color": "#ffcc00", - "selection_color": "#4a90e2", - "visual_mode_color": "#9b59b6", - } - show_settings_window( - self.window, - self.on_settings_window_close, - self.restart_application, - update_style_cb=self.update_style_css, - style_defaults=style_defaults, - ) + return False + + def _handle_clear_all(self): + self.clear_all_items() + + def _show_help(self): + show_help_window(self.window, self.on_help_window_close) + + def _show_settings(self): + style_defaults = { + "border_radius": 6, + "accent_color": "#ffcc00", + "selection_color": "#4a90e2", + "visual_mode_color": "#9b59b6", + } + show_settings_window( + self.window, + self.on_settings_window_close, + self.restart_application, + update_style_cb=self.update_style_css, + style_defaults=style_defaults, + ) + + def _handle_tab(self): + self.pin_filter_button.set_active(not self.pin_filter_button.get_active()) + self.list_box.grab_focus() + + def _handle_escape(self): + """Escape: exit selection → clear search → minimize/quit.""" + if self.selection_mode: + self.toggle_selection_mode() return True - if keyval == Gdk.KEY_Tab: - self.pin_filter_button.set_active(not self.pin_filter_button.get_active()) + if self.search_entry.get_text(): + self.search_entry.set_text("") self.list_box.grab_focus() return True - if keyval == Gdk.KEY_Escape: - # Priority order: exit selection mode -> clear search -> quit - if self.selection_mode: - self.toggle_selection_mode() - return True - elif self.search_entry.get_text(): - self.search_entry.set_text("") - self.list_box.grab_focus() - else: - app = self.window.get_application() - if app: - # Try to minimize to tray if enabled, otherwise quit - from .. import constants - - if ( - hasattr(app, "tray_manager") - and app.tray_manager - and constants.MINIMIZE_TO_TRAY - ): - if app.tray_manager.minimize_to_tray(): - return True # Successfully minimized to tray - app.quit() - else: - log.warning("Application instance is None. Cannot quit.") - return True - if ctrl and keyval == Gdk.KEY_q: - app = self.window.get_application() - if app: - app.quit() - else: - log.warning("Application instance is None. Cannot quit.") - return True - # Zoom - if ctrl and keyval in [Gdk.KEY_plus, Gdk.KEY_equal]: - self.zoom_level *= 1.1 - self.update_zoom() - return True - if ctrl and keyval == Gdk.KEY_minus: - self.zoom_level /= 1.1 - self.update_zoom() - return True - if ctrl and keyval == Gdk.KEY_0: - self.zoom_level = 1.0 - self.update_zoom() - return True + app = self.window.get_application() + if app: + from .. import constants - return False + if ( + hasattr(app, "tray_manager") + and app.tray_manager + and constants.MINIMIZE_TO_TRAY + ): + if app.tray_manager.minimize_to_tray(): + return True + app.quit() + else: + log.warning("Application instance is None. Cannot quit.") + + def _handle_quit(self): + app = self.window.get_application() + if app: + app.quit() + else: + log.warning("Application instance is None. Cannot quit.") + + def _zoom_in(self): + self.zoom_level *= 1.1 + self.update_zoom() + + def _zoom_out(self): + self.zoom_level /= 1.1 + self.update_zoom() + + def _zoom_reset(self): + self.zoom_level = 1.0 + self.update_zoom() + + # ── Public callbacks ──────────────────────────────────────── def on_row_activated(self, row, with_paste_simulation=False): """Handles double-click or Enter on a list row.""" @@ -298,17 +321,16 @@ def on_row_activated(self, row, with_paste_simulation=False): def _on_row_single_click(self, row): """Handles single-click on a list row - copies and pastes.""" - log.debug(f"Row single-clicked: original_index={getattr(row, 'item_index', 'N/A')}") - # Select the row first + log.debug( + f"Row single-clicked: original_index={getattr(row, 'item_index', 'N/A')}" + ) self.list_box.select_row(row) - # Trigger copy with paste simulation self.copy_selected_item_to_clipboard(with_paste_simulation=True) def on_compact_mode_toggled(self, button): """Handles compact mode toggle button state changes.""" self.compact_mode = button.get_active() self.update_compact_mode() - # Save the setting if not config.config.has_section("General"): config.config.add_section("General") config.config.set("General", "compact_mode", str(self.compact_mode)) diff --git a/justfile b/justfile index 04b5a22..6196cf2 100644 --- a/justfile +++ b/justfile @@ -169,9 +169,15 @@ format-check: @echo "{{ BLUE }}-> Checking code formatting...{{ RESET }}" {{ VENV_ACTIVATE }}ruff format --check . -# Run full quality pipeline - format, lint, type-check (group: 'qa') +# Run tests with pytest (group: 'qa') [group('qa')] -qa: format lint type-check +test *args: + @echo "{{ BLUE }}-> Running tests...{{ RESET }}" + python -m pytest tests/ {{ args }} + +# Run full quality pipeline - format, lint, type-check, test (group: 'qa') +[group('qa')] +qa: format lint type-check test @echo "{{ GREEN }}✓ Quality assurance complete!{{ RESET }}" # ============================================================================ diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md new file mode 100644 index 0000000..adfdcb1 --- /dev/null +++ b/tests/CLAUDE.md @@ -0,0 +1,7 @@ + +# Recent Activity + + + +*No recent activity* + \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fb1828a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,91 @@ +"""Shared fixtures for clipse-gui tests. + +GTK 3 requires gi.require_version() before any gi.repository import. +This conftest runs early enough to satisfy that constraint. +""" + +import gi + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") +gi.require_version("GLib", "2.0") + +from unittest.mock import MagicMock # noqa: E402 + +import pytest # noqa: E402 +from gi.repository import Gdk # noqa: E402 + +from clipse_gui.controller_mixins.keyboard_mixin import KeyboardMixin # noqa: E402 + + +class FakeController(KeyboardMixin): + """Minimal stand-in for ClipboardHistoryController. + + Provides every attribute / method that KeyboardMixin touches through + ``self``. Every mixin callback (toggle_selection_mode, etc.) is a + MagicMock so tests can assert call counts without side effects. + """ + + def __init__(self): + # ── GTK widget stubs ────────────────────────────────────── + self.search_entry = MagicMock() + self.search_entry.has_focus.return_value = False + self.search_entry.get_text.return_value = "" + self.search_entry.get_position.return_value = 0 + + self.list_box = MagicMock() + self.list_box.get_selected_row.return_value = None + self.list_box.get_children.return_value = [] + self.list_box.get_row_at_index.return_value = None + + self.scrolled_window = MagicMock() + self.window = MagicMock() + self.window.get_focus.return_value = None + self.window.get_application.return_value = MagicMock() + + self.pin_filter_button = MagicMock() + self.pin_filter_button.get_active.return_value = False + + # ── State ───────────────────────────────────────────────── + self.selection_mode = False + self.selected_indices = set() + self.zoom_level = 1.0 + self.compact_mode = False + + # ── Mixin callbacks (MagicMocks) ────────────────────────── + self.toggle_selection_mode = MagicMock() + self.toggle_item_selection = MagicMock() + self.select_all_items = MagicMock() + self.deselect_all_items = MagicMock() + self.toggle_pin_selected = MagicMock() + self.remove_selected_item = MagicMock() + self.delete_selected_items = MagicMock() + self.clear_all_items = MagicMock() + self.show_item_preview = MagicMock() + self.open_url_with_gtk = MagicMock() + self.copy_selected_item_to_clipboard = MagicMock() + self.update_zoom = MagicMock() + self.update_compact_mode = MagicMock() + self.update_style_css = MagicMock() + self.on_help_window_close = MagicMock() + self.on_settings_window_close = MagicMock() + self.restart_application = MagicMock() + + +def make_event(keyval, ctrl=False, shift=False): + """Build a fake Gdk.EventKey-like object.""" + event = MagicMock() + event.keyval = keyval + state = 0 + if ctrl: + state |= Gdk.ModifierType.CONTROL_MASK + if shift: + state |= Gdk.ModifierType.SHIFT_MASK + event.state = state + return event + + +@pytest.fixture +def ctrl(request): + """Provide a fresh FakeController for each test.""" + return FakeController() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..ab9afe7 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,222 @@ +"""Tests for clipse_gui.cli — argument parsing, logging setup, and ColorFormatter. + +main() is intentionally excluded: it launches GTK and calls sys.exit(). +""" + +import logging +import sys +from logging.handlers import RotatingFileHandler +from unittest.mock import patch + +import pytest + +# cli.py calls gi.require_version at module level; conftest.py has already +# issued those calls, so importing cli here is safe. +from clipse_gui.cli import ColorFormatter, parse_args_from_sys_argv, setup_logging + + +# --------------------------------------------------------------------------- +# parse_args_from_sys_argv() +# --------------------------------------------------------------------------- + + +class TestParseArgs: + def test_no_args_returns_debug_false(self): + with patch.object(sys, "argv", ["clipse-gui"]): + args, _ = parse_args_from_sys_argv() + assert args.debug is False + + @pytest.mark.parametrize("flag", ["--debug", "-d"]) + def test_debug_flag_sets_debug_true(self, flag: str): + with patch.object(sys, "argv", ["clipse-gui", flag]): + args, _ = parse_args_from_sys_argv() + assert args.debug is True + + def test_unknown_args_passed_through_in_second_element(self): + with patch.object(sys, "argv", ["clipse-gui", "--display=:0", "--some-gtk-arg"]): + args, remainder = parse_args_from_sys_argv() + assert "--display=:0" in remainder + assert "--some-gtk-arg" in remainder + + def test_known_args_not_in_remainder(self): + with patch.object(sys, "argv", ["clipse-gui", "--debug", "--extra"]): + args, remainder = parse_args_from_sys_argv() + assert "--debug" not in remainder + assert "-d" not in remainder + + def test_returns_namespace_and_list(self): + with patch.object(sys, "argv", ["clipse-gui"]): + result = parse_args_from_sys_argv() + namespace, remainder = result + assert hasattr(namespace, "debug") + assert isinstance(remainder, list) + + def test_version_flag_exits(self): + with patch.object(sys, "argv", ["clipse-gui", "--version"]): + with pytest.raises(SystemExit): + parse_args_from_sys_argv() + + +# --------------------------------------------------------------------------- +# setup_logging() +# +# Strategy: patch logging.basicConfig to intercept the handlers list it +# receives. This avoids fighting pytest's own LogCaptureHandler (which +# pre-exists on the root logger and would make basicConfig a no-op). +# --------------------------------------------------------------------------- + + +def _capture_basicconfig(tmp_path, debug: bool) -> list[logging.Handler]: + """Call setup_logging and return the handlers list passed to basicConfig.""" + captured: list[logging.Handler] = [] + + def fake_basicconfig(**kwargs): + captured.extend(kwargs.get("handlers", [])) + + with patch("clipse_gui.cli.constants.CONFIG_DIR", str(tmp_path)), \ + patch("logging.basicConfig", side_effect=fake_basicconfig): + setup_logging(debug=debug) + + return captured + + +class TestSetupLogging: + def test_default_mode_sends_one_stream_handler(self, tmp_path): + handlers = _capture_basicconfig(tmp_path, debug=False) + stream = [h for h in handlers if type(h).__name__ == "StreamHandler"] + assert len(stream) == 1 + + def test_default_mode_stream_handler_level_is_info(self, tmp_path): + handlers = _capture_basicconfig(tmp_path, debug=False) + stream = [h for h in handlers if type(h).__name__ == "StreamHandler"] + assert stream[0].level == logging.INFO + + def test_debug_mode_stream_handler_level_is_debug(self, tmp_path): + handlers = _capture_basicconfig(tmp_path, debug=True) + stream = [h for h in handlers if type(h).__name__ == "StreamHandler"] + assert stream[0].level == logging.DEBUG + + def test_no_file_handler_in_default_mode(self, tmp_path): + handlers = _capture_basicconfig(tmp_path, debug=False) + file_handlers = [h for h in handlers if isinstance(h, RotatingFileHandler)] + assert len(file_handlers) == 0 + + def test_file_handler_created_in_debug_mode(self, tmp_path): + handlers = _capture_basicconfig(tmp_path, debug=True) + file_handlers = [h for h in handlers if isinstance(h, RotatingFileHandler)] + assert len(file_handlers) == 1 + + def test_file_handler_path_uses_config_dir(self, tmp_path): + handlers = _capture_basicconfig(tmp_path, debug=True) + file_handlers = [h for h in handlers if isinstance(h, RotatingFileHandler)] + assert str(tmp_path) in file_handlers[0].baseFilename + + def test_file_handler_level_is_debug(self, tmp_path): + handlers = _capture_basicconfig(tmp_path, debug=True) + file_handlers = [h for h in handlers if isinstance(h, RotatingFileHandler)] + assert file_handlers[0].level == logging.DEBUG + + def test_stream_handler_formatter_is_color_formatter(self, tmp_path): + handlers = _capture_basicconfig(tmp_path, debug=False) + stream = [h for h in handlers if type(h).__name__ == "StreamHandler"] + assert isinstance(stream[0].formatter, ColorFormatter) + + def test_file_handler_uses_plain_formatter(self, tmp_path): + handlers = _capture_basicconfig(tmp_path, debug=True) + file_handlers = [h for h in handlers if isinstance(h, RotatingFileHandler)] + assert not isinstance(file_handlers[0].formatter, ColorFormatter) + + def test_sets_module_level_log_variable(self, tmp_path): + import clipse_gui.cli as cli_module + + with patch("clipse_gui.cli.constants.CONFIG_DIR", str(tmp_path)), \ + patch("logging.basicConfig"): + setup_logging(debug=False) + + assert cli_module.log is not None + assert isinstance(cli_module.log, logging.Logger) + + def test_basicconfig_receives_debug_root_level(self, tmp_path): + """Root logger level passed to basicConfig must always be DEBUG so + individual handler levels govern what actually appears.""" + received_level: list[int] = [] + + def fake_basicconfig(**kwargs): + received_level.append(kwargs.get("level", -1)) + + with patch("clipse_gui.cli.constants.CONFIG_DIR", str(tmp_path)), \ + patch("logging.basicConfig", side_effect=fake_basicconfig): + setup_logging(debug=False) + + assert received_level == [logging.DEBUG] + + +# --------------------------------------------------------------------------- +# ColorFormatter +# --------------------------------------------------------------------------- + + +class TestColorFormatter: + """ColorFormatter must inject ANSI color codes into log records.""" + + _FMT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + + def _make_record(self, level: int, message: str = "test") -> logging.LogRecord: + return logging.LogRecord( + name="test.logger", + level=level, + pathname="", + lineno=0, + msg=message, + args=(), + exc_info=None, + ) + + def _formatter(self) -> ColorFormatter: + return ColorFormatter(self._FMT, datefmt="%Y-%m-%d %H:%M:%S") + + @pytest.mark.parametrize("level,color_fragment", [ + (logging.DEBUG, "\033[1;36m"), + (logging.INFO, "\033[1;32m"), + (logging.WARNING, "\033[1;33m"), + (logging.ERROR, "\033[1;31m"), + (logging.CRITICAL, "\033[1;41m"), + ]) + def test_injects_ansi_color_for_each_level(self, level: int, color_fragment: str): + formatter = self._formatter() + record = self._make_record(level) + formatted = formatter.format(record) + assert color_fragment in formatted + + def test_reset_code_present_in_output(self): + formatter = self._formatter() + record = self._make_record(logging.INFO) + formatted = formatter.format(record) + assert "\033[0m" in formatted + + def test_levelname_is_colorized_after_format(self): + formatter = self._formatter() + record = self._make_record(logging.WARNING) + formatter.format(record) + # format() mutates record.levelname in-place to include ANSI prefix + assert "\033[" in record.levelname + + def test_name_is_colorized_after_format(self): + formatter = self._formatter() + record = self._make_record(logging.INFO, "hello") + formatter.format(record) + assert "\033[1;34m" in record.name + + def test_message_content_preserved(self): + formatter = self._formatter() + record = self._make_record(logging.INFO, "important message") + formatted = formatter.format(record) + assert "important message" in formatted + + def test_unknown_level_does_not_raise(self): + formatter = self._formatter() + record = self._make_record(logging.INFO) + record.levelname = "CUSTOM" + # Should not raise; missing key returns empty string via dict.get + formatted = formatter.format(record) + assert "CUSTOM" in formatted diff --git a/tests/test_config_manager.py b/tests/test_config_manager.py new file mode 100644 index 0000000..a42a2b2 --- /dev/null +++ b/tests/test_config_manager.py @@ -0,0 +1,305 @@ +"""Tests for clipse_gui.config_manager.ConfigManager.""" + +import configparser +import os + +import pytest + +from clipse_gui.config_manager import ConfigManager + + +# --------------------------------------------------------------------------- +# Shared fixtures and helpers +# --------------------------------------------------------------------------- + +DEFAULTS: dict = { + "general": { + "max_items": "200", + "font_size": "1.0", + "dark_mode": "true", + "theme": "default", + }, + "behaviour": { + "hover_to_select": "false", + "compact_mode": "off", + }, +} + + +def make_manager(tmp_path, filename: str = "config.ini", defaults: dict | None = None) -> ConfigManager: + """Return a ConfigManager pointing at a path inside *tmp_path*.""" + path = str(tmp_path / filename) + return ConfigManager(config_path=path, default_settings=defaults if defaults is not None else DEFAULTS) + + +def write_ini(path: str, content: str) -> None: + """Write raw INI text to *path*, creating parent dirs as needed.""" + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(content) + + +# --------------------------------------------------------------------------- +# File creation / loading +# --------------------------------------------------------------------------- + + +class TestFileCreation: + def test_creates_file_when_missing(self, tmp_path): + cm = make_manager(tmp_path) + assert os.path.exists(cm.config_path) + + def test_created_file_contains_header_comment(self, tmp_path): + cm = make_manager(tmp_path) + text = open(cm.config_path).read() + assert "# Clipse GUI Configuration File" in text + + def test_created_file_contains_default_values(self, tmp_path): + cm = make_manager(tmp_path) + text = open(cm.config_path).read() + assert "max_items" in text + assert "200" in text + + def test_no_load_error_when_created_fresh(self, tmp_path): + cm = make_manager(tmp_path) + assert cm.load_error_message is None + + +class TestLoadingExistingConfig: + def test_user_value_overrides_default(self, tmp_path): + path = str(tmp_path / "config.ini") + write_ini( + path, + "[general]\nmax_items = 99\nfont_size = 1.0\ndark_mode = true\ntheme = default\n" + "[behaviour]\nhover_to_select = false\ncompact_mode = off\n", + ) + cm = ConfigManager(config_path=path, default_settings=DEFAULTS) + assert cm.getint("general", "max_items") == 99 + + def test_missing_key_falls_back_to_default(self, tmp_path): + path = str(tmp_path / "config.ini") + # Only 'theme' present; 'max_items' absent + write_ini(path, "[general]\ntheme = custom\n") + cm = ConfigManager(config_path=path, default_settings=DEFAULTS) + assert cm.getint("general", "max_items") == 200 + + def test_no_load_error_on_valid_file(self, tmp_path): + path = str(tmp_path / "config.ini") + write_ini( + path, + "[general]\nmax_items = 50\nfont_size = 1.2\ndark_mode = false\ntheme = light\n" + "[behaviour]\nhover_to_select = true\ncompact_mode = on\n", + ) + cm = ConfigManager(config_path=path, default_settings=DEFAULTS) + assert cm.load_error_message is None + + def test_extra_user_section_is_preserved(self, tmp_path): + path = str(tmp_path / "config.ini") + write_ini(path, "[custom]\nmy_key = hello\n") + cm = ConfigManager(config_path=path, default_settings=DEFAULTS) + assert cm.get("custom", "my_key") == "hello" + + +class TestCorruptConfig: + def test_sets_load_error_message_on_parse_error(self, tmp_path): + path = str(tmp_path / "config.ini") + write_ini(path, "this is not valid ini %%% [[[") + cm = ConfigManager(config_path=path, default_settings=DEFAULTS) + assert cm.load_error_message is not None + + def test_still_uses_defaults_after_parse_error(self, tmp_path): + path = str(tmp_path / "config.ini") + write_ini(path, "%%% totally broken") + cm = ConfigManager(config_path=path, default_settings=DEFAULTS) + assert cm.getint("general", "max_items") == 200 + + +# --------------------------------------------------------------------------- +# get() +# --------------------------------------------------------------------------- + + +class TestGet: + def test_returns_value_from_config(self, tmp_path): + cm = make_manager(tmp_path) + assert cm.get("general", "theme") == "default" + + def test_returns_fallback_for_missing_section(self, tmp_path): + cm = make_manager(tmp_path) + assert cm.get("nonexistent", "key", fallback="fb") == "fb" + + def test_returns_empty_string_fallback_by_default(self, tmp_path): + cm = make_manager(tmp_path) + assert cm.get("nonexistent", "key") == "" + + def test_returns_fallback_for_missing_key_in_existing_section(self, tmp_path): + cm = make_manager(tmp_path) + assert cm.get("general", "no_such_key", fallback="x") == "x" + + +# --------------------------------------------------------------------------- +# getint() +# --------------------------------------------------------------------------- + + +class TestGetInt: + def test_returns_int_for_valid_value(self, tmp_path): + cm = make_manager(tmp_path) + result = cm.getint("general", "max_items") + assert result == 200 + assert isinstance(result, int) + + def test_returns_fallback_for_missing_section(self, tmp_path): + cm = make_manager(tmp_path) + assert cm.getint("ghost", "key", fallback=7) == 7 + + def test_falls_back_to_defaults_dict_on_invalid_value(self, tmp_path): + path = str(tmp_path / "config.ini") + write_ini(path, "[general]\nmax_items = abc\nfont_size = 1.0\ndark_mode = true\ntheme = default\n") + cm = ConfigManager(config_path=path, default_settings=DEFAULTS) + # Defaults dict says 200; that should be returned + assert cm.getint("general", "max_items") == 200 + + def test_falls_back_to_fallback_param_when_no_default(self, tmp_path): + path = str(tmp_path / "config.ini") + write_ini(path, "[general]\nmax_items = abc\nfont_size = 1.0\ndark_mode = true\ntheme = default\n") + defaults_no_max = { + "general": {"font_size": "1.0", "dark_mode": "true", "theme": "default"}, + "behaviour": DEFAULTS["behaviour"], + } + cm = ConfigManager(config_path=path, default_settings=defaults_no_max) + assert cm.getint("general", "max_items", fallback=42) == 42 + + +# --------------------------------------------------------------------------- +# getfloat() +# --------------------------------------------------------------------------- + + +class TestGetFloat: + def test_returns_float_for_valid_value(self, tmp_path): + cm = make_manager(tmp_path) + result = cm.getfloat("general", "font_size") + assert result == pytest.approx(1.0) + assert isinstance(result, float) + + def test_returns_fallback_for_missing_section(self, tmp_path): + cm = make_manager(tmp_path) + assert cm.getfloat("ghost", "key", fallback=3.14) == pytest.approx(3.14) + + def test_falls_back_to_defaults_dict_on_invalid_value(self, tmp_path): + path = str(tmp_path / "config.ini") + write_ini(path, "[general]\nmax_items = 200\nfont_size = not_a_float\ndark_mode = true\ntheme = default\n") + cm = ConfigManager(config_path=path, default_settings=DEFAULTS) + assert cm.getfloat("general", "font_size") == pytest.approx(1.0) + + def test_falls_back_to_fallback_param_when_no_default(self, tmp_path): + path = str(tmp_path / "config.ini") + write_ini(path, "[general]\nmax_items = 200\nfont_size = bad\ndark_mode = true\ntheme = default\n") + defaults_no_font = { + "general": {"max_items": "200", "dark_mode": "true", "theme": "default"}, + "behaviour": DEFAULTS["behaviour"], + } + cm = ConfigManager(config_path=path, default_settings=defaults_no_font) + assert cm.getfloat("general", "font_size", fallback=9.9) == pytest.approx(9.9) + + +# --------------------------------------------------------------------------- +# getboolean() +# --------------------------------------------------------------------------- + + +class TestGetBoolean: + @pytest.mark.parametrize("raw,expected", [ + ("true", True), + ("True", True), + ("yes", True), + ("on", True), + ("1", True), + ("false", False), + ("False", False), + ("no", False), + ("off", False), + ("0", False), + ]) + def test_parses_boolean_strings(self, tmp_path, raw: str, expected: bool): + path = str(tmp_path / "config.ini") + write_ini( + path, + f"[general]\nmax_items = 200\nfont_size = 1.0\ndark_mode = {raw}\ntheme = default\n" + "[behaviour]\nhover_to_select = false\ncompact_mode = off\n", + ) + cm = ConfigManager(config_path=path, default_settings=DEFAULTS) + assert cm.getboolean("general", "dark_mode") is expected + + def test_returns_bool_type(self, tmp_path): + cm = make_manager(tmp_path) + result = cm.getboolean("general", "dark_mode") + assert isinstance(result, bool) + + def test_returns_fallback_for_missing_section(self, tmp_path): + cm = make_manager(tmp_path) + assert cm.getboolean("ghost", "key", fallback=True) is True + + def test_falls_back_to_defaults_dict_on_invalid_value(self, tmp_path): + path = str(tmp_path / "config.ini") + write_ini( + path, + "[general]\nmax_items = 200\nfont_size = 1.0\ndark_mode = maybe\ntheme = default\n", + ) + cm = ConfigManager(config_path=path, default_settings=DEFAULTS) + # Defaults dict says "true", so should return True + assert cm.getboolean("general", "dark_mode") is True + + def test_falls_back_to_fallback_param_when_no_default(self, tmp_path): + path = str(tmp_path / "config.ini") + write_ini(path, "[general]\nmax_items = 200\nfont_size = 1.0\ndark_mode = maybe\ntheme = default\n") + defaults_no_dark = { + "general": {"max_items": "200", "font_size": "1.0", "theme": "default"}, + "behaviour": DEFAULTS["behaviour"], + } + cm = ConfigManager(config_path=path, default_settings=defaults_no_dark) + assert cm.getboolean("general", "dark_mode", fallback=False) is False + + def test_default_bool_value_in_defaults_dict(self, tmp_path): + """Defaults dict may store native Python bools, not just strings.""" + defaults_with_bool = { + "general": { + "max_items": "200", + "font_size": "1.0", + "dark_mode": True, # native bool + "theme": "default", + }, + "behaviour": DEFAULTS["behaviour"], + } + path = str(tmp_path / "config.ini") + write_ini(path, "[general]\nmax_items = 200\nfont_size = 1.0\ndark_mode = invalid\ntheme = default\n") + cm = ConfigManager(config_path=path, default_settings=defaults_with_bool) + assert cm.getboolean("general", "dark_mode") is True + + +# --------------------------------------------------------------------------- +# Save / directory creation +# --------------------------------------------------------------------------- + + +class TestSave: + def test_creates_parent_directories_on_first_save(self, tmp_path): + nested_path = str(tmp_path / "a" / "b" / "c" / "config.ini") + ConfigManager(config_path=nested_path, default_settings=DEFAULTS) + assert os.path.exists(nested_path) + + def test_saved_file_is_valid_ini(self, tmp_path): + cm = make_manager(tmp_path) + parser = configparser.ConfigParser(interpolation=None) + parser.read(cm.config_path, encoding="utf-8") + # All default sections must be present + for section in DEFAULTS: + assert parser.has_section(section) + + def test_saved_file_roundtrips_values(self, tmp_path): + cm = make_manager(tmp_path) + # Re-read the saved file with a brand-new manager + cm2 = ConfigManager(config_path=cm.config_path, default_settings=DEFAULTS) + assert cm2.get("general", "theme") == "default" + assert cm2.getint("general", "max_items") == 200 diff --git a/tests/test_detection.py b/tests/test_detection.py new file mode 100644 index 0000000..f67461b --- /dev/null +++ b/tests/test_detection.py @@ -0,0 +1,193 @@ +"""Tests for clipse_gui/ui/detection.py — pure content-type detector functions.""" + +import pytest + +from clipse_gui.ui.detection import _is_data_uri, _is_image_url, _is_svg_content, _is_url + + +# --------------------------------------------------------------------------- +# _is_image_url +# --------------------------------------------------------------------------- + +class TestIsImageUrl: + @pytest.mark.parametrize("ext", [ + ".jpg", ".jpeg", ".png", ".gif", ".webp", + ".svg", ".bmp", ".ico", ".tiff", ".tif", + ]) + def test_http_with_each_extension(self, ext): + assert _is_image_url(f"http://example.com/image{ext}") is True + + @pytest.mark.parametrize("ext", [ + ".jpg", ".jpeg", ".png", ".gif", ".webp", + ".svg", ".bmp", ".ico", ".tiff", ".tif", + ]) + def test_https_with_each_extension(self, ext): + assert _is_image_url(f"https://example.com/photo{ext}") is True + + def test_query_string_ignored_for_extension(self): + assert _is_image_url("https://cdn.example.com/img.png?v=2&w=800") is True + + def test_query_string_with_non_image_extension_returns_false(self): + assert _is_image_url("https://example.com/page.html?img=photo.jpg") is False + + def test_non_image_extension_returns_false(self): + assert _is_image_url("https://example.com/document.pdf") is False + + def test_html_extension_returns_false(self): + assert _is_image_url("https://example.com/index.html") is False + + def test_no_extension_returns_false(self): + assert _is_image_url("https://example.com/image") is False + + def test_ftp_scheme_returns_false(self): + assert _is_image_url("ftp://example.com/photo.jpg") is False + + def test_bare_domain_returns_false(self): + assert _is_image_url("example.com/photo.jpg") is False + + def test_none_returns_false(self): + assert _is_image_url(None) is False + + def test_empty_string_returns_false(self): + assert _is_image_url("") is False + + def test_integer_returns_false(self): + assert _is_image_url(123) is False + + def test_whitespace_only_returns_false(self): + assert _is_image_url(" ") is False + + def test_whitespace_padded_valid_url(self): + assert _is_image_url(" https://example.com/photo.png ") is True + + def test_uppercase_extension_treated_as_match(self): + # strip().lower() normalises — .PNG becomes .png + assert _is_image_url("https://example.com/image.PNG") is True + + def test_uppercase_scheme_treated_as_match(self): + assert _is_image_url("HTTPS://example.com/image.png") is True + + def test_deeply_nested_path(self): + assert _is_image_url("https://cdn.example.com/a/b/c/photo.webp") is True + + +# --------------------------------------------------------------------------- +# _is_svg_content +# --------------------------------------------------------------------------- + +class TestIsSvgContent: + def test_starts_with_svg_tag(self): + assert _is_svg_content("...") is True + + def test_svg_within_first_200_chars(self): + prefix = "X" * 100 + assert _is_svg_content(prefix + "") is True + + def test_svg_beyond_200_chars_returns_false(self): + prefix = "X" * 201 + assert _is_svg_content(prefix + "") is False + + def test_whitespace_before_svg_tag(self): + assert _is_svg_content(" content") is True + + def test_plain_text_returns_false(self): + assert _is_svg_content("Hello, world!") is False + + def test_html_not_svg_returns_false(self): + assert _is_svg_content("") is False + + def test_none_returns_false(self): + assert _is_svg_content(None) is False + + def test_empty_string_returns_false(self): + assert _is_svg_content("") is False + + def test_integer_returns_false(self): + assert _is_svg_content(42) is False + + def test_xml_with_svg_namespace(self): + assert _is_svg_content('') is True + + +# --------------------------------------------------------------------------- +# _is_data_uri +# --------------------------------------------------------------------------- + +class TestIsDataUri: + def test_valid_png_data_uri(self): + assert _is_data_uri("data:image/png;base64,iVBORw0KGgo=") is True + + def test_valid_jpeg_data_uri(self): + assert _is_data_uri("data:image/jpeg;base64,/9j/4AAQSkZJRgAB") is True + + def test_valid_gif_data_uri(self): + assert _is_data_uri("data:image/gif;base64,R0lGODlh") is True + + def test_valid_webp_data_uri(self): + assert _is_data_uri("data:image/webp;base64,UklGRg==") is True + + def test_non_image_data_uri_returns_false(self): + assert _is_data_uri("data:text/plain;base64,SGVsbG8=") is False + + def test_missing_base64_marker_returns_false(self): + assert _is_data_uri("data:image/png,raw_data") is False + + def test_plain_url_returns_false(self): + assert _is_data_uri("https://example.com/image.png") is False + + def test_none_returns_false(self): + assert _is_data_uri(None) is False + + def test_empty_string_returns_false(self): + assert _is_data_uri("") is False + + def test_integer_returns_false(self): + assert _is_data_uri(0) is False + + def test_whitespace_padded_valid_uri(self): + assert _is_data_uri(" data:image/png;base64,abc123 ") is True + + def test_svg_data_uri(self): + assert _is_data_uri("data:image/svg+xml;base64,PHN2Zz4=") is True + + +# --------------------------------------------------------------------------- +# _is_url +# --------------------------------------------------------------------------- + +class TestIsUrl: + def test_http_url(self): + assert _is_url("http://example.com") is True + + def test_https_url(self): + assert _is_url("https://example.com/path?q=1") is True + + def test_ftp_scheme_returns_false(self): + assert _is_url("ftp://example.com") is False + + def test_bare_domain_returns_false(self): + assert _is_url("example.com") is False + + def test_plain_text_returns_false(self): + assert _is_url("hello world") is False + + def test_none_returns_false(self): + assert _is_url(None) is False + + def test_empty_string_returns_false(self): + assert _is_url("") is False + + def test_integer_returns_false(self): + assert _is_url(99) is False + + def test_whitespace_padded_valid_url(self): + assert _is_url(" https://example.com ") is True + + def test_uppercase_scheme_normalised(self): + assert _is_url("HTTP://example.com") is True + + def test_url_with_port(self): + assert _is_url("http://localhost:8080/api") is True + + def test_image_url_also_is_url(self): + assert _is_url("https://cdn.example.com/photo.jpg") is True diff --git a/tests/test_keyboard_mixin.py b/tests/test_keyboard_mixin.py new file mode 100644 index 0000000..3b8b6a3 --- /dev/null +++ b/tests/test_keyboard_mixin.py @@ -0,0 +1,502 @@ +"""Tests for KeyboardMixin dispatch table and handler methods.""" + +from unittest.mock import MagicMock, patch + +import pytest +from gi.repository import Gdk, Gtk + +from tests.conftest import make_event + + +# ════════════════════════════════════════════════════════════════ +# Dispatch routing — verify the right handler fires for each key +# ════════════════════════════════════════════════════════════════ + + +class TestDispatchRouting: + """Bare keys, ctrl combos, and shift combos reach the correct handler.""" + + # ── Navigation ──────────────────────────────────────────── + + def test_j_emits_move_cursor_down(self, ctrl): + ctrl.on_key_press(None, make_event(Gdk.KEY_j)) + ctrl.list_box.emit.assert_called_once_with( + "move-cursor", Gtk.MovementStep.DISPLAY_LINES, 1 + ) + + def test_k_emits_move_cursor_up(self, ctrl): + ctrl.on_key_press(None, make_event(Gdk.KEY_k)) + ctrl.list_box.emit.assert_called_once_with( + "move-cursor", Gtk.MovementStep.DISPLAY_LINES, -1 + ) + + # ── Selection mode ──────────────────────────────────────── + + def test_v_toggles_selection_mode(self, ctrl): + ctrl.on_key_press(None, make_event(Gdk.KEY_v)) + ctrl.toggle_selection_mode.assert_called_once() + + def test_ctrl_a_selects_all(self, ctrl): + ctrl.on_key_press(None, make_event(Gdk.KEY_a, ctrl=True)) + ctrl.select_all_items.assert_called_once() + + def test_ctrl_shift_a_deselects_all(self, ctrl): + ctrl.on_key_press(None, make_event(Gdk.KEY_a, ctrl=True, shift=True)) + ctrl.deselect_all_items.assert_called_once() + + # ── Delete variants ─────────────────────────────────────── + + def test_x_removes_item_when_selected(self, ctrl): + ctrl.list_box.get_selected_row.return_value = MagicMock() + result = ctrl.on_key_press(None, make_event(Gdk.KEY_x)) + ctrl.remove_selected_item.assert_called_once() + assert result is True + + def test_x_noop_in_selection_mode(self, ctrl): + ctrl.list_box.get_selected_row.return_value = MagicMock() + ctrl.selection_mode = True + ctrl.on_key_press(None, make_event(Gdk.KEY_x)) + ctrl.remove_selected_item.assert_not_called() + + def test_delete_removes_item_when_selected(self, ctrl): + ctrl.list_box.get_selected_row.return_value = MagicMock() + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Delete)) + ctrl.remove_selected_item.assert_called_once() + assert result is True + + def test_ctrl_x_deletes_selected_in_selection_mode(self, ctrl): + ctrl.selection_mode = True + ctrl.selected_indices = {0, 2} + result = ctrl.on_key_press(None, make_event(Gdk.KEY_x, ctrl=True)) + ctrl.delete_selected_items.assert_called_once() + assert result is True + + def test_ctrl_x_noop_outside_selection_mode(self, ctrl): + ctrl.selection_mode = False + ctrl.on_key_press(None, make_event(Gdk.KEY_x, ctrl=True)) + ctrl.delete_selected_items.assert_not_called() + + def test_shift_delete_deletes_selected(self, ctrl): + ctrl.selection_mode = True + ctrl.selected_indices = {1} + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Delete, shift=True)) + ctrl.delete_selected_items.assert_called_once() + assert result is True + + def test_ctrl_shift_delete_clears_all(self, ctrl): + result = ctrl.on_key_press( + None, make_event(Gdk.KEY_Delete, ctrl=True, shift=True) + ) + ctrl.clear_all_items.assert_called_once() + assert result is True + + def test_ctrl_d_clears_all(self, ctrl): + result = ctrl.on_key_press(None, make_event(Gdk.KEY_d, ctrl=True)) + ctrl.clear_all_items.assert_called_once() + assert result is True + + # ── Pin ─────────────────────────────────────────────────── + + def test_p_toggles_pin_when_row_selected(self, ctrl): + ctrl.list_box.get_selected_row.return_value = MagicMock() + result = ctrl.on_key_press(None, make_event(Gdk.KEY_p)) + ctrl.toggle_pin_selected.assert_called_once() + assert result is True + + def test_p_noop_when_no_selection(self, ctrl): + result = ctrl.on_key_press(None, make_event(Gdk.KEY_p)) + ctrl.toggle_pin_selected.assert_not_called() + assert result is False + + # ── Tab ─────────────────────────────────────────────────── + + def test_tab_toggles_pin_filter(self, ctrl): + ctrl.pin_filter_button.get_active.return_value = True + ctrl.on_key_press(None, make_event(Gdk.KEY_Tab)) + ctrl.pin_filter_button.set_active.assert_called_once_with(False) + ctrl.list_box.grab_focus.assert_called_once() + + # ── Help ────────────────────────────────────────────────── + + @pytest.mark.parametrize( + "keyval,shift", + [ + (Gdk.KEY_question, False), + (Gdk.KEY_question, True), + (Gdk.KEY_slash, True), + ], + ) + @patch("clipse_gui.controller_mixins.keyboard_mixin.show_help_window") + def test_help_keys(self, mock_help, keyval, shift, ctrl): + result = ctrl.on_key_press(None, make_event(keyval, shift=shift)) + mock_help.assert_called_once() + assert result is True + + # ── Settings ────────────────────────────────────────────── + + @patch("clipse_gui.controller_mixins.keyboard_mixin.show_settings_window") + def test_ctrl_comma_opens_settings(self, mock_settings, ctrl): + result = ctrl.on_key_press(None, make_event(Gdk.KEY_comma, ctrl=True)) + mock_settings.assert_called_once() + assert result is True + + # ── Quit ────────────────────────────────────────────────── + + def test_ctrl_q_quits(self, ctrl): + app = MagicMock() + ctrl.window.get_application.return_value = app + result = ctrl.on_key_press(None, make_event(Gdk.KEY_q, ctrl=True)) + app.quit.assert_called_once() + assert result is True + + # ── Zoom ────────────────────────────────────────────────── + + def test_ctrl_plus_zooms_in(self, ctrl): + ctrl.on_key_press(None, make_event(Gdk.KEY_plus, ctrl=True)) + assert ctrl.zoom_level == pytest.approx(1.1) + ctrl.update_zoom.assert_called_once() + + def test_ctrl_equal_zooms_in(self, ctrl): + ctrl.on_key_press(None, make_event(Gdk.KEY_equal, ctrl=True)) + assert ctrl.zoom_level == pytest.approx(1.1) + + def test_ctrl_minus_zooms_out(self, ctrl): + ctrl.on_key_press(None, make_event(Gdk.KEY_minus, ctrl=True)) + assert ctrl.zoom_level == pytest.approx(1.0 / 1.1) + ctrl.update_zoom.assert_called_once() + + def test_ctrl_0_resets_zoom(self, ctrl): + ctrl.zoom_level = 2.5 + ctrl.on_key_press(None, make_event(Gdk.KEY_0, ctrl=True)) + assert ctrl.zoom_level == 1.0 + ctrl.update_zoom.assert_called_once() + + # ── Unbound key → False ─────────────────────────────────── + + def test_unbound_key_returns_false(self, ctrl): + result = ctrl.on_key_press(None, make_event(Gdk.KEY_z)) + assert result is False + + +# ════════════════════════════════════════════════════════════════ +# Search-focused key handling +# ════════════════════════════════════════════════════════════════ + + +class TestSearchFocused: + """Keys while the search entry has focus.""" + + @pytest.fixture(autouse=True) + def _focus_search(self, ctrl): + ctrl.search_entry.has_focus.return_value = True + + # ── Character insertion ─────────────────────────────────── + + @pytest.mark.parametrize( + "keyval,expected_char", + [ + (Gdk.KEY_v, "v"), + (Gdk.KEY_x, "x"), + (Gdk.KEY_p, "p"), + (Gdk.KEY_j, "j"), + (Gdk.KEY_k, "k"), + (Gdk.KEY_f, "f"), + (Gdk.KEY_slash, "/"), + (Gdk.KEY_question, "?"), + (Gdk.KEY_space, " "), + ], + ) + def test_bound_keys_insert_char(self, keyval, expected_char, ctrl): + ctrl.search_entry.get_text.return_value = "abc" + ctrl.search_entry.get_position.return_value = 2 + result = ctrl.on_key_press(None, make_event(keyval)) + ctrl.search_entry.set_text.assert_called_once_with(f"ab{expected_char}c") + ctrl.search_entry.set_position.assert_called_once_with(3) + assert result is True + + def test_char_appended_when_no_get_position(self, ctrl): + ctrl.search_entry.get_text.return_value = "ab" + del ctrl.search_entry.get_position # remove the attribute + result = ctrl.on_key_press(None, make_event(Gdk.KEY_v)) + ctrl.search_entry.set_text.assert_called_once_with("abv") + assert result is True + + # ── Blocked keys ────────────────────────────────────────── + + def test_tab_blocked(self, ctrl): + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Tab)) + assert result is True + + def test_return_blocked(self, ctrl): + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Return)) + assert result is True + + # ── Passthrough ─────────────────────────────────────────── + + def test_unbound_key_passes_through(self, ctrl): + result = ctrl.on_key_press(None, make_event(Gdk.KEY_a)) + assert result is False + + # ── Escape in search ────────────────────────────────────── + + def test_escape_exits_selection_mode_first(self, ctrl): + ctrl.selection_mode = True + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Escape)) + ctrl.toggle_selection_mode.assert_called_once() + assert result is True + + @patch("clipse_gui.controller_mixins.keyboard_mixin.GLib") + def test_escape_clears_text_and_unfocuses(self, mock_glib, ctrl): + ctrl.search_entry.get_text.return_value = "query" + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Escape)) + ctrl.search_entry.set_text.assert_called_once_with("") + mock_glib.idle_add.assert_called_once() + assert result is True + + @patch("clipse_gui.controller_mixins.keyboard_mixin.GLib") + def test_escape_unfocuses_even_when_text_empty(self, mock_glib, ctrl): + ctrl.search_entry.get_text.return_value = "" + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Escape)) + # Should NOT call set_text (nothing to clear) + ctrl.search_entry.set_text.assert_not_called() + mock_glib.idle_add.assert_called_once() + assert result is True + + +# ════════════════════════════════════════════════════════════════ +# Navigation from search +# ════════════════════════════════════════════════════════════════ + + +class TestSearchNavigation: + """Arrow / Page keys while search is focused navigate the list.""" + + @pytest.fixture(autouse=True) + def _focus_search(self, ctrl): + ctrl.search_entry.has_focus.return_value = True + + def _make_rows(self, ctrl, n=10): + rows = [] + for i in range(n): + row = MagicMock() + alloc = MagicMock() + alloc.y = i * 40 + row.get_allocation.return_value = alloc + rows.append(row) + ctrl.list_box.get_children.return_value = rows + # vadjustment needs real numbers for min() comparison + adj = MagicMock() + adj.get_upper.return_value = n * 40 + adj.get_page_size.return_value = 200 + ctrl.scrolled_window.get_vadjustment.return_value = adj + return rows + + def test_down_from_no_focus_selects_first(self, ctrl): + rows = self._make_rows(ctrl) + ctrl.window.get_focus.return_value = None # not on any row + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Down)) + ctrl.list_box.select_row.assert_called_once_with(rows[0]) + rows[0].grab_focus.assert_called_once() + assert result is True + + def test_up_from_no_focus_selects_last(self, ctrl): + rows = self._make_rows(ctrl, 5) + ctrl.window.get_focus.return_value = None + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Up)) + ctrl.list_box.select_row.assert_called_once_with(rows[4]) + assert result is True + + def test_down_advances_by_one(self, ctrl): + rows = self._make_rows(ctrl, 5) + ctrl.window.get_focus.return_value = rows[1] + ctrl.on_key_press(None, make_event(Gdk.KEY_Down)) + ctrl.list_box.select_row.assert_called_once_with(rows[2]) + + def test_page_down_jumps_by_page_step(self, ctrl): + rows = self._make_rows(ctrl, 20) + ctrl.window.get_focus.return_value = rows[2] + ctrl.on_key_press(None, make_event(Gdk.KEY_Page_Down)) + ctrl.list_box.select_row.assert_called_once_with(rows[7]) # 2 + 5 + + def test_page_up_clamps_to_zero(self, ctrl): + rows = self._make_rows(ctrl, 10) + ctrl.window.get_focus.return_value = rows[2] + ctrl.on_key_press(None, make_event(Gdk.KEY_Page_Up)) + ctrl.list_box.select_row.assert_called_once_with(rows[0]) # max(2-5, 0) + + def test_empty_list_returns_false(self, ctrl): + ctrl.list_box.get_children.return_value = [] + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Down)) + assert result is False + + def test_down_past_end_returns_false(self, ctrl): + rows = self._make_rows(ctrl, 3) + ctrl.window.get_focus.return_value = rows[2] # last row + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Down)) + # target = 3, last = 2 → out of range + assert result is False + + +# ════════════════════════════════════════════════════════════════ +# Return key handler +# ════════════════════════════════════════════════════════════════ + + +class TestReturnKey: + + def test_return_activates_selected_row(self, ctrl): + selected = MagicMock() + ctrl.list_box.get_selected_row.return_value = selected + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Return)) + ctrl.copy_selected_item_to_clipboard.assert_called_once_with(False) + assert result is True + + @patch( + "clipse_gui.controller_mixins.keyboard_mixin.ENTER_TO_PASTE", new=False + ) + def test_shift_return_pastes_when_enter_to_paste_off(self, ctrl): + selected = MagicMock() + ctrl.list_box.get_selected_row.return_value = selected + ctrl.on_key_press(None, make_event(Gdk.KEY_Return, shift=True)) + # not ENTER_TO_PASTE → True → with_paste_simulation=True + ctrl.copy_selected_item_to_clipboard.assert_called_once_with(True) + + @patch( + "clipse_gui.controller_mixins.keyboard_mixin.ENTER_TO_PASTE", new=True + ) + def test_shift_return_no_paste_when_enter_to_paste_on(self, ctrl): + selected = MagicMock() + ctrl.list_box.get_selected_row.return_value = selected + ctrl.on_key_press(None, make_event(Gdk.KEY_Return, shift=True)) + # not ENTER_TO_PASTE → False + ctrl.copy_selected_item_to_clipboard.assert_called_once_with(False) + + def test_return_selects_first_row_when_none_selected(self, ctrl): + first = MagicMock() + ctrl.list_box.get_selected_row.return_value = None + ctrl.list_box.get_children.return_value = [first] + ctrl.list_box.get_row_at_index.return_value = first + ctrl.on_key_press(None, make_event(Gdk.KEY_Return)) + ctrl.list_box.select_row.assert_called_once_with(first) + first.grab_focus.assert_called_once() + + def test_return_focuses_search_when_list_empty(self, ctrl): + ctrl.list_box.get_selected_row.return_value = None + ctrl.list_box.get_children.return_value = [] + ctrl.on_key_press(None, make_event(Gdk.KEY_Return)) + ctrl.search_entry.grab_focus.assert_called_once() + + +# ════════════════════════════════════════════════════════════════ +# Space key handler +# ════════════════════════════════════════════════════════════════ + + +class TestSpaceKey: + + def test_space_toggles_item_in_selection_mode(self, ctrl): + ctrl.selection_mode = True + result = ctrl.on_key_press(None, make_event(Gdk.KEY_space)) + ctrl.toggle_item_selection.assert_called_once() + assert result is True + + def test_space_shows_preview_when_row_selected(self, ctrl): + ctrl.list_box.get_selected_row.return_value = MagicMock(is_url=False) + result = ctrl.on_key_press(None, make_event(Gdk.KEY_space)) + ctrl.show_item_preview.assert_called_once() + assert result is True + + @patch( + "clipse_gui.controller_mixins.keyboard_mixin.OPEN_LINKS_WITH_BROWSER", + new=True, + ) + def test_space_opens_url_when_url_row(self, ctrl): + row = MagicMock(is_url=True, website_url="https://example.com") + ctrl.list_box.get_selected_row.return_value = row + result = ctrl.on_key_press(None, make_event(Gdk.KEY_space)) + ctrl.open_url_with_gtk.assert_called_once_with("https://example.com") + assert result is True + + def test_space_noop_when_nothing_selected(self, ctrl): + ctrl.list_box.get_selected_row.return_value = None + result = ctrl.on_key_press(None, make_event(Gdk.KEY_space)) + assert result is False + + +# ════════════════════════════════════════════════════════════════ +# Escape handler (main, not search) +# ════════════════════════════════════════════════════════════════ + + +class TestEscapeKey: + + def test_escape_exits_selection_mode_first(self, ctrl): + ctrl.selection_mode = True + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Escape)) + ctrl.toggle_selection_mode.assert_called_once() + assert result is True + + def test_escape_clears_search_text_second(self, ctrl): + ctrl.search_entry.get_text.return_value = "query" + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Escape)) + ctrl.search_entry.set_text.assert_called_once_with("") + ctrl.list_box.grab_focus.assert_called_once() + assert result is True + + def test_escape_quits_when_no_text_no_selection(self, ctrl): + app = MagicMock() + app.tray_manager = None + ctrl.window.get_application.return_value = app + ctrl.on_key_press(None, make_event(Gdk.KEY_Escape)) + app.quit.assert_called_once() + + def test_escape_minimizes_to_tray_when_enabled(self, ctrl): + app = MagicMock() + app.tray_manager = MagicMock() + app.tray_manager.minimize_to_tray.return_value = True + ctrl.window.get_application.return_value = app + # _handle_escape does `from .. import constants` which resolves to + # clipse_gui.constants already in sys.modules — patch it there. + import clipse_gui.constants as real_const + + orig = getattr(real_const, "MINIMIZE_TO_TRAY", False) + real_const.MINIMIZE_TO_TRAY = True + try: + result = ctrl.on_key_press(None, make_event(Gdk.KEY_Escape)) + finally: + real_const.MINIMIZE_TO_TRAY = orig + app.tray_manager.minimize_to_tray.assert_called_once() + assert result is True + + +# ════════════════════════════════════════════════════════════════ +# Focus search (/ and f) +# ════════════════════════════════════════════════════════════════ + + +class TestFocusSearch: + + @pytest.mark.parametrize("keyval", [Gdk.KEY_slash, Gdk.KEY_f]) + @patch("clipse_gui.controller_mixins.keyboard_mixin.GLib") + def test_slash_and_f_show_search(self, mock_glib, keyval, ctrl): + result = ctrl.on_key_press(None, make_event(keyval)) + ctrl.search_entry.set_no_show_all.assert_called_once_with(False) + ctrl.search_entry.show.assert_called_once() + mock_glib.idle_add.assert_called_once() + assert result is True + + +# ════════════════════════════════════════════════════════════════ +# Public callbacks +# ════════════════════════════════════════════════════════════════ + + +class TestPublicCallbacks: + + def test_on_row_single_click_selects_and_copies(self, ctrl): + row = MagicMock() + ctrl._on_row_single_click(row) + ctrl.list_box.select_row.assert_called_once_with(row) + ctrl.copy_selected_item_to_clipboard.assert_called_once_with( + with_paste_simulation=True + ) diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 0000000..c2ac064 --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,264 @@ +"""Tests for clipse_gui/ui/text.py — escape_markup, highlight_search_term, _format_text_content. + +gi.require_version is handled by conftest.py before this module is collected. +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + +from clipse_gui.ui.text import ( + _format_text_content, + escape_markup, + highlight_search_term, +) + + +# --------------------------------------------------------------------------- +# escape_markup +# --------------------------------------------------------------------------- + +class TestEscapeMarkup: + def test_ampersand_escaped(self): + assert escape_markup("a & b") == "a & b" + + def test_less_than_escaped(self): + assert escape_markup("a < b") == "a < b" + + def test_greater_than_escaped(self): + assert escape_markup("a > b") == "a > b" + + def test_all_three_in_one_string(self): + assert escape_markup("") == "<a & b>" + + def test_no_special_chars_unchanged(self): + assert escape_markup("hello world") == "hello world" + + def test_empty_string_unchanged(self): + assert escape_markup("") == "" + + def test_multiple_ampersands(self): + result = escape_markup("a & b & c") + assert result == "a & b & c" + + def test_xml_like_tag_fully_escaped(self): + assert escape_markup("") == "<tag>" + + def test_already_escaped_not_double_escaped(self): + # The function replaces & → &, so & becomes &amp; + result = escape_markup("&") + assert result == "&amp;" + + +# --------------------------------------------------------------------------- +# highlight_search_term +# --------------------------------------------------------------------------- + +HIGHLIGHT_SPAN = '' +HIGHLIGHT_CLOSE = "" + + +class TestHighlightSearchTerm: + def test_empty_search_term_returns_escaped_text(self): + result = highlight_search_term("hello ", "") + assert result == "hello <world>" + + def test_whitespace_only_search_term_returns_escaped_text(self): + result = highlight_search_term("hello", " ") + assert result == "hello" + + def test_single_match_wrapped_in_span(self): + result = highlight_search_term("hello world", "world") + assert HIGHLIGHT_SPAN in result + assert "world" in result + assert HIGHLIGHT_CLOSE in result + + def test_no_match_returns_escaped_text(self): + result = highlight_search_term("hello world", "xyz") + assert result == "hello world" + assert HIGHLIGHT_SPAN not in result + + def test_case_insensitive_match(self): + result = highlight_search_term("Hello World", "hello") + assert HIGHLIGHT_SPAN in result + assert "Hello" in result + + def test_multiple_occurrences_all_highlighted(self): + result = highlight_search_term("cat cat cat", "cat") + assert result.count(HIGHLIGHT_SPAN) == 3 + + def test_match_at_start(self): + result = highlight_search_term("start of text", "start") + assert result.startswith(HIGHLIGHT_SPAN) + + def test_match_at_end(self): + result = highlight_search_term("text at end", "end") + before_span = result.split(HIGHLIGHT_SPAN)[0] + assert before_span == "text at " + + def test_special_chars_in_surrounding_text_escaped(self): + result = highlight_search_term("a & match here", "match") + assert "&" in result + assert "<" in result + + def test_special_chars_in_match_itself_escaped(self): + result = highlight_search_term("a & b", "&") + assert "&" in result + assert HIGHLIGHT_SPAN in result + + def test_full_text_is_match(self): + result = highlight_search_term("python", "python") + assert result == f'{HIGHLIGHT_SPAN}python{HIGHLIGHT_CLOSE}' + + def test_search_term_longer_than_text_no_match(self): + result = highlight_search_term("hi", "hello world") + assert result == "hi" + assert HIGHLIGHT_SPAN not in result + + @pytest.mark.parametrize("text,term,expected_count", [ + ("aaa", "a", 3), + ("abab", "ab", 2), + ("hello hello hello", "hello", 3), + ]) + def test_occurrence_counts(self, text, term, expected_count): + result = highlight_search_term(text, term) + assert result.count(HIGHLIGHT_SPAN) == expected_count + + +# --------------------------------------------------------------------------- +# _format_text_content +# --------------------------------------------------------------------------- + +def _make_text_view(content: str) -> MagicMock: + """Build a minimal mock GTK TextView with the given buffer content.""" + buffer_mock = MagicMock() + start = MagicMock() + end = MagicMock() + buffer_mock.get_bounds.return_value = (start, end) + buffer_mock.get_text.return_value = content + + text_view = MagicMock() + text_view.get_buffer.return_value = buffer_mock + return text_view + + +class TestFormatTextContent: + def test_valid_json_object_pretty_printed(self): + raw = '{"b": 2, "a": 1}' + text_view = _make_text_view(raw) + with patch("clipse_gui.ui.text.GLib"): + _format_text_content(text_view) + buffer = text_view.get_buffer() + call_args = buffer.set_text.call_args + assert call_args is not None + formatted = call_args[0][0] + parsed = json.loads(formatted) + assert parsed == {"a": 1, "b": 2} + assert " " in formatted + + def test_valid_json_array_pretty_printed(self): + raw = '[3, 1, 2]' + text_view = _make_text_view(raw) + with patch("clipse_gui.ui.text.GLib"): + _format_text_content(text_view) + buffer = text_view.get_buffer() + formatted = buffer.set_text.call_args[0][0] + assert json.loads(formatted) == [3, 1, 2] + + def test_json_keys_sorted_ascending(self): + raw = '{"z": 1, "a": 2, "m": 3}' + text_view = _make_text_view(raw) + with patch("clipse_gui.ui.text.GLib"): + _format_text_content(text_view) + formatted = text_view.get_buffer().set_text.call_args[0][0] + keys = [line.strip().split('"')[1] for line in formatted.split("\n") if ":" in line] + assert keys == sorted(keys) + + def test_invalid_json_falls_back_to_text_normalisation(self): + raw = "not json at all" + text_view = _make_text_view(raw) + with patch("clipse_gui.ui.text.GLib"): + _format_text_content(text_view) + # For plain text with no changes needed, set_text is not called + # (formatted_text == text → branch skipped) + buffer = text_view.get_buffer() + if buffer.set_text.called: + result = buffer.set_text.call_args[0][0] + assert "not json at all" in result + + def test_trailing_whitespace_stripped_from_lines(self): + raw = "hello \nworld " + text_view = _make_text_view(raw) + with patch("clipse_gui.ui.text.GLib"): + _format_text_content(text_view) + buffer = text_view.get_buffer() + if buffer.set_text.called: + result = buffer.set_text.call_args[0][0] + for line in result.split("\n"): + assert line == line.rstrip() + + def test_excessive_blank_lines_collapsed_to_two(self): + raw = "line1\n\n\n\n\nline2" + text_view = _make_text_view(raw) + with patch("clipse_gui.ui.text.GLib"): + _format_text_content(text_view) + buffer = text_view.get_buffer() + assert buffer.set_text.called + result = buffer.set_text.call_args[0][0] + # No run of more than 2 consecutive blank lines + import re + assert not re.search(r"\n{4,}", result) + + def test_empty_content_skipped(self): + text_view = _make_text_view(" ") + with patch("clipse_gui.ui.text.GLib"): + _format_text_content(text_view) + buffer = text_view.get_buffer() + buffer.set_text.assert_not_called() + + def test_already_formatted_json_no_set_text(self): + obj = {"a": 1, "b": 2} + raw = json.dumps(obj, indent=2, ensure_ascii=False, sort_keys=True) + text_view = _make_text_view(raw) + with patch("clipse_gui.ui.text.GLib"): + _format_text_content(text_view) + buffer = text_view.get_buffer() + # formatted_text == text, so set_text should NOT be called + buffer.set_text.assert_not_called() + + def test_glib_timeout_called_after_format(self): + raw = '{"x": 1}' + text_view = _make_text_view(raw) + with patch("clipse_gui.ui.text.GLib") as glib_mock: + _format_text_content(text_view) + glib_mock.timeout_add.assert_called() + + def test_indentation_preserved_in_plain_text(self): + raw = "top\n indented line\nback" + text_view = _make_text_view(raw) + with patch("clipse_gui.ui.text.GLib"): + _format_text_content(text_view) + buffer = text_view.get_buffer() + if buffer.set_text.called: + result = buffer.set_text.call_args[0][0] + assert " indented line" in result + + def test_json_with_unicode_preserved(self): + raw = '{"emoji": "\u2764"}' + text_view = _make_text_view(raw) + with patch("clipse_gui.ui.text.GLib"): + _format_text_content(text_view) + buffer = text_view.get_buffer() + if buffer.set_text.called: + result = buffer.set_text.call_args[0][0] + assert "\u2764" in result + + def test_curly_brace_string_that_is_not_valid_json(self): + raw = "{this is not valid json}" + text_view = _make_text_view(raw) + with patch("clipse_gui.ui.text.GLib"): + _format_text_content(text_view) + # Should not raise; falls back to plain text formatting + buffer = text_view.get_buffer() + assert buffer.get_text.called diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..889f015 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,272 @@ +"""Tests for clipse_gui/utils.py — format_date, fuzzy_search, _calculate_similarity.""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from clipse_gui.utils import _calculate_similarity, format_date, fuzzy_search + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_item(value, pinned=False, file_path=""): + return {"value": value, "pinned": pinned, "filePath": file_path} + + +# --------------------------------------------------------------------------- +# format_date +# --------------------------------------------------------------------------- + +class TestFormatDate: + def test_none_returns_unknown(self): + assert format_date(None) == "Unknown date" + + def test_empty_string_returns_unknown(self): + assert format_date("") == "Unknown date" + + def test_invalid_date_returns_original(self): + bad = "not-a-date" + assert format_date(bad) == bad + + def test_today_naive(self): + now = datetime.now() + iso = now.isoformat() + result = format_date(iso) + assert result.startswith("Today at ") + assert now.strftime("%H:%M") in result + + def test_yesterday_naive(self): + yesterday = datetime.now() - timedelta(days=1) + iso = yesterday.isoformat() + result = format_date(iso) + assert result.startswith("Yesterday at ") + assert yesterday.strftime("%H:%M") in result + + def test_same_year_not_today_or_yesterday(self): + now = datetime.now() + two_weeks_ago = now - timedelta(days=14) + iso = two_weeks_ago.isoformat() + result = format_date(iso) + assert result == two_weeks_ago.strftime("%b %d, %H:%M") + assert str(now.year) not in result + + def test_different_year(self): + dt = datetime(2020, 3, 5, 9, 30) + result = format_date(dt.isoformat()) + assert result == "Mar 05, 2020, 09:30" + + def test_z_suffix_treated_as_utc(self): + utc_now = datetime.now(timezone.utc) + iso_z = utc_now.strftime("%Y-%m-%dT%H:%M:%S") + "Z" + result = format_date(iso_z) + assert result.startswith("Today at ") + + def test_timezone_aware_iso(self): + utc_now = datetime.now(timezone.utc) + iso = utc_now.isoformat() + result = format_date(iso) + assert result.startswith("Today at ") + + def test_yesterday_timezone_aware(self): + yesterday_utc = datetime.now(timezone.utc) - timedelta(days=1) + iso = yesterday_utc.isoformat() + result = format_date(iso) + assert result.startswith("Yesterday at ") + + def test_different_year_timezone_aware(self): + dt = datetime(2019, 11, 22, 15, 45, tzinfo=timezone.utc) + result = format_date(dt.isoformat()) + assert "2019" in result + assert result.startswith("Nov 22") + + +# --------------------------------------------------------------------------- +# fuzzy_search +# --------------------------------------------------------------------------- + +class TestFuzzySearch: + def _items(self): + return [ + _make_item("hello world", pinned=True), + _make_item("foo bar baz"), + _make_item("python programming"), + _make_item("clipboard history"), + _make_item("", file_path="/home/user/image.png"), + ] + + def test_empty_search_returns_all(self): + items = self._items() + results = fuzzy_search(items, "") + assert len(results) == len(items) + + def test_empty_search_preserves_original_indices(self): + items = self._items() + results = fuzzy_search(items, "") + indices = [r["original_index"] for r in results] + assert indices == list(range(len(items))) + + def test_exact_match_score_100(self): + items = [_make_item("hello world")] + results = fuzzy_search(items, "hello") + assert len(results) == 1 + assert results[0]["match_quality"] == 100 + + def test_exact_match_returns_correct_item(self): + items = self._items() + results = fuzzy_search(items, "python programming") + assert len(results) == 1 + assert results[0]["item"]["value"] == "python programming" + + def test_substring_match_scores_100(self): + # Python's `in` operator finds substrings, so any prefix or substring of + # a word hits the `token in item_value` branch and gets score 100. + items = [_make_item("hello world")] + results = fuzzy_search(items, "hel") + assert len(results) == 1 + assert results[0]["match_quality"] == 100 + + def test_reverse_prefix_match_score_60(self): + # token.startswith(word) and len(word) >= 3 → score 60 + # "foo" is a word in value, token "foobar" starts with "foo" + # AND "foobar" is NOT in item_value "foo" (substring check fails first) + items = [_make_item("foo")] + results = fuzzy_search(items, "foobar") + assert len(results) == 1 + assert results[0]["match_quality"] == 60 + + def test_multi_token_all_must_match(self): + items = [ + _make_item("hello world"), + _make_item("hello darkness"), + ] + results = fuzzy_search(items, "hello world") + assert len(results) == 1 + assert results[0]["item"]["value"] == "hello world" + + def test_multi_token_scores_accumulate(self): + items = [_make_item("foo bar")] + results = fuzzy_search(items, "foo bar") + assert results[0]["match_quality"] == 200 + + def test_no_match_returns_empty(self): + items = self._items() + results = fuzzy_search(items, "zzzzzzzzz") + assert results == [] + + def test_show_only_pinned_filters_unpinned(self): + items = [ + _make_item("hello", pinned=True), + _make_item("world", pinned=False), + ] + results = fuzzy_search(items, "", show_only_pinned=True) + assert len(results) == 1 + assert results[0]["item"]["value"] == "hello" + + def test_show_only_pinned_with_search_term(self): + items = [ + _make_item("hello", pinned=True), + _make_item("hello world", pinned=False), + ] + results = fuzzy_search(items, "hello", show_only_pinned=True) + assert len(results) == 1 + assert results[0]["item"]["pinned"] is True + + def test_results_sorted_by_quality_descending(self): + items = [ + _make_item("foo"), # reverse-prefix for "foobar" → 60 + _make_item("foobar"), # exact → 100 + _make_item("foob"), # reverse-prefix for "foobar" → 60 + ] + results = fuzzy_search(items, "foobar") + qualities = [r["match_quality"] for r in results] + assert qualities == sorted(qualities, reverse=True) + assert qualities[0] == 100 + + def test_file_path_matching(self): + items = [ + _make_item("", file_path="/home/user/photo.png"), + _make_item("unrelated text"), + ] + results = fuzzy_search(items, "photo") + assert len(results) == 1 + assert results[0]["item"]["filePath"] == "/home/user/photo.png" + + def test_case_insensitive_search(self): + items = [_make_item("Hello World")] + results = fuzzy_search(items, "HELLO") + assert len(results) == 1 + + def test_original_index_is_correct(self): + items = [ + _make_item("alpha"), + _make_item("beta"), + _make_item("gamma"), + ] + results = fuzzy_search(items, "gamma") + assert results[0]["original_index"] == 2 + + def test_similarity_match_high_threshold(self): + # "helo" vs "hello" — similar enough to exceed 70% threshold + items = [_make_item("hello")] + results = fuzzy_search(items, "helo") + # Either found via substring or similarity — if found, quality > 0 + if results: + assert results[0]["match_quality"] > 0 + + def test_empty_items_list(self): + results = fuzzy_search([], "anything") + assert results == [] + + def test_item_without_value_key(self): + items = [{"pinned": False, "filePath": ""}] + results = fuzzy_search(items, "hello") + assert results == [] + + +# --------------------------------------------------------------------------- +# _calculate_similarity +# --------------------------------------------------------------------------- + +class TestCalculateSimilarity: + def test_identical_strings_returns_one(self): + assert _calculate_similarity("hello", "hello") == pytest.approx(1.0) + + def test_no_char_overlap_same_length(self): + # set("abc") ∩ set("xyz") = {} → basic_score = 0 + # len_ratio = 3/3 = 1.0 + # result = 0*0.7 + 1.0*0.3 = 0.3 + score = _calculate_similarity("abc", "xyz") + assert score == pytest.approx(0.3) + + def test_no_char_overlap_different_length(self): + # set("ab") ∩ set("xyz") = {} → basic_score = 0 + # len_ratio = 2/3 + # result = 0 + (2/3)*0.3 = 0.2 + score = _calculate_similarity("ab", "xyz") + assert score == pytest.approx(0.2) + + def test_partial_overlap(self): + score = _calculate_similarity("hello", "helo") + assert 0.0 < score < 1.0 + + def test_empty_both_returns_zero(self): + assert _calculate_similarity("", "") == 0.0 + + def test_one_empty_string(self): + score = _calculate_similarity("hello", "") + assert score == 0.0 + + def test_symmetry(self): + a, b = "python", "typhon" + assert _calculate_similarity(a, b) == pytest.approx(_calculate_similarity(b, a)) + + def test_score_bounded_zero_to_one(self): + for a, b in [("cat", "dog"), ("abc", "abcdef"), ("x", "xyz")]: + score = _calculate_similarity(a, b) + assert 0.0 <= score <= 1.0 + + def test_longer_string_pair(self): + score = _calculate_similarity("programming", "program") + assert score > 0.5