From 230545adf9519d3f19acb5412c9f7c9cdc84a3cf Mon Sep 17 00:00:00 2001 From: eduardom Date: Wed, 6 May 2026 21:28:57 +0200 Subject: [PATCH] Refactor folder stack handling --- docking/core/config.py | 6 +- docking/locale/docking.pot | 141 ++- docking/ui/dock_window.py | 16 + docking/ui/folder/__init__.py | 1 + docking/ui/folder/_browser.py | 282 +++++ docking/ui/folder/stack.py | 1234 +++++++++++++++++++ docking/ui/menu.py | 1391 +--------------------- tests/core/test_config.py | 4 +- tests/ui/test_dock_window_integration.py | 77 ++ tests/ui/test_menu_integration.py | 483 +++++--- tests/visual/render_cases.py | 16 +- 11 files changed, 2026 insertions(+), 1625 deletions(-) create mode 100644 docking/ui/folder/__init__.py create mode 100644 docking/ui/folder/_browser.py create mode 100644 docking/ui/folder/stack.py diff --git a/docking/core/config.py b/docking/core/config.py index 57e5b871..fa93cef0 100644 --- a/docking/core/config.py +++ b/docking/core/config.py @@ -434,7 +434,7 @@ class FolderStackUnfold(str, Enum): DEFAULT_LEFT_CLICK_ACTION = LeftClickAction.TOGGLE.value DEFAULT_MIDDLE_CLICK_ACTION = MiddleClickAction.NEW_WINDOW.value -DEFAULT_FOLDER_STACK_UNFOLD = FolderStackUnfold.CLICK.value +DEFAULT_FOLDER_STACK_UNFOLD = FolderStackUnfold.HOVER.value def _normalize_left_click_action(value: object) -> str: @@ -473,10 +473,10 @@ def _normalize_folder_stack_unfold(value: object) -> str: logger.warning( "Invalid folder stack unfold mode %r; using default %r (%s)", value, - FolderStackUnfold.CLICK.value, + DEFAULT_FOLDER_STACK_UNFOLD, exc, ) - return FolderStackUnfold.CLICK.value + return DEFAULT_FOLDER_STACK_UNFOLD @dataclass diff --git a/docking/locale/docking.pot b/docking/locale/docking.pot index 582f2e6e..6860447a 100644 --- a/docking/locale/docking.pot +++ b/docking/locale/docking.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: docking\n" "Report-Msgid-Bugs-To: edumucelli@gmail.com\n" -"POT-Creation-Date: 2026-05-01 21:13+0200\n" +"POT-Creation-Date: 2026-05-06 21:28+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -141,7 +141,7 @@ msgid "Checks" msgstr "" #: docking/applets/apod/applet.py:118 docking/applets/certwatch/applet.py:128 -#: docking/applets/currencyfx/applet.py:183 +#: docking/applets/currencyfx/applet.py:187 #: docking/applets/hackernews/applet.py:175 #: docking/applets/speedtest/applet.py:103 #: docking/applets/thermals/applet.py:134 docking/applets/tooltip.py:33 @@ -162,7 +162,7 @@ msgstr "" #: docking/applets/camshield/applet.py:114 #: docking/applets/capslock/applet.py:84 #: docking/applets/certwatch/applet.py:145 -#: docking/applets/currencyfx/applet.py:191 +#: docking/applets/currencyfx/applet.py:195 #: docking/applets/hackernews/applet.py:192 #: docking/applets/micshield/applet.py:118 docking/applets/moon/applet.py:124 #: docking/applets/quote/applet.py:92 docking/applets/thermals/applet.py:144 @@ -660,65 +660,65 @@ msgstr "" msgid "Show Hex" msgstr "" -#: docking/applets/currencyfx/applet.py:84 +#: docking/applets/currencyfx/applet.py:88 msgid "Currency FX" msgstr "" -#: docking/applets/currencyfx/applet.py:169 +#: docking/applets/currencyfx/applet.py:173 #: docking/applets/currencyfx/state.py:726 msgid "Day samples" msgstr "" -#: docking/applets/currencyfx/applet.py:187 +#: docking/applets/currencyfx/applet.py:191 msgid "Swap Pair" msgstr "" -#: docking/applets/currencyfx/applet.py:197 +#: docking/applets/currencyfx/applet.py:201 msgid "Chart Interval" msgstr "" -#: docking/applets/currencyfx/applet.py:199 +#: docking/applets/currencyfx/applet.py:203 msgid "Day" msgstr "" -#: docking/applets/currencyfx/applet.py:200 +#: docking/applets/currencyfx/applet.py:204 msgid "Week" msgstr "" -#: docking/applets/currencyfx/applet.py:201 +#: docking/applets/currencyfx/applet.py:205 msgid "Month" msgstr "" -#: docking/applets/currencyfx/applet.py:216 +#: docking/applets/currencyfx/applet.py:220 msgid "Added Pairs" msgstr "" -#: docking/applets/currencyfx/applet.py:226 +#: docking/applets/currencyfx/applet.py:230 msgid "Remove Current Pair" msgstr "" -#: docking/applets/currencyfx/applet.py:233 +#: docking/applets/currencyfx/applet.py:237 msgid "Add Pair..." msgstr "" -#: docking/applets/currencyfx/applet.py:346 +#: docking/applets/currencyfx/applet.py:350 msgid "No rate data" msgstr "" -#: docking/applets/currencyfx/applet.py:428 +#: docking/applets/currencyfx/applet.py:432 #, python-brace-format msgid "{pair}: {rate} ({change})" msgstr "" -#: docking/applets/currencyfx/applet.py:471 +#: docking/applets/currencyfx/applet.py:475 msgid "Add FX Pair" msgstr "" -#: docking/applets/currencyfx/applet.py:485 +#: docking/applets/currencyfx/applet.py:489 msgid "Base" msgstr "" -#: docking/applets/currencyfx/applet.py:487 docking/applets/quote/applet.py:43 +#: docking/applets/currencyfx/applet.py:491 docking/applets/quote/applet.py:43 #: docking/applets/quote/applet.py:83 docking/applets/quote/applet.py:208 #: docking/applets/quote/applet.py:214 msgid "Quote" @@ -1667,30 +1667,30 @@ msgstr "" msgid "Celsius" msgstr "" -#: docking/applets/thermals/applet.py:53 docking/applets/thermals/state.py:199 -#: docking/applets/thermals/state.py:212 docking/applets/thermals/state.py:224 -#: docking/applets/thermals/state.py:258 +#: docking/applets/thermals/applet.py:53 docking/applets/thermals/state.py:406 +#: docking/applets/thermals/state.py:419 docking/applets/thermals/state.py:431 +#: docking/applets/thermals/state.py:465 msgid "Thermals" msgstr "" -#: docking/applets/thermals/applet.py:96 docking/applets/thermals/state.py:149 -#: docking/applets/thermals/state.py:219 +#: docking/applets/thermals/applet.py:96 docking/applets/thermals/state.py:113 +#: docking/applets/thermals/state.py:426 msgid "lm-sensors not installed" msgstr "" -#: docking/applets/thermals/applet.py:105 docking/applets/thermals/state.py:238 +#: docking/applets/thermals/applet.py:105 docking/applets/thermals/state.py:445 #, python-brace-format msgid "Hot: {label} {temp}" msgstr "" -#: docking/applets/thermals/applet.py:119 docking/applets/thermals/state.py:250 +#: docking/applets/thermals/applet.py:119 docking/applets/thermals/state.py:457 #, python-brace-format msgid "Fan: {label} {rpm}" msgstr "" -#: docking/applets/thermals/applet.py:139 docking/applets/thermals/state.py:205 -#: docking/applets/thermals/state.py:217 docking/applets/thermals/state.py:229 -#: docking/applets/thermals/state.py:265 +#: docking/applets/thermals/applet.py:139 docking/applets/thermals/state.py:412 +#: docking/applets/thermals/state.py:424 docking/applets/thermals/state.py:436 +#: docking/applets/thermals/state.py:472 msgid "Samples" msgstr "" @@ -1698,19 +1698,19 @@ msgstr "" msgid "Thermal readings unavailable" msgstr "" -#: docking/applets/thermals/state.py:167 +#: docking/applets/thermals/state.py:134 msgid "sensors failed" msgstr "" -#: docking/applets/thermals/state.py:174 +#: docking/applets/thermals/state.py:141 msgid "No thermal readings" msgstr "" -#: docking/applets/thermals/state.py:246 +#: docking/applets/thermals/state.py:453 msgid "Hot: unavailable" msgstr "" -#: docking/applets/thermals/state.py:256 +#: docking/applets/thermals/state.py:463 msgid "Fan: unavailable" msgstr "" @@ -1808,16 +1808,17 @@ msgstr "" msgid "Correct: {answer}" msgstr "" -#: docking/applets/unitconverter/applet.py:74 -#: docking/applets/unitconverter/applet.py:101 +#: docking/applets/unitconverter/applet.py:102 +#: docking/applets/unitconverter/applet.py:129 +#: docking/applets/unitconverter/applet.py:161 msgid "Unit Converter" msgstr "" -#: docking/applets/unitconverter/applet.py:166 +#: docking/applets/unitconverter/applet.py:210 msgid "Swap units" msgstr "" -#: docking/applets/unitconverter/applet.py:183 +#: docking/applets/unitconverter/applet.py:227 msgid "Value" msgstr "" @@ -1930,115 +1931,111 @@ msgstr "" msgid "Docking" msgstr "" -#: docking/ui/menu.py:186 +#: docking/ui/folder/_browser.py:26 msgid "Name" msgstr "" -#: docking/ui/menu.py:187 +#: docking/ui/folder/_browser.py:27 msgid "Kind" msgstr "" -#: docking/ui/menu.py:188 +#: docking/ui/folder/_browser.py:28 msgid "Size" msgstr "" -#: docking/ui/menu.py:189 +#: docking/ui/folder/_browser.py:29 msgid "Created" msgstr "" -#: docking/ui/menu.py:190 +#: docking/ui/folder/_browser.py:30 msgid "Modified" msgstr "" -#: docking/ui/menu.py:909 +#: docking/ui/folder/stack.py:660 msgid "Folder not found" msgstr "" -#: docking/ui/menu.py:948 +#: docking/ui/folder/stack.py:674 msgid "Folder is empty" msgstr "" -#: docking/ui/menu.py:1403 +#: docking/ui/folder/stack.py:1131 #, python-format msgid "Open in %s" msgstr "" -#: docking/ui/menu.py:1405 +#: docking/ui/folder/stack.py:1133 #, python-format msgid "%d More in %s" msgstr "" -#: docking/ui/menu.py:1408 +#: docking/ui/folder/stack.py:1136 msgid "Open Folder" msgstr "" -#: docking/ui/menu.py:1410 +#: docking/ui/folder/stack.py:1138 #, python-format msgid "%d More in Folder" msgstr "" -#: docking/ui/menu.py:1536 docking/ui/menu.py:1556 docking/ui/menu.py:1572 -#: docking/ui/menu.py:1625 +#: docking/ui/menu.py:387 docking/ui/menu.py:407 docking/ui/menu.py:423 +#: docking/ui/menu.py:471 msgid "Remove from Dock" msgstr "" -#: docking/ui/menu.py:1549 +#: docking/ui/menu.py:400 msgid "Open" msgstr "" -#: docking/ui/menu.py:1579 +#: docking/ui/menu.py:430 msgid "Keep in Dock" msgstr "" -#: docking/ui/menu.py:1588 +#: docking/ui/menu.py:439 msgid "Close All" msgstr "" -#: docking/ui/menu.py:1588 +#: docking/ui/menu.py:439 msgid "Close" msgstr "" -#: docking/ui/menu.py:1605 +#: docking/ui/menu.py:456 msgid "Sort By" msgstr "" -#: docking/ui/menu.py:1613 +#: docking/ui/menu.py:464 msgid "Show Hidden Files" msgstr "" -#: docking/ui/menu.py:1618 -msgid "Large Icons" -msgstr "" - -#: docking/ui/menu.py:1655 +#: docking/ui/menu.py:501 msgid "Add Applet" msgstr "" -#: docking/ui/menu.py:1693 +#: docking/ui/menu.py:539 msgid "No Applets Available" msgstr "" -#: docking/ui/menu.py:1700 +#: docking/ui/menu.py:546 msgid "Add Separator" msgstr "" -#: docking/ui/menu.py:1710 docking/ui/settings.py:167 +#: docking/ui/menu.py:556 docking/ui/settings.py:167 msgid "Preferences" msgstr "" -#: docking/ui/menu.py:1715 +#: docking/ui/menu.py:561 msgid "About" msgstr "" -#: docking/ui/menu.py:1720 +#: docking/ui/menu.py:566 msgid "Get Support" msgstr "" -#: docking/ui/menu.py:1727 +#: docking/ui/menu.py:573 msgid "Quit" msgstr "" -#: docking/ui/menu.py:1764 +#: docking/ui/menu.py:610 msgid "Window" msgstr "" @@ -2291,24 +2288,24 @@ msgid "" "Hides when the focused window is maximized or a dialog overlaps the dock." msgstr "" -#: docking/ui/update_popup.py:183 +#: docking/ui/update_popup.py:173 #, python-brace-format msgid "Docking {version} is available" msgstr "" -#: docking/ui/update_popup.py:189 +#: docking/ui/update_popup.py:179 #, python-brace-format msgid "You are using {version}." msgstr "" -#: docking/ui/update_popup.py:198 +#: docking/ui/update_popup.py:188 msgid "View Release" msgstr "" -#: docking/ui/update_popup.py:199 +#: docking/ui/update_popup.py:189 msgid "Later" msgstr "" -#: docking/ui/update_popup.py:200 +#: docking/ui/update_popup.py:190 msgid "Ignore" msgstr "" diff --git a/docking/ui/dock_window.py b/docking/ui/dock_window.py index ac5d7d04..7802f8b7 100644 --- a/docking/ui/dock_window.py +++ b/docking/ui/dock_window.py @@ -707,6 +707,18 @@ def _on_draw(self, widget: Gtk.DrawingArea, cr: cairo.Context) -> bool: self.cursor_x if is_horizontal(pos=self.config.pos) else self.cursor_y ) self.hover.update(cursor_main, frame=frame) + if ( + self.config.folder_stack_unfold == FolderStackUnfold.HOVER.value + and self.hover.hovered_item is not None + and self.hover.hovered_item.kind == FOLDER_KIND + ): + self._show_folder_stack_for_item( + item=self.hover.hovered_item, + frame=frame, + fallback_x=self.cursor_x, + fallback_y=self.cursor_y, + toggle_if_same_item=False, + ) # Keep redraw pump alive while urgent glow is visible (dock hidden) if self.renderer.has_active_urgent_glow( @@ -750,6 +762,10 @@ def _on_motion(self, widget: Gtk.DrawingArea, event: Gdk.EventMotion) -> bool: self.config.folder_stack_unfold == FolderStackUnfold.HOVER.value and hovered_item is not None and hovered_item.kind == FOLDER_KIND + and ( + not self.autohide.enabled + or self.autohide.state == HideState.VISIBLE + ) ): self._show_folder_stack_for_item( item=hovered_item, diff --git a/docking/ui/folder/__init__.py b/docking/ui/folder/__init__.py new file mode 100644 index 00000000..15977738 --- /dev/null +++ b/docking/ui/folder/__init__.py @@ -0,0 +1 @@ +"""Folder stack UI helpers.""" diff --git a/docking/ui/folder/_browser.py b/docking/ui/folder/_browser.py new file mode 100644 index 00000000..d0b3df66 --- /dev/null +++ b/docking/ui/folder/_browser.py @@ -0,0 +1,282 @@ +"""Private directory browser owned by the folder stack controller.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import gi + +gi.require_version("GdkPixbuf", "2.0") +gi.require_version("Gio", "2.0") +from gi.repository import GdkPixbuf, Gio + +import docking.platform.launcher as launcher_mod +from docking.i18n import _ +from docking.log import get_logger + +if TYPE_CHECKING: + from docking.platform.launcher import Launcher + + +FOLDER_DIRECTORY_CACHE_MAX_ENTRIES = 48 +FOLDER_SMALL_ICON_PX = 16 +FOLDER_SORT_OPTIONS = ( + (_("Name"), "name"), + (_("Kind"), "kind"), + (_("Size"), "size"), + (_("Created"), "created"), + (_("Modified"), "modified"), +) + +log = get_logger("folder.browser") + + +@dataclass(frozen=True) +class FolderPrefs: + """Persistent folder display preferences.""" + + sort: str = "name" + show_hidden: bool = False + + @classmethod + def from_mapping(cls, raw: Mapping[str, Any]) -> FolderPrefs: + return cls( + sort=str(raw.get("sort", "name") or "name"), + show_hidden=bool(raw.get("show_hidden", False)), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "sort": self.sort, + "show_hidden": self.show_hidden, + } + + +@dataclass(frozen=True) +class FolderRow: + """One visible child row in a browsed folder.""" + + target: str + name: str + kind: str + is_dir: bool + has_children: bool + size: int + created: int + modified: int + icon: GdkPixbuf.Pixbuf | None + + def __getitem__(self, key: str) -> Any: + return getattr(self, key) + + def get(self, key: str, default: Any = None) -> Any: + return getattr(self, key, default) + + def as_dict(self) -> dict[str, Any]: + return { + "target": self.target, + "name": self.name, + "kind": self.kind, + "is_dir": self.is_dir, + "has_children": self.has_children, + "size": self.size, + "created": self.created, + "modified": self.modified, + "icon": self.icon, + } + + +class FolderBrowser: + """List folder children with bounded caching and stable sort behavior.""" + + def __init__(self, launcher: Launcher | None = None) -> None: + self._launcher = launcher + self._directory_rows: dict[tuple[str, int, bool, int], list[FolderRow]] = {} + + @staticmethod + def _get_lru(cache: dict[Any, Any], key: Any) -> Any | None: + cached = cache.pop(key, None) + if cached is not None: + cache[key] = cached + return cached + + @staticmethod + def _put_lru( + cache: dict[Any, Any], key: Any, value: Any, *, max_entries: int + ) -> None: + cache[key] = value + while len(cache) > max_entries: + cache.pop(next(iter(cache))) + + def invalidate_target(self, target: str) -> None: + uri = launcher_mod.normalize_file_target(target) + if uri is None: + return + for key in [key for key in self._directory_rows if key[0] == uri]: + self._directory_rows.pop(key, None) + + def target_state(self, target: str) -> str: + uri = launcher_mod.normalize_file_target(target) + if uri is None: + return "missing" + try: + folder = Gio.File.new_for_uri(uri) + return "ok" if folder.query_exists(None) else "missing" + except Exception as exc: + log.debug("Failed to query folder target %s: %s", target, exc) + return "missing" + + def cache_stamp(self, target: str) -> int: + uri = launcher_mod.normalize_file_target(target) + if uri is None: + return 0 + try: + folder = Gio.File.new_for_uri(uri) + info = folder.query_info( + "time::modified", + Gio.FileQueryInfoFlags.NONE, + None, + ) + except Exception: + return 0 + return int(info.get_attribute_uint64("time::modified")) + + def list_directory( + self, + *, + target: str, + prefs: FolderPrefs, + icon_px: int | None = None, + ) -> list[FolderRow]: + uri = launcher_mod.normalize_file_target(target) + if uri is None: + return [] + resolved_icon_px = FOLDER_SMALL_ICON_PX if icon_px is None else max(icon_px, 1) + cache_key = ( + uri, + self.cache_stamp(target), + bool(prefs.show_hidden), + resolved_icon_px, + ) + cached = self._get_lru(self._directory_rows, cache_key) + if cached is not None: + rows = list(cached) + rows.sort(key=lambda row: self.sort_key(row=row, mode=prefs.sort)) + return rows + try: + folder = Gio.File.new_for_uri(uri) + enumerator = folder.enumerate_children( + ",".join( + ( + "standard::name", + "standard::display-name", + "standard::icon", + "standard::type", + "standard::content-type", + "standard::is-hidden", + "standard::size", + "time::created", + "time::modified", + ) + ), + Gio.FileQueryInfoFlags.NONE, + None, + ) + except Exception as exc: + log.warning("Failed to enumerate folder menu target %s: %s", target, exc) + return [] + + rows: list[FolderRow] = [] + while True: + info = enumerator.next_file(None) + if info is None: + break + if info.get_is_hidden() and not prefs.show_hidden: + continue + child = folder.get_child(info.get_name()) + child_uri = child.get_uri() + icon = info.get_icon() + is_dir = info.get_file_type() == Gio.FileType.DIRECTORY + rows.append( + FolderRow( + target=child_uri, + name=info.get_display_name() or info.get_name(), + kind="dir" if is_dir else "file", + is_dir=is_dir, + has_children=( + self.directory_has_visible_children( + target=child_uri, + show_hidden=prefs.show_hidden, + ) + if is_dir + else False + ), + size=int(info.get_size()), + created=int(info.get_attribute_uint64("time::created")), + modified=int(info.get_attribute_uint64("time::modified")), + icon=( + self._launcher.resolve_file_icon( + target=child_uri, + gicon=icon, + content_type=info.get_content_type() or "", + size=resolved_icon_px, + is_dir=is_dir, + ) + ) + if self._launcher + else None, + ) + ) + self._put_lru( + self._directory_rows, + cache_key, + list(rows), + max_entries=FOLDER_DIRECTORY_CACHE_MAX_ENTRIES, + ) + rows.sort(key=lambda row: self.sort_key(row=row, mode=prefs.sort)) + return rows + + def directory_has_visible_children(self, target: str, show_hidden: bool) -> bool: + uri = launcher_mod.normalize_file_target(target) + if uri is None: + return False + try: + folder = Gio.File.new_for_uri(uri) + enumerator = folder.enumerate_children( + "standard::is-hidden", + Gio.FileQueryInfoFlags.NONE, + None, + ) + except Exception as exc: + log.warning( + "Failed to inspect folder children for target %s: %s", + target, + exc, + ) + return False + + while True: + info = enumerator.next_file(None) + if info is None: + return False + if show_hidden or not info.get_is_hidden(): + return True + + def sort_key( + self, + row: FolderRow | Mapping[str, Any], + mode: str, + ) -> tuple[Any, ...]: + value = row.as_dict() if isinstance(row, FolderRow) else row + name = str(value["name"]).casefold() + if mode == "kind": + return (value["kind"], name) + if mode == "size": + return (value["size"], name) + if mode == "created": + return (value["created"], name) + if mode == "modified": + return (value["modified"], name) + return (name,) diff --git a/docking/ui/folder/stack.py b/docking/ui/folder/stack.py new file mode 100644 index 00000000..ba09305c --- /dev/null +++ b/docking/ui/folder/stack.py @@ -0,0 +1,1234 @@ +"""Folder stack popup, layout, drawing, and interaction.""" + +from __future__ import annotations + +import math +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import cairo +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +gi.require_version("GdkPixbuf", "2.0") +gi.require_version("Gio", "2.0") +gi.require_version("PangoCairo", "1.0") +from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango, PangoCairo + +import docking.platform.launcher as launcher_mod +from docking.core.items import FOLDER_KIND +from docking.i18n import _ +from docking.log import get_logger +from docking.ui.display import clamp_to_screen +from docking.ui.folder._browser import ( + FOLDER_SMALL_ICON_PX, + FOLDER_SORT_OPTIONS, + FolderBrowser, + FolderPrefs, + FolderRow, +) +from docking.ui.shelf import rounded_rect + +if TYPE_CHECKING: + from docking.core.config import Config + from docking.core.items import DockItem + from docking.platform.launcher import Launcher + from docking.ui.runtime import DockRuntime + + +FOLDER_STACK_MAX_VISIBLE_ROWS = 9 +FOLDER_STACK_GAP_PX = 8 +FOLDER_STACK_POPUP_SIDE_PADDING_PX = 14 +FOLDER_STACK_TOP_PADDING_PX = 6 +FOLDER_STACK_ACTION_GAP_PX = 18 +FOLDER_STACK_ICON_GAP_PX = 10 +FOLDER_STACK_LABEL_HEIGHT_PX = 24 +FOLDER_STACK_LABEL_MAX_WIDTH_PX = 148 +FOLDER_STACK_ACTION_MAX_WIDTH_PX = 240 +FOLDER_STACK_ROW_STEP_PX = 54 +FOLDER_STACK_CURVE_X_PX = 40 +FOLDER_STACK_ARC_BASE_SHIFT_PX = 8 +FOLDER_STACK_ARC_RADIUS_FACTOR = 2.45 +FOLDER_STACK_ARC_LINEAR_BLEND = 0.34 +FOLDER_STACK_RIGHT_BLEED_PX = 24 +FOLDER_STACK_LABEL_RADIUS_PX = 6 +FOLDER_STACK_LABEL_TEXT_MARGIN_PX = 8 +FOLDER_STACK_ACTION_ARROW_GAP_PX = 7 +FOLDER_STACK_ACTION_ARROW_SIZE_PX = 7 +FOLDER_STACK_ROTATION_MAX_DEG = 5.5 +FOLDER_STACK_REVEAL_DURATION_MS = 160 +FOLDER_STACK_REVEAL_STAGGER_MS = 28 +FOLDER_STACK_ANIM_FRAME_MS = 16 +FOLDER_STACK_HOVER_SCALE = 1.14 +FOLDER_STACK_HOVER_EASE = 0.35 +FOLDER_STACK_LAYOUT_CACHE_MAX_ENTRIES = 32 +FOLDER_STACK_REFRESH_DEBOUNCE_MS = 120 + +log = get_logger("folder.stack") + + +@dataclass(frozen=True) +class FolderStackCard: + label: str + target: str | None + icon: GdkPixbuf.Pixbuf | None + icon_x: int + icon_y: int + icon_size: int + label_x: int + label_y: int + label_w: int + label_h: int + centered: bool = False + stack_progress: float = 0.0 + arc_span: float = 0.0 + + +@dataclass(frozen=True) +class FolderStackCardGeometry: + reveal: float + hover_value: float + rotation_radians: float + icon_x: float + icon_y: float + icon_size: float + icon_center_x: float + icon_center_y: float + label_x: float + label_y: float + + +@dataclass(frozen=True) +class FolderStackLayout: + cards: tuple[FolderStackCard, ...] + popup_w: int + popup_h: int + fold_center_x: int + + +class FolderStackCache: + """Bounded cache state for folder stack layouts and prewarm queue.""" + + def __init__(self) -> None: + self.layouts: dict[ + tuple[str, int, str, bool, int, str | None], FolderStackLayout + ] = {} + self.prewarm_queue: list[DockItem] = [] + self.prewarm_targets: set[str] = set() + self.prewarm_source: int = 0 + + @staticmethod + def _get_lru(cache: dict[Any, Any], key: Any) -> Any | None: + cached = cache.pop(key, None) + if cached is not None: + cache[key] = cached + return cached + + @staticmethod + def _put_lru( + cache: dict[Any, Any], key: Any, value: Any, *, max_entries: int + ) -> None: + cache[key] = value + while len(cache) > max_entries: + cache.pop(next(iter(cache))) + + def get_layout( + self, key: tuple[str, int, str, bool, int, str | None] + ) -> FolderStackLayout | None: + return self._get_lru(self.layouts, key) + + def put_layout( + self, + key: tuple[str, int, str, bool, int, str | None], + layout: FolderStackLayout, + ) -> None: + self._put_lru( + self.layouts, + key, + layout, + max_entries=FOLDER_STACK_LAYOUT_CACHE_MAX_ENTRIES, + ) + + def queue_prewarm(self, item: DockItem, *, uri: str) -> bool: + if uri in self.prewarm_targets: + return False + self.prewarm_targets.add(uri) + self.prewarm_queue.append(item) + return True + + def pop_next_prewarm(self) -> DockItem | None: + if not self.prewarm_queue: + return None + item = self.prewarm_queue.pop(0) + uri = launcher_mod.normalize_file_target(item.target) + if uri is not None: + self.prewarm_targets.discard(uri) + return item + + def invalidate_target(self, *, uri: str) -> None: + for key in [key for key in self.layouts if key[0] == uri]: + self.layouts.pop(key, None) + self.prewarm_targets.discard(uri) + self.prewarm_queue = [ + item + for item in self.prewarm_queue + if launcher_mod.normalize_file_target(item.target) != uri + ] + + +def _is_folder_stack_action_card(card: FolderStackCard) -> bool: + return card.centered and card.target is not None and card.icon is None + + +def _ease_out_cubic(value: float) -> float: + value = min(max(value, 0.0), 1.0) + return 1.0 - (1.0 - value) ** 3 + + +def _folder_stack_arc_offset(progress: float, span: float) -> float: + progress = min(max(progress, 0.0), 1.0) + max_offset = max(FOLDER_STACK_CURVE_X_PX, span * 0.08) + linear = max_offset * progress + if span <= 0: + curved = linear + else: + radius = max(span * FOLDER_STACK_ARC_RADIUS_FACTOR, span + 1.0) + y = progress * span + curved = radius - math.sqrt(max(radius * radius - y * y, 0.0)) + max_curved = radius - math.sqrt(max(radius * radius - span * span, 0.0)) + curved = max_offset * (curved / max_curved) if max_curved > 0 else linear + offset = ( + FOLDER_STACK_ARC_LINEAR_BLEND * linear + + (1.0 - FOLDER_STACK_ARC_LINEAR_BLEND) * curved + ) + return FOLDER_STACK_ARC_BASE_SHIFT_PX + offset + + +def _folder_stack_rotation(progress: float, position: Any, span: float) -> float: + progress = min(max(progress, 0.0), 1.0) + direction = 1.0 if position in {"bottom", "left"} else -1.0 + degrees = min( + (0.2 + 0.8 * progress) * FOLDER_STACK_ROTATION_MAX_DEG, + FOLDER_STACK_ROTATION_MAX_DEG, + ) + return math.radians(degrees * direction) + + +def _measure_stack_text_px(text: str) -> int: + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1) + cr = cairo.Context(surface) + layout = PangoCairo.create_layout(cr) + layout.set_text(text, -1) + desc = Pango.FontDescription() + desc.set_family("Sans") + desc.set_size(10 * Pango.SCALE) + layout.set_font_description(desc) + _ink, logical = layout.get_pixel_extents() + return max(int(logical.width), 0) + + +class FolderStackController: + """Owns the left-click folder stack popup and visual interaction.""" + + def __init__( + self, + *, + config: Config, + runtime: DockRuntime, + launcher: Launcher | None, + ) -> None: + self._config = config + self._runtime = runtime + self._launcher = launcher + self._browser = FolderBrowser(launcher=launcher) + self._folder_stack_cache = FolderStackCache() + self._folder_stack_window: Gtk.Window | None = None + self._folder_stack_revealer: Gtk.Revealer | None = None + self._folder_stack_item: DockItem | None = None + self._folder_stack_anchor_x: int = 0 + self._folder_stack_anchor_y: int = 0 + self._folder_stack_icon_w: int = 0 + self._folder_stack_fold_center_x: int = 0 + self._folder_stack_position_value = self._config.pos + self._folder_stack_area: Gtk.DrawingArea | None = None + self._folder_stack_cards: list[FolderStackCard] = [] + self._folder_stack_monitor: Gio.FileMonitor | None = None + self._folder_stack_refresh_source: int = 0 + self._folder_stack_anim_source: int = 0 + self._folder_stack_show_started_us: int = 0 + self._folder_stack_hover_target: str | None = None + self._folder_stack_hover_values: dict[str, float] = {} + self._folder_stack_pressed_target: str | None = None + + def schedule_prewarm(self, item: DockItem) -> None: + """Queue a folder stack warm-up during idle time.""" + if item.kind != FOLDER_KIND: + return + uri = launcher_mod.normalize_file_target(item.target) + if uri is None: + return + if not self._folder_stack_cache.queue_prewarm(item, uri=uri): + return + if self._folder_stack_cache.prewarm_source == 0: + self._folder_stack_cache.prewarm_source = GLib.idle_add( + self._drain_folder_stack_prewarm + ) + + def schedule_visible_prewarm(self, items: Sequence[DockItem]) -> None: + """Warm visible folder stacks so hover-open can render from cache.""" + for item in items: + self.schedule_prewarm(item) + + def show( + self, + *, + item: DockItem, + anchor_x: int, + anchor_y: int, + icon_w: int, + position: Any, + toggle_if_same_item: bool = True, + ) -> None: + """Show a folder stack popup, or optionally toggle it closed.""" + if ( + self._folder_stack_window is not None + and self._folder_stack_window.get_visible() + and self._folder_stack_item is not None + and self._folder_stack_item.desktop_id == item.desktop_id + ): + if toggle_if_same_item: + self._close_folder_stack() + return + + self._close_folder_stack() + self._runtime.hide_hover_ui() + self._runtime.menu_popup_opened() + + window = self._ensure_folder_stack_window() + revealer = self._folder_stack_revealer + assert revealer is not None + + self._replace_folder_stack_content(item=item) + + self._folder_stack_anchor_x = int(anchor_x) + self._folder_stack_anchor_y = int(anchor_y) + self._folder_stack_icon_w = max(int(icon_w), 1) + self._folder_stack_position_value = position + self._folder_stack_item = item + self._track_folder_stack(target=item.target) + self._restart_folder_stack_animation() + self._position_folder_stack_window() + revealer.set_reveal_child(True) + window.show_all() + + def close(self) -> None: + self._close_folder_stack() + + def open_item_id(self) -> str | None: + window = self._folder_stack_window + if ( + window is None + or not window.get_visible() + or self._folder_stack_item is None + ): + return None + return self._folder_stack_item.desktop_id + + def invalidate_target(self, target: str) -> None: + self._browser.invalidate_target(target) + uri = launcher_mod.normalize_file_target(target) + if uri is not None: + self._folder_stack_cache.invalidate_target(uri=uri) + + def folder_prefs(self, item: DockItem) -> dict[str, Any]: + return self._folder_prefs_for_item(item).to_dict() + + def sort_options(self) -> Sequence[tuple[str, str]]: + return FOLDER_SORT_OPTIONS + + def list_directory( + self, + *, + folder_item: DockItem, + target: str, + icon_px: int | None = None, + ) -> list[dict[str, Any]]: + return [ + row.as_dict() + for row in self._list_directory_rows( + folder_item=folder_item, + target=target, + icon_px=icon_px, + ) + ] + + def icon_px(self, folder_item: DockItem) -> int: + return FOLDER_SMALL_ICON_PX + + def update_folder_pref(self, item: DockItem, key: str, value: Any) -> None: + if key not in {"sort", "show_hidden"}: + return + prefs = self.folder_prefs(item) + prefs[key] = value + self._config.item_prefs[item.prefs_key or item.target] = prefs + self._config.save() + self._runtime.queue_draw() + self.invalidate_target(item.target) + self.schedule_prewarm(item) + + def _folder_prefs_for_item(self, item: DockItem) -> FolderPrefs: + stored = dict(self._config.item_prefs.get(item.prefs_key or item.target, {})) + return FolderPrefs.from_mapping(stored) + + def _list_directory_rows( + self, + *, + folder_item: DockItem, + target: str, + icon_px: int | None = None, + ) -> list[FolderRow]: + return self._browser.list_directory( + target=target, + prefs=self._folder_prefs_for_item(folder_item), + icon_px=icon_px, + ) + + def _drain_folder_stack_prewarm(self) -> bool: + item = self._folder_stack_cache.pop_next_prewarm() + if item is None: + self._folder_stack_cache.prewarm_source = 0 + return False + self._folder_stack_layout_for_item(item) + if not self._folder_stack_cache.prewarm_queue: + self._folder_stack_cache.prewarm_source = 0 + return False + return True + + def _close_folder_stack(self) -> None: + window = self._folder_stack_window + if window is None or not window.get_visible(): + return + revealer = self._folder_stack_revealer + if revealer is not None: + revealer.set_reveal_child(False) + window.hide() + self._cleanup_folder_stack() + self._runtime.menu_popup_closed() + + def _cleanup_folder_stack(self) -> None: + if self._folder_stack_refresh_source: + GLib.source_remove(self._folder_stack_refresh_source) + self._folder_stack_refresh_source = 0 + if self._folder_stack_anim_source: + GLib.source_remove(self._folder_stack_anim_source) + self._folder_stack_anim_source = 0 + if self._folder_stack_monitor is not None: + self._folder_stack_monitor.cancel() + self._folder_stack_monitor = None + self._folder_stack_area = None + self._folder_stack_item = None + self._folder_stack_anchor_x = 0 + self._folder_stack_anchor_y = 0 + self._folder_stack_icon_w = 0 + self._folder_stack_fold_center_x = 0 + self._folder_stack_show_started_us = 0 + self._folder_stack_hover_target = None + self._folder_stack_hover_values.clear() + self._folder_stack_pressed_target = None + + def _ensure_folder_stack_window(self) -> Gtk.Window: + if self._folder_stack_window is not None: + return self._folder_stack_window + + window = Gtk.Window(type=Gtk.WindowType.POPUP) + window.set_decorated(False) + window.set_skip_taskbar_hint(True) + window.set_resizable(False) + window.set_type_hint(Gdk.WindowTypeHint.TOOLTIP) + window.set_app_paintable(True) + + screen = window.get_screen() + visual = screen.get_rgba_visual() + if visual: + window.set_visual(visual) + + revealer = Gtk.Revealer() + revealer.set_transition_type(self._folder_stack_transition_type()) + revealer.set_transition_duration(140) + revealer.set_reveal_child(False) + window.add(revealer) + + self._folder_stack_window = window + self._folder_stack_revealer = revealer + return window + + def _folder_stack_transition_type(self): + pos = self._config.pos + if pos == "bottom": + return Gtk.RevealerTransitionType.SLIDE_UP + if pos == "top": + return Gtk.RevealerTransitionType.SLIDE_DOWN + if pos == "left": + return Gtk.RevealerTransitionType.SLIDE_RIGHT + return Gtk.RevealerTransitionType.SLIDE_LEFT + + def _replace_folder_stack_content(self, item: DockItem) -> None: + revealer = self._folder_stack_revealer + if revealer is None: + return + child = revealer.get_child() + if child is not None: + revealer.remove(child) + content = self._build_folder_stack_content(item=item) + revealer.add(content) + content.show_all() + + def _position_folder_stack_window(self) -> None: + window = self._folder_stack_window + revealer = self._folder_stack_revealer + if window is None or revealer is None: + return + child = revealer.get_child() + if child is None: + return + + preferred = child.get_preferred_size()[1] + popup_w = max(int(preferred.width), 1) + popup_h = max(int(preferred.height), 1) + anchor_x = self._folder_stack_anchor_x + anchor_y = self._folder_stack_anchor_y + icon_w = max(self._folder_stack_icon_w, 1) + pos = self._folder_stack_position_value + local_icon_center_x = max(self._folder_stack_fold_center_x, 1) + + if pos == "bottom": + popup_x = int(anchor_x + icon_w / 2 - local_icon_center_x) + popup_y = int(anchor_y - popup_h - FOLDER_STACK_GAP_PX) + elif pos == "top": + popup_x = int(anchor_x + icon_w / 2 - local_icon_center_x) + popup_y = int(anchor_y + FOLDER_STACK_GAP_PX) + elif pos == "left": + popup_x = int(anchor_x + FOLDER_STACK_GAP_PX) + popup_y = int(anchor_y + icon_w / 2 - popup_h / 2) + else: + popup_x = int(anchor_x - popup_w - FOLDER_STACK_GAP_PX) + popup_y = int(anchor_y + icon_w / 2 - popup_h / 2) + + screen = window.get_screen() + popup_pos = clamp_to_screen( + popup_x, + popup_y, + popup_w, + popup_h, + screen.get_width(), + screen.get_height(), + ) + window.move(popup_pos.x, popup_pos.y) + + def _track_folder_stack(self, target: str) -> None: + uri = launcher_mod.normalize_file_target(target) + if uri is None: + return + try: + folder = Gio.File.new_for_uri(uri) + monitor = folder.monitor_directory(Gio.FileMonitorFlags.NONE, None) + monitor.connect("changed", self._on_folder_stack_changed) + self._folder_stack_monitor = monitor + except GLib.Error as exc: + log.warning("Failed to monitor folder stack target %s: %s", target, exc) + + def _on_folder_stack_changed( + self, + _monitor: Gio.FileMonitor, + _file: Gio.File, + _other_file: Gio.File | None, + _event_type: Gio.FileMonitorEvent, + ) -> None: + if self._folder_stack_item is not None: + self.invalidate_target(self._folder_stack_item.target) + if self._folder_stack_refresh_source: + GLib.source_remove(self._folder_stack_refresh_source) + self._folder_stack_refresh_source = GLib.timeout_add( + FOLDER_STACK_REFRESH_DEBOUNCE_MS, + self._refresh_folder_stack, + ) + + def _refresh_folder_stack(self) -> bool: + self._folder_stack_refresh_source = 0 + window = self._folder_stack_window + item = self._folder_stack_item + if window is None or item is None: + return False + self._replace_folder_stack_content(item=item) + self._restart_folder_stack_animation() + self._position_folder_stack_window() + window.show_all() + return False + + def _build_folder_stack_content(self, item: DockItem) -> Gtk.Widget: + cards, popup_w, popup_h = self._folder_stack_cards_for_item(item) + self._folder_stack_cards = cards + + area = Gtk.DrawingArea() + area.set_size_request(popup_w, popup_h) + area.add_events( + Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + | Gdk.EventMask.LEAVE_NOTIFY_MASK + ) + area.connect("draw", self._on_folder_stack_draw) + area.connect("button-press-event", self._on_folder_stack_button_press) + area.connect("button-release-event", self._on_folder_stack_button_release) + area.connect("motion-notify-event", self._on_folder_stack_motion_notify) + area.connect("leave-notify-event", self._on_folder_stack_leave_notify) + self._folder_stack_area = area + return area + + def _folder_stack_cards_for_item( + self, item: DockItem + ) -> tuple[list[FolderStackCard], int, int]: + layout = self._folder_stack_layout_for_item(item) + self._folder_stack_fold_center_x = layout.fold_center_x + return list(layout.cards), layout.popup_w, layout.popup_h + + def _folder_stack_layout_for_item(self, item: DockItem) -> FolderStackLayout: + prefs = self._folder_prefs_for_item(item) + icon_px = max(int(self._config.icon_size), 1) + app_name = ( + self._launcher.default_directory_app_name() + if self._launcher is not None + else None + ) + uri = launcher_mod.normalize_file_target(item.target) or item.target + state = self._browser.target_state(item.target) + if state == "missing": + return self._compute_folder_stack_layout( + item=item, + icon_px=icon_px, + app_name=app_name, + state=state, + ) + cache_key = ( + uri, + self._browser.cache_stamp(item.target), + str(prefs.sort), + bool(prefs.show_hidden), + icon_px, + app_name, + ) + cached = self._folder_stack_cache.get_layout(cache_key) + if cached is not None: + return cached + + layout = self._compute_folder_stack_layout( + item=item, + icon_px=icon_px, + app_name=app_name, + state=state, + ) + self._folder_stack_cache.put_layout(cache_key, layout) + return layout + + def _compute_folder_stack_layout( + self, + *, + item: DockItem, + icon_px: int, + app_name: str | None, + state: str, + ) -> FolderStackLayout: + cards: list[FolderStackCard] = [] + label_h = FOLDER_STACK_LABEL_HEIGHT_PX + row_step = max(FOLDER_STACK_ROW_STEP_PX, round(icon_px * 1.08)) + curve_extent = max(FOLDER_STACK_CURVE_X_PX, round(icon_px * 0.65)) + right_bleed = max( + FOLDER_STACK_RIGHT_BLEED_PX, + round(curve_extent + icon_px * FOLDER_STACK_HOVER_SCALE * 0.35), + ) + fold_center_x = int( + FOLDER_STACK_POPUP_SIDE_PADDING_PX + + FOLDER_STACK_LABEL_MAX_WIDTH_PX + + FOLDER_STACK_ICON_GAP_PX + + icon_px / 2 + ) + + if state == "missing": + return self._centered_layout( + label=_("Folder not found"), + label_h=label_h, + fold_center_x=fold_center_x, + icon_px=icon_px, + right_bleed=right_bleed, + ) + + rows = self._list_directory_rows( + folder_item=item, + target=item.target, + icon_px=icon_px, + ) + if not rows: + return self._centered_layout( + label=_("Folder is empty"), + label_h=label_h, + fold_center_x=fold_center_x, + icon_px=icon_px, + right_bleed=right_bleed, + ) + + visible_rows = list(rows)[:FOLDER_STACK_MAX_VISIBLE_ROWS] + hidden_count = max(len(rows) - len(visible_rows), 0) + action_label = self._folder_stack_action_label( + hidden_count=hidden_count, + app_name=app_name, + ) + chip_w = self._folder_stack_action_width(label=action_label) + chip_h = label_h + total_rows = len(visible_rows) + top_progress = 1.0 if total_rows > 0 else 0.0 + total_span = (total_rows - 1) * row_step + top_center_x = round( + fold_center_x + _folder_stack_arc_offset(top_progress, total_span) + ) + chip_x = max( + FOLDER_STACK_POPUP_SIDE_PADDING_PX, + int(top_center_x - chip_w / 2 + curve_extent * 0.1), + ) + chip_y = FOLDER_STACK_TOP_PADDING_PX + cards.append( + FolderStackCard( + label=action_label, + target=item.target, + icon=None, + icon_x=0, + icon_y=0, + icon_size=0, + label_x=chip_x, + label_y=chip_y, + label_w=chip_w, + label_h=chip_h, + centered=True, + stack_progress=1.0, + arc_span=float(total_span), + ) + ) + + stack_top = chip_y + chip_h + FOLDER_STACK_ACTION_GAP_PX + max_right = chip_x + chip_w + bottom_center_y = ( + stack_top + (total_rows - 1) * row_step + icon_px / 2 if total_rows else 0 + ) + for index, child in enumerate(visible_rows): + raw_progress = ( + (total_rows - 1 - index) / max(total_rows - 1, 1) + if total_rows > 1 + else 1.0 + ) + arc_progress = raw_progress + icon_center_x = fold_center_x + _folder_stack_arc_offset( + arc_progress, + total_span, + ) + icon_center_y = bottom_center_y - total_span * raw_progress + icon_x = round(icon_center_x - icon_px / 2) + icon_y = round(icon_center_y - icon_px / 2) + name = str(child["name"]) + label_w = self._folder_stack_label_width(label=name) + label_pull = round(arc_progress * 10) + label_x = max( + FOLDER_STACK_POPUP_SIDE_PADDING_PX, + icon_x - FOLDER_STACK_ICON_GAP_PX - label_w - label_pull, + ) + cards.append( + FolderStackCard( + label=name, + target=str(child["target"]), + icon=child["icon"], + icon_x=icon_x, + icon_y=icon_y, + icon_size=icon_px, + label_x=label_x, + label_y=icon_y + max(int((icon_px - label_h) / 2), 0), + label_w=label_w, + label_h=label_h, + centered=False, + stack_progress=arc_progress, + arc_span=float(total_span), + ) + ) + max_right = max(max_right, icon_x + icon_px) + + popup_w = int( + max( + max_right + right_bleed, + fold_center_x + + _folder_stack_arc_offset(1.0, total_span) + + icon_px / 2 + + right_bleed, + ) + ) + popup_h = ( + stack_top + + (total_rows - 1) * row_step + + icon_px + + FOLDER_STACK_TOP_PADDING_PX + ) + return FolderStackLayout( + cards=tuple(cards), + popup_w=popup_w, + popup_h=popup_h, + fold_center_x=fold_center_x, + ) + + def _centered_layout( + self, + *, + label: str, + label_h: int, + fold_center_x: int, + icon_px: int, + right_bleed: int, + ) -> FolderStackLayout: + label_w = 190 + card = FolderStackCard( + label=label, + target=None, + icon=None, + icon_x=0, + icon_y=0, + icon_size=0, + label_x=max( + FOLDER_STACK_POPUP_SIDE_PADDING_PX, + int(fold_center_x - label_w / 2), + ), + label_y=FOLDER_STACK_TOP_PADDING_PX, + label_w=label_w, + label_h=label_h, + centered=True, + ) + popup_w = int( + max( + fold_center_x + label_w / 2 + FOLDER_STACK_POPUP_SIDE_PADDING_PX, + fold_center_x + icon_px / 2 + right_bleed, + ) + ) + popup_h = label_h + 2 * FOLDER_STACK_TOP_PADDING_PX + return FolderStackLayout( + cards=(card,), + popup_w=popup_w, + popup_h=popup_h, + fold_center_x=fold_center_x, + ) + + def _on_folder_stack_draw(self, widget: Gtk.DrawingArea, cr: cairo.Context) -> bool: + cr.set_operator(cairo.OPERATOR_CLEAR) + cr.paint() + cr.set_operator(cairo.OPERATOR_OVER) + now_us = GLib.get_monotonic_time() + total_cards = len(self._folder_stack_cards) + for draw_index, card in enumerate(self._folder_stack_cards): + self._draw_folder_stack_card( + cr=cr, + card=card, + sequence_index=total_cards - 1 - draw_index, + now_us=now_us, + ) + return False + + def _folder_stack_card_geometry( + self, + *, + card: FolderStackCard, + sequence_index: int, + now_us: int, + ) -> FolderStackCardGeometry | None: + reveal = self._folder_stack_reveal_progress( + sequence_index=sequence_index, + now_us=now_us, + ) + if reveal <= 0: + return None + + hover_value = ( + self._folder_stack_hover_values.get(card.target, 0.0) + if card.target is not None and not card.centered + else 0.0 + ) + y_offset = (1.0 - reveal) * 18.0 + rotation_radians = ( + _folder_stack_rotation( + card.stack_progress, + self._folder_stack_position_value, + card.arc_span, + ) + * reveal + ) + open_label_center_x = card.label_x + card.label_w / 2 + label_center_x = ( + self._folder_stack_fold_center_x + + (open_label_center_x - self._folder_stack_fold_center_x) * reveal + ) + label_x = label_center_x - card.label_w / 2 + label_y = card.label_y + y_offset + + icon_size = 0.0 + icon_x = 0.0 + icon_y = 0.0 + icon_center_x = 0.0 + icon_center_y = 0.0 + if card.icon is not None and card.icon_size > 0: + icon_size = max( + ( + card.icon_size + * (0.82 + 0.18 * reveal) + * (1.0 + hover_value * (FOLDER_STACK_HOVER_SCALE - 1.0)) + ), + 1.0, + ) + open_icon_center_x = card.icon_x + card.icon_size / 2 + icon_center_x = ( + self._folder_stack_fold_center_x + + (open_icon_center_x - self._folder_stack_fold_center_x) * reveal + ) + icon_center_y = ( + card.icon_y + card.icon_size / 2 + y_offset - hover_value * 4.0 + ) + icon_x = icon_center_x - icon_size / 2 + icon_y = icon_center_y - icon_size / 2 + + return FolderStackCardGeometry( + reveal=reveal, + hover_value=hover_value, + rotation_radians=rotation_radians, + icon_x=icon_x, + icon_y=icon_y, + icon_size=icon_size, + icon_center_x=icon_center_x, + icon_center_y=icon_center_y, + label_x=label_x, + label_y=label_y, + ) + + def _draw_folder_stack_card( + self, + *, + cr: cairo.Context, + card: FolderStackCard, + sequence_index: int, + now_us: int, + ) -> None: + geometry = self._folder_stack_card_geometry( + card=card, + sequence_index=sequence_index, + now_us=now_us, + ) + if geometry is None: + return + is_action_card = _is_folder_stack_action_card(card) + + if card.icon is not None and card.icon_size > 0: + pixbuf = card.icon + draw_icon_size = max(round(geometry.icon_size), 1) + if ( + pixbuf.get_width() != draw_icon_size + or pixbuf.get_height() != draw_icon_size + ): + scaled = pixbuf.scale_simple( + draw_icon_size, + draw_icon_size, + GdkPixbuf.InterpType.BILINEAR, + ) + if scaled is not None: + pixbuf = scaled + + cr.save() + cr.translate(geometry.icon_center_x + 2, geometry.icon_center_y + 2) + cr.rotate(geometry.rotation_radians) + Gdk.cairo_set_source_pixbuf( + cr, + pixbuf, + -draw_icon_size / 2, + -draw_icon_size / 2, + ) + cr.paint_with_alpha(0.16 * geometry.reveal) + cr.restore() + + cr.save() + cr.translate(geometry.icon_center_x, geometry.icon_center_y) + cr.rotate(geometry.rotation_radians) + Gdk.cairo_set_source_pixbuf( + cr, + pixbuf, + -draw_icon_size / 2, + -draw_icon_size / 2, + ) + cr.paint_with_alpha(0.55 + 0.45 * geometry.reveal) + cr.restore() + + radius = FOLDER_STACK_LABEL_RADIUS_PX + label_center_x = geometry.label_x + card.label_w / 2 + label_center_y = geometry.label_y + card.label_h / 2 + cr.save() + cr.translate(label_center_x, label_center_y + 1) + cr.rotate(geometry.rotation_radians * 0.85) + rounded_rect( + cr, + -card.label_w / 2, + -card.label_h / 2, + card.label_w, + card.label_h, + radius, + ) + cr.set_source_rgba(0, 0, 0, 0.08 * geometry.reveal) + cr.fill() + cr.restore() + + cr.save() + cr.translate(label_center_x, label_center_y) + cr.rotate(geometry.rotation_radians * 0.85) + rounded_rect( + cr, + -card.label_w / 2, + -card.label_h / 2, + card.label_w, + card.label_h, + radius, + ) + cr.set_source_rgba(0.98, 0.98, 0.98, 0.95) + cr.fill_preserve() + cr.set_source_rgba(0, 0, 0, 0.08) + cr.set_line_width(1.0) + cr.stroke() + cr.restore() + + cr.save() + layout = PangoCairo.create_layout(cr) + layout.set_text(card.label, -1) + desc = Pango.FontDescription() + desc.set_family("Sans") + desc.set_size(10 * Pango.SCALE) + layout.set_font_description(desc) + layout.set_ellipsize(Pango.EllipsizeMode.END) + arrow_reserve = ( + FOLDER_STACK_ACTION_ARROW_GAP_PX + FOLDER_STACK_ACTION_ARROW_SIZE_PX + if is_action_card + else 0 + ) + available_text_w = max( + int(card.label_w - 2 * FOLDER_STACK_LABEL_TEXT_MARGIN_PX - arrow_reserve), + 1, + ) + layout.set_width(available_text_w * Pango.SCALE) + layout.set_alignment(Pango.Alignment.CENTER) + _, logical = layout.get_pixel_extents() + text_y = int(-card.label_h / 2 + (card.label_h - logical.height) / 2) + text_x = -card.label_w / 2 + FOLDER_STACK_LABEL_TEXT_MARGIN_PX + cr.set_source_rgba(0.16, 0.2, 0.26, 1.0) + cr.translate(label_center_x, label_center_y) + cr.rotate(geometry.rotation_radians * 0.85) + cr.move_to(text_x, text_y) + PangoCairo.show_layout(cr, layout) + if is_action_card: + arrow_center_x = ( + card.label_w / 2 + - FOLDER_STACK_LABEL_TEXT_MARGIN_PX + - FOLDER_STACK_ACTION_ARROW_SIZE_PX / 2 + ) + half = FOLDER_STACK_ACTION_ARROW_SIZE_PX / 2 + cr.set_line_width(1.4) + cr.set_line_cap(cairo.LineCap.ROUND) + cr.set_line_join(cairo.LineJoin.ROUND) + cr.move_to(arrow_center_x - half, -half) + cr.line_to(arrow_center_x, 0.0) + cr.line_to(arrow_center_x - half, half) + cr.stroke() + cr.restore() + + def _folder_stack_card_at(self, x: float, y: float) -> FolderStackCard | None: + now_us = GLib.get_monotonic_time() + total_cards = len(self._folder_stack_cards) + for index in range(total_cards - 1, -1, -1): + card = self._folder_stack_cards[index] + geometry = self._folder_stack_card_geometry( + card=card, + sequence_index=total_cards - 1 - index, + now_us=now_us, + ) + if geometry is None: + continue + within_label = ( + geometry.label_x <= x <= geometry.label_x + card.label_w + and geometry.label_y <= y <= geometry.label_y + card.label_h + ) + within_icon = ( + geometry.icon_size > 0 + and geometry.icon_x <= x <= geometry.icon_x + geometry.icon_size + and geometry.icon_y <= y <= geometry.icon_y + geometry.icon_size + ) + if within_label or within_icon: + return card + return None + + def _on_folder_stack_button_press( + self, _widget: Gtk.DrawingArea, event: Gdk.EventButton + ) -> bool: + if int(event.button) != 1: + self._folder_stack_pressed_target = None + return False + card = self._folder_stack_card_at(event.x, event.y) + self._folder_stack_pressed_target = ( + card.target if card is not None and card.target is not None else None + ) + return self._folder_stack_pressed_target is not None + + def _on_folder_stack_button_release( + self, _widget: Gtk.DrawingArea, event: Gdk.EventButton + ) -> bool: + if int(event.button) != 1: + self._folder_stack_pressed_target = None + return False + card = self._folder_stack_card_at(event.x, event.y) + target = card.target if card is not None and card.target is not None else None + pressed_target = self._folder_stack_pressed_target + self._folder_stack_pressed_target = None + if target is not None and (pressed_target is None or pressed_target == target): + self._open_folder_stack_target(target) + return True + return False + + def _on_folder_stack_motion_notify( + self, _widget: Gtk.DrawingArea, event: Gdk.EventMotion + ) -> bool: + card = self._folder_stack_card_at(event.x, event.y) + target = ( + card.target + if card is not None and card.target is not None and not card.centered + else None + ) + if target != self._folder_stack_hover_target: + self._folder_stack_hover_target = target + self._ensure_folder_stack_animating() + return False + + def _on_folder_stack_leave_notify( + self, _widget: Gtk.DrawingArea, _event: Gdk.EventCrossing + ) -> bool: + if self._folder_stack_hover_target is not None: + self._folder_stack_hover_target = None + self._ensure_folder_stack_animating() + self._folder_stack_pressed_target = None + return False + + def _folder_stack_action_label( + self, *, hidden_count: int, app_name: str | None = None + ) -> str: + if app_name is None and self._launcher is not None: + app_name = self._launcher.default_directory_app_name() + if app_name: + return ( + _("Open in %s") % app_name + if hidden_count == 0 + else _("%d More in %s") % (hidden_count, app_name) + ) + return ( + _("Open Folder") + if hidden_count == 0 + else _("%d More in Folder") % hidden_count + ) + + def _folder_stack_action_width(self, *, label: str) -> int: + return min( + FOLDER_STACK_ACTION_MAX_WIDTH_PX, + _measure_stack_text_px(label) + + 2 * FOLDER_STACK_LABEL_TEXT_MARGIN_PX + + FOLDER_STACK_ACTION_ARROW_GAP_PX + + FOLDER_STACK_ACTION_ARROW_SIZE_PX + + 10, + ) + + def _folder_stack_label_width(self, *, label: str) -> int: + return min( + FOLDER_STACK_LABEL_MAX_WIDTH_PX, + max( + 24, + _measure_stack_text_px(label) + + 2 * FOLDER_STACK_LABEL_TEXT_MARGIN_PX + + 10, + ), + ) + + def _restart_folder_stack_animation(self) -> None: + self._folder_stack_show_started_us = GLib.get_monotonic_time() + self._folder_stack_hover_target = None + self._folder_stack_hover_values.clear() + self._ensure_folder_stack_animating() + + def _ensure_folder_stack_animating(self) -> None: + area = self._folder_stack_area + if area is None: + return + area.queue_draw() + if self._folder_stack_anim_source == 0: + self._folder_stack_anim_source = GLib.timeout_add( + FOLDER_STACK_ANIM_FRAME_MS, + self._on_folder_stack_animation_frame, + ) + + def _on_folder_stack_animation_frame(self) -> bool: + area = self._folder_stack_area + window = self._folder_stack_window + if area is None or window is None or not window.get_visible(): + self._folder_stack_anim_source = 0 + return False + + active = False + now_us = GLib.get_monotonic_time() + elapsed_ms = max((now_us - self._folder_stack_show_started_us) / 1000.0, 0.0) + reveal_budget_ms = ( + FOLDER_STACK_REVEAL_DURATION_MS + + max( + len(self._folder_stack_cards) - 1, + 0, + ) + * FOLDER_STACK_REVEAL_STAGGER_MS + ) + if elapsed_ms < reveal_budget_ms: + active = True + + for card in self._folder_stack_cards: + if card.target is None or card.centered: + continue + current = self._folder_stack_hover_values.get(card.target, 0.0) + target = 1.0 if self._folder_stack_hover_target == card.target else 0.0 + updated = current + (target - current) * FOLDER_STACK_HOVER_EASE + if abs(updated - target) < 0.02: + updated = target + if updated <= 0.0 and target == 0.0: + self._folder_stack_hover_values.pop(card.target, None) + else: + self._folder_stack_hover_values[card.target] = updated + if updated != target: + active = True + + area.queue_draw() + if not active: + self._folder_stack_anim_source = 0 + return False + return True + + def _folder_stack_reveal_progress( + self, *, sequence_index: int, now_us: int + ) -> float: + if self._folder_stack_show_started_us <= 0: + return 1.0 + elapsed_ms = max((now_us - self._folder_stack_show_started_us) / 1000.0, 0.0) + elapsed_ms -= sequence_index * FOLDER_STACK_REVEAL_STAGGER_MS + if elapsed_ms <= 0: + return 0.0 + return _ease_out_cubic(elapsed_ms / FOLDER_STACK_REVEAL_DURATION_MS) + + def _open_folder_stack_target(self, target: str) -> None: + launcher_mod.open_target(target) + self._close_folder_stack() diff --git a/docking/ui/menu.py b/docking/ui/menu.py index cb09d86a..2c78f7e1 100644 --- a/docking/ui/menu.py +++ b/docking/ui/menu.py @@ -7,7 +7,7 @@ - item menu for an application launcher, - applet menu with applet-specific actions, -- folder stack menu, +- folder item menu, - dock background menu, - live menu for currently open application windows. @@ -30,7 +30,7 @@ - constructing GTK menu trees, - building item-specific and dock-specific actions, - applet submenu organization, -- folder stack menus, +- folder item menus, - live window menu entries with thumbnails, - popup creation and lifecycle hookup. @@ -92,8 +92,8 @@ 2. Applet menu Applet-specific commands and applet insertion choices. -3. Folder stack menu - A live view into a directory with sorting/filtering behavior. +3. Folder item menu + A right-click directory view with sorting/filtering behavior. 4. Dock background menu Global dock behavior such as: @@ -104,6 +104,9 @@ - applet insertion - quit/about +Left-click folder stacks are coordinated through `FolderStackController`; this +module only exposes the facade methods used by dock input handling. + Window thumbnails in menus For running applications, the menu may include live window entries. Those use @@ -134,20 +137,16 @@ from __future__ import annotations -import math from collections.abc import Sequence -from dataclasses import dataclass from typing import TYPE_CHECKING, Any -import cairo import gi gi.require_version("Gtk", "3.0") gi.require_version("Gdk", "3.0") gi.require_version("GdkPixbuf", "2.0") gi.require_version("Gio", "2.0") -gi.require_version("PangoCairo", "1.0") -from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango, PangoCairo +from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk, Pango import docking.platform.launcher as launcher_mod from docking.applets import get_applet_catalog @@ -164,12 +163,11 @@ from docking.i18n import _ from docking.log import get_logger from docking.ui.about import AboutDialogController -from docking.ui.display import clamp_to_screen +from docking.ui.folder.stack import FolderStackController from docking.ui.geometry import DockGeometryBuilder, DockGeometryFrame from docking.ui.preview import capture_window from docking.ui.runtime import DockRuntime from docking.ui.settings import SettingsWindowController -from docking.ui.shelf import rounded_rect if TYPE_CHECKING: from docking.core.config import Config @@ -182,231 +180,17 @@ APPLET_MENU_ICON_PX = 16 MENU_LABEL_MAX_CHARS = 32 MENU_ROW_SPACING_PX = 6 -FOLDER_SORT_OPTIONS = ( - (_("Name"), "name"), - (_("Kind"), "kind"), - (_("Size"), "size"), - (_("Created"), "created"), - (_("Modified"), "modified"), -) WINDOW_MENU_THUMB_W = 28 WINDOW_MENU_THUMB_H = 20 WINDOW_MENU_CLOSE_HIT_W = 44 WINDOW_MENU_CLOSE_LABEL_XALIGN = 0.5 WINDOW_MENU_CLOSE_MARGIN_END_PX = 12 FOLDER_MENU_REFRESH_DEBOUNCE_MS = 120 -FOLDER_SMALL_ICON_PX = 16 -FOLDER_LARGE_ICON_PX = 24 -FOLDER_STACK_MAX_VISIBLE_ROWS = 9 -FOLDER_STACK_GAP_PX = 8 -FOLDER_STACK_POPUP_SIDE_PADDING_PX = 14 -FOLDER_STACK_TOP_PADDING_PX = 6 -FOLDER_STACK_ACTION_GAP_PX = 18 -FOLDER_STACK_ICON_GAP_PX = 10 -FOLDER_STACK_LABEL_HEIGHT_PX = 24 -FOLDER_STACK_LABEL_MAX_WIDTH_PX = 148 -FOLDER_STACK_ACTION_MAX_WIDTH_PX = 240 -FOLDER_STACK_ROW_STEP_PX = 54 -FOLDER_STACK_CURVE_X_PX = 40 -FOLDER_STACK_ARC_BASE_SHIFT_PX = 8 -FOLDER_STACK_ARC_RADIUS_FACTOR = 2.45 -FOLDER_STACK_ARC_LINEAR_BLEND = 0.34 -FOLDER_STACK_RIGHT_BLEED_PX = 24 -FOLDER_STACK_LABEL_RADIUS_PX = 6 -FOLDER_STACK_LABEL_TEXT_MARGIN_PX = 8 -FOLDER_STACK_ACTION_ARROW_GAP_PX = 7 -FOLDER_STACK_ACTION_ARROW_SIZE_PX = 7 -FOLDER_STACK_ROTATION_MAX_DEG = 5.5 -FOLDER_STACK_REVEAL_DURATION_MS = 160 -FOLDER_STACK_REVEAL_STAGGER_MS = 28 -FOLDER_STACK_ANIM_FRAME_MS = 16 -FOLDER_STACK_HOVER_SCALE = 1.14 -FOLDER_STACK_HOVER_EASE = 0.35 -FOLDER_DIRECTORY_CACHE_MAX_ENTRIES = 48 -FOLDER_STACK_LAYOUT_CACHE_MAX_ENTRIES = 32 log = get_logger("menu") SUPPORT_URL = "https://github.com/edumucelli/docking/issues" -@dataclass(frozen=True) -class FolderStackCard: - label: str - target: str | None - icon: GdkPixbuf.Pixbuf | None - icon_x: int - icon_y: int - icon_size: int - label_x: int - label_y: int - label_w: int - label_h: int - centered: bool = False - stack_progress: float = 0.0 - arc_span: float = 0.0 - - -@dataclass(frozen=True) -class FolderStackCardGeometry: - reveal: float - hover_value: float - rotation_radians: float - icon_x: float - icon_y: float - icon_size: float - icon_center_x: float - icon_center_y: float - label_x: float - label_y: float - - -@dataclass(frozen=True) -class FolderStackLayout: - cards: tuple[FolderStackCard, ...] - popup_w: int - popup_h: int - fold_center_x: int - - -class FolderStackCache: - """Bounded cache state for folder menu listings and stack layouts.""" - - def __init__(self) -> None: - self.directory_rows: dict[tuple[str, int, bool, int], list[dict[str, Any]]] = {} - self.layouts: dict[ - tuple[str, int, str, bool, int, str | None], FolderStackLayout - ] = {} - self.prewarm_queue: list[DockItem] = [] - self.prewarm_targets: set[str] = set() - self.prewarm_source: int = 0 - - @staticmethod - def _get_lru(cache: dict[Any, Any], key: Any) -> Any | None: - cached = cache.pop(key, None) - if cached is not None: - cache[key] = cached - return cached - - @staticmethod - def _put_lru( - cache: dict[Any, Any], key: Any, value: Any, *, max_entries: int - ) -> None: - cache[key] = value - while len(cache) > max_entries: - cache.pop(next(iter(cache))) - - def get_directory_rows( - self, key: tuple[str, int, bool, int] - ) -> list[dict[str, Any]] | None: - return self._get_lru(self.directory_rows, key) - - def put_directory_rows( - self, key: tuple[str, int, bool, int], rows: list[dict[str, Any]] - ) -> None: - self._put_lru( - self.directory_rows, - key, - rows, - max_entries=FOLDER_DIRECTORY_CACHE_MAX_ENTRIES, - ) - - def get_layout( - self, key: tuple[str, int, str, bool, int, str | None] - ) -> FolderStackLayout | None: - return self._get_lru(self.layouts, key) - - def put_layout( - self, - key: tuple[str, int, str, bool, int, str | None], - layout: FolderStackLayout, - ) -> None: - self._put_lru( - self.layouts, - key, - layout, - max_entries=FOLDER_STACK_LAYOUT_CACHE_MAX_ENTRIES, - ) - - def queue_prewarm(self, item: DockItem, *, uri: str) -> bool: - if uri in self.prewarm_targets: - return False - self.prewarm_targets.add(uri) - self.prewarm_queue.append(item) - return True - - def pop_next_prewarm(self) -> DockItem | None: - if not self.prewarm_queue: - return None - item = self.prewarm_queue.pop(0) - uri = launcher_mod.normalize_file_target(item.target) - if uri is not None: - self.prewarm_targets.discard(uri) - return item - - def invalidate_target(self, *, uri: str) -> None: - for key in [key for key in self.directory_rows if key[0] == uri]: - self.directory_rows.pop(key, None) - for key in [key for key in self.layouts if key[0] == uri]: - self.layouts.pop(key, None) - self.prewarm_targets.discard(uri) - self.prewarm_queue = [ - item - for item in self.prewarm_queue - if launcher_mod.normalize_file_target(item.target) != uri - ] - - -def _is_folder_stack_action_card(card: FolderStackCard) -> bool: - return card.centered and card.target is not None and card.icon is None - - -def _ease_out_cubic(value: float) -> float: - value = min(max(value, 0.0), 1.0) - return 1.0 - (1.0 - value) ** 3 - - -def _folder_stack_arc_offset(progress: float, span: float) -> float: - progress = min(max(progress, 0.0), 1.0) - max_offset = max(FOLDER_STACK_CURVE_X_PX, span * 0.08) - linear = max_offset * progress - if span <= 0: - curved = linear - else: - radius = max(span * FOLDER_STACK_ARC_RADIUS_FACTOR, span + 1.0) - y = progress * span - curved = radius - math.sqrt(max(radius * radius - y * y, 0.0)) - max_curved = radius - math.sqrt(max(radius * radius - span * span, 0.0)) - curved = max_offset * (curved / max_curved) if max_curved > 0 else linear - offset = ( - FOLDER_STACK_ARC_LINEAR_BLEND * linear - + (1.0 - FOLDER_STACK_ARC_LINEAR_BLEND) * curved - ) - return FOLDER_STACK_ARC_BASE_SHIFT_PX + offset - - -def _folder_stack_rotation(progress: float, position: Any, span: float) -> float: - progress = min(max(progress, 0.0), 1.0) - direction = 1.0 if position in {"bottom", "left"} else -1.0 - degrees = min( - (0.2 + 0.8 * progress) * FOLDER_STACK_ROTATION_MAX_DEG, - FOLDER_STACK_ROTATION_MAX_DEG, - ) - return math.radians(degrees * direction) - - -def _measure_stack_text_px(text: str) -> int: - surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1) - cr = cairo.Context(surface) - layout = PangoCairo.create_layout(cr) - layout.set_text(text, -1) - desc = Pango.FontDescription() - desc.set_family("Sans") - desc.set_size(10 * Pango.SCALE) - layout.set_font_description(desc) - _ink, logical = layout.get_pixel_extents() - return max(int(logical.width), 0) - - def _make_menu_header(label: str) -> Gtk.MenuItem: item = Gtk.MenuItem(label=label) item.set_sensitive(False) @@ -501,47 +285,23 @@ def __init__( self._tracker = window_tracker self._launcher = launcher self._geometry_builder = geometry_builder - self._folder_stack_cache = FolderStackCache() + self._folder_stack = FolderStackController( + config=config, + runtime=runtime, + launcher=launcher, + ) self._folder_menu_monitors: dict[int, Gio.FileMonitor] = {} self._folder_menu_context: dict[int, tuple[Gtk.Menu, DockItem, str, bool]] = {} self._folder_menu_refresh_sources: dict[int, int] = {} self._folder_menu_signal_connected: set[int] = set() - self._folder_stack_window: Gtk.Window | None = None - self._folder_stack_revealer: Gtk.Revealer | None = None - self._folder_stack_item: DockItem | None = None - self._folder_stack_anchor_x: int = 0 - self._folder_stack_anchor_y: int = 0 - self._folder_stack_icon_w: int = 0 - self._folder_stack_fold_center_x: int = 0 - self._folder_stack_position_value = self._config.pos - self._folder_stack_area: Gtk.DrawingArea | None = None - self._folder_stack_cards: list[FolderStackCard] = [] - self._folder_stack_monitor: Gio.FileMonitor | None = None - self._folder_stack_refresh_source: int = 0 - self._folder_stack_anim_source: int = 0 - self._folder_stack_show_started_us: int = 0 - self._folder_stack_hover_target: str | None = None - self._folder_stack_hover_values: dict[str, float] = {} - self._folder_stack_pressed_target: str | None = None def schedule_folder_stack_prewarm(self, item: DockItem) -> None: """Queue a folder stack warm-up during idle time.""" - if item.kind != FOLDER_KIND: - return - uri = launcher_mod.normalize_file_target(item.target) - if uri is None: - return - if not self._folder_stack_cache.queue_prewarm(item, uri=uri): - return - if self._folder_stack_cache.prewarm_source == 0: - self._folder_stack_cache.prewarm_source = GLib.idle_add( - self._drain_folder_stack_prewarm - ) + self._folder_stack.schedule_prewarm(item) def schedule_visible_folder_stack_prewarm(self, items: Sequence[DockItem]) -> None: """Warm visible folder stacks so hover-open can render from cache.""" - for item in items: - self.schedule_folder_stack_prewarm(item) + self._folder_stack.schedule_visible_prewarm(items) def show(self, event: Gdk.EventButton, cursor_main: float) -> None: """Build and show the right-click context menu. @@ -552,7 +312,7 @@ def show(self, event: Gdk.EventButton, cursor_main: float) -> None: """ frame = self._geometry_builder.build_frame(cursor_x=event.x, cursor_y=event.y) item = frame.item_at_point(event.x, event.y) - self._close_folder_stack() + self._folder_stack.close() if item: menu = self._new_popup_menu() @@ -576,68 +336,25 @@ def show_folder_stack( toggle_if_same_item: bool = True, ) -> None: """Show a folder stack popup, or optionally toggle it closed.""" - if ( - self._folder_stack_window is not None - and self._folder_stack_window.get_visible() - and self._folder_stack_item is not None - and self._folder_stack_item.desktop_id == item.desktop_id - ): - if toggle_if_same_item: - self._close_folder_stack() - return - - self._close_folder_stack() - self._runtime.hide_hover_ui() - self._runtime.menu_popup_opened() - - window = self._ensure_folder_stack_window() - revealer = self._folder_stack_revealer - assert revealer is not None - - self._replace_folder_stack_content(item=item) - - self._folder_stack_anchor_x = int(anchor_x) - self._folder_stack_anchor_y = int(anchor_y) - self._folder_stack_icon_w = max(int(icon_w), 1) - self._folder_stack_position_value = position - self._folder_stack_item = item - self._track_folder_stack(target=item.target) - self._restart_folder_stack_animation() - self._position_folder_stack_window() - revealer.set_reveal_child(True) - window.show_all() + self._folder_stack.show( + item=item, + anchor_x=anchor_x, + anchor_y=anchor_y, + icon_w=icon_w, + position=position, + toggle_if_same_item=toggle_if_same_item, + ) def close_folder_stack(self) -> None: """Close the left-click folder stack if it is currently visible.""" - self._close_folder_stack() + self._folder_stack.close() def open_folder_stack_item_id(self) -> str | None: """Return the folder item id that currently owns the visible stack.""" - window = self._folder_stack_window - if ( - window is None - or not window.get_visible() - or self._folder_stack_item is None - ): - return None - return self._folder_stack_item.desktop_id - - def _drain_folder_stack_prewarm(self) -> bool: - item = self._folder_stack_cache.pop_next_prewarm() - if item is None: - self._folder_stack_cache.prewarm_source = 0 - return False - self._folder_stack_layout_for_item(item) - if not self._folder_stack_cache.prewarm_queue: - self._folder_stack_cache.prewarm_source = 0 - return False - return True + return self._folder_stack.open_item_id() def _invalidate_folder_target_cache(self, target: str) -> None: - uri = launcher_mod.normalize_file_target(target) - if uri is None: - return - self._folder_stack_cache.invalidate_target(uri=uri) + self._folder_stack.invalidate_target(target) def _new_popup_menu(self) -> Gtk.Menu: menu = Gtk.Menu() @@ -646,876 +363,10 @@ def _new_popup_menu(self) -> Gtk.Menu: menu.connect("deactivate", self._on_menu_popup_closed) return menu - def _on_menu_popup_closed(self, _menu: Gtk.Menu) -> None: - self._cleanup_folder_menu_tree(_menu) - self._runtime.menu_popup_closed() - - def _close_folder_stack(self) -> None: - window = self._folder_stack_window - if window is None or not window.get_visible(): - return - revealer = self._folder_stack_revealer - if revealer is not None: - revealer.set_reveal_child(False) - window.hide() - self._cleanup_folder_stack() + def _on_menu_popup_closed(self, menu: Gtk.Menu) -> None: + self._cleanup_folder_menu_tree(menu) self._runtime.menu_popup_closed() - def _cleanup_folder_stack(self) -> None: - if self._folder_stack_refresh_source: - GLib.source_remove(self._folder_stack_refresh_source) - self._folder_stack_refresh_source = 0 - if self._folder_stack_anim_source: - GLib.source_remove(self._folder_stack_anim_source) - self._folder_stack_anim_source = 0 - if self._folder_stack_monitor is not None: - self._folder_stack_monitor.cancel() - self._folder_stack_monitor = None - self._folder_stack_area = None - self._folder_stack_item = None - self._folder_stack_anchor_x = 0 - self._folder_stack_anchor_y = 0 - self._folder_stack_icon_w = 0 - self._folder_stack_fold_center_x = 0 - self._folder_stack_show_started_us = 0 - self._folder_stack_hover_target = None - self._folder_stack_hover_values.clear() - self._folder_stack_pressed_target = None - - def _ensure_folder_stack_window(self) -> Gtk.Window: - if self._folder_stack_window is not None: - return self._folder_stack_window - - window = Gtk.Window(type=Gtk.WindowType.POPUP) - window.set_decorated(False) - window.set_skip_taskbar_hint(True) - window.set_resizable(False) - window.set_type_hint(Gdk.WindowTypeHint.TOOLTIP) - window.set_app_paintable(True) - - screen = window.get_screen() - visual = screen.get_rgba_visual() - if visual: - window.set_visual(visual) - - revealer = Gtk.Revealer() - revealer.set_transition_type(self._folder_stack_transition_type()) - revealer.set_transition_duration(140) - revealer.set_reveal_child(False) - window.add(revealer) - - self._folder_stack_window = window - self._folder_stack_revealer = revealer - return window - - def _folder_stack_transition_type(self): - pos = self._config.pos - if pos == "bottom": - return Gtk.RevealerTransitionType.SLIDE_UP - if pos == "top": - return Gtk.RevealerTransitionType.SLIDE_DOWN - if pos == "left": - return Gtk.RevealerTransitionType.SLIDE_RIGHT - return Gtk.RevealerTransitionType.SLIDE_LEFT - - def _replace_folder_stack_content(self, item: DockItem) -> None: - revealer = self._folder_stack_revealer - if revealer is None: - return - child = revealer.get_child() - if child is not None: - revealer.remove(child) - content = self._build_folder_stack_content(item=item) - revealer.add(content) - content.show_all() - - def _position_folder_stack_window(self) -> None: - window = self._folder_stack_window - revealer = self._folder_stack_revealer - if window is None or revealer is None: - return - child = revealer.get_child() - if child is None: - return - - preferred = child.get_preferred_size()[1] - popup_w = max(int(preferred.width), 1) - popup_h = max(int(preferred.height), 1) - anchor_x = self._folder_stack_anchor_x - anchor_y = self._folder_stack_anchor_y - icon_w = max(self._folder_stack_icon_w, 1) - pos = self._folder_stack_position_value - local_icon_center_x = max(self._folder_stack_fold_center_x, 1) - - if pos == "bottom": - popup_x = int(anchor_x + icon_w / 2 - local_icon_center_x) - popup_y = int(anchor_y - popup_h - FOLDER_STACK_GAP_PX) - elif pos == "top": - popup_x = int(anchor_x + icon_w / 2 - local_icon_center_x) - popup_y = int(anchor_y + FOLDER_STACK_GAP_PX) - elif pos == "left": - popup_x = int(anchor_x + FOLDER_STACK_GAP_PX) - popup_y = int(anchor_y + icon_w / 2 - popup_h / 2) - else: - popup_x = int(anchor_x - popup_w - FOLDER_STACK_GAP_PX) - popup_y = int(anchor_y + icon_w / 2 - popup_h / 2) - - screen = window.get_screen() - screen_w = screen.get_width() - screen_h = screen.get_height() - popup_pos = clamp_to_screen( - popup_x, - popup_y, - popup_w, - popup_h, - screen_w, - screen_h, - ) - window.move(popup_pos.x, popup_pos.y) - - def _track_folder_stack(self, target: str) -> None: - uri = launcher_mod.normalize_file_target(target) - if uri is None: - return - try: - folder = Gio.File.new_for_uri(uri) - monitor = folder.monitor_directory(Gio.FileMonitorFlags.NONE, None) - monitor.connect("changed", self._on_folder_stack_changed) - self._folder_stack_monitor = monitor - except GLib.Error as exc: - log.warning("Failed to monitor folder stack target %s: %s", target, exc) - - def _on_folder_stack_changed( - self, - _monitor: Gio.FileMonitor, - _file: Gio.File, - _other_file: Gio.File | None, - _event_type: Gio.FileMonitorEvent, - ) -> None: - if self._folder_stack_item is not None: - self._invalidate_folder_target_cache(self._folder_stack_item.target) - if self._folder_stack_refresh_source: - GLib.source_remove(self._folder_stack_refresh_source) - self._folder_stack_refresh_source = GLib.timeout_add( - FOLDER_MENU_REFRESH_DEBOUNCE_MS, - self._refresh_folder_stack, - ) - - def _refresh_folder_stack(self) -> bool: - self._folder_stack_refresh_source = 0 - window = self._folder_stack_window - item = self._folder_stack_item - if window is None or item is None: - return False - self._replace_folder_stack_content(item=item) - self._restart_folder_stack_animation() - self._position_folder_stack_window() - window.show_all() - return False - - def _build_folder_stack_content(self, item: DockItem) -> Gtk.Widget: - cards, popup_w, popup_h = self._folder_stack_cards_for_item(item) - self._folder_stack_cards = cards - - area = Gtk.DrawingArea() - area.set_size_request(popup_w, popup_h) - area.add_events( - Gdk.EventMask.BUTTON_PRESS_MASK - | Gdk.EventMask.BUTTON_RELEASE_MASK - | Gdk.EventMask.POINTER_MOTION_MASK - | Gdk.EventMask.LEAVE_NOTIFY_MASK - ) - area.connect("draw", self._on_folder_stack_draw) - area.connect("button-press-event", self._on_folder_stack_button_press) - area.connect("button-release-event", self._on_folder_stack_button_release) - area.connect("motion-notify-event", self._on_folder_stack_motion_notify) - area.connect("leave-notify-event", self._on_folder_stack_leave_notify) - self._folder_stack_area = area - return area - - def _folder_stack_cards_for_item( - self, item: DockItem - ) -> tuple[list[FolderStackCard], int, int]: - layout = self._folder_stack_layout_for_item(item) - self._folder_stack_fold_center_x = layout.fold_center_x - return list(layout.cards), layout.popup_w, layout.popup_h - - def _folder_stack_layout_for_item(self, item: DockItem) -> FolderStackLayout: - prefs = self._folder_prefs(item) - icon_px = max(int(self._config.icon_size), 1) - app_name = ( - self._launcher.default_directory_app_name() - if self._launcher is not None - else None - ) - uri = launcher_mod.normalize_file_target(item.target) or item.target - state = self._folder_target_state(item.target) - if state == "missing": - return self._compute_folder_stack_layout( - item=item, - icon_px=icon_px, - app_name=app_name, - state=state, - ) - folder_stamp = self._folder_cache_stamp(item.target) - cache_key = ( - uri, - folder_stamp, - str(prefs["sort"]), - bool(prefs["show_hidden"]), - icon_px, - app_name, - ) - cached = self._folder_stack_cache.get_layout(cache_key) - if cached is not None: - return cached - - layout = self._compute_folder_stack_layout( - item=item, - icon_px=icon_px, - app_name=app_name, - state=state, - ) - self._folder_stack_cache.put_layout(cache_key, layout) - return layout - - def _compute_folder_stack_layout( - self, - *, - item: DockItem, - icon_px: int, - app_name: str | None, - state: str, - ) -> FolderStackLayout: - cards: list[FolderStackCard] = [] - label_h = FOLDER_STACK_LABEL_HEIGHT_PX - row_step = max(FOLDER_STACK_ROW_STEP_PX, round(icon_px * 1.08)) - curve_extent = max(FOLDER_STACK_CURVE_X_PX, round(icon_px * 0.65)) - right_bleed = max( - FOLDER_STACK_RIGHT_BLEED_PX, - round(curve_extent + icon_px * FOLDER_STACK_HOVER_SCALE * 0.35), - ) - fold_center_x = int( - FOLDER_STACK_POPUP_SIDE_PADDING_PX - + FOLDER_STACK_LABEL_MAX_WIDTH_PX - + FOLDER_STACK_ICON_GAP_PX - + icon_px / 2 - ) - - if state == "missing": - label_w = 190 - cards.append( - FolderStackCard( - label=_("Folder not found"), - target=None, - icon=None, - icon_x=0, - icon_y=0, - icon_size=0, - label_x=max( - FOLDER_STACK_POPUP_SIDE_PADDING_PX, - int(fold_center_x - label_w / 2), - ), - label_y=FOLDER_STACK_TOP_PADDING_PX, - label_w=label_w, - label_h=label_h, - centered=True, - ) - ) - popup_w = int( - max( - fold_center_x + label_w / 2 + FOLDER_STACK_POPUP_SIDE_PADDING_PX, - fold_center_x + icon_px / 2 + right_bleed, - ) - ) - popup_h = label_h + 2 * FOLDER_STACK_TOP_PADDING_PX - return FolderStackLayout( - cards=tuple(cards), - popup_w=popup_w, - popup_h=popup_h, - fold_center_x=fold_center_x, - ) - - rows = self._list_directory( - folder_item=item, - target=item.target, - icon_px=icon_px, - ) - if not rows: - label_w = 190 - cards.append( - FolderStackCard( - label=_("Folder is empty"), - target=None, - icon=None, - icon_x=0, - icon_y=0, - icon_size=0, - label_x=max( - FOLDER_STACK_POPUP_SIDE_PADDING_PX, - int(fold_center_x - label_w / 2), - ), - label_y=FOLDER_STACK_TOP_PADDING_PX, - label_w=label_w, - label_h=label_h, - centered=True, - ) - ) - popup_w = int( - max( - fold_center_x + label_w / 2 + FOLDER_STACK_POPUP_SIDE_PADDING_PX, - fold_center_x + icon_px / 2 + right_bleed, - ) - ) - popup_h = label_h + 2 * FOLDER_STACK_TOP_PADDING_PX - return FolderStackLayout( - cards=tuple(cards), - popup_w=popup_w, - popup_h=popup_h, - fold_center_x=fold_center_x, - ) - - visible_rows = rows[:FOLDER_STACK_MAX_VISIBLE_ROWS] - hidden_count = max(len(rows) - len(visible_rows), 0) - action_label = self._folder_stack_action_label( - hidden_count=hidden_count, - app_name=app_name, - ) - chip_w = self._folder_stack_action_width(label=action_label) - chip_h = label_h - total_rows = len(visible_rows) - top_progress = 1.0 if total_rows > 0 else 0.0 - total_span = (total_rows - 1) * row_step - top_center_x = round( - fold_center_x + _folder_stack_arc_offset(top_progress, total_span) - ) - chip_x = max( - FOLDER_STACK_POPUP_SIDE_PADDING_PX, - int(top_center_x - chip_w / 2 + curve_extent * 0.1), - ) - chip_y = FOLDER_STACK_TOP_PADDING_PX - cards.append( - FolderStackCard( - label=action_label, - target=item.target, - icon=None, - icon_x=0, - icon_y=0, - icon_size=0, - label_x=chip_x, - label_y=chip_y, - label_w=chip_w, - label_h=chip_h, - centered=True, - stack_progress=1.0, - arc_span=float(total_span), - ) - ) - - stack_top = chip_y + chip_h + FOLDER_STACK_ACTION_GAP_PX - max_right = chip_x + chip_w - bottom_center_y = ( - stack_top + (total_rows - 1) * row_step + icon_px / 2 if total_rows else 0 - ) - for index, child in enumerate(visible_rows): - raw_progress = ( - (total_rows - 1 - index) / max(total_rows - 1, 1) - if total_rows > 1 - else 1.0 - ) - arc_progress = raw_progress - icon_center_x = fold_center_x + _folder_stack_arc_offset( - arc_progress, - total_span, - ) - icon_center_y = bottom_center_y - total_span * raw_progress - icon_x = round(icon_center_x - icon_px / 2) - icon_y = round(icon_center_y - icon_px / 2) - label_w = self._folder_stack_label_width(label=str(child["name"])) - label_pull = round(arc_progress * 10) - label_x = max( - FOLDER_STACK_POPUP_SIDE_PADDING_PX, - icon_x - FOLDER_STACK_ICON_GAP_PX - label_w - label_pull, - ) - cards.append( - FolderStackCard( - label=str(child["name"]), - target=str(child["target"]), - icon=child["icon"], - icon_x=icon_x, - icon_y=icon_y, - icon_size=icon_px, - label_x=label_x, - label_y=icon_y + max(int((icon_px - label_h) / 2), 0), - label_w=label_w, - label_h=label_h, - centered=False, - stack_progress=arc_progress, - arc_span=float(total_span), - ) - ) - max_right = max(max_right, icon_x + icon_px) - - popup_w = int( - max( - max_right + right_bleed, - fold_center_x - + _folder_stack_arc_offset(1.0, total_span) - + icon_px / 2 - + right_bleed, - ) - ) - popup_h = ( - stack_top - + (total_rows - 1) * row_step - + icon_px - + FOLDER_STACK_TOP_PADDING_PX - ) - return FolderStackLayout( - cards=tuple(cards), - popup_w=popup_w, - popup_h=popup_h, - fold_center_x=fold_center_x, - ) - - def _folder_cache_stamp(self, target: str) -> int: - uri = launcher_mod.normalize_file_target(target) - if uri is None: - return 0 - try: - folder = Gio.File.new_for_uri(uri) - info = folder.query_info( - "time::modified", - Gio.FileQueryInfoFlags.NONE, - None, - ) - except Exception: - return 0 - return int(info.get_attribute_uint64("time::modified")) - - def _on_folder_stack_draw(self, widget: Gtk.DrawingArea, cr: cairo.Context) -> bool: - cr.set_operator(cairo.OPERATOR_CLEAR) - cr.paint() - cr.set_operator(cairo.OPERATOR_OVER) - now_us = GLib.get_monotonic_time() - total_cards = len(self._folder_stack_cards) - for draw_index, card in enumerate(self._folder_stack_cards): - self._draw_folder_stack_card( - cr=cr, - card=card, - sequence_index=total_cards - 1 - draw_index, - now_us=now_us, - ) - return False - - def _folder_stack_card_geometry( - self, - *, - card: FolderStackCard, - sequence_index: int, - now_us: int, - ) -> FolderStackCardGeometry | None: - reveal = self._folder_stack_reveal_progress( - sequence_index=sequence_index, - now_us=now_us, - ) - if reveal <= 0: - return None - - hover_value = ( - self._folder_stack_hover_values.get(card.target, 0.0) - if card.target is not None and not card.centered - else 0.0 - ) - y_offset = (1.0 - reveal) * 18.0 - rotation_radians = ( - _folder_stack_rotation( - card.stack_progress, - self._folder_stack_position_value, - card.arc_span, - ) - * reveal - ) - open_label_center_x = card.label_x + card.label_w / 2 - label_center_x = ( - self._folder_stack_fold_center_x - + (open_label_center_x - self._folder_stack_fold_center_x) * reveal - ) - label_x = label_center_x - card.label_w / 2 - label_y = card.label_y + y_offset - - icon_size = 0.0 - icon_x = 0.0 - icon_y = 0.0 - icon_center_x = 0.0 - icon_center_y = 0.0 - if card.icon is not None and card.icon_size > 0: - icon_size = max( - ( - card.icon_size - * (0.82 + 0.18 * reveal) - * (1.0 + hover_value * (FOLDER_STACK_HOVER_SCALE - 1.0)) - ), - 1.0, - ) - open_icon_center_x = card.icon_x + card.icon_size / 2 - icon_center_x = ( - self._folder_stack_fold_center_x - + (open_icon_center_x - self._folder_stack_fold_center_x) * reveal - ) - icon_center_y = ( - card.icon_y + card.icon_size / 2 + y_offset - hover_value * 4.0 - ) - icon_x = icon_center_x - icon_size / 2 - icon_y = icon_center_y - icon_size / 2 - - return FolderStackCardGeometry( - reveal=reveal, - hover_value=hover_value, - rotation_radians=rotation_radians, - icon_x=icon_x, - icon_y=icon_y, - icon_size=icon_size, - icon_center_x=icon_center_x, - icon_center_y=icon_center_y, - label_x=label_x, - label_y=label_y, - ) - - def _draw_folder_stack_card( - self, - *, - cr: cairo.Context, - card: FolderStackCard, - sequence_index: int, - now_us: int, - ) -> None: - geometry = self._folder_stack_card_geometry( - card=card, - sequence_index=sequence_index, - now_us=now_us, - ) - if geometry is None: - return - is_action_card = _is_folder_stack_action_card(card) - - if card.icon is not None and card.icon_size > 0: - pixbuf = card.icon - draw_icon_size = max(round(geometry.icon_size), 1) - if ( - pixbuf.get_width() != draw_icon_size - or pixbuf.get_height() != draw_icon_size - ): - scaled = pixbuf.scale_simple( - draw_icon_size, - draw_icon_size, - GdkPixbuf.InterpType.BILINEAR, - ) - if scaled is not None: - pixbuf = scaled - - cr.save() - cr.translate(geometry.icon_center_x + 2, geometry.icon_center_y + 2) - cr.rotate(geometry.rotation_radians) - Gdk.cairo_set_source_pixbuf( - cr, - pixbuf, - -draw_icon_size / 2, - -draw_icon_size / 2, - ) - cr.paint_with_alpha(0.16 * geometry.reveal) - cr.restore() - - cr.save() - cr.translate(geometry.icon_center_x, geometry.icon_center_y) - cr.rotate(geometry.rotation_radians) - Gdk.cairo_set_source_pixbuf( - cr, - pixbuf, - -draw_icon_size / 2, - -draw_icon_size / 2, - ) - cr.paint_with_alpha(0.55 + 0.45 * geometry.reveal) - cr.restore() - - radius = FOLDER_STACK_LABEL_RADIUS_PX - label_center_x = geometry.label_x + card.label_w / 2 - label_center_y = geometry.label_y + card.label_h / 2 - cr.save() - cr.translate(label_center_x, label_center_y + 1) - cr.rotate(geometry.rotation_radians * 0.85) - rounded_rect( - cr, - -card.label_w / 2, - -card.label_h / 2, - card.label_w, - card.label_h, - radius, - ) - cr.set_source_rgba(0, 0, 0, 0.08 * geometry.reveal) - cr.fill() - cr.restore() - - cr.save() - cr.translate(label_center_x, label_center_y) - cr.rotate(geometry.rotation_radians * 0.85) - rounded_rect( - cr, - -card.label_w / 2, - -card.label_h / 2, - card.label_w, - card.label_h, - radius, - ) - cr.set_source_rgba(0.98, 0.98, 0.98, 0.95) - cr.fill_preserve() - cr.set_source_rgba(0, 0, 0, 0.08) - cr.set_line_width(1.0) - cr.stroke() - cr.restore() - - cr.save() - layout = PangoCairo.create_layout(cr) - layout.set_text(card.label, -1) - desc = Pango.FontDescription() - desc.set_family("Sans") - desc.set_size(10 * Pango.SCALE) - layout.set_font_description(desc) - layout.set_ellipsize(Pango.EllipsizeMode.END) - arrow_reserve = ( - FOLDER_STACK_ACTION_ARROW_GAP_PX + FOLDER_STACK_ACTION_ARROW_SIZE_PX - if is_action_card - else 0 - ) - available_text_w = max( - int(card.label_w - 2 * FOLDER_STACK_LABEL_TEXT_MARGIN_PX - arrow_reserve), - 1, - ) - layout.set_width(available_text_w * Pango.SCALE) - layout.set_alignment(Pango.Alignment.CENTER) - _, logical = layout.get_pixel_extents() - text_y = int(-card.label_h / 2 + (card.label_h - logical.height) / 2) - text_x = -card.label_w / 2 + FOLDER_STACK_LABEL_TEXT_MARGIN_PX - cr.set_source_rgba(0.16, 0.2, 0.26, 1.0) - cr.translate(label_center_x, label_center_y) - cr.rotate(geometry.rotation_radians * 0.85) - cr.move_to(text_x, text_y) - PangoCairo.show_layout(cr, layout) - if is_action_card: - arrow_center_x = ( - card.label_w / 2 - - FOLDER_STACK_LABEL_TEXT_MARGIN_PX - - FOLDER_STACK_ACTION_ARROW_SIZE_PX / 2 - ) - arrow_center_y = 0.0 - half = FOLDER_STACK_ACTION_ARROW_SIZE_PX / 2 - cr.set_line_width(1.4) - cr.set_line_cap(cairo.LineCap.ROUND) - cr.set_line_join(cairo.LineJoin.ROUND) - cr.move_to(arrow_center_x - half, arrow_center_y - half) - cr.line_to(arrow_center_x, arrow_center_y) - cr.line_to(arrow_center_x - half, arrow_center_y + half) - cr.stroke() - cr.restore() - - def _folder_stack_card_at(self, x: float, y: float) -> FolderStackCard | None: - now_us = GLib.get_monotonic_time() - total_cards = len(self._folder_stack_cards) - for index in range(total_cards - 1, -1, -1): - card = self._folder_stack_cards[index] - geometry = self._folder_stack_card_geometry( - card=card, - sequence_index=total_cards - 1 - index, - now_us=now_us, - ) - if geometry is None: - continue - within_label = ( - geometry.label_x <= x <= geometry.label_x + card.label_w - and geometry.label_y <= y <= geometry.label_y + card.label_h - ) - within_icon = ( - geometry.icon_size > 0 - and geometry.icon_x <= x <= geometry.icon_x + geometry.icon_size - and geometry.icon_y <= y <= geometry.icon_y + geometry.icon_size - ) - if within_label or within_icon: - return card - return None - - def _on_folder_stack_button_press( - self, _widget: Gtk.DrawingArea, event: Gdk.EventButton - ) -> bool: - if int(event.button) != 1: - self._folder_stack_pressed_target = None - return False - card = self._folder_stack_card_at(event.x, event.y) - self._folder_stack_pressed_target = ( - card.target if card is not None and card.target is not None else None - ) - return self._folder_stack_pressed_target is not None - - def _on_folder_stack_button_release( - self, _widget: Gtk.DrawingArea, event: Gdk.EventButton - ) -> bool: - if int(event.button) != 1: - self._folder_stack_pressed_target = None - return False - card = self._folder_stack_card_at(event.x, event.y) - target = card.target if card is not None and card.target is not None else None - pressed_target = self._folder_stack_pressed_target - self._folder_stack_pressed_target = None - if target is not None and (pressed_target is None or pressed_target == target): - self._open_folder_stack_target(target) - return True - return False - - def _on_folder_stack_motion_notify( - self, _widget: Gtk.DrawingArea, event: Gdk.EventMotion - ) -> bool: - card = self._folder_stack_card_at(event.x, event.y) - target = ( - card.target - if card is not None and card.target is not None and not card.centered - else None - ) - if target != self._folder_stack_hover_target: - self._folder_stack_hover_target = target - self._ensure_folder_stack_animating() - return False - - def _on_folder_stack_leave_notify( - self, _widget: Gtk.DrawingArea, _event: Gdk.EventCrossing - ) -> bool: - if self._folder_stack_hover_target is not None: - self._folder_stack_hover_target = None - self._ensure_folder_stack_animating() - self._folder_stack_pressed_target = None - return False - - def _folder_stack_action_label( - self, *, hidden_count: int, app_name: str | None = None - ) -> str: - if app_name is None and self._launcher is not None: - app_name = self._launcher.default_directory_app_name() - if app_name: - return ( - _("Open in %s") % app_name - if hidden_count == 0 - else _("%d More in %s") % (hidden_count, app_name) - ) - return ( - _("Open Folder") - if hidden_count == 0 - else _("%d More in Folder") % hidden_count - ) - - def _folder_stack_action_width(self, *, label: str) -> int: - return min( - FOLDER_STACK_ACTION_MAX_WIDTH_PX, - _measure_stack_text_px(label) - + 2 * FOLDER_STACK_LABEL_TEXT_MARGIN_PX - + FOLDER_STACK_ACTION_ARROW_GAP_PX - + FOLDER_STACK_ACTION_ARROW_SIZE_PX - + 10, - ) - - def _folder_stack_label_width(self, *, label: str) -> int: - return min( - FOLDER_STACK_LABEL_MAX_WIDTH_PX, - max( - 24, - _measure_stack_text_px(label) - + 2 * FOLDER_STACK_LABEL_TEXT_MARGIN_PX - + 10, - ), - ) - - def _restart_folder_stack_animation(self) -> None: - self._folder_stack_show_started_us = GLib.get_monotonic_time() - self._folder_stack_hover_target = None - self._folder_stack_hover_values.clear() - self._ensure_folder_stack_animating() - - def _ensure_folder_stack_animating(self) -> None: - area = self._folder_stack_area - if area is None: - return - area.queue_draw() - if self._folder_stack_anim_source == 0: - self._folder_stack_anim_source = GLib.timeout_add( - FOLDER_STACK_ANIM_FRAME_MS, - self._on_folder_stack_animation_frame, - ) - - def _on_folder_stack_animation_frame(self) -> bool: - area = self._folder_stack_area - window = self._folder_stack_window - if area is None or window is None or not window.get_visible(): - self._folder_stack_anim_source = 0 - return False - - active = False - now_us = GLib.get_monotonic_time() - elapsed_ms = max((now_us - self._folder_stack_show_started_us) / 1000.0, 0.0) - reveal_budget_ms = ( - FOLDER_STACK_REVEAL_DURATION_MS - + max( - len(self._folder_stack_cards) - 1, - 0, - ) - * FOLDER_STACK_REVEAL_STAGGER_MS - ) - if elapsed_ms < reveal_budget_ms: - active = True - - for card in self._folder_stack_cards: - if card.target is None or card.centered: - continue - current = self._folder_stack_hover_values.get(card.target, 0.0) - target = 1.0 if self._folder_stack_hover_target == card.target else 0.0 - updated = current + (target - current) * FOLDER_STACK_HOVER_EASE - if abs(updated - target) < 0.02: - updated = target - if updated <= 0.0 and target == 0.0: - self._folder_stack_hover_values.pop(card.target, None) - else: - self._folder_stack_hover_values[card.target] = updated - if updated != target: - active = True - - area.queue_draw() - if not active: - self._folder_stack_anim_source = 0 - return False - return True - - def _folder_stack_reveal_progress( - self, *, sequence_index: int, now_us: int - ) -> float: - if self._folder_stack_show_started_us <= 0: - return 1.0 - elapsed_ms = max((now_us - self._folder_stack_show_started_us) / 1000.0, 0.0) - elapsed_ms -= sequence_index * FOLDER_STACK_REVEAL_STAGGER_MS - if elapsed_ms <= 0: - return 0.0 - return _ease_out_cubic(elapsed_ms / FOLDER_STACK_REVEAL_DURATION_MS) - - def _open_folder_stack_target(self, target: str) -> None: - launcher_mod.open_target(target) - self._close_folder_stack() - - def _folder_target_state(self, target: str) -> str: - uri = launcher_mod.normalize_file_target(target) - if uri is None: - return "missing" - try: - folder = Gio.File.new_for_uri(uri) - return "ok" if folder.query_exists(None) else "missing" - except Exception as exc: - log.debug("Failed to query folder target %s: %s", target, exc) - return "missing" - def _build_item_menu(self, menu: Gtk.Menu, item: DockItem) -> None: """Build context menu for a specific dock item. @@ -1599,11 +450,11 @@ def _build_folder_item_menu(self, menu: Gtk.Menu, item: DockItem) -> None: ) self._populate_directory_menu(menu=menu, folder_item=item, target=item.target) menu.append(Gtk.SeparatorMenuItem()) - prefs = self._folder_prefs(item) + prefs = self._folder_stack.folder_prefs(item) menu.append( _build_radio_submenu( label=_("Sort By"), - items=FOLDER_SORT_OPTIONS, + items=self._folder_stack.sort_options(), current=prefs["sort"], on_changed=lambda widget, value, folder=item: ( self._on_folder_sort_changed(widget, folder, value) @@ -1615,11 +466,6 @@ def _build_folder_item_menu(self, menu: Gtk.Menu, item: DockItem) -> None: hidden.connect("toggled", self._on_folder_hidden_toggled, item) menu.append(hidden) - large = Gtk.CheckMenuItem(label=_("Large Icons")) - large.set_active(bool(prefs["large_icons"])) - large.connect("toggled", self._on_folder_large_icons_toggled, item) - menu.append(large) - if not self._config.lock_icons: menu.append(Gtk.SeparatorMenuItem()) remove = Gtk.MenuItem(label=_("Remove from Dock")) @@ -1851,24 +697,10 @@ def _remove_window_row( def _on_add_applet_activate(self, _widget: Gtk.MenuItem, applet_id: str) -> None: self._model.add_applet(applet_id) - def _folder_prefs(self, item: DockItem) -> dict[str, Any]: - item_prefs = self._config.item_prefs - stored = dict(item_prefs.get(item.prefs_key or item.target, {})) - return { - "sort": stored.get("sort", "name"), - "show_hidden": bool(stored.get("show_hidden", False)), - "large_icons": bool(stored.get("large_icons", False)), - } - - def _save_folder_prefs(self, item: DockItem, prefs: dict[str, Any]) -> None: - self._config.item_prefs[item.prefs_key or item.target] = prefs - self._config.save() - self._runtime.queue_draw() - def _populate_directory_menu( self, menu: Gtk.Menu, folder_item: DockItem, target: str ) -> None: - rows = self._list_directory(folder_item=folder_item, target=target) + rows = self._folder_stack.list_directory(folder_item=folder_item, target=target) for child in rows: self._append_directory_row(menu=menu, folder_item=folder_item, child=child) @@ -1880,7 +712,7 @@ def _append_directory_row( item=row, label=child["name"], pixbuf=child["icon"], - icon_px=self._folder_icon_px(folder_item=folder_item), + icon_px=self._folder_stack.icon_px(folder_item=folder_item), ) if child["is_dir"]: if not child.get("has_children", False): @@ -2010,156 +842,8 @@ def _cleanup_folder_menu(self, menu: Gtk.Menu) -> None: self._folder_menu_context.pop(menu_id, None) self._folder_menu_signal_connected.discard(menu_id) - def _list_directory( - self, - folder_item: DockItem, - target: str, - icon_px: int | None = None, - ) -> list[dict[str, Any]]: - uri = launcher_mod.normalize_file_target(target) - if uri is None: - return [] - prefs = self._folder_prefs(folder_item) - resolved_icon_px = ( - self._folder_icon_px(folder_item=folder_item) - if icon_px is None - else max(int(icon_px), 1) - ) - cache_key = ( - uri, - self._folder_cache_stamp(target), - bool(prefs["show_hidden"]), - resolved_icon_px, - ) - cached = self._folder_stack_cache.get_directory_rows(cache_key) - if cached is not None: - rows = [dict(row) for row in cached] - rows.sort( - key=lambda row: self._folder_sort_key( - row=row, - mode=prefs["sort"], - ) - ) - return rows - try: - folder = Gio.File.new_for_uri(uri) - enumerator = folder.enumerate_children( - ",".join( - ( - "standard::name", - "standard::display-name", - "standard::icon", - "standard::type", - "standard::content-type", - "standard::is-hidden", - "standard::size", - "time::created", - "time::modified", - ) - ), - Gio.FileQueryInfoFlags.NONE, - None, - ) - except Exception as exc: - log.warning("Failed to enumerate folder menu target %s: %s", target, exc) - return [] - rows: list[dict[str, Any]] = [] - while True: - info = enumerator.next_file(None) - if info is None: - break - if info.get_is_hidden() and not prefs["show_hidden"]: - continue - child = folder.get_child(info.get_name()) - child_uri = child.get_uri() - icon = info.get_icon() - is_dir = info.get_file_type() == Gio.FileType.DIRECTORY - rows.append( - { - "target": child_uri, - "name": info.get_display_name() or info.get_name(), - "kind": "dir" if is_dir else "file", - "is_dir": is_dir, - "has_children": ( - self._directory_has_visible_children( - target=child_uri, - show_hidden=bool(prefs["show_hidden"]), - ) - if is_dir - else False - ), - "size": int(info.get_size()), - "created": int(info.get_attribute_uint64("time::created")), - "modified": int(info.get_attribute_uint64("time::modified")), - "icon": ( - self._launcher.resolve_file_icon( - target=child_uri, - gicon=icon, - content_type=info.get_content_type() or "", - size=resolved_icon_px, - is_dir=is_dir, - ) - ) - if self._launcher - else None, - } - ) - self._folder_stack_cache.put_directory_rows( - cache_key, - [dict(row) for row in rows], - ) - rows.sort(key=lambda row: self._folder_sort_key(row=row, mode=prefs["sort"])) - return rows - - def _directory_has_visible_children(self, target: str, show_hidden: bool) -> bool: - uri = launcher_mod.normalize_file_target(target) - if uri is None: - return False - try: - folder = Gio.File.new_for_uri(uri) - enumerator = folder.enumerate_children( - "standard::is-hidden", - Gio.FileQueryInfoFlags.NONE, - None, - ) - except Exception as exc: - log.warning( - "Failed to inspect folder children for target %s: %s", - target, - exc, - ) - return False - - while True: - info = enumerator.next_file(None) - if info is None: - return False - if show_hidden or not info.get_is_hidden(): - return True - - def _folder_sort_key(self, row: dict[str, Any], mode: str) -> tuple[Any, ...]: - if mode == "kind": - return (row["kind"], row["name"].casefold()) - if mode == "size": - return (row["size"], row["name"].casefold()) - if mode == "created": - return (row["created"], row["name"].casefold()) - if mode == "modified": - return (row["modified"], row["name"].casefold()) - return (row["name"].casefold(),) - - def _folder_icon_px(self, folder_item: DockItem) -> int: - prefs = self._folder_prefs(folder_item) - if prefs["large_icons"]: - return FOLDER_LARGE_ICON_PX - return FOLDER_SMALL_ICON_PX - def _update_folder_pref(self, item: DockItem, key: str, value: Any) -> None: - prefs = self._folder_prefs(item) - prefs[key] = value - self._save_folder_prefs(item, prefs) - self._invalidate_folder_target_cache(item.target) - self.schedule_folder_stack_prewarm(item) + self._folder_stack.update_folder_pref(item, key, value) def _on_folder_sort_changed( self, widget: Gtk.MenuItem, item: DockItem, value: str @@ -2171,8 +855,3 @@ def _on_folder_hidden_toggled( self, widget: Gtk.CheckMenuItem, item: DockItem ) -> None: self._update_folder_pref(item, "show_hidden", widget.get_active()) - - def _on_folder_large_icons_toggled( - self, widget: Gtk.CheckMenuItem, item: DockItem - ) -> None: - self._update_folder_pref(item, "large_icons", widget.get_active()) diff --git a/tests/core/test_config.py b/tests/core/test_config.py index 1383dda5..9bf847fb 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -37,7 +37,7 @@ def test_defaults(self): assert c.update_check_interval_hours == 24 assert c.left_click_action == "toggle" assert c.middle_click_action == "new-window" - assert c.folder_stack_unfold == "click" + assert c.folder_stack_unfold == "hover" assert c.theme == "default" assert c.transparency == 1.0 assert isinstance(c.pinned, list) @@ -296,7 +296,7 @@ def test_load_invalid_click_actions_fall_back_to_defaults(self, tmp_path): assert config.left_click_action == "toggle" assert config.middle_click_action == "new-window" - assert config.folder_stack_unfold == "click" + assert config.folder_stack_unfold == "hover" def test_load_ignores_legacy_autohide_key(self, tmp_path): path = tmp_path / "dock.json" diff --git a/tests/ui/test_dock_window_integration.py b/tests/ui/test_dock_window_integration.py index 1ca8ee9f..c55242f6 100644 --- a/tests/ui/test_dock_window_integration.py +++ b/tests/ui/test_dock_window_integration.py @@ -400,6 +400,24 @@ def test_hover_folder_item_opens_folder_stack_when_enabled(self): assert kwargs["item"] is item assert kwargs["toggle_if_same_item"] is False + def test_hover_folder_item_waits_for_autohide_visible_state(self): + item = DockItem( + desktop_id="file:///tmp/docs", + kind=FOLDER_KIND, + target="file:///tmp/docs", + ) + stub, _ = _make_stub(item=item) + stub.config.folder_stack_unfold = "hover" + stub.autohide = _autohide(enabled=True, state=HideState.SHOWING) + widget = MagicMock() + event = SimpleNamespace(x=12.0, y=9.0) + + handled = dock_window_mod.DockWindow._on_motion(stub, widget, event) + + assert handled is False + stub._menu.schedule_folder_stack_prewarm.assert_called_once_with(item) + stub._menu.show_folder_stack.assert_not_called() + def test_motion_prewarms_hovered_folder_item_in_click_mode(self): item = DockItem( desktop_id="file:///tmp/docs", @@ -1314,6 +1332,65 @@ def test_on_draw_refreshes_tooltip_once_when_showing_finishes(self): stub.hover.update.assert_called_once_with(25.0, frame=frame) assert stub._last_autohide_state == HideState.VISIBLE + def test_on_draw_opens_deferred_hover_folder_stack_when_showing_finishes(self): + hovered = DockItem( + desktop_id="file:///tmp/docs", + kind=FOLDER_KIND, + target="file:///tmp/docs", + ) + item_geometry = SimpleNamespace( + draw_rect=SimpleNamespace(x=4, y=5, w=48, h=48), + anchor_point=lambda *, win_x, win_y, position: (win_x + 4, win_y + 5), + ) + frame = SimpleNamespace( + cursor_rect=Rect(0, 0, 100, 100), + item_geometries=(), + geometry_for_item=MagicMock(return_value=item_geometry), + ) + stub = _bind_geometry_signature( + SimpleNamespace( + autohide=_autohide(enabled=True, state=HideState.VISIBLE), + _last_autohide_state=HideState.SHOWING, + dock_hovered=True, + dnd=SimpleNamespace( + drag_index=-1, drop_insert_index=-1, drop_target_id="" + ), + hover=SimpleNamespace(hovered_item=hovered, update=MagicMock()), + renderer=SimpleNamespace( + draw=MagicMock(), + has_active_urgent_glow=lambda **_kwargs: False, + ), + model=SimpleNamespace(tick_animations=MagicMock(return_value=False)), + config=SimpleNamespace( + pos=Position.BOTTOM, + folder_stack_unfold="hover", + icon_size=48, + ), + theme=MagicMock(), + tooltip=MagicMock(), + _menu=MagicMock(), + _test_geometry_frame=frame, + update_input_region=MagicMock(), + cursor_x=25.0, + cursor_y=33.0, + get_position=MagicMock(return_value=(100, 200)), + get_size=MagicMock(return_value=(1920, 122)), + _sync_background_blur_hint=MagicMock(), + zoom_animator=SimpleNamespace(progress=1.0), + geometry=SimpleNamespace(build_frame=lambda **_kwargs: frame), + _cache=_window_cache(), + ) + ) + + dock_window_mod.DockWindow._on_draw(stub, MagicMock(), MagicMock()) + + stub._menu.show_folder_stack.assert_called_once() + kwargs = stub._menu.show_folder_stack.call_args.kwargs + assert kwargs["item"] is hovered + assert kwargs["anchor_x"] == 104 + assert kwargs["anchor_y"] == 205 + assert kwargs["toggle_if_same_item"] is False + def test_on_motion_updates_cursor_and_hover(self, monkeypatch): # Given widget = MagicMock() diff --git a/tests/ui/test_menu_integration.py b/tests/ui/test_menu_integration.py index e32e01bb..cd47754f 100644 --- a/tests/ui/test_menu_integration.py +++ b/tests/ui/test_menu_integration.py @@ -17,6 +17,7 @@ sys.modules.setdefault("gi", gi_mock) sys.modules.setdefault("gi.repository", gi_mock.repository) +import docking.ui.folder.stack as folder_stack_mod import docking.ui.menu as menu_mod from docking.core.items import FILE_KIND, FOLDER_KIND from docking.platform.model import DockItem @@ -707,7 +708,9 @@ def _frame(*, item=None, item_index: int = -1, insert_index: int = 0): def handler(monkeypatch): monkeypatch.setattr(menu_mod, "Gtk", FakeGtk) monkeypatch.setattr(menu_mod, "Pango", FakePango) - monkeypatch.setattr(menu_mod, "PangoCairo", FakePangoCairo) + monkeypatch.setattr(folder_stack_mod, "Gtk", FakeGtk) + monkeypatch.setattr(folder_stack_mod, "Pango", FakePango) + monkeypatch.setattr(folder_stack_mod, "PangoCairo", FakePangoCairo) monkeypatch.setattr(menu_mod, "load_catalog_icon", lambda applet_id, size: None) about = MagicMock() settings = MagicMock() @@ -833,7 +836,7 @@ def test_folder_item_menu_exposes_view_options(self, handler, monkeypatch): labels = _labels(menu) assert "Sort By" in labels assert "Show Hidden Files" in labels - assert "Large Icons" in labels + assert "Large Icons" not in labels def test_folder_item_menu_keeps_all_entries_and_actions_in_one_menu( self, handler, monkeypatch @@ -847,8 +850,8 @@ def test_folder_item_menu_keeps_all_entries_and_actions_in_one_menu( is_pinned=True, ) monkeypatch.setattr( - handler, - "_list_directory", + handler._folder_stack, + "list_directory", lambda **kwargs: [ { "target": f"file:///tmp/docs/{i}", @@ -866,7 +869,7 @@ def test_folder_item_menu_keeps_all_entries_and_actions_in_one_menu( assert "Item 22" in labels assert "Sort By" in labels assert "Show Hidden Files" in labels - assert "Large Icons" in labels + assert "Large Icons" not in labels assert not any(label.startswith("More (") for label in labels) def test_directory_rows_ellipsize_long_names(self, handler, monkeypatch): @@ -879,8 +882,8 @@ def test_directory_rows_ellipsize_long_names(self, handler, monkeypatch): is_pinned=True, ) monkeypatch.setattr( - handler, - "_list_directory", + handler._folder_stack, + "list_directory", lambda **kwargs: [ { "target": "file:///tmp/docs/very-long-name", @@ -952,7 +955,9 @@ def test_directory_rows_prefer_system_gicon(self, handler, monkeypatch): folder.get_child.return_value = child monkeypatch.setattr(menu_mod.Gio.File, "new_for_uri", lambda _uri: folder) monkeypatch.setattr( - handler, "_directory_has_visible_children", lambda **_kwargs: False + handler._folder_stack._browser, + "directory_has_visible_children", + lambda **_kwargs: False, ) handler._launcher.resolve_file_icon.return_value = "folder-pixbuf" item = DockItem( @@ -962,7 +967,9 @@ def test_directory_rows_prefer_system_gicon(self, handler, monkeypatch): prefs_key="file:///tmp/root", ) - rows = handler._list_directory(folder_item=item, target="file:///tmp/root") + rows = handler._folder_stack.list_directory( + folder_item=item, target="file:///tmp/root" + ) assert rows[0]["icon"] == "folder-pixbuf" handler._launcher.resolve_file_icon.assert_called_once_with( @@ -995,7 +1002,9 @@ def test_list_directory_reuses_cached_rows_for_same_folder( folder.get_child.return_value = child monkeypatch.setattr(menu_mod.Gio.File, "new_for_uri", lambda _uri: folder) monkeypatch.setattr( - handler, "_directory_has_visible_children", lambda **_kwargs: False + handler._folder_stack._browser, + "directory_has_visible_children", + lambda **_kwargs: False, ) handler._launcher.resolve_file_icon.return_value = "folder-pixbuf" item = DockItem( @@ -1005,8 +1014,12 @@ def test_list_directory_reuses_cached_rows_for_same_folder( prefs_key="file:///tmp/root", ) - first = handler._list_directory(folder_item=item, target="file:///tmp/root") - second = handler._list_directory(folder_item=item, target="file:///tmp/root") + first = handler._folder_stack.list_directory( + folder_item=item, target="file:///tmp/root" + ) + second = handler._folder_stack.list_directory( + folder_item=item, target="file:///tmp/root" + ) assert first == second folder.enumerate_children.assert_called_once() @@ -1297,11 +1310,11 @@ def test_folder_pref_callbacks_persist(self, handler): toggle.set_active(True) handler._on_folder_hidden_toggled(toggle, item) - handler._on_folder_large_icons_toggled(toggle, item) + handler._folder_stack.update_folder_pref(item, "unsupported", True) assert handler._config.item_prefs["file:///tmp/docs"]["show_hidden"] is True - assert handler._config.item_prefs["file:///tmp/docs"]["large_icons"] is True - assert handler._config.save.call_count >= 2 + assert "unsupported" not in handler._config.item_prefs["file:///tmp/docs"] + assert handler._config.save.call_count >= 1 def test_folder_menu_change_debounces_refresh(self, handler, monkeypatch): menu = FakeMenu() @@ -1360,10 +1373,12 @@ def test_show_folder_stack_builds_popup_window(self, handler, monkeypatch): target="file:///tmp/docs", ) monkeypatch.setattr(menu_mod.GLib, "timeout_add", lambda *_args: 1) - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "ok") monkeypatch.setattr( - handler, - "_list_directory", + handler._folder_stack._browser, "target_state", lambda _target: "ok" + ) + monkeypatch.setattr( + handler._folder_stack, + "_list_directory_rows", lambda **_kwargs: [ { "target": "file:///tmp/docs/readme.txt", @@ -1375,7 +1390,9 @@ def test_show_folder_stack_builds_popup_window(self, handler, monkeypatch): ) tracked: list[str] = [] monkeypatch.setattr( - handler, "_track_folder_stack", lambda target: tracked.append(target) + handler._folder_stack, + "_track_folder_stack", + lambda target: tracked.append(target), ) handler.show_folder_stack( @@ -1401,9 +1418,15 @@ def test_show_folder_stack_second_click_toggles_closed(self, handler, monkeypatc target="file:///tmp/docs", ) monkeypatch.setattr(menu_mod.GLib, "timeout_add", lambda *_args: 1) - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "ok") - monkeypatch.setattr(handler, "_list_directory", lambda **_kwargs: []) - monkeypatch.setattr(handler, "_track_folder_stack", lambda target: None) + monkeypatch.setattr( + handler._folder_stack._browser, "target_state", lambda _target: "ok" + ) + monkeypatch.setattr( + handler._folder_stack, "_list_directory_rows", lambda **_kwargs: [] + ) + monkeypatch.setattr( + handler._folder_stack, "_track_folder_stack", lambda target: None + ) handler.show_folder_stack( item=item, @@ -1433,9 +1456,15 @@ def test_show_folder_stack_same_item_can_stay_open(self, handler, monkeypatch): target="file:///tmp/docs", ) monkeypatch.setattr(menu_mod.GLib, "timeout_add", lambda *_args: 1) - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "ok") - monkeypatch.setattr(handler, "_list_directory", lambda **_kwargs: []) - monkeypatch.setattr(handler, "_track_folder_stack", lambda target: None) + monkeypatch.setattr( + handler._folder_stack._browser, "target_state", lambda _target: "ok" + ) + monkeypatch.setattr( + handler._folder_stack, "_list_directory_rows", lambda **_kwargs: [] + ) + monkeypatch.setattr( + handler._folder_stack, "_track_folder_stack", lambda target: None + ) handler.show_folder_stack( item=item, @@ -1468,10 +1497,12 @@ def test_folder_stack_cards_reuse_cached_layout(self, handler, monkeypatch): target="file:///tmp/docs", ) calls: list[str] = [] - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "ok") monkeypatch.setattr( - handler, - "_list_directory", + handler._folder_stack._browser, "target_state", lambda _target: "ok" + ) + monkeypatch.setattr( + handler._folder_stack, + "_list_directory_rows", lambda **_kwargs: ( calls.append("listed") or [ @@ -1485,8 +1516,8 @@ def test_folder_stack_cards_reuse_cached_layout(self, handler, monkeypatch): ), ) - first = handler._folder_stack_cards_for_item(item) - second = handler._folder_stack_cards_for_item(item) + first = handler._folder_stack._folder_stack_cards_for_item(item) + second = handler._folder_stack._folder_stack_cards_for_item(item) assert first == second assert calls == ["listed"] @@ -1500,10 +1531,12 @@ def test_folder_stack_requests_dock_sized_icons(self, handler, monkeypatch): ) requested_icon_sizes: list[int] = [] - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "ok") monkeypatch.setattr( - handler, - "_list_directory", + handler._folder_stack._browser, "target_state", lambda _target: "ok" + ) + monkeypatch.setattr( + handler._folder_stack, + "_list_directory_rows", lambda **kwargs: ( requested_icon_sizes.append(kwargs["icon_px"]) or [ @@ -1517,11 +1550,13 @@ def test_folder_stack_requests_dock_sized_icons(self, handler, monkeypatch): ), ) - cards, _popup_w, _popup_h = handler._folder_stack_cards_for_item(item) + cards, _popup_w, _popup_h = handler._folder_stack._folder_stack_cards_for_item( + item + ) assert requested_icon_sizes == [52] assert cards[1].icon_size == 52 - assert cards[1].label_w <= menu_mod.FOLDER_STACK_LABEL_MAX_WIDTH_PX + assert cards[1].label_w <= folder_stack_mod.FOLDER_STACK_LABEL_MAX_WIDTH_PX def test_folder_stack_action_chip_allows_wider_more_label( self, handler, monkeypatch @@ -1531,11 +1566,13 @@ def test_folder_stack_action_chip_allows_wider_more_label( kind=FOLDER_KIND, target="file:///tmp/docs", ) - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "ok") + monkeypatch.setattr( + handler._folder_stack._browser, "target_state", lambda _target: "ok" + ) handler._launcher.default_directory_app_name.return_value = "Caja" monkeypatch.setattr( - handler, - "_list_directory", + handler._folder_stack, + "_list_directory_rows", lambda **_kwargs: [ { "target": f"file:///tmp/docs/{i}.txt", @@ -1547,21 +1584,23 @@ def test_folder_stack_action_chip_allows_wider_more_label( ], ) - cards, _popup_w, _popup_h = handler._folder_stack_cards_for_item(item) + cards, _popup_w, _popup_h = handler._folder_stack._folder_stack_cards_for_item( + item + ) assert cards[0].label == "5 More in Caja" expected_width = ( - menu_mod._measure_stack_text_px("5 More in Caja") - + 2 * menu_mod.FOLDER_STACK_LABEL_TEXT_MARGIN_PX - + menu_mod.FOLDER_STACK_ACTION_ARROW_GAP_PX - + menu_mod.FOLDER_STACK_ACTION_ARROW_SIZE_PX + folder_stack_mod._measure_stack_text_px("5 More in Caja") + + 2 * folder_stack_mod.FOLDER_STACK_LABEL_TEXT_MARGIN_PX + + folder_stack_mod.FOLDER_STACK_ACTION_ARROW_GAP_PX + + folder_stack_mod.FOLDER_STACK_ACTION_ARROW_SIZE_PX + 10 ) - assert cards[0].label_w == handler._folder_stack_action_width( + assert cards[0].label_w == handler._folder_stack._folder_stack_action_width( label="5 More in Caja" ) assert cards[0].label_w == expected_width - assert cards[0].label_w <= menu_mod.FOLDER_STACK_ACTION_MAX_WIDTH_PX + assert cards[0].label_w <= folder_stack_mod.FOLDER_STACK_ACTION_MAX_WIDTH_PX def test_folder_stack_action_chip_falls_back_without_directory_app( self, handler, monkeypatch @@ -1571,11 +1610,13 @@ def test_folder_stack_action_chip_falls_back_without_directory_app( kind=FOLDER_KIND, target="file:///tmp/docs", ) - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "ok") + monkeypatch.setattr( + handler._folder_stack._browser, "target_state", lambda _target: "ok" + ) handler._launcher.default_directory_app_name.return_value = None monkeypatch.setattr( - handler, - "_list_directory", + handler._folder_stack, + "_list_directory_rows", lambda **_kwargs: [ { "target": f"file:///tmp/docs/{i}.txt", @@ -1587,7 +1628,9 @@ def test_folder_stack_action_chip_falls_back_without_directory_app( ], ) - cards, _popup_w, _popup_h = handler._folder_stack_cards_for_item(item) + cards, _popup_w, _popup_h = handler._folder_stack._folder_stack_cards_for_item( + item + ) assert cards[0].label == "5 More in Folder" @@ -1597,10 +1640,12 @@ def test_folder_stack_short_labels_fit_chip_width(self, handler, monkeypatch): kind=FOLDER_KIND, target="file:///tmp/docs", ) - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "ok") monkeypatch.setattr( - handler, - "_list_directory", + handler._folder_stack._browser, "target_state", lambda _target: "ok" + ) + monkeypatch.setattr( + handler._folder_stack, + "_list_directory_rows", lambda **_kwargs: [ { "target": "file:///tmp/docs/doc", @@ -1611,7 +1656,9 @@ def test_folder_stack_short_labels_fit_chip_width(self, handler, monkeypatch): ], ) - cards, _popup_w, _popup_h = handler._folder_stack_cards_for_item(item) + cards, _popup_w, _popup_h = handler._folder_stack._folder_stack_cards_for_item( + item + ) assert cards[1].label == "doc" assert cards[1].label_w < 50 @@ -1624,10 +1671,12 @@ def test_folder_stack_arc_starts_from_first_visible_item( kind=FOLDER_KIND, target="file:///tmp/docs", ) - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "ok") monkeypatch.setattr( - handler, - "_list_directory", + handler._folder_stack._browser, "target_state", lambda _target: "ok" + ) + monkeypatch.setattr( + handler._folder_stack, + "_list_directory_rows", lambda **_kwargs: [ { "target": f"file:///tmp/docs/{i}.txt", @@ -1639,11 +1688,13 @@ def test_folder_stack_arc_starts_from_first_visible_item( ], ) - cards, popup_w, _popup_h = handler._folder_stack_cards_for_item(item) + cards, popup_w, _popup_h = handler._folder_stack._folder_stack_cards_for_item( + item + ) icon_cards = [card for card in cards if card.icon_size > 0] assert len(icon_cards) == 4 - fold_center_x = handler._folder_stack_fold_center_x + fold_center_x = handler._folder_stack._folder_stack_fold_center_x for card in icon_cards: icon_center_x = card.icon_x + card.icon_size / 2 assert icon_center_x > fold_center_x @@ -1656,10 +1707,12 @@ def test_folder_stack_keeps_uniform_vertical_spacing(self, handler, monkeypatch) kind=FOLDER_KIND, target="file:///tmp/docs", ) - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "ok") monkeypatch.setattr( - handler, - "_list_directory", + handler._folder_stack._browser, "target_state", lambda _target: "ok" + ) + monkeypatch.setattr( + handler._folder_stack, + "_list_directory_rows", lambda **_kwargs: [ { "target": f"file:///tmp/docs/{i}.txt", @@ -1671,7 +1724,9 @@ def test_folder_stack_keeps_uniform_vertical_spacing(self, handler, monkeypatch) ], ) - cards, _popup_w, _popup_h = handler._folder_stack_cards_for_item(item) + cards, _popup_w, _popup_h = handler._folder_stack._folder_stack_cards_for_item( + item + ) icon_cards = [card for card in cards if card.icon_size > 0] centers = [card.icon_y + card.icon_size / 2 for card in icon_cards] @@ -1689,13 +1744,15 @@ def test_folder_stack_change_debounces_refresh(self, handler, monkeypatch): "timeout_add", lambda delay, cb, *args: timeout_calls.append((delay, cb, args)) or 77, ) - handler._folder_stack_refresh_source = 12 + handler._folder_stack._folder_stack_refresh_source = 12 - handler._on_folder_stack_changed(MagicMock(), MagicMock(), None, MagicMock()) + handler._folder_stack._on_folder_stack_changed( + MagicMock(), MagicMock(), None, MagicMock() + ) assert removed == [12] assert timeout_calls[0][0] == 120 - assert handler._folder_stack_refresh_source == 77 + assert handler._folder_stack._folder_stack_refresh_source == 77 def test_folder_stack_change_invalidates_cached_layout(self, handler, monkeypatch): item = DockItem( @@ -1703,10 +1760,10 @@ def test_folder_stack_change_invalidates_cached_layout(self, handler, monkeypatc kind=FOLDER_KIND, target="file:///tmp/docs", ) - handler._folder_stack_item = item - handler._folder_stack_cache.layouts[ + handler._folder_stack._folder_stack_item = item + handler._folder_stack._folder_stack_cache.layouts[ ("file:///tmp/docs", 0, "name", False, 48, None) - ] = menu_mod.FolderStackLayout( + ] = folder_stack_mod.FolderStackLayout( cards=(), popup_w=1, popup_h=1, @@ -1714,14 +1771,16 @@ def test_folder_stack_change_invalidates_cached_layout(self, handler, monkeypatc ) monkeypatch.setattr(menu_mod.GLib, "timeout_add", lambda *_args: 77) - handler._on_folder_stack_changed(MagicMock(), MagicMock(), None, MagicMock()) + handler._folder_stack._on_folder_stack_changed( + MagicMock(), MagicMock(), None, MagicMock() + ) - assert handler._folder_stack_cache.layouts == {} + assert handler._folder_stack._folder_stack_cache.layouts == {} def test_folder_stack_click_opens_target(self, handler): target = "file:///tmp/docs/readme.txt" - handler._folder_stack_cards = [ - menu_mod.FolderStackCard( + handler._folder_stack._folder_stack_cards = [ + folder_stack_mod.FolderStackCard( label="readme.txt", target=target, icon=None, @@ -1736,15 +1795,24 @@ def test_folder_stack_click_opens_target(self, handler): ) ] opened: list[str] = [] - with patch.object(handler, "_open_folder_stack_target", opened.append): + with patch.object( + handler._folder_stack, + "_open_folder_stack_target", + opened.append, + ): press = SimpleNamespace(x=32.0, y=60.0, button=1) release = SimpleNamespace(x=32.0, y=60.0, button=1) assert ( - handler._on_folder_stack_button_press(FakeDrawingArea(), press) is True + handler._folder_stack._on_folder_stack_button_press( + FakeDrawingArea(), press + ) + is True ) assert ( - handler._on_folder_stack_button_release(FakeDrawingArea(), release) + handler._folder_stack._on_folder_stack_button_release( + FakeDrawingArea(), release + ) is True ) @@ -1762,20 +1830,20 @@ def test_refresh_folder_stack_rebuilds_popup_content(self, handler, monkeypatch) revealer.add(old_child) built: list[object] = [] monkeypatch.setattr( - handler, + handler._folder_stack, "_replace_folder_stack_content", lambda item: built.append(object()), ) monkeypatch.setattr( - handler, + handler._folder_stack, "_position_folder_stack_window", lambda: built.append(object()) or built[-1], ) - handler._folder_stack_window = window - handler._folder_stack_revealer = revealer - handler._folder_stack_item = item + handler._folder_stack._folder_stack_window = window + handler._folder_stack._folder_stack_revealer = revealer + handler._folder_stack._folder_stack_item = item - result = handler._refresh_folder_stack() + result = handler._folder_stack._refresh_folder_stack() assert result is False assert window.visible is True @@ -1797,27 +1865,27 @@ def test_schedule_folder_stack_prewarm_deduplicates_target( handler.schedule_folder_stack_prewarm(item) assert len(idle_calls) == 1 - assert len(handler._folder_stack_cache.prewarm_queue) == 1 + assert len(handler._folder_stack._folder_stack_cache.prewarm_queue) == 1 def test_folder_stack_transition_type_matches_position(self, handler): handler._config.pos = "bottom" assert ( - handler._folder_stack_transition_type() + handler._folder_stack._folder_stack_transition_type() == menu_mod.Gtk.RevealerTransitionType.SLIDE_UP ) handler._config.pos = "top" assert ( - handler._folder_stack_transition_type() + handler._folder_stack._folder_stack_transition_type() == menu_mod.Gtk.RevealerTransitionType.SLIDE_DOWN ) handler._config.pos = "left" assert ( - handler._folder_stack_transition_type() + handler._folder_stack._folder_stack_transition_type() == menu_mod.Gtk.RevealerTransitionType.SLIDE_RIGHT ) handler._config.pos = "right" assert ( - handler._folder_stack_transition_type() + handler._folder_stack._folder_stack_transition_type() == menu_mod.Gtk.RevealerTransitionType.SLIDE_LEFT ) @@ -1830,9 +1898,9 @@ def test_replace_folder_stack_content_replaces_existing_child(self, handler): revealer = FakeRevealer() stale = FakeBox() revealer.add(stale) - handler._folder_stack_revealer = revealer + handler._folder_stack._folder_stack_revealer = revealer - handler._replace_folder_stack_content(item=item) + handler._folder_stack._replace_folder_stack_content(item=item) assert revealer.get_child() is not stale assert revealer.get_child().shown is True @@ -1842,27 +1910,27 @@ def test_position_folder_stack_window_supports_all_edges(self, handler): revealer = FakeRevealer() revealer.add(child) window = FakeWindow() - handler._folder_stack_window = window - handler._folder_stack_revealer = revealer - handler._folder_stack_anchor_x = 120 - handler._folder_stack_anchor_y = 200 - handler._folder_stack_icon_w = 48 - handler._folder_stack_fold_center_x = 40 - - handler._folder_stack_position_value = "bottom" - handler._position_folder_stack_window() + handler._folder_stack._folder_stack_window = window + handler._folder_stack._folder_stack_revealer = revealer + handler._folder_stack._folder_stack_anchor_x = 120 + handler._folder_stack._folder_stack_anchor_y = 200 + handler._folder_stack._folder_stack_icon_w = 48 + handler._folder_stack._folder_stack_fold_center_x = 40 + + handler._folder_stack._folder_stack_position_value = "bottom" + handler._folder_stack._position_folder_stack_window() assert window.moved_to == (104, 158) - handler._folder_stack_position_value = "top" - handler._position_folder_stack_window() + handler._folder_stack._folder_stack_position_value = "top" + handler._folder_stack._position_folder_stack_window() assert window.moved_to == (104, 208) - handler._folder_stack_position_value = "left" - handler._position_folder_stack_window() + handler._folder_stack._folder_stack_position_value = "left" + handler._folder_stack._position_folder_stack_window() assert window.moved_to == (128, 207) - handler._folder_stack_position_value = "right" - handler._position_folder_stack_window() + handler._folder_stack._folder_stack_position_value = "right" + handler._folder_stack._position_folder_stack_window() assert window.moved_to == (0, 207) def test_track_folder_stack_handles_invalid_target_and_error( @@ -1871,11 +1939,11 @@ def test_track_folder_stack_handles_invalid_target_and_error( monkeypatch.setattr( menu_mod.launcher_mod, "normalize_file_target", lambda _t: None ) - handler._track_folder_stack("invalid") - assert handler._folder_stack_monitor is None + handler._folder_stack._track_folder_stack("invalid") + assert handler._folder_stack._folder_stack_monitor is None warned = MagicMock() - monkeypatch.setattr(menu_mod.log, "warning", warned) + monkeypatch.setattr(folder_stack_mod.log, "warning", warned) monkeypatch.setattr( menu_mod.launcher_mod, "normalize_file_target", @@ -1889,7 +1957,7 @@ def monitor_directory(self, *_args): monkeypatch.setattr(menu_mod.Gio.File, "new_for_uri", lambda _uri: _Folder()) - handler._track_folder_stack("file:///tmp/docs") + handler._folder_stack._track_folder_stack("file:///tmp/docs") warned.assert_called_once() @@ -1907,16 +1975,16 @@ def monitor_directory(self, *_args): monkeypatch.setattr(menu_mod.Gio.File, "new_for_uri", lambda _uri: _Folder()) - handler._track_folder_stack("file:///tmp/docs") + handler._folder_stack._track_folder_stack("file:///tmp/docs") - assert handler._folder_stack_monitor is monitor + assert handler._folder_stack._folder_stack_monitor is monitor assert monitor.changed[0] == "changed" def test_refresh_folder_stack_returns_false_without_window_or_item(self, handler): - handler._folder_stack_window = None - handler._folder_stack_item = None + handler._folder_stack._folder_stack_window = None + handler._folder_stack._folder_stack_item = None - assert handler._refresh_folder_stack() is False + assert handler._folder_stack._refresh_folder_stack() is False def test_folder_stack_cards_for_missing_and_empty_folder( self, handler, monkeypatch @@ -1926,18 +1994,28 @@ def test_folder_stack_cards_for_missing_and_empty_folder( kind=FOLDER_KIND, target="file:///tmp/docs", ) - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "missing") + monkeypatch.setattr( + handler._folder_stack._browser, "target_state", lambda _target: "missing" + ) - cards, popup_w, popup_h = handler._folder_stack_cards_for_item(item) + cards, popup_w, popup_h = handler._folder_stack._folder_stack_cards_for_item( + item + ) assert cards[0].label == "Folder not found" assert popup_w > 0 assert popup_h > 0 - monkeypatch.setattr(handler, "_folder_target_state", lambda _target: "ok") - monkeypatch.setattr(handler, "_list_directory", lambda **_kwargs: []) + monkeypatch.setattr( + handler._folder_stack._browser, "target_state", lambda _target: "ok" + ) + monkeypatch.setattr( + handler._folder_stack, "_list_directory_rows", lambda **_kwargs: [] + ) - cards, popup_w, popup_h = handler._folder_stack_cards_for_item(item) + cards, popup_w, popup_h = handler._folder_stack._folder_stack_cards_for_item( + item + ) assert cards[0].label == "Folder is empty" assert popup_w > 0 @@ -1948,12 +2026,14 @@ def test_draw_folder_stack_card_returns_when_geometry_missing( ): cr = MagicMock() monkeypatch.setattr( - handler, "_folder_stack_card_geometry", lambda **_kwargs: None + handler._folder_stack, + "_folder_stack_card_geometry", + lambda **_kwargs: None, ) - handler._draw_folder_stack_card( + handler._folder_stack._draw_folder_stack_card( cr=cr, - card=menu_mod.FolderStackCard( + card=folder_stack_mod.FolderStackCard( label="x", target=None, icon=None, @@ -1975,7 +2055,7 @@ def test_draw_folder_stack_card_returns_when_geometry_missing( def test_draw_folder_stack_card_scales_icon_and_draws_action_arrow( self, handler, monkeypatch ): - geometry = menu_mod.FolderStackCardGeometry( + geometry = folder_stack_mod.FolderStackCardGeometry( reveal=1.0, hover_value=0.2, rotation_radians=0.1, @@ -1991,19 +2071,25 @@ def test_draw_folder_stack_card_scales_icon_and_draws_action_arrow( pixbuf = FakePixbuf(48, 48, scaled=scaled) cr = MagicMock() monkeypatch.setattr( - handler, "_folder_stack_card_geometry", lambda **_kwargs: geometry + handler._folder_stack, + "_folder_stack_card_geometry", + lambda **_kwargs: geometry, ) - monkeypatch.setattr(menu_mod, "rounded_rect", MagicMock()) - monkeypatch.setattr(menu_mod.Gdk, "cairo_set_source_pixbuf", MagicMock()) + monkeypatch.setattr(folder_stack_mod, "rounded_rect", MagicMock()) monkeypatch.setattr( - menu_mod, + folder_stack_mod.Gdk, + "cairo_set_source_pixbuf", + MagicMock(), + ) + monkeypatch.setattr( + folder_stack_mod, "GdkPixbuf", SimpleNamespace(InterpType=SimpleNamespace(BILINEAR=1)), ) - handler._draw_folder_stack_card( + handler._folder_stack._draw_folder_stack_card( cr=cr, - card=menu_mod.FolderStackCard( + card=folder_stack_mod.FolderStackCard( label="Open Folder", target="file:///tmp/docs", icon=pixbuf, @@ -2025,9 +2111,9 @@ def test_draw_folder_stack_card_scales_icon_and_draws_action_arrow( assert cr.stroke.call_count >= 1 cr.reset_mock() - handler._draw_folder_stack_card( + handler._folder_stack._draw_folder_stack_card( cr=cr, - card=menu_mod.FolderStackCard( + card=folder_stack_mod.FolderStackCard( label="Open Folder", target="file:///tmp/docs", icon=None, @@ -2047,7 +2133,7 @@ def test_draw_folder_stack_card_scales_icon_and_draws_action_arrow( assert cr.stroke.call_count >= 2 def test_folder_stack_card_at_and_button_mismatch_paths(self, handler, monkeypatch): - top = menu_mod.FolderStackCard( + top = folder_stack_mod.FolderStackCard( label="Top", target="file:///tmp/top", icon=None, @@ -2060,7 +2146,7 @@ def test_folder_stack_card_at_and_button_mismatch_paths(self, handler, monkeypat label_h=24, centered=False, ) - bottom = menu_mod.FolderStackCard( + bottom = folder_stack_mod.FolderStackCard( label="Bottom", target="file:///tmp/bottom", icon=None, @@ -2073,11 +2159,11 @@ def test_folder_stack_card_at_and_button_mismatch_paths(self, handler, monkeypat label_h=24, centered=False, ) - handler._folder_stack_cards = [bottom, top] + handler._folder_stack._folder_stack_cards = [bottom, top] monkeypatch.setattr( - handler, + handler._folder_stack, "_folder_stack_card_geometry", - lambda *, card, **_kwargs: menu_mod.FolderStackCardGeometry( + lambda *, card, **_kwargs: folder_stack_mod.FolderStackCardGeometry( reveal=1.0, hover_value=0.0, rotation_radians=0.0, @@ -2091,16 +2177,16 @@ def test_folder_stack_card_at_and_button_mismatch_paths(self, handler, monkeypat ), ) - assert handler._folder_stack_card_at(10, 10) is top + assert handler._folder_stack._folder_stack_card_at(10, 10) is top assert ( - handler._on_folder_stack_button_press( + handler._folder_stack._on_folder_stack_button_press( FakeDrawingArea(), SimpleNamespace(x=10.0, y=10.0, button=2) ) is False ) - handler._folder_stack_pressed_target = "file:///tmp/top" + handler._folder_stack._folder_stack_pressed_target = "file:///tmp/top" assert ( - handler._on_folder_stack_button_release( + handler._folder_stack._on_folder_stack_button_release( FakeDrawingArea(), SimpleNamespace(x=200.0, y=200.0, button=1) ) is False @@ -2109,7 +2195,7 @@ def test_folder_stack_card_at_and_button_mismatch_paths(self, handler, monkeypat def test_folder_stack_motion_leave_and_animation_helpers( self, handler, monkeypatch ): - card = menu_mod.FolderStackCard( + card = folder_stack_mod.FolderStackCard( label="doc", target="file:///tmp/doc", icon=None, @@ -2122,8 +2208,10 @@ def test_folder_stack_motion_leave_and_animation_helpers( label_h=20, centered=False, ) - monkeypatch.setattr(handler, "_folder_stack_card_at", lambda *_args: card) - handler._folder_stack_area = FakeDrawingArea() + monkeypatch.setattr( + handler._folder_stack, "_folder_stack_card_at", lambda *_args: card + ) + handler._folder_stack._folder_stack_area = FakeDrawingArea() timeout_calls: list[tuple[int, object]] = [] monkeypatch.setattr( menu_mod.GLib, @@ -2132,34 +2220,36 @@ def test_folder_stack_motion_leave_and_animation_helpers( ) assert ( - handler._on_folder_stack_motion_notify( + handler._folder_stack._on_folder_stack_motion_notify( FakeDrawingArea(), SimpleNamespace(x=1.0, y=2.0) ) is False ) - assert handler._folder_stack_hover_target == "file:///tmp/doc" - assert handler._folder_stack_anim_source == 33 - assert handler._folder_stack_area.draw_queued is True + assert handler._folder_stack._folder_stack_hover_target == "file:///tmp/doc" + assert handler._folder_stack._folder_stack_anim_source == 33 + assert handler._folder_stack._folder_stack_area.draw_queued is True assert ( - handler._on_folder_stack_leave_notify(FakeDrawingArea(), MagicMock()) + handler._folder_stack._on_folder_stack_leave_notify( + FakeDrawingArea(), MagicMock() + ) is False ) - assert handler._folder_stack_hover_target is None - assert handler._folder_stack_pressed_target is None + assert handler._folder_stack._folder_stack_hover_target is None + assert handler._folder_stack._folder_stack_pressed_target is None def test_folder_stack_animation_frame_paths(self, handler, monkeypatch): - handler._folder_stack_area = FakeDrawingArea() - handler._folder_stack_window = FakeWindow() - handler._folder_stack_window.hide() + handler._folder_stack._folder_stack_area = FakeDrawingArea() + handler._folder_stack._folder_stack_window = FakeWindow() + handler._folder_stack._folder_stack_window.hide() - assert handler._on_folder_stack_animation_frame() is False - assert handler._folder_stack_anim_source == 0 + assert handler._folder_stack._on_folder_stack_animation_frame() is False + assert handler._folder_stack._folder_stack_anim_source == 0 - handler._folder_stack_window.show_all() - handler._folder_stack_show_started_us = 0 - handler._folder_stack_cards = [ - menu_mod.FolderStackCard( + handler._folder_stack._folder_stack_window.show_all() + handler._folder_stack._folder_stack_show_started_us = 0 + handler._folder_stack._folder_stack_cards = [ + folder_stack_mod.FolderStackCard( label="doc", target="file:///tmp/doc", icon=None, @@ -2173,51 +2263,62 @@ def test_folder_stack_animation_frame_paths(self, handler, monkeypatch): centered=False, ) ] - handler._folder_stack_hover_target = "file:///tmp/doc" - handler._folder_stack_hover_values = {"file:///tmp/doc": 0.99} + handler._folder_stack._folder_stack_hover_target = "file:///tmp/doc" + handler._folder_stack._folder_stack_hover_values = {"file:///tmp/doc": 0.99} monkeypatch.setattr(menu_mod.GLib, "get_monotonic_time", lambda: 1_000_000) - assert handler._on_folder_stack_animation_frame() is False - assert handler._folder_stack_hover_values["file:///tmp/doc"] == 1.0 + assert handler._folder_stack._on_folder_stack_animation_frame() is False + assert ( + handler._folder_stack._folder_stack_hover_values["file:///tmp/doc"] == 1.0 + ) - handler._folder_stack_show_started_us = 900_000 - handler._folder_stack_hover_target = "file:///tmp/doc" - handler._folder_stack_hover_values = {"file:///tmp/doc": 0.0} + handler._folder_stack._folder_stack_show_started_us = 900_000 + handler._folder_stack._folder_stack_hover_target = "file:///tmp/doc" + handler._folder_stack._folder_stack_hover_values = {"file:///tmp/doc": 0.0} - assert handler._on_folder_stack_animation_frame() is True - assert handler._folder_stack_area.draw_queued is True + assert handler._folder_stack._on_folder_stack_animation_frame() is True + assert handler._folder_stack._folder_stack_area.draw_queued is True def test_folder_stack_reveal_open_and_target_state_helpers( self, handler, monkeypatch ): - handler._folder_stack_show_started_us = 0 + handler._folder_stack._folder_stack_show_started_us = 0 assert ( - handler._folder_stack_reveal_progress(sequence_index=1, now_us=100) == 1.0 + handler._folder_stack._folder_stack_reveal_progress( + sequence_index=1, now_us=100 + ) + == 1.0 ) - handler._folder_stack_show_started_us = 1_000_000 + handler._folder_stack._folder_stack_show_started_us = 1_000_000 assert ( - handler._folder_stack_reveal_progress(sequence_index=10, now_us=1_000_000) + handler._folder_stack._folder_stack_reveal_progress( + sequence_index=10, now_us=1_000_000 + ) == 0.0 ) assert ( 0.0 - < handler._folder_stack_reveal_progress(sequence_index=0, now_us=1_100_000) + < handler._folder_stack._folder_stack_reveal_progress( + sequence_index=0, now_us=1_100_000 + ) <= 1.0 ) opened: list[str] = [] monkeypatch.setattr(menu_mod.launcher_mod, "open_target", opened.append) monkeypatch.setattr( - handler, "_close_folder_stack", lambda: opened.append("closed") + handler._folder_stack, + "_close_folder_stack", + lambda: opened.append("closed"), ) - handler._open_folder_stack_target("file:///tmp/docs") + handler._folder_stack._open_folder_stack_target("file:///tmp/docs") assert opened == ["file:///tmp/docs", "closed"] monkeypatch.setattr( menu_mod.launcher_mod, "normalize_file_target", lambda _t: None ) - assert handler._folder_target_state("invalid") == "missing" + assert handler._folder_stack._browser.target_state("invalid") == "missing" monkeypatch.setattr( menu_mod.launcher_mod, @@ -2230,7 +2331,9 @@ def query_exists(self, _arg): raise RuntimeError("boom") monkeypatch.setattr(menu_mod.Gio.File, "new_for_uri", lambda _uri: _BadFolder()) - assert handler._folder_target_state("file:///tmp/docs") == "missing" + assert ( + handler._folder_stack._browser.target_state("file:///tmp/docs") == "missing" + ) def test_folder_menu_submenu_tracking_cleanup_and_helpers( self, handler, monkeypatch @@ -2280,14 +2383,13 @@ def test_folder_menu_submenu_tracking_cleanup_and_helpers( populate.assert_not_called() row = {"kind": 1, "name": "B", "size": 2, "created": 3, "modified": 4} - assert handler._folder_sort_key(row, "kind") == (1, "b") - assert handler._folder_sort_key(row, "size") == (2, "b") - assert handler._folder_sort_key(row, "created") == (3, "b") - assert handler._folder_sort_key(row, "modified") == (4, "b") - assert handler._folder_sort_key(row, "name") == ("b",) + assert handler._folder_stack._browser.sort_key(row, "kind") == (1, "b") + assert handler._folder_stack._browser.sort_key(row, "size") == (2, "b") + assert handler._folder_stack._browser.sort_key(row, "created") == (3, "b") + assert handler._folder_stack._browser.sort_key(row, "modified") == (4, "b") + assert handler._folder_stack._browser.sort_key(row, "name") == ("b",) - handler._config.item_prefs["file:///tmp/docs"] = {"large_icons": True} - assert handler._folder_icon_px(folder_item) == menu_mod.FOLDER_LARGE_ICON_PX + assert handler._folder_stack.icon_px(folder_item) == 16 def test_directory_has_visible_children_and_sort_callback_paths( self, handler, monkeypatch @@ -2324,16 +2426,27 @@ def enumerate_children(self, *_args): lambda _uri: _Folder([_Info(True), _Info(False)]), ) assert ( - handler._directory_has_visible_children("file:///tmp/docs", False) is True + handler._folder_stack._browser.directory_has_visible_children( + "file:///tmp/docs", False + ) + is True ) monkeypatch.setattr( menu_mod.Gio.File, "new_for_uri", lambda _uri: _Folder([_Info(True)]) ) assert ( - handler._directory_has_visible_children("file:///tmp/docs", False) is False + handler._folder_stack._browser.directory_has_visible_children( + "file:///tmp/docs", False + ) + is False + ) + assert ( + handler._folder_stack._browser.directory_has_visible_children( + "file:///tmp/docs", True + ) + is True ) - assert handler._directory_has_visible_children("file:///tmp/docs", True) is True item = DockItem( desktop_id="file:///tmp/docs", diff --git a/tests/visual/render_cases.py b/tests/visual/render_cases.py index 9896868e..4132ee49 100644 --- a/tests/visual/render_cases.py +++ b/tests/visual/render_cases.py @@ -225,8 +225,8 @@ def _folder_stack_handler() -> MenuHandler: geometry_builder=MagicMock(), launcher=launcher, ) - handler._folder_stack_position_value = "bottom" - handler._folder_target_state = lambda _target: "ok" + handler._folder_stack._folder_stack_position_value = "bottom" + handler._folder_stack._browser.target_state = lambda _target: "ok" return handler @@ -264,14 +264,16 @@ def _draw_folder_stack_case(case_name: str) -> cairo.ImageSurface: kind=FOLDER_KIND, target="file:///tmp/docs", ) - handler._list_directory = lambda **_kwargs: _folder_stack_rows() - cards, popup_w, popup_h = handler._folder_stack_cards_for_item(folder_item) - handler._folder_stack_cards = cards + handler._folder_stack._list_directory_rows = lambda **_kwargs: _folder_stack_rows() + cards, popup_w, popup_h = handler._folder_stack._folder_stack_cards_for_item( + folder_item + ) + handler._folder_stack._folder_stack_cards = cards if case_name == "folder-stack-hover-item-bottom": hover_target = next( card.target for card in cards if card.target and card.label == "Notes" ) - handler._folder_stack_hover_values[hover_target] = 1.0 + handler._folder_stack._folder_stack_hover_values[hover_target] = 1.0 elif case_name != "folder-stack-open-bottom": raise AssertionError(f"Unknown folder stack case {case_name}") @@ -287,7 +289,7 @@ def _draw_folder_stack_case(case_name: str) -> cairo.ImageSurface: now_us = 500_000 total_cards = len(cards) for draw_index, card in enumerate(cards): - handler._draw_folder_stack_card( + handler._folder_stack._draw_folder_stack_card( cr=cr, card=card, sequence_index=total_cards - 1 - draw_index,