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 + "