From e4ab459349961695adbc358fcde3c5e709a70f39 Mon Sep 17 00:00:00 2001 From: Leandro Rodrigues Date: Sat, 1 Aug 2026 08:09:57 -0300 Subject: [PATCH] refactor: modularize shell feature lifecycles Decompose the largest Aurora Shell modules into cohesive resource owners for Screenshot UI hooks, capture toolbar behavior and OCR sessions; tray viewport calculations and SNI entries; dock configuration, per-monitor bindings, contextual drag reveal and external-storage operations; Dash visibility, application activation, fixed items and spring loading; Aurora Menu recent-item parsing; Meeting Clock presentation and alerts; clipboard card construction; and IconWeave patching, inspection and window registration. Preserve the existing facades, public module APIs and DevTool contracts while making each extracted component responsible for its own actors, signals, cancellables, GLib sources and teardown. Introduce factory-based ManagedSource replacement and apply symmetric lifecycle orchestration across the affected modules, including explicit asynchronous identity checks, safe actor transition cleanup, conditional monkey-patch restoration and deterministic enable, disable and re-enable behavior. Tighten the implementation against the EGO review rules by removing redundant guards, lifecycle flags, defensive cleanup wrappers, unnecessary aliases and stale compatibility helpers, while documenting accepted analyzer findings and keeping settings, translations and review guidance synchronized. Expand pure unit coverage for tray layout, SNI icon handling and identity matching, dock configuration transitions, Dash visibility and window selection, Aurora Menu recent parsing, clipboard card classification, IconWeave scoring and registration, Meeting Clock alert timing and ManagedSource ordering. Extend Shell integration coverage for the refactored Dock, IconWeave and Volume Mixer paths so resource destruction and lifecycle restoration remain exercised in a real GNOME Shell environment. --- AGENTS.md | 82 +- EGO_REVIEW.md | 50 +- data/po/pt_BR.po | 201 ++-- ....shell.extensions.aurora-shell.gschema.xml | 5 + src/capture/captureOcrSession.ts | 329 ++++++ src/capture/captureToolbar.ts | 141 +++ src/capture/captureToolbarPositioner.ts | 165 +++ src/capture/captureTools.ts | 992 +++++----------- src/capture/captureTooltip.ts | 64 +- src/capture/ocrController.ts | 56 +- src/capture/screenshotHooks.ts | 117 ++ src/capture/screenshotUiAdapter.ts | 28 +- src/clipboard/clipboardCardBuilders.ts | 187 +++ src/clipboard/clipboardCardLayouts.ts | 75 ++ src/clipboard/clipboardCardState.ts | 66 ++ src/clipboard/clipboardHistory.ts | 164 +-- src/clipboard/clipboardItem.ts | 400 +------ src/clipboard/clipboardMonitor.ts | 28 +- src/clipboard/clipboardPanel.ts | 41 +- src/clipboard/linkMetadata.ts | 187 --- src/core/extensionBase.ts | 7 +- src/core/lifecycleScope.ts | 60 +- src/core/mainLoop.ts | 12 + src/desktop/trayIcons/appIdentity.ts | 39 + src/desktop/trayIcons/backgroundAppsSource.ts | 17 +- src/desktop/trayIcons/dbusMenu.ts | 4 +- src/desktop/trayIcons/sniEntry.ts | 29 + src/desktop/trayIcons/sniHost.ts | 139 +-- src/desktop/trayIcons/sniIconState.ts | 55 + src/desktop/trayIcons/sniWatcher.ts | 6 +- src/desktop/trayIcons/trayClipArea.ts | 126 ++ src/desktop/trayIcons/trayContainer.ts | 262 +--- src/desktop/trayIcons/trayIconItem.ts | 1 - src/desktop/trayIcons/trayIcons.ts | 96 +- src/desktop/trayIcons/trayLayout.ts | 85 ++ src/desktop/trayIcons/trayState.ts | 4 - src/dev/captureToolsDevTool.ts | 71 +- src/dev/clipboardHistoryDevTool.ts | 20 +- src/dev/devTool.ts | 34 +- src/dev/dockDevTool.ts | 17 +- src/dev/generalDevTool.ts | 2 - src/dev/meetingClockDevTool.ts | 15 +- src/dev/weatherClockDevTool.ts | 9 +- src/device/device.ts | 6 +- src/dock/contextualDragReveal.ts | 109 ++ src/dock/dock.ts | 363 ++---- src/dock/dockBinding.ts | 57 + src/dock/dockConfiguration.ts | 77 ++ src/dock/externalStorageIcon.ts | 418 +------ src/dock/externalStorageModel.ts | 4 +- src/dock/externalStorageMonitor.ts | 168 +++ src/dock/externalStorageOperations.ts | 225 ++++ src/dock/hotArea.ts | 48 +- src/dock/intellihide.ts | 84 +- src/dock/intellihideState.ts | 12 +- src/dock/motion/iconMotionController.ts | 28 +- src/dock/trashIcon.ts | 7 +- src/extension.dev.ts | 22 +- src/moduleManager.ts | 15 +- src/panel/auroraMenu.ts | 297 +++-- src/panel/auroraMenuState.ts | 35 + src/panel/auroraRecentItems.ts | 43 + src/panel/bluetoothMenu/bluetoothMenu.ts | 9 +- src/panel/bluetoothMenu/deviceItem.ts | 105 +- .../meetingClock/calendarServerBackend.ts | 13 +- .../meetingClock/meetingAlertController.ts | 265 +++++ src/panel/clock/meetingClock/meetingClock.ts | 528 ++------- .../clock/meetingClock/meetingClockLogic.ts | 23 + .../clock/meetingClock/meetingClockPill.ts | 147 +++ src/panel/clock/weatherClock/weatherClock.ts | 62 +- src/panel/lockKeyIndicators.ts | 18 +- src/panel/lowBatteryPercentage.ts | 17 +- src/panel/volumeMixer/mixerItem.ts | 84 +- src/panel/volumeMixer/mixerList.ts | 21 +- src/panel/volumeMixer/mixerPanel.ts | 26 +- src/panel/volumeMixer/streamSlider.ts | 56 +- src/panel/volumeMixer/volumeMixer.manifest.ts | 8 + src/panel/volumeMixer/volumeMixer.ts | 27 +- src/patches/appSearchTooltip.ts | 99 +- src/patches/iconWeave.ts | 521 +------- src/patches/iconWeaveInspector.ts | 325 +++++ src/patches/iconWeavePatches.ts | 97 ++ src/patches/iconWeaveRegistry.ts | 53 + src/patches/iconWeaveScoring.ts | 20 + src/patches/pipOnTop.ts | 12 +- src/patches/pipWindowPolicy.ts | 9 +- src/patches/velaVpnQuickSettings.ts | 87 +- src/patches/xwaylandIndicator.ts | 78 +- src/privacy/privacyPanel.ts | 24 +- src/privacy/sharingIndicator.ts | 11 +- src/shared/ui/dash.ts | 1049 ++++------------- src/shared/ui/dashApplications.ts | 141 +++ src/shared/ui/dashFixedItems.ts | 137 +++ src/shared/ui/dashSpringLoad.ts | 91 ++ src/shared/ui/dashState.ts | 42 + src/shared/ui/dashVisibility.ts | 379 ++++++ src/theme/autoThemeSwitcher.ts | 79 +- src/theme/themeChanger.ts | 30 +- tests/shell/auroraDock.js | 126 +- tests/shell/auroraIconWeave.js | 50 +- tests/shell/auroraVolumeMixer.js | 61 +- tests/unit/appIdentity.test.ts | 19 +- tests/unit/auroraMenuState.test.ts | 13 + tests/unit/clipboardCardState.test.ts | 27 + tests/unit/dashState.test.ts | 27 + tests/unit/dockConfiguration.test.ts | 40 + tests/unit/iconWeaveRegistry.test.ts | 52 + tests/unit/iconWeaveScoring.test.ts | 14 +- tests/unit/intellihideState.test.ts | 22 + tests/unit/lifecycleScope.test.ts | 78 +- tests/unit/meetingClock.test.ts | 25 + tests/unit/pipWindowPolicy.test.ts | 2 + tests/unit/schema.test.ts | 4 + tests/unit/sniIconState.test.ts | 23 + tests/unit/trayLayout.test.ts | 41 + 115 files changed, 6694 insertions(+), 5129 deletions(-) create mode 100644 src/capture/captureOcrSession.ts create mode 100644 src/capture/captureToolbar.ts create mode 100644 src/capture/captureToolbarPositioner.ts create mode 100644 src/capture/screenshotHooks.ts create mode 100644 src/clipboard/clipboardCardBuilders.ts create mode 100644 src/clipboard/clipboardCardLayouts.ts create mode 100644 src/clipboard/clipboardCardState.ts delete mode 100644 src/clipboard/linkMetadata.ts create mode 100644 src/core/mainLoop.ts create mode 100644 src/desktop/trayIcons/sniEntry.ts create mode 100644 src/desktop/trayIcons/sniIconState.ts create mode 100644 src/desktop/trayIcons/trayClipArea.ts create mode 100644 src/desktop/trayIcons/trayLayout.ts create mode 100644 src/dock/contextualDragReveal.ts create mode 100644 src/dock/dockBinding.ts create mode 100644 src/dock/dockConfiguration.ts create mode 100644 src/dock/externalStorageMonitor.ts create mode 100644 src/dock/externalStorageOperations.ts create mode 100644 src/panel/auroraRecentItems.ts create mode 100644 src/panel/clock/meetingClock/meetingAlertController.ts create mode 100644 src/panel/clock/meetingClock/meetingClockPill.ts create mode 100644 src/patches/iconWeaveInspector.ts create mode 100644 src/patches/iconWeavePatches.ts create mode 100644 src/patches/iconWeaveRegistry.ts create mode 100644 src/shared/ui/dashApplications.ts create mode 100644 src/shared/ui/dashFixedItems.ts create mode 100644 src/shared/ui/dashSpringLoad.ts create mode 100644 src/shared/ui/dashState.ts create mode 100644 src/shared/ui/dashVisibility.ts create mode 100644 tests/unit/clipboardCardState.test.ts create mode 100644 tests/unit/dashState.test.ts create mode 100644 tests/unit/dockConfiguration.test.ts create mode 100644 tests/unit/iconWeaveRegistry.test.ts create mode 100644 tests/unit/sniIconState.test.ts create mode 100644 tests/unit/trayLayout.test.ts diff --git a/AGENTS.md b/AGENTS.md index e4123ce..a49d772 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,29 @@ Per the GNOME review guidelines, clipboard-related keyboard shortcuts must not s - Constants: `UPPER_CASE` - Keep `enable()` and `disable()` symmetric. - Read settings through `this.context.settings`. Importing `Main`/`Shell`/`St` directly is fine — keep heavy algorithms in shell-free pure files so they stay unit-testable. +- Optimize refactors for human readability, not line count. Do not compress control flow, callback bodies, + object literals, or several operations onto one line merely to shorten a file. +- Visually separate guard clauses, state preparation, actor mutation, animation, scheduling, and cleanup + with blank lines. Keep local constants next to the logical block that consumes them; avoid unexplained + aliases in the middle of a stateful method. +- Do not add pass-through methods that only forward the same arguments to a stored function or object. + Expose a meaningful domain operation, return the required callable directly, or keep the call at its + natural owner. +- Do not hide lifecycle invariants behind optional chaining with fallback values, such as + `owner?.value ?? default` or `owner?.operation() ?? false`. At public boundaries, guard the inactive + state explicitly and access stable fields directly during synchronous work. Reserve optional + chaining and nullish fallbacks for genuinely optional external data and idempotent cleanup. +- Do not create a local alias for an instance field merely to shorten `this._field`, repeat the same + name, or satisfy nullable type narrowing during synchronous work. Guard the field explicitly and + use it directly when it cannot change inside the block. A snapshot of an instance field is justified + only when it transfers ownership before the field is cleared or captures the exact resource across + an `await` or asynchronous callback. A local result is also appropriate for a genuinely dynamic + lookup or computation that must remain stable; directly reading `this._field` is not such a lookup. + Name identity captures explicitly, such as `scheduledRetry` or `activeRequest`, so the reason is + visible. +- Before finishing a refactor, review every newly created or substantially edited file as prose: expand + dense one-line branches and loops, remove redundant wrappers, and make lifecycle ownership obvious + without requiring the reader to infer it from implementation details. ## Human Review Quality Bar @@ -185,29 +208,50 @@ Changes intended for the production extension must follow both: Apply these rules during implementation and review: -- Target the Shell versions declared in `metadata.json`; do not add speculative compatibility checks - or optional calls for APIs guaranteed by those versions. -- Keep `extension.ts` small and keep `enable()`/`disable()` close, symmetric, and limited to lifecycle - orchestration. -- Every signal, GLib source, cancellable, child actor, menu, and other resource created by a component - must be cleaned up by that same component. Remove sources and signals before destroying owned actors, - and call `super.destroy()` last in widget overrides. -- Override a widget's `destroy()` method for its cleanup. Do not connect the widget's own `destroy` - signal as a substitute. +- Target only the Shell versions declared in `metadata.json`. Do not add speculative compatibility + branches, `typeof method === 'function'` checks, or optional calls for methods guaranteed by those + versions. For real multi-version support, follow the + [official port guide](https://gjs.guide/extensions/upgrading/gnome-shell.html). +- Do not wrap deterministic lifecycle methods such as `destroy()`, `connect()`, `disconnect()`, + `disconnectObject()`, `abort()`, `GLib.Source.remove()`, or `Gio.DBusConnection.unregister_object()` + in defensive `try`/`catch`. Catch failures only at operations whose contract can genuinely fail, + such as I/O, parsing, D-Bus calls, subprocesses, and asynchronous result propagation. +- Do not add optional calls such as `object?.method(...)` or `object?.method?.(...)` when the object and + method are guaranteed by the active lifecycle or the targeted API. Use an explicit boundary guard + when the owning object itself is legitimately inactive or absent. - Do not add `_enabled`, `_destroyed`, or similar lifecycle flags when owned references, cancellables, - or the underlying GObject lifecycle already express the state. Any unavoidable exception needs a - concise invariant comment and regression coverage. -- Avoid defensive `try`/`catch` around deterministic cleanup and avoid trivial comments that merely - restate the next line. -- Do not use emoji or ASCII art as UI icons, do not ship placeholders, keep generated JavaScript lines - at 200 characters or fewer, and keep production packages free of developer-only files. -- Avoid subprocesses in the Shell process. If a subprocess is unavoidable, document why a D-Bus - service is not practical and keep invocation local, explicit, cancellable, and free of shell - interpretation. + or the underlying GObject lifecycle already express the state. After destruction, the owner must + clear its reference and must not call the instance again. +- In widget `destroy()` overrides, remove GLib sources and timeouts first, disconnect signals next, + release owned children and references after that, and call `super.destroy()` last. A widget must + override its own `destroy()` method instead of connecting its own `destroy` signal for cleanup; + observing the destruction of an external actor is valid when the observer owns that connection. +- Every signal, GLib source, cancellable, child actor, menu, Soup session, and other resource created + by a component must be cleaned up by that same component. Never spread initialization and cleanup + ownership across unrelated classes. +- When a repeatable operation creates a timeout, remove or replace its prior source immediately next + to the new source creation. Do not separate replacement and creation into distant methods or blocks. +- Keep `extension.ts` minimal. Keep `enable()` and `disable()` adjacent, symmetric, and limited to + lifecycle orchestration; avoid aliases that merely forward lifecycle calls. Never ship empty, + placeholder, or partially implemented lifecycle methods. +- Split large features into cohesive, single-responsibility modules. Extract repeated logic into + helpers instead of copying blocks. Modules imported by both Shell and preferences must remain free + of `St`, `Clutter`, `Gtk`, `Gdk`, and `Adw`; keep process-specific UI under clearly named runtime or + `preferences/` directories. +- Keep the extension's schema ID in `metadata.json` as `settings-schema` and call `this.getSettings()` + without repeating the schema ID in source code. +- Use `St.Icon` or `icon_name` for Shell UI and `Gtk.Image` for preferences. Do not use Unicode emoji + as icons or ASCII strings as progress indicators; use Shell widgets such as `BarLevel` or `St.Bin`. +- Keep generated JavaScript lines at 200 characters or fewer. Prefer self-explanatory names and remove + comments that restate syntax or translate the following statement into prose. +- Avoid subprocesses in the Shell process. Prefer D-Bus for system services and move heavy work to a + separate application. If a subprocess is unavoidable, document why D-Bus is not practical and keep + invocation local, explicit, cancellable, and free of shell interpretation. - Review every Shexli finding. Fix real ownership/lifecycle defects and record accepted manual-review findings or analyzer false positives in `EGO_REVIEW.md`. -- Do not add optional calls such as `object?.method?.(...)` unless that method is a real, documented API or the local type intentionally models it. Never use patterns like `this.disconnectObject?.(this)` on objects that do not own that signal connection contract. +- Never use patterns like `this.disconnectObject?.(this)` on objects that do not own that signal + connection contract. - Do not ship fake behavior. If a UI label, schema description, README entry, or module subtitle says a feature is wired to NetworkManager, ModemManager, UPower, sensors, widgets, or GNOME internals, the code must actually call the relevant API or clearly describe itself as a fallback. - Keep runtime capability checks honest. Hardware-specific modules must detect missing services/devices at runtime and stay inactive or degrade explicitly. - Do not scatter `as unknown as ...` casts through feature modules. If GObject construction or Shell internals require a cast, isolate it in a small shared helper/factory with a clear name. diff --git a/EGO_REVIEW.md b/EGO_REVIEW.md index e97900e..56dc1d1 100644 --- a/EGO_REVIEW.md +++ b/EGO_REVIEW.md @@ -17,38 +17,54 @@ default, and neither clipboard nor OCR data is shared with third parties. ### Capture Tools OCR subprocess Capture Tools invokes the local `tesseract` executable only after an explicit OCR action. The command -uses `Gio.SubprocessLauncher` arguments directly rather than a shell, supports cancellation and forced +uses `Gio.Subprocess.new()` with an argument vector rather than a shell, supports cancellation and forced termination, and does not transmit captured content. A companion D-Bus service would add installation and lifecycle complexity disproportionate to this optional, user-triggered operation, so this remains a documented subprocess exception. +### Other subprocesses + +Aurora Shell does not package executable binaries or invoke a command through a shell. The remaining +process launches use explicit argument vectors and follow direct user actions: + +- Aurora Menu launches only commands configured by the user and selected from its menu. +- Volume Mixer opens `gnome-control-center sound` from its Sound Settings item. +- Background Apps first requests the application's `quit` action and uses `flatpak kill ` only + as a fallback for a user-selected Quit action. + ## Analyzer interpretation Aurora Shell uses `LifecycleScope`, `connectObject()`, and actor ownership for cleanup. Static analyzers can miss those indirect ownership paths. Treat a warning as a false positive only after tracing the corresponding enable/disable or create/destroy path; do not suppress or ignore findings by category. -`AuroraDash._isDestroyed` is a narrow compatibility exception. The GNOME Shell base Dash creates raw -connections that may call overridden methods after the subclass begins teardown, so the guard protects -those callbacks until the base actor finishes destruction. Keep the invariant comment and Shell -integration coverage if this exception changes. +### Shexli baseline (2026-07-31) -### Shexli baseline (2026-07-28) - -The production ZIP reports five findings, zero errors, and four warnings: +The production ZIP reports four findings, zero errors, and three warnings: - `EGO-A-005` is the declared clipboard manual review described above. -- `EGO-L-002` is a structural false positive. Capture Tools destroys session actors through its - session `LifecycleScope`; Trash and External Storage are custom actors whose menus, monitors, - cancellables, and signals are released by their `destroy()` overrides before `super.destroy()` - destroys the owned child tree. -- `EGO-L-005` is a custom-actor false positive for the non-null `toggleButton` child expected by - `DashItemContainer`. It remains actor-owned and is released by `super.destroy()`. +- `EGO-L-002` is an ownership-indirection false positive. Clipboard Item releases its menu before + `super.destroy()` destroys the card actor tree; Trash and External Storage release their menus, + monitors, cancellables, operations, and signals before their final `super.destroy()`; Meeting Clock + Pill unregisters and destroys its widget in its own `destroy()` method. +- `EGO-L-005` reports child references retained by the short-lived owner object. Clipboard Item's + actions and the Trash/External Storage `toggleButton` are actor-owned and released by + `super.destroy()`. Meeting Clock Pill destroys its widget, after which the module drops the pill + owner itself. - `EGO-L-003` is an indirection false positive. The listed signals are owned by `LifecycleScope`, `connectObject()`, widget destruction, or the corresponding backend/manager `destroy()` method. -- `EGO-L-004` is an indirection false positive. Clipboard History removes its startup idle through - `LifecycleScope`; Aurora Dash removes all six stored source IDs in `destroy()`; Auto Theme Switcher - registers `_cancelScheduledTick()` in its `LifecycleScope`. + This includes the signals in Capture Tools, Clipboard History/Panel, Tray Icons, Aurora Menu, + Bluetooth, Meeting/Weather Clock, Lock Keys, Low Battery, Volume Mixer, App Search Tooltip, Privacy, + Theme Changer, and Auto Theme Switcher. A scope deterministically disconnects its registrations in + reverse order when the owning module or widget is disabled or destroyed. + +Capture Tools destroys its session actors directly in `disable()`. Replaceable main-loop sources are +owned by `LifecycleScope` through `ManagedSource`; replacing a source removes the previous one and +disposing the scope removes the active source. Aurora Dash, Dock bindings, Clipboard History, Auto +Theme Switcher, clocks, tray widgets, Bluetooth, and the remaining single-source owners use this +path. Dynamic source collections in Icon Weave and Dock Intellihide remain explicitly removed because +their per-operation ownership is clearer as a set. Shexli does not currently report `EGO-L-004` for +either cleanup form. Recheck this classification against every new Shexli run. A stable rule ID does not imply that new locations are automatically accepted. diff --git a/data/po/pt_BR.po b/data/po/pt_BR.po index 0c81c0c..8df036e 100644 --- a/data/po/pt_BR.po +++ b/data/po/pt_BR.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: aurora-shell\n" "Report-Msgid-Bugs-To: https://github.com/luminusOS/aurora-shell/issues\n" -"POT-Creation-Date: 2026-07-16 13:57-0300\n" +"POT-Creation-Date: 2026-07-31 15:36-0300\n" "PO-Revision-Date: 2026-07-16 09:20-0300\n" "Last-Translator: Aurora Shell Contributors\n" "Language-Team: Portuguese (Brazil)\n" @@ -16,116 +16,116 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" -#: dist/capture/captureTools.js:39 +#: dist/capture/captureTools.js:40 msgid "Selection" msgstr "Seleção" -#: dist/capture/captureTools.js:40 +#: dist/capture/captureTools.js:41 msgid "Pointer" msgstr "Ponteiro" -#: dist/capture/captureTools.js:41 +#: dist/capture/captureTools.js:42 msgid "Freehand" msgstr "Desenho livre" -#: dist/capture/captureTools.js:42 +#: dist/capture/captureTools.js:43 msgid "Rectangle" msgstr "Retângulo" -#: dist/capture/captureTools.js:43 +#: dist/capture/captureTools.js:44 msgid "Solid rectangle" msgstr "Retângulo preenchido" -#: dist/capture/captureTools.js:44 +#: dist/capture/captureTools.js:45 msgid "Highlighter" msgstr "Marca-texto" -#: dist/capture/captureTools.js:45 +#: dist/capture/captureTools.js:46 msgid "Arrow" msgstr "Seta" -#: dist/capture/captureTools.js:46 +#: dist/capture/captureTools.js:47 msgid "Text" msgstr "Texto" -#: dist/capture/captureTools.js:47 +#: dist/capture/captureTools.js:48 msgid "Numbered marker" msgstr "Marcador numerado" -#: dist/capture/captureTools.js:111 dist/capture/captureTools.js:344 +#: dist/capture/captureTools.js:113 dist/capture/captureTools.js:366 msgid "Extract text" msgstr "Extrair texto" -#: dist/capture/captureTools.js:264 +#: dist/capture/captureTools.js:268 dist/capture/captureTools.js:289 msgid "Screenshot failed" msgstr "Falha na captura de tela" -#: dist/capture/captureTools.js:264 +#: dist/capture/captureTools.js:268 dist/capture/captureTools.js:289 msgid "Could not export the annotated screenshot" msgstr "Não foi possível exportar a captura de tela anotada" -#: dist/capture/captureTools.js:279 +#: dist/capture/captureTools.js:301 msgid "Move toolbar" msgstr "Mover barra de ferramentas" -#: dist/capture/captureTools.js:303 +#: dist/capture/captureTools.js:325 msgid "Annotation color" msgstr "Cor da anotação" -#: dist/capture/captureTools.js:319 +#: dist/capture/captureTools.js:341 msgid "Annotation width" msgstr "Espessura da anotação" -#: dist/capture/captureTools.js:331 +#: dist/capture/captureTools.js:353 msgid "Undo" msgstr "Desfazer" -#: dist/capture/captureTools.js:334 +#: dist/capture/captureTools.js:356 msgid "Clear annotations" msgstr "Limpar anotações" -#: dist/capture/captureTools.js:358 dist/capture/captureTools.js:362 +#: dist/capture/captureTools.js:380 dist/capture/captureTools.js:384 msgid "Copy text" msgstr "Copiar texto" -#: dist/capture/captureTools.js:363 dist/capture/captureTools.js:367 +#: dist/capture/captureTools.js:385 dist/capture/captureTools.js:389 msgid "Search the web" msgstr "Pesquisar na web" -#: dist/capture/captureTools.js:478 +#: dist/capture/captureTools.js:501 msgid "Type annotation text" msgstr "Digite o texto da anotação" -#: dist/capture/captureTools.js:520 dist/capture/captureTools.js:551 -#: dist/capture/captureTools.js:574 +#: dist/capture/captureTools.js:543 dist/capture/captureTools.js:574 +#: dist/capture/captureTools.js:597 msgid "OCR" msgstr "OCR" -#: dist/capture/captureTools.js:520 +#: dist/capture/captureTools.js:543 msgid "No text found in the screenshot" msgstr "Nenhum texto encontrado na captura de tela" -#: dist/capture/captureTools.js:534 +#: dist/capture/captureTools.js:557 msgid "OCR unavailable" msgstr "OCR indisponível" -#: dist/capture/captureTools.js:534 +#: dist/capture/captureTools.js:557 msgid "Tesseract OCR and language data are not installed" msgstr "O Tesseract OCR e os dados de idioma não estão instalados" -#: dist/capture/captureTools.js:537 +#: dist/capture/captureTools.js:560 msgid "OCR failed" msgstr "Falha no OCR" -#: dist/capture/captureTools.js:537 +#: dist/capture/captureTools.js:560 msgid "Text recognition failed" msgstr "Falha no reconhecimento de texto" -#: dist/capture/captureTools.js:551 +#: dist/capture/captureTools.js:574 msgid "Recognized text copied" msgstr "Texto reconhecido copiado" -#: dist/capture/captureTools.js:574 +#: dist/capture/captureTools.js:597 msgid "Could not open the web search" msgstr "Não foi possível abrir a pesquisa na web." @@ -181,11 +181,11 @@ msgstr "Bing" msgid "Screenshot taken" msgstr "Captura de tela realizada" -#: dist/capture/screenshotCapture.js:204 +#: dist/capture/screenshotCapture.js:205 msgid "Screenshots" msgstr "Capturas de tela" -#: dist/capture/screenshotCapture.js:212 +#: dist/capture/screenshotCapture.js:213 #, javascript-format msgid "Screenshot From %s" msgstr "Captura de tela de %s" @@ -229,24 +229,24 @@ msgstr "Intervalo de verificação (ms)" msgid "How often to check the clipboard for changes" msgstr "Frequência de verificação de alterações na área de transferência" -#: dist/clipboard/clipboardItem.js:323 +#: dist/clipboard/clipboardItem.js:324 #, javascript-format msgid "%d lines" msgstr "%d linhas" -#: dist/clipboard/clipboardItem.js:382 +#: dist/clipboard/clipboardItem.js:383 msgid "Copy" msgstr "Copiar" -#: dist/clipboard/clipboardItem.js:385 +#: dist/clipboard/clipboardItem.js:386 msgid "Unpin" msgstr "Desafixar" -#: dist/clipboard/clipboardItem.js:385 +#: dist/clipboard/clipboardItem.js:386 msgid "Pin" msgstr "Fixar" -#: dist/clipboard/clipboardItem.js:388 +#: dist/clipboard/clipboardItem.js:389 msgid "Delete" msgstr "Excluir" @@ -258,12 +258,12 @@ msgstr "O histórico da área de transferência está vazio" msgid "Copy text, links, code, or images to see them here." msgstr "Copie textos, links, códigos ou imagens para vê-los aqui." -#: dist/clipboard/clipboardPanel.js:40 +#: dist/clipboard/clipboardPanel.js:41 msgid "Search…" msgstr "Pesquisar…" #: dist/desktop/trayIcons/backgroundAppsSource.js:109 -#: dist/dock/externalStorageIcon.js:237 dist/dock/trashIcon.js:94 +#: dist/dock/externalStorageIcon.js:249 dist/dock/trashIcon.js:107 msgid "Open" msgstr "Abrir" @@ -390,33 +390,61 @@ msgstr "Mostrar armazenamento externo" msgid "Show removable drives in the dock when they are connected" msgstr "Mostra unidades removíveis no dock quando estão conectadas" -#: dist/dock/externalStorageIcon.js:237 +#: dist/dock/dock.manifest.js:45 +msgid "Icon Hover & Press Effects" +msgstr "" + +#: dist/dock/dock.manifest.js:46 +msgid "Animate dock icons on hover and click" +msgstr "" + +#: dist/dock/dock.manifest.js:51 +msgid "Effect Intensity" +msgstr "" + +#: dist/dock/dock.manifest.js:52 +msgid "How strong the hover and press effects are" +msgstr "" + +#: dist/dock/dock.manifest.js:55 +msgid "Subtle" +msgstr "" + +#: dist/dock/dock.manifest.js:56 +msgid "Balanced" +msgstr "" + +#: dist/dock/dock.manifest.js:57 +msgid "Expressive" +msgstr "" + +#: dist/dock/externalStorageIcon.js:249 msgid "Mount and Open" msgstr "Montar e abrir" -#: dist/dock/externalStorageIcon.js:241 +#: dist/dock/externalStorageIcon.js:253 msgid "Eject" msgstr "Ejetar" -#: dist/dock/externalStorageIcon.js:241 +#: dist/dock/externalStorageIcon.js:253 msgid "Unmount" msgstr "Desmontar" -#: dist/dock/externalStorageIcon.js:267 +#: dist/dock/externalStorageIcon.js:280 #, javascript-format msgid "Failed to open “%s”" msgstr "Falha ao abrir “%s”" -#: dist/dock/externalStorageIcon.js:318 +#: dist/dock/externalStorageIcon.js:332 #, javascript-format msgid "Failed to eject “%s”" msgstr "Falha ao ejetar “%s”" -#: dist/dock/trashIcon.js:39 dist/dock/trashIcon.js:49 +#: dist/dock/trashIcon.js:38 dist/dock/trashIcon.js:48 msgid "Trash" msgstr "Lixeira" -#: dist/dock/trashIcon.js:97 +#: dist/dock/trashIcon.js:110 msgid "Empty Trash" msgstr "Esvaziar lixeira" @@ -596,27 +624,27 @@ msgstr "" "Mostra o nível da bateria e ícones animados no painel de Configurações " "Rápidas do Bluetooth" -#: dist/panel/clock/meetingClock/meetingClock.js:332 +#: dist/panel/clock/meetingClock/meetingClock.js:324 msgid "Meeting starting soon" msgstr "Reunião começará em breve" -#: dist/panel/clock/meetingClock/meetingClock.js:340 +#: dist/panel/clock/meetingClock/meetingClock.js:332 msgid "Join" msgstr "Participar" -#: dist/panel/clock/meetingClock/meetingClock.js:341 +#: dist/panel/clock/meetingClock/meetingClock.js:333 msgid "Snooze" msgstr "Adiar" -#: dist/panel/clock/meetingClock/meetingClock.js:342 +#: dist/panel/clock/meetingClock/meetingClock.js:334 msgid "Dismiss" msgstr "Dispensar" -#: dist/panel/clock/meetingClock/meetingClock.js:343 +#: dist/panel/clock/meetingClock/meetingClock.js:335 msgid "Ignore" msgstr "Ignorar" -#: dist/panel/clock/meetingClock/meetingClock.js:504 +#: dist/panel/clock/meetingClock/meetingClock.js:477 #: dist/panel/clock/meetingClock/meetingClock.manifest.js:8 msgid "Meeting Clock" msgstr "Relógio de reuniões" @@ -721,29 +749,33 @@ msgid "Shows battery percentage in the panel while below 30%" msgstr "" "Mostra a porcentagem da bateria no painel enquanto estiver abaixo de 30%" -#: dist/panel/volumeMixer/mixerItem.js:94 +#: dist/panel/volumeMixer/mixerItem.js:114 +msgid "Audio" +msgstr "Áudio" + +#: dist/panel/volumeMixer/mixerItem.js:140 msgid "Unknown" msgstr "Desconhecido" -#: dist/panel/volumeMixer/mixerPanel.js:18 +#: dist/panel/volumeMixer/mixerPanel.js:29 msgid "No audio playing" msgstr "Nenhum áudio em reprodução" -#: dist/panel/volumeMixer/streamSlider.js:108 +#: dist/panel/volumeMixer/streamSlider.js:112 msgid "Volume changed" msgstr "Volume alterado" -#: dist/panel/volumeMixer/volumeMixer.js:76 +#: dist/panel/volumeMixer/volumeMixer.js:77 msgid "Sound Settings" msgstr "Configurações de som" -#: dist/panel/volumeMixer/volumeMixer.js:94 -#: dist/panel/volumeMixer/volumeMixer.js:104 +#: dist/panel/volumeMixer/volumeMixer.js:95 +#: dist/panel/volumeMixer/volumeMixer.js:114 #: dist/panel/volumeMixer/volumeMixer.manifest.js:8 msgid "Volume Mixer" msgstr "Mixer de Volume" -#: dist/panel/volumeMixer/volumeMixer.js:114 +#: dist/panel/volumeMixer/volumeMixer.js:124 msgid "Sound Output" msgstr "Saída de som" @@ -751,6 +783,17 @@ msgstr "Saída de som" msgid "Per-application volume control in Quick Settings" msgstr "Controle de volume por aplicativo nas Configurações Rápidas" +#: dist/panel/volumeMixer/volumeMixer.manifest.js:13 +msgid "Always Show" +msgstr "Sempre mostrar" + +#: dist/panel/volumeMixer/volumeMixer.manifest.js:14 +msgid "" +"Show the Volume Mixer button even when no applications are playing audio" +msgstr "" +"Mostrar o botão do Mixer de Volume mesmo quando nenhum aplicativo estiver " +"reproduzindo áudio" + #: dist/patches/appSearchTooltip.manifest.js:8 msgid "App Search Tooltip" msgstr "Dica de pesquisa de aplicativos" @@ -796,7 +839,9 @@ msgstr "PiP Sempre no Topo" #: dist/patches/pipOnTop.manifest.js:9 msgid "Keeps Picture-in-Picture windows above others across workspaces" -msgstr "Mantém janelas Picture-in-Picture acima das demais em todas as áreas de trabalho" +msgstr "" +"Mantém janelas Picture-in-Picture acima das demais em todas as áreas de " +"trabalho" #: dist/patches/velaVpnQuickSettings.manifest.js:8 msgid "Vela VPN Quick Settings" @@ -846,73 +891,73 @@ msgid "Menu Commands" msgstr "Comandos do menu" #: dist/preferences/commandListEditor.js:63 -#: dist/preferences/commandListEditor.js:203 +#: dist/preferences/commandListEditor.js:230 msgid "Add Command" msgstr "Adicionar comando" -#: dist/preferences/commandListEditor.js:75 +#: dist/preferences/commandListEditor.js:86 msgid "No commands yet" msgstr "Nenhum comando ainda" -#: dist/preferences/commandListEditor.js:76 +#: dist/preferences/commandListEditor.js:87 msgid "Use Add Command to create a shortcut in Aurora Menu" msgstr "Use Adicionar comando para criar um atalho no Menu Aurora" -#: dist/preferences/commandListEditor.js:120 +#: dist/preferences/commandListEditor.js:147 msgid "Move Up" msgstr "Mover para cima" -#: dist/preferences/commandListEditor.js:121 +#: dist/preferences/commandListEditor.js:148 msgid "Move Down" msgstr "Mover para baixo" -#: dist/preferences/commandListEditor.js:122 -#: dist/preferences/commandListEditor.js:203 +#: dist/preferences/commandListEditor.js:149 +#: dist/preferences/commandListEditor.js:230 msgid "Edit Command" msgstr "Editar comando" -#: dist/preferences/commandListEditor.js:123 +#: dist/preferences/commandListEditor.js:150 msgid "Remove Command" msgstr "Remover comando" -#: dist/preferences/commandListEditor.js:142 +#: dist/preferences/commandListEditor.js:169 msgid "Name" msgstr "Nome" -#: dist/preferences/commandListEditor.js:147 +#: dist/preferences/commandListEditor.js:174 msgid "Command" msgstr "Comando" -#: dist/preferences/commandListEditor.js:164 +#: dist/preferences/commandListEditor.js:191 msgid "The name cannot contain “|”" msgstr "O nome não pode conter “|”" -#: dist/preferences/commandListEditor.js:179 +#: dist/preferences/commandListEditor.js:206 msgid "Choose the name shown in Aurora Menu and the command to run." msgstr "Escolha o nome exibido no Menu Aurora e o comando a ser executado." -#: dist/preferences/commandListEditor.js:190 +#: dist/preferences/commandListEditor.js:217 msgid "Save" msgstr "Salvar" -#: dist/preferences/commandListEditor.js:190 +#: dist/preferences/commandListEditor.js:217 msgid "Add" msgstr "Adicionar" -#: dist/preferences/commandListEditor.js:239 +#: dist/preferences/commandListEditor.js:266 msgid "Remove Command?" msgstr "Remover comando?" -#: dist/preferences/commandListEditor.js:240 +#: dist/preferences/commandListEditor.js:267 #, javascript-format msgid "“%s” will be removed from Aurora Menu." msgstr "“%s” será removido do Menu Aurora." -#: dist/preferences/commandListEditor.js:245 +#: dist/preferences/commandListEditor.js:272 msgid "Cancel" msgstr "Cancelar" -#: dist/preferences/commandListEditor.js:246 +#: dist/preferences/commandListEditor.js:273 msgid "Remove" msgstr "Remover" diff --git a/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml b/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml index bcfc550..2bd3573 100644 --- a/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml +++ b/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml @@ -67,6 +67,11 @@ Enable Volume Mixer module Per-application volume control in Quick Settings + + false + Always show Volume Mixer + Show the Volume Mixer button even when no applications are playing audio + true Enable XWayland Indicator module diff --git a/src/capture/captureOcrSession.ts b/src/capture/captureOcrSession.ts new file mode 100644 index 0000000..f2a5926 --- /dev/null +++ b/src/capture/captureOcrSession.ts @@ -0,0 +1,329 @@ +import Clutter from '@girs/clutter-18'; +import Gio from '@girs/gio-2.0'; +import St from '@girs/st-18'; +import { gettext as _ } from '~/shared/i18n.ts'; +import * as Main from '@girs/gnome-shell/ui/main'; + +import type { AnnotationCanvas } from './annotationCanvas.ts'; +import { CaptureTooltip } from './captureTooltip.ts'; +import { iconButton } from './captureToolbar.ts'; +import { OcrController, OcrUnavailableError } from './ocrController.ts'; +import { buildWebSearchUri, placeOcrActionBelow, type OcrWord } from './ocrLogic.ts'; +import { captureScreenshot } from './screenshotCapture.ts'; +import type { ScreenshotUi } from './screenshotUiAdapter.ts'; +import { LifecycleScope } from '~/core/lifecycleScope.ts'; +import { logger } from '~/core/logger.ts'; +import type { SettingsManager } from '~/core/settings.ts'; + +const OCR_ENABLED_KEY = 'capture-tools-ocr-enabled'; +const WEB_SEARCH_ENGINE_KEY = 'capture-tools-web-search-engine'; + +type SelectionGeometry = { + x: number; + y: number; + width: number; + height: number; +}; + +export class CaptureOcrSession { + private _scope = new LifecycleScope(); + private _controller: OcrController | null; + private _layer: St.Widget; + private _panel: St.BoxLayout; + private _button: St.Button; + private _buttonTooltip: InstanceType; + private _actionTooltips: InstanceType[] = []; + private _text = ''; + private _busy = false; + private _availabilityOverride: boolean | null = null; + + constructor( + private _ui: ScreenshotUi, + private _canvas: InstanceType, + private _settings: SettingsManager, + ) { + this._controller = new OcrController(_settings); + this._button = this._createButton(); + this._panel = this._createPanel(); + this._layer = this._createLayer(this._panel); + this._buttonTooltip = new CaptureTooltip(); + this._buttonTooltip.configure(this._button, _('Extract text')); + + _ui._showPointerButtonContainer.insert_child_below(this._button, _ui._showPointerButton); + _ui.add_child(this._buttonTooltip); + _ui.add_child(this._layer); + + this._scope.connect(_settings, `changed::${OCR_ENABLED_KEY}`, () => this.syncButton(false)); + } + + get available(): boolean | null { + if (this._availabilityOverride !== null) { + return this._availabilityOverride; + } + + if (!this._controller) return null; + + return this._controller.available; + } + + get availabilityOverridden(): boolean { + return this._availabilityOverride !== null; + } + + get hasResult(): boolean { + return this._text.length > 0; + } + + get panelVisible(): boolean { + return this._panel.visible; + } + + get searchUri(): string | null { + if (!this._text) return null; + + return buildWebSearchUri(this._text, this._settings.getString(WEB_SEARCH_ENGINE_KEY)); + } + + async run(): Promise { + const controller = this._controller; + if (!controller || this._busy) return; + + this._busy = true; + this._text = ''; + this._canvas.setOcrWords([]); + this.hidePanel(); + + try { + const capture = await captureScreenshot(this._ui, false); + if (this._controller !== controller) return; + + const result = await controller.recognize(capture.pixbuf, capture.scale, capture.origin); + if (this._controller !== controller) return; + + if (!result.text) { + this.clear(); + Main.notify(_('OCR'), _('No text found in the screenshot')); + return; + } + + this._text = result.text; + this._canvas.setOcrWords(result.words); + this._showAction({ + x: capture.origin.x, + y: capture.origin.y, + width: capture.pixbuf.get_width() / capture.scale, + height: capture.pixbuf.get_height() / capture.scale, + }); + } catch (error) { + if (this._controller !== controller) return; + + this._canvas.setOcrWords([]); + if (error instanceof OcrUnavailableError) { + Main.notify(_('OCR unavailable'), _('Tesseract OCR and language data are not installed')); + } else { + logger.warn(`[CaptureTools] OCR failed: ${String(error)}`); + Main.notify(_('OCR failed'), _('Text recognition failed')); + } + + this.hidePanel(); + } finally { + if (this._controller === controller) { + this._busy = false; + } + } + } + + async probe(): Promise { + const controller = this._controller; + if (!controller) return; + + try { + await controller.probe(); + } catch (error) { + if (this._controller === controller) { + logger.warn(`[CaptureTools] Tesseract probe failed: ${String(error)}`); + } + } finally { + if (this._controller === controller) { + this.syncButton(false); + } + } + } + + syncButton(portalMode: boolean): void { + const enabled = + this._availabilityOverride !== null || this._settings.getBoolean(OCR_ENABLED_KEY); + this._button.visible = + enabled && this.available === true && !portalMode && this._ui._shotButton.checked === true; + + if (!this._button.visible) { + this._buttonTooltip.close(); + } + } + + setAvailabilityOverride(available: boolean | null): void { + this._availabilityOverride = available; + } + + injectResult(text: string, selection: SelectionGeometry): void { + const words: OcrWord[] = text + .split(/\s+/) + .filter(Boolean) + .slice(0, 6) + .map((word, index) => ({ + text: word, + confidence: 96, + lineKey: 'devtool:1', + bounds: { + x: selection.x + 18 + index * 72, + y: selection.y + 20, + width: Math.max(36, Math.min(68, word.length * 9)), + height: 24, + }, + })); + + this._text = text; + this._canvas.setOcrWords(words); + this._showAction(selection); + } + + copy(): boolean { + if (!this._text) return false; + + const clipboard = St.Clipboard.get_default(); + clipboard.set_text(St.ClipboardType.CLIPBOARD, this._text); + clipboard.set_text(St.ClipboardType.PRIMARY, this._text); + this.hidePanel(); + Main.notify(_('OCR'), _('Recognized text copied')); + return true; + } + + search(): boolean { + const uri = this.searchUri; + if (!uri) return false; + + void this._launchSearch(uri); + return true; + } + + clear(): void { + this._controller?.cancel(); + this._text = ''; + this._canvas.setOcrWords([]); + this.hidePanel(); + } + + hidePanel(): void { + this._panel.hide(); + + for (const tooltip of this._actionTooltips) { + tooltip.close(); + } + } + + destroy(): void { + this._controller?.destroy(); + this._controller = null; + this._scope.dispose(); + + for (const tooltip of this._actionTooltips) { + tooltip.destroy(); + } + this._actionTooltips = []; + + this._layer.destroy(); + this._buttonTooltip.destroy(); + this._button.destroy(); + this._text = ''; + this._busy = false; + this._availabilityOverride = null; + } + + private _createButton(): St.Button { + const button = new St.Button({ + style_class: 'screenshot-ui-show-pointer-button capture-tools-native-ocr-button', + icon_name: 'scanner-symbolic', + accessible_name: _('Extract text'), + toggle_mode: false, + can_focus: true, + }); + button.connect('clicked', () => void this.run()); + return button; + } + + private _createPanel(): St.BoxLayout { + const panel = new St.BoxLayout({ + style_class: 'screenshot-ui-panel capture-tools-ocr-panel', + reactive: true, + visible: false, + }); + + const copy = iconButton('edit-copy-symbolic', _('Copy text')); + copy.add_style_class_name('capture-tools-ocr-copy-button'); + copy.connect('clicked', () => this.copy()); + panel.add_child(copy); + this._addActionTooltip(copy, _('Copy text')); + + const search = iconButton('system-search-symbolic', _('Search the web')); + search.add_style_class_name('capture-tools-ocr-search-button'); + search.connect('clicked', () => this.search()); + panel.add_child(search); + this._addActionTooltip(search, _('Search the web')); + + return panel; + } + + private _createLayer(panel: St.BoxLayout): St.Widget { + const layer = new St.Widget({ reactive: false, x_expand: true, y_expand: true }); + layer.add_constraint( + new Clutter.BindConstraint({ + source: global.stage, + coordinate: Clutter.BindCoordinate.ALL, + }), + ); + layer.add_child(panel); + return layer; + } + + private _addActionTooltip(anchor: St.Button, text: string): void { + const tooltip = new CaptureTooltip(); + tooltip.configure(anchor, text); + this._ui.add_child(tooltip); + this._actionTooltips.push(tooltip); + } + + private _showAction(selection: SelectionGeometry): void { + const [, naturalWidth] = this._panel.get_preferred_width(-1); + const [, naturalHeight] = this._panel.get_preferred_height(naturalWidth); + const position = placeOcrActionBelow( + selection, + { width: naturalWidth, height: naturalHeight }, + { width: global.stage.width, height: global.stage.height }, + ); + + this._panel.set_position(position.x, position.y); + this._panel.show(); + } + + private async _launchSearch(uri: string): Promise { + const launchContext = global.create_app_launch_context(global.get_current_time(), -1); + this.hidePanel(); + + try { + await new Promise((resolve, reject) => { + Gio.app_info_launch_default_for_uri_async(uri, launchContext, null, (_source, result) => { + try { + Gio.app_info_launch_default_for_uri_finish(result); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + this._ui.close(true); + } catch (error) { + logger.warn(`[CaptureTools] Could not open OCR web search: ${String(error)}`); + Main.notifyError(_('OCR'), _('Could not open the web search')); + } + } +} diff --git a/src/capture/captureToolbar.ts b/src/capture/captureToolbar.ts new file mode 100644 index 0000000..0e09f5c --- /dev/null +++ b/src/capture/captureToolbar.ts @@ -0,0 +1,141 @@ +import Clutter from '@girs/clutter-18'; +import St from '@girs/st-18'; +import { Slider } from '@girs/gnome-shell/ui/slider'; + +import { gettext as _ } from '~/shared/i18n.ts'; +import type { AnnotationTool } from '~/capture/annotationModel.ts'; +import { createIcon } from '~/shared/icons.ts'; + +export const CAPTURE_COLORS = [ + '#ffffff', + '#000000', + '#e01b24', + '#ff8800', + '#ffdd00', + '#44cc44', + '#4488ff', + '#aa44ff', +] as const; +export const CAPTURE_WIDTH_MIN = 1; +export const CAPTURE_WIDTH_MAX = 16; +const TOOLS: ReadonlyArray<{ tool: AnnotationTool; icon: string; label: string }> = [ + { tool: 'select', icon: 'selection-opaque-3-symbolic', label: _('Selection') }, + { tool: 'pointer', icon: 'pointer-primary-click-symbolic', label: _('Pointer') }, + { tool: 'freehand', icon: 'document-edit-symbolic', label: _('Freehand') }, + { tool: 'rectangle', icon: 'square-outline-thick-symbolic', label: _('Rectangle') }, + { tool: 'solid-rectangle', icon: 'square-filled-symbolic', label: _('Solid rectangle') }, + { tool: 'highlighter', icon: 'marker-symbolic', label: _('Highlighter') }, + { tool: 'arrow', icon: 'arrow1-top-right-symbolic', label: _('Arrow') }, + { tool: 'text', icon: 'text-insert2-symbolic', label: _('Text') }, + { tool: 'stamp', icon: 'one-circle-symbolic', label: _('Numbered marker') }, +]; + +export type CaptureToolbarCallbacks = { + beginDrag(handle: St.Button, event: Clutter.Event): boolean; + moveDrag(event: Clutter.Event): boolean; + releaseDrag(event: Clutter.Event): boolean; + selectTool(tool: AnnotationTool): void; + selectColor(color: string): void; + setWidth(width: number): void; + undo(): void; + clear(): void; +}; + +export type CaptureToolbarActors = { + actor: St.BoxLayout; + toolButtons: Map; + colorButtons: Map; + widthSlider: Slider; +}; + +export function createCaptureToolbar( + initialWidth: number, + callbacks: CaptureToolbarCallbacks, +): CaptureToolbarActors { + const actor = new St.BoxLayout({ + style_class: 'screenshot-ui-panel capture-tools-toolbar', + reactive: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.START, + y_expand: true, + }); + const toolButtons = new Map(); + const colorButtons = new Map(); + + const drag = iconButton('list-drag-handle-symbolic', _('Move toolbar')); + drag.add_style_class_name('capture-tools-drag-handle'); + drag.connect('button-press-event', (_actor, event) => callbacks.beginDrag(drag, event)); + drag.connect('motion-event', (_actor, event) => callbacks.moveDrag(event)); + drag.connect('button-release-event', (_actor, event) => callbacks.releaseDrag(event)); + actor.add_child(drag); + + for (const [index, definition] of TOOLS.entries()) { + const button = iconButton(definition.icon, definition.label, true); + button.add_style_class_name(`capture-tools-tool-${definition.tool}`); + button.connect('clicked', () => callbacks.selectTool(definition.tool)); + actor.add_child(button); + toolButtons.set(definition.tool, button); + if (index === 1) { + actor.add_child(separator()); + } + } + + actor.add_child(separator()); + + for (const color of CAPTURE_COLORS) { + const button = new St.Button({ + style_class: 'capture-tools-ring-button', + accessible_name: `${_('Annotation color')}: ${color}`, + child: new St.Widget({ + style_class: 'capture-tools-swatch', + style: `background-color: ${color};`, + }), + toggle_mode: true, + can_focus: true, + }); + button.connect('clicked', () => callbacks.selectColor(color)); + actor.add_child(button); + colorButtons.set(color, button); + } + + actor.add_child(separator()); + + const boundedWidth = Math.max(CAPTURE_WIDTH_MIN, Math.min(CAPTURE_WIDTH_MAX, initialWidth)); + const widthSlider = new Slider( + (boundedWidth - CAPTURE_WIDTH_MIN) / (CAPTURE_WIDTH_MAX - CAPTURE_WIDTH_MIN), + ); + widthSlider.add_style_class_name('capture-tools-width-slider'); + widthSlider.accessible_name = _('Annotation width'); + widthSlider.y_align = Clutter.ActorAlign.CENTER; + widthSlider.connect('notify::value', () => + callbacks.setWidth( + CAPTURE_WIDTH_MIN + widthSlider.value * (CAPTURE_WIDTH_MAX - CAPTURE_WIDTH_MIN), + ), + ); + actor.add_child(widthSlider); + actor.add_child(separator()); + + const undo = iconButton('edit-undo-symbolic', _('Undo')); + undo.connect('clicked', callbacks.undo); + actor.add_child(undo); + + const clear = iconButton('user-trash-symbolic', _('Clear annotations')); + clear.connect('clicked', callbacks.clear); + actor.add_child(clear); + + return { actor, toolButtons, colorButtons, widthSlider }; +} + +export function iconButton(icon: string, label: string, toggle = false): St.Button { + return new St.Button({ + style_class: 'screenshot-ui-type-button capture-tools-button', + child: createIcon(icon), + accessible_name: label, + toggle_mode: toggle, + can_focus: true, + }); +} + +function separator(): St.Widget { + return new St.Widget({ style_class: 'capture-tools-separator', y_expand: true }); +} diff --git a/src/capture/captureToolbarPositioner.ts b/src/capture/captureToolbarPositioner.ts new file mode 100644 index 0000000..142da83 --- /dev/null +++ b/src/capture/captureToolbarPositioner.ts @@ -0,0 +1,165 @@ +import Clutter from '@girs/clutter-18'; +import type St from '@girs/st-18'; +import * as Main from '@girs/gnome-shell/ui/main'; + +import type { Geometry, ScreenshotUi } from './screenshotUiAdapter.ts'; +import { calculateToolbarTranslation, findMonitorForSelection } from './toolbarPlacement.ts'; + +type ToolbarDrag = { + pointerX: number; + pointerY: number; + toolbarX: number; + toolbarY: number; + baseX: number; + baseY: number; +}; + +export class CaptureToolbarPositioner { + private _drag: ToolbarDrag | null = null; + private _grab: Clutter.Grab | null = null; + private _draggedByUser = false; + + constructor( + private _ui: ScreenshotUi, + private _toolbar: St.BoxLayout, + ) {} + + beginDrag(handle: St.Button, event: Clutter.Event): boolean { + if (event.get_button() !== Clutter.BUTTON_PRIMARY) { + return Clutter.EVENT_PROPAGATE; + } + + const [pointerX, pointerY] = event.get_coords(); + const [toolbarX, toolbarY] = this._toolbar.get_transformed_position(); + this._drag = { + pointerX, + pointerY, + toolbarX, + toolbarY, + baseX: toolbarX - this._toolbar.translation_x, + baseY: toolbarY - this._toolbar.translation_y, + }; + + this._grab?.dismiss(); + this._grab = global.stage.grab(handle); + global.stage.get_grab_actor()?.set_cursor_type(Clutter.CursorType.GRABBING); + + return Clutter.EVENT_STOP; + } + + moveDrag(event: Clutter.Event): boolean { + const monitor = Main.layoutManager.primaryMonitor; + if (!this._drag || !monitor) { + return Clutter.EVENT_PROPAGATE; + } + + const [pointerX, pointerY] = event.get_coords(); + const extents = this._toolbar.get_transformed_extents(); + const desiredX = this._drag.toolbarX + pointerX - this._drag.pointerX; + const desiredY = this._drag.toolbarY + pointerY - this._drag.pointerY; + const x = Math.max( + monitor.x, + Math.min(desiredX, monitor.x + monitor.width - extents.get_width()), + ); + const y = Math.max( + monitor.y, + Math.min(desiredY, monitor.y + monitor.height - extents.get_height()), + ); + + this._toolbar.translation_x = Math.round(x - this._drag.baseX); + this._toolbar.translation_y = Math.round(y - this._drag.baseY); + + return Clutter.EVENT_STOP; + } + + releaseDrag(event: Clutter.Event): boolean { + if (!this._drag || event.get_button() !== Clutter.BUTTON_PRIMARY) { + return Clutter.EVENT_PROPAGATE; + } + + this._draggedByUser = true; + this.endDrag(); + return Clutter.EVENT_STOP; + } + + endDrag(): void { + this._drag = null; + + if (this._grab) { + global.stage.get_grab_actor()?.set_cursor_type(Clutter.CursorType.INHERIT); + } + + this._grab?.dismiss(); + this._grab = null; + } + + sync(portalMode: boolean): void { + if (!this._toolbar.visible || !this._toolbar.mapped || this._draggedByUser) return; + + const selection = this._getSelection(portalMode); + if (!selection) { + this._resetTranslation(); + return; + } + + const [x, y, width, height] = selection; + const selectionRectangle = { x, y, width, height }; + const monitor = findMonitorForSelection( + selectionRectangle, + Main.layoutManager.monitors ?? [], + Main.layoutManager.primaryIndex, + ); + if (!monitor) { + this._resetTranslation(); + return; + } + + const [stageX, stageY] = this._toolbar.get_transformed_position(); + const translation = calculateToolbarTranslation({ + monitor, + selection: selectionRectangle, + toolbar: { + width: this._toolbar.width, + height: this._toolbar.height, + stageX, + stageY, + translationX: this._toolbar.translation_x, + translationY: this._toolbar.translation_y, + }, + margin: 12, + }); + if (!translation) return; + + this._toolbar.translation_x = translation.x; + this._toolbar.translation_y = translation.y; + } + + reset(): void { + this.endDrag(); + this._draggedByUser = false; + this._resetTranslation(); + } + + handleMonitorsChanged(): void { + this.endDrag(); + this._resetTranslation(); + } + + destroy(): void { + this.endDrag(); + } + + private _getSelection(portalMode: boolean): Geometry | null { + if (portalMode || !this._ui._shotButton.checked || !this._ui._selectionButton.checked) { + return null; + } + + const selection = this._ui._areaSelector.getGeometry(); + return selection[2] > 0 && selection[3] > 0 ? selection : null; + } + + private _resetTranslation(): void { + this._toolbar.translation_x = 0; + this._toolbar.translation_y = 0; + } +} diff --git a/src/capture/captureTools.ts b/src/capture/captureTools.ts index 3d93d9f..793b7b0 100644 --- a/src/capture/captureTools.ts +++ b/src/capture/captureTools.ts @@ -1,69 +1,29 @@ import Clutter from '@girs/clutter-18'; -import Gio from '@girs/gio-2.0'; import St from '@girs/st-18'; import { gettext as _ } from '~/shared/i18n.ts'; import * as Main from '@girs/gnome-shell/ui/main'; -import { Slider } from '@girs/gnome-shell/ui/slider'; +import type { Slider } from '@girs/gnome-shell/ui/slider'; import { AnnotationCanvas } from '~/capture/annotationCanvas.ts'; +import { AnnotationModel, type AnnotationTool, type Point } from '~/capture/annotationModel.ts'; +import { CaptureOcrSession } from '~/capture/captureOcrSession.ts'; +import { CaptureToolbarPositioner } from '~/capture/captureToolbarPositioner.ts'; import { - AnnotationModel, - type Annotation, - type AnnotationTool, - type Point, -} from '~/capture/annotationModel.ts'; -import { OcrController, OcrUnavailableError } from '~/capture/ocrController.ts'; -import { buildWebSearchUri, placeOcrActionBelow, type OcrWord } from '~/capture/ocrLogic.ts'; -import { CaptureTooltip } from '~/capture/captureTooltip.ts'; -import { - captureScreenshot, - exportAnnotatedScreenshot, - type CapturedScreenshot, -} from '~/capture/screenshotCapture.ts'; -import { - getScreenshotUi, - type Geometry, - type ScreenshotUi, -} from '~/capture/screenshotUiAdapter.ts'; -import { - calculateToolbarTranslation, - findMonitorForSelection, -} from '~/capture/toolbarPlacement.ts'; + CAPTURE_COLORS as COLORS, + CAPTURE_WIDTH_MAX as LINE_WIDTH_MAX, + CAPTURE_WIDTH_MIN as LINE_WIDTH_MIN, + createCaptureToolbar, +} from '~/capture/captureToolbar.ts'; +import { ScreenshotHooks } from '~/capture/screenshotHooks.ts'; +import { getScreenshotUi, type ScreenshotUi } from '~/capture/screenshotUiAdapter.ts'; import type { ExtensionContext } from '~/core/context.ts'; import { LifecycleScope } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; import type { SettingsManager } from '~/core/settings.ts'; import { Module } from '~/module.ts'; -import { createIcon } from '~/shared/icons.ts'; const LOG_PREFIX = 'CaptureTools'; const COLOR_KEY = 'capture-tools-color'; const WIDTH_KEY = 'capture-tools-stroke-width'; -const OCR_ENABLED_KEY = 'capture-tools-ocr-enabled'; -const WEB_SEARCH_ENGINE_KEY = 'capture-tools-web-search-engine'; -const COLORS = [ - '#ffffff', - '#000000', - '#e01b24', - '#ff8800', - '#ffdd00', - '#44cc44', - '#4488ff', - '#aa44ff', -] as const; -const LINE_WIDTH_MIN = 1; -const LINE_WIDTH_MAX = 16; - -type ScreenshotOpen = (mode?: number, ...args: unknown[]) => Promise; -type ScreenshotSave = () => Promise; -type ToolDefinition = { tool: AnnotationTool; icon: string; label: string }; -type ToolbarDrag = { - pointerX: number; - pointerY: number; - toolbarX: number; - toolbarY: number; - baseX: number; - baseY: number; -}; export type CaptureToolsDevInteraction = 'idle' | 'selection' | 'drawing'; @@ -84,18 +44,6 @@ export type CaptureToolsDevState = { searchUri: string | null; }; -const TOOLS: readonly ToolDefinition[] = [ - { tool: 'select', icon: 'selection-opaque-3-symbolic', label: _('Selection') }, - { tool: 'pointer', icon: 'pointer-primary-click-symbolic', label: _('Pointer') }, - { tool: 'freehand', icon: 'document-edit-symbolic', label: _('Freehand') }, - { tool: 'rectangle', icon: 'square-outline-thick-symbolic', label: _('Rectangle') }, - { tool: 'solid-rectangle', icon: 'square-filled-symbolic', label: _('Solid rectangle') }, - { tool: 'highlighter', icon: 'marker-symbolic', label: _('Highlighter') }, - { tool: 'arrow', icon: 'arrow1-top-right-symbolic', label: _('Arrow') }, - { tool: 'text', icon: 'text-insert2-symbolic', label: _('Text') }, - { tool: 'stamp', icon: 'one-circle-symbolic', label: _('Numbered marker') }, -]; - export class CaptureTools extends Module { private _ui: ScreenshotUi | null = null; private _scope: LifecycleScope | null = null; @@ -103,29 +51,16 @@ export class CaptureTools extends Module { private _model: AnnotationModel | null = null; private _canvas: InstanceType | null = null; private _toolbar: St.BoxLayout | null = null; - private _ocrLayer: St.Widget | null = null; - private _ocrPanel: St.BoxLayout | null = null; - private _ocrButton: St.Button | null = null; - private _ocrTooltip: InstanceType | null = null; - private _ocrActionTooltips: InstanceType[] = []; - private _ocrController: OcrController | null = null; + private _toolbarPositioner: CaptureToolbarPositioner | null = null; + private _ocr: CaptureOcrSession | null = null; private _widthSlider: Slider | null = null; private _toolButtons = new Map(); private _colorButtons = new Map(); private _textEntry: St.Entry | null = null; private _textPoint: Point | null = null; - private _toolbarDrag: ToolbarDrag | null = null; - private _toolbarGrab: Clutter.Grab | null = null; - private _toolbarDraggedByUser = false; - private _ocrText = ''; - private _ocrBusy = false; - private _devOcrAvailable: boolean | null = null; private _devInteraction: CaptureToolsDevInteraction = 'idle'; private _portalMode = false; - private _originalOpen: ScreenshotOpen | null = null; - private _openWrapper: ScreenshotOpen | null = null; - private _originalSaveScreenshot: ScreenshotSave | null = null; - private _saveScreenshotWrapper: ScreenshotSave | null = null; + private _hooks: ScreenshotHooks | null = null; constructor(context: ExtensionContext) { super(context); @@ -134,148 +69,125 @@ export class CaptureTools extends Module { override enable(): void { this.disable(); const ui = getScreenshotUi(); - if (!ui) { - logger.warn('[CaptureTools] GNOME screenshot UI contract is unavailable'); - return; - } + const settings = this.context.settings; + const model = new AnnotationModel(); this._ui = ui; - this._scope = new LifecycleScope(); - this._settings = this.context.settings; - this._model = new AnnotationModel(); - const configuredColor = this._settings.getString(COLOR_KEY).toLowerCase(); - this._model.setColor(COLORS.find((color) => color === configuredColor) ?? COLORS[2]); - this._model.setWidth(Math.min(LINE_WIDTH_MAX, this._settings.getInt(WIDTH_KEY))); - this._ocrController = new OcrController(this._settings); - - this._canvas = new AnnotationCanvas(); - this._canvas.configure( - this._model, + const scope = new LifecycleScope(); + this._scope = scope; + this._settings = settings; + this._model = model; + const configuredColor = settings.getString(COLOR_KEY).toLowerCase(); + model.setColor(COLORS.find((color) => color === configuredColor) ?? COLORS[2]); + model.setWidth(Math.min(LINE_WIDTH_MAX, settings.getInt(WIDTH_KEY))); + + const canvas = new AnnotationCanvas(); + this._canvas = canvas; + canvas.configure( + model, (point) => this._requestText(point), (drawing) => this._setInteractionState(drawing ? 'drawing' : 'idle'), ); - ui.insert_child_below(this._canvas, ui._primaryMonitorBin); - this._toolbar = this._buildToolbar(); - ui._primaryMonitorBin.add_child(this._toolbar); - this._ocrButton = this._buildOcrButton(); - ui._showPointerButtonContainer.insert_child_below(this._ocrButton, ui._showPointerButton); - this._ocrTooltip = new CaptureTooltip(); - this._ocrTooltip.configure(this._ocrButton, _('Extract text')); - ui.add_child(this._ocrTooltip); - this._ocrPanel = this._buildOcrPanel(); - this._ocrLayer = new St.Widget({ reactive: false, x_expand: true, y_expand: true }); - this._ocrLayer.add_constraint( - new Clutter.BindConstraint({ source: global.stage, coordinate: Clutter.BindCoordinate.ALL }), - ); - this._ocrLayer.add_child(this._ocrPanel); - ui.add_child(this._ocrLayer); - - this._scope.onDispose(() => this._canvas?.destroy()); - this._scope.onDispose(() => this._toolbar?.destroy()); - this._scope.onDispose(() => this._ocrButton?.destroy()); - this._scope.onDispose(() => this._ocrTooltip?.destroy()); - this._scope.onDispose(() => this._ocrLayer?.destroy()); - this._scope.onDispose(() => { - for (const tooltip of this._ocrActionTooltips) tooltip.destroy(); + ui.insert_child_below(canvas, ui._primaryMonitorBin); + + const toolbar = this._buildToolbar(model, settings); + ui._primaryMonitorBin.add_child(toolbar); + + const toolbarPositioner = new CaptureToolbarPositioner(ui, toolbar); + const ocr = new CaptureOcrSession(ui, canvas, settings); + this._toolbar = toolbar; + this._toolbarPositioner = toolbarPositioner; + this._ocr = ocr; + + this._connectLifecycle(scope, ui, canvas, toolbar, toolbarPositioner, ocr); + this._hooks = new ScreenshotHooks(ui, { + setPortalMode: (portalMode) => { + this._portalMode = portalMode; + }, + syncVisibility: () => this._syncVisibility(), + commitText: () => this._commitText(true), + getAnnotations: () => (this._portalMode ? [] : [...model.annotations]), }); - this._connectLifecycle(ui); - this._patchOpen(ui); - this._patchSaveScreenshot(ui); this._selectTool('select'); - this._selectColor(this._model.color); + this._selectColor(model.color); this._syncVisibility(); logger.debug('Enabled; floating toolbar attached to the native screenshot UI', { prefix: LOG_PREFIX, }); - void this._probeOcr(); + void ocr.probe(); } override disable(): void { - const ui = this._ui; - if (ui && this._openWrapper && this._originalOpen && ui.open === this._openWrapper) { - ui.open = this._originalOpen; - } else if (ui && this._openWrapper && ui.open !== this._openWrapper) { - logger.warn('[CaptureTools] Screenshot open hook changed externally; leaving it untouched'); - } - if ( - ui && - this._saveScreenshotWrapper && - this._originalSaveScreenshot && - ui._saveScreenshot === this._saveScreenshotWrapper - ) { - ui._saveScreenshot = this._originalSaveScreenshot; - } else if ( - ui && - this._saveScreenshotWrapper && - ui._saveScreenshot !== this._saveScreenshotWrapper - ) { - logger.warn('[CaptureTools] Screenshot save hook changed externally; leaving it untouched'); - } + this._hooks?.destroy(); + this._hooks = null; + + this._scope?.dispose(); + this._scope = null; + + this._textEntry?.destroy(); + this._textEntry = null; + this._textPoint = null; - this._commitText(false); this._canvas?.cancelDrawing(); this._resetControlsOpacity(); - this._endToolbarDrag(); - this._ocrController?.destroy(); - this._scope?.dispose(); + + this._toolbarPositioner?.destroy(); + this._toolbarPositioner = null; + this._ocr?.destroy(); + this._ocr = null; + this._toolbar?.destroy(); + this._canvas?.destroy(); + this._toolButtons.clear(); this._colorButtons.clear(); this._ui = null; - this._scope = null; this._settings = null; this._model = null; this._canvas = null; this._toolbar = null; - this._ocrLayer = null; - this._ocrPanel = null; - this._ocrButton = null; - this._ocrTooltip = null; - this._ocrActionTooltips = []; - this._ocrController = null; this._widthSlider = null; - this._ocrText = ''; - this._ocrBusy = false; - this._devOcrAvailable = null; this._devInteraction = 'idle'; this._portalMode = false; - this._toolbarDrag = null; - this._toolbarGrab = null; - this._toolbarDraggedByUser = false; - this._originalOpen = null; - this._openWrapper = null; - this._originalSaveScreenshot = null; - this._saveScreenshotWrapper = null; } - private _connectLifecycle(ui: ScreenshotUi): void { - const scope = this._scope!; + private _connectLifecycle( + scope: LifecycleScope, + ui: ScreenshotUi, + canvas: InstanceType, + toolbar: St.BoxLayout, + toolbarPositioner: CaptureToolbarPositioner, + ocr: CaptureOcrSession, + ): void { scope.connect(ui, 'closed', () => this._resetSession()); - for (const button of [ui._shotButton, ui._castButton]) + for (const button of [ui._shotButton, ui._castButton]) { scope.connect(button, 'notify::checked', () => this._syncVisibility()); - for (const button of [ui._selectionButton, ui._screenButton, ui._windowButton]) - scope.connect(button, 'notify::checked', () => this._clearOcr()); - scope.connect(ui._selectionButton, 'notify::checked', () => this._syncToolbarPlacement()); + } + + for (const button of [ui._selectionButton, ui._screenButton, ui._windowButton]) { + scope.connect(button, 'notify::checked', () => ocr.clear()); + } + + scope.connect(ui._selectionButton, 'notify::checked', () => { + toolbarPositioner.sync(this._portalMode); + }); scope.connect(ui._areaSelector, 'drag-started', () => { - this._clearOcr(); + ocr.clear(); this._setInteractionState('selection'); }); scope.connect(ui._areaSelector, 'drag-ended', () => { this._setInteractionState('idle'); - this._syncToolbarPlacement(); + toolbarPositioner.sync(this._portalMode); }); - if (this._toolbar) { - for (const signal of ['notify::allocation', 'notify::mapped']) - scope.connect(this._toolbar, signal, () => this._syncToolbarPlacement()); + + for (const signal of ['notify::allocation', 'notify::mapped']) { + scope.connect(toolbar, signal, () => toolbarPositioner.sync(this._portalMode)); } const monitorsChangedId = Main.layoutManager.connect('monitors-changed', () => { - this._endToolbarDrag(); - if (this._toolbar) { - this._toolbar.translation_x = 0; - this._toolbar.translation_y = 0; + toolbarPositioner.handleMonitorsChanged(); + if (canvas.get_parent() === ui) { + ui.set_child_below_sibling(canvas, ui._primaryMonitorBin); } - if (this._canvas?.get_parent() === ui) - ui.set_child_below_sibling(this._canvas, ui._primaryMonitorBin); }); scope.onDispose(() => Main.layoutManager.disconnect(monitorsChangedId)); @@ -284,290 +196,80 @@ export class CaptureTools extends Module { (_actor: St.Widget, event: Clutter.Event): boolean => this._onKeyPress(event), ); scope.onDispose(() => ui.disconnect(keyPressId)); - const settingsId = this._settings!.connect(`changed::${OCR_ENABLED_KEY}`, () => - this._syncOcrButton(), - ); - scope.onDispose(() => this._settings?.disconnect(settingsId)); - } - - private _patchOpen(ui: ScreenshotUi): void { - const original = ui.open; - this._originalOpen = original; - const wrapper: ScreenshotOpen = async (mode = 0, ...args: unknown[]) => { - this._portalMode = mode === 2; - const result = await original.call(ui, mode, ...args); - this._syncVisibility(); - logger.debug( - `Native screenshot UI opened; toolbar visible=${!this._portalMode} (mode=${mode})`, - { - prefix: LOG_PREFIX, - }, - ); - return result; - }; - this._openWrapper = wrapper; - ui.open = wrapper; - } - - private _patchSaveScreenshot(ui: ScreenshotUi): void { - const original = ui._saveScreenshot; - this._originalSaveScreenshot = original; - const wrapper = async (): Promise => { - this._commitText(true); - const model = this._model; - if (this._portalMode || !model?.hasAnnotations) return original.call(ui); - - const annotations = [...model.annotations]; - try { - const capture = await captureScreenshot(ui); - // Return now so the caller closes the UI with the default animation; - // the annotated export finishes in the background. - void this._exportAnnotatedScreenshot(ui, capture, annotations); - } catch (error) { - logger.warn(`[CaptureTools] Annotated screenshot export failed: ${String(error)}`); - Main.notify(_('Screenshot failed'), _('Could not export the annotated screenshot')); - } - }; - this._saveScreenshotWrapper = wrapper; - ui._saveScreenshot = wrapper; - } - - private async _exportAnnotatedScreenshot( - ui: ScreenshotUi, - capture: CapturedScreenshot, - annotations: readonly Annotation[], - ): Promise { - try { - const file = await exportAnnotatedScreenshot( - capture.pixbuf, - annotations, - { origin: capture.origin, scale: capture.scale }, - { copy: true, save: true }, - ); - if (file) ui.emit('screenshot-taken', file); - logger.debug(`Saved screenshot with ${annotations.length} annotation(s)`, { - prefix: LOG_PREFIX, - }); - } catch (error) { - logger.warn(`[CaptureTools] Annotated screenshot export failed: ${String(error)}`); - Main.notify(_('Screenshot failed'), _('Could not export the annotated screenshot')); - } } - private _buildToolbar(): St.BoxLayout { - const toolbar = new St.BoxLayout({ - style_class: 'screenshot-ui-panel capture-tools-toolbar', - reactive: true, - x_align: Clutter.ActorAlign.CENTER, - y_align: Clutter.ActorAlign.START, - y_expand: true, - }); - - const dragHandle = this._iconButton('list-drag-handle-symbolic', _('Move toolbar')); - dragHandle.add_style_class_name('capture-tools-drag-handle'); - dragHandle.connect('button-press-event', (_actor: St.Button, event: Clutter.Event) => - this._beginToolbarDrag(dragHandle, event), - ); - dragHandle.connect('motion-event', (_actor: St.Button, event: Clutter.Event) => - this._moveToolbar(event), - ); - dragHandle.connect('button-release-event', (_actor: St.Button, event: Clutter.Event) => - this._releaseToolbar(event), - ); - toolbar.add_child(dragHandle); - - for (const [index, definition] of TOOLS.entries()) { - const button = this._iconButton(definition.icon, definition.label, true); - button.add_style_class_name(`capture-tools-tool-${definition.tool}`); - button.connect('clicked', () => this._selectTool(definition.tool)); - toolbar.add_child(button); - this._toolButtons.set(definition.tool, button); - if (index === 1) toolbar.add_child(this._separator()); - } - - toolbar.add_child(this._separator()); - for (const color of COLORS) { - const swatch = new St.Widget({ - style_class: 'capture-tools-swatch', - style: `background-color: ${color};`, - }); - const button = new St.Button({ - style_class: 'capture-tools-ring-button', - accessible_name: `${_('Annotation color')}: ${color}`, - child: swatch, - toggle_mode: true, - can_focus: true, - }); - button.connect('clicked', () => this._selectColor(color)); - toolbar.add_child(button); - this._colorButtons.set(color, button); - } + private _buildToolbar(model: AnnotationModel, settings: SettingsManager): St.BoxLayout { + const toolbar = createCaptureToolbar(model.width, { + beginDrag: (handle, event) => { + if (!this._toolbarPositioner) { + return Clutter.EVENT_PROPAGATE; + } - toolbar.add_child(this._separator()); - const initialWidth = Math.max( - LINE_WIDTH_MIN, - Math.min(LINE_WIDTH_MAX, this._model?.width ?? 4), - ); - const slider = new Slider((initialWidth - LINE_WIDTH_MIN) / (LINE_WIDTH_MAX - LINE_WIDTH_MIN)); - slider.add_style_class_name('capture-tools-width-slider'); - slider.accessible_name = _('Annotation width'); - slider.y_align = Clutter.ActorAlign.CENTER; - slider.connect('notify::value', () => { - const model = this._model; - if (!model || !this._settings) return; - const next = LINE_WIDTH_MIN + slider.value * (LINE_WIDTH_MAX - LINE_WIDTH_MIN); - model.setWidth(next); - this._settings.setInt(WIDTH_KEY, model.width); - }); - this._widthSlider = slider; - toolbar.add_child(slider); - - toolbar.add_child(this._separator()); - const undo = this._iconButton('edit-undo-symbolic', _('Undo')); - undo.connect('clicked', () => this._undo()); - toolbar.add_child(undo); - const clear = this._iconButton('user-trash-symbolic', _('Clear annotations')); - clear.connect('clicked', () => this._clearAnnotations()); - toolbar.add_child(clear); - return toolbar; - } + return this._toolbarPositioner.beginDrag(handle, event); + }, + moveDrag: (event) => { + if (!this._toolbarPositioner) { + return Clutter.EVENT_PROPAGATE; + } - private _buildOcrButton(): St.Button { - const button = new St.Button({ - style_class: 'screenshot-ui-show-pointer-button capture-tools-native-ocr-button', - icon_name: 'scanner-symbolic', - accessible_name: _('Extract text'), - toggle_mode: false, - can_focus: true, - }); - button.connect('clicked', () => void this._runOcr()); - return button; - } + return this._toolbarPositioner.moveDrag(event); + }, + releaseDrag: (event) => { + if (!this._toolbarPositioner) { + return Clutter.EVENT_PROPAGATE; + } - private _buildOcrPanel(): St.BoxLayout { - const panel = new St.BoxLayout({ - style_class: 'screenshot-ui-panel capture-tools-ocr-panel', - reactive: true, - visible: false, + return this._toolbarPositioner.releaseDrag(event); + }, + selectTool: (tool) => this._selectTool(tool), + selectColor: (color) => this._selectColor(color), + setWidth: (width) => { + model.setWidth(width); + settings.setInt(WIDTH_KEY, model.width); + }, + undo: () => this._undo(), + clear: () => this._clearAnnotations(), }); - const copy = this._iconButton('edit-copy-symbolic', _('Copy text')); - copy.add_style_class_name('capture-tools-ocr-copy-button'); - copy.connect('clicked', () => this._copyOcrText()); - panel.add_child(copy); - this._attachOcrActionTooltip(copy, _('Copy text')); - - const search = this._iconButton('system-search-symbolic', _('Search the web')); - search.add_style_class_name('capture-tools-ocr-search-button'); - search.connect('clicked', () => void this._searchOcrText()); - panel.add_child(search); - this._attachOcrActionTooltip(search, _('Search the web')); - return panel; + this._toolButtons = toolbar.toolButtons; + this._colorButtons = toolbar.colorButtons; + this._widthSlider = toolbar.widthSlider; + return toolbar.actor; } - private _attachOcrActionTooltip(anchor: St.Button, text: string): void { - const tooltip = new CaptureTooltip(); - tooltip.configure(anchor, text); - this._ui?.add_child(tooltip); - this._ocrActionTooltips.push(tooltip); - } + private _selectTool(tool: AnnotationTool): void { + if (!this._model || !this._canvas || !this._toolbar) return; - private _iconButton(icon: string, label: string, toggle = false): St.Button { - return new St.Button({ - style_class: 'screenshot-ui-type-button capture-tools-button', - child: createIcon(icon), - accessible_name: label, - toggle_mode: toggle, - can_focus: true, - }); - } + this._commitText(true); + this._model.setTool(tool); - private _separator(): St.Widget { - return new St.Widget({ style_class: 'capture-tools-separator', y_expand: true }); - } + for (const [candidate, button] of this._toolButtons) { + button.checked = candidate === tool; + } - private _selectTool(tool: AnnotationTool): void { - this._commitText(true); - this._model?.setTool(tool); - for (const [candidate, button] of this._toolButtons) button.checked = candidate === tool; - this._canvas?.setDrawingEnabled(this._usesCanvas(tool) && this._toolbar?.visible === true); + this._canvas.setDrawingEnabled(this._usesCanvas(tool) && this._toolbar.visible); } private _usesCanvas(tool: AnnotationTool): boolean { return tool !== 'select'; } - private _beginToolbarDrag(handle: St.Button, event: Clutter.Event): boolean { - const toolbar = this._toolbar; - if (!toolbar || event.get_button() !== Clutter.BUTTON_PRIMARY) return Clutter.EVENT_PROPAGATE; - const [pointerX, pointerY] = event.get_coords(); - const [toolbarX, toolbarY] = toolbar.get_transformed_position(); - this._toolbarDrag = { - pointerX, - pointerY, - toolbarX, - toolbarY, - baseX: toolbarX - toolbar.translation_x, - baseY: toolbarY - toolbar.translation_y, - }; - this._toolbarGrab?.dismiss(); - this._toolbarGrab = global.stage.grab(handle); - global.stage.get_grab_actor()?.set_cursor_type(Clutter.CursorType.GRABBING); - return Clutter.EVENT_STOP; - } - - private _moveToolbar(event: Clutter.Event): boolean { - const toolbar = this._toolbar; - const drag = this._toolbarDrag; - const monitor = Main.layoutManager.primaryMonitor; - if (!toolbar || !drag || !monitor) return Clutter.EVENT_PROPAGATE; - const [pointerX, pointerY] = event.get_coords(); - const extents = toolbar.get_transformed_extents(); - const desiredX = drag.toolbarX + pointerX - drag.pointerX; - const desiredY = drag.toolbarY + pointerY - drag.pointerY; - const x = Math.max( - monitor.x, - Math.min(desiredX, monitor.x + monitor.width - extents.get_width()), - ); - const y = Math.max( - monitor.y, - Math.min(desiredY, monitor.y + monitor.height - extents.get_height()), - ); - toolbar.translation_x = Math.round(x - drag.baseX); - toolbar.translation_y = Math.round(y - drag.baseY); - return Clutter.EVENT_STOP; - } - - private _releaseToolbar(event: Clutter.Event): boolean { - if (!this._toolbarDrag || event.get_button() !== Clutter.BUTTON_PRIMARY) - return Clutter.EVENT_PROPAGATE; - this._toolbarDraggedByUser = true; - this._endToolbarDrag(); - return Clutter.EVENT_STOP; - } + private _selectColor(color: string): void { + if (!this._model || !this._settings || !this._canvas) return; - private _endToolbarDrag(): void { - this._toolbarDrag = null; - if (this._toolbarGrab) - global.stage.get_grab_actor()?.set_cursor_type(Clutter.CursorType.INHERIT); - this._toolbarGrab?.dismiss(); - this._toolbarGrab = null; - } + this._model.setColor(color); + const selectedColor = this._model.color; + this._settings.setString(COLOR_KEY, selectedColor); - private _selectColor(color: string): void { - const model = this._model; - model?.setColor(color); - const selectedColor = model?.color ?? color.toLowerCase(); - this._settings?.setString(COLOR_KEY, selectedColor); for (const [candidate, button] of this._colorButtons) { button.checked = candidate === selectedColor; button.style = `border-color: ${button.checked ? candidate : 'transparent'};`; } - this._canvas?.refresh(); + this._canvas.refresh(); } private _requestText(point: Point): void { - const ui = this._ui; - if (!ui) return; + if (!this._ui) return; + this._commitText(true); this._textPoint = point; const entry = new St.Entry({ @@ -585,7 +287,7 @@ export class CaptureTools extends Module { return Clutter.EVENT_STOP; }); entry.clutter_text.connect('key-focus-out', () => this._commitText(true)); - ui.insert_child_above(entry, this._canvas); + this._ui.insert_child_above(entry, this._canvas); this._textEntry = entry; entry.grab_key_focus(); } @@ -595,210 +297,87 @@ export class CaptureTools extends Module { const point = this._textPoint; this._textEntry = null; this._textPoint = null; - if (!entry) return; - const text = entry.get_text(); - entry.destroy(); - if (save && point && this._model?.addText(point, text)) this._canvas?.refresh(); - } - private async _runOcr(): Promise { - const ui = this._ui; - const controller = this._ocrController; - if (!ui || !controller || this._ocrBusy) return; - this._ocrBusy = true; - this._ocrText = ''; - this._canvas?.setOcrWords([]); - this._hideOcrPanel(); - try { - const capture = await captureScreenshot(ui, false); - const result = await controller.recognize(capture.pixbuf, capture.scale, capture.origin); - if (!result.text) { - this._clearOcr(); - Main.notify(_('OCR'), _('No text found in the screenshot')); - return; - } - this._ocrText = result.text; - this._canvas?.setOcrWords(result.words); - this._showOcrCopyAction({ - x: capture.origin.x, - y: capture.origin.y, - width: capture.pixbuf.get_width() / capture.scale, - height: capture.pixbuf.get_height() / capture.scale, - }); - } catch (error) { - this._canvas?.setOcrWords([]); - if (error instanceof OcrUnavailableError) - Main.notify(_('OCR unavailable'), _('Tesseract OCR and language data are not installed')); - else { - logger.warn(`[CaptureTools] OCR failed: ${String(error)}`); - Main.notify(_('OCR failed'), _('Text recognition failed')); - } - this._hideOcrPanel(); - } finally { - this._ocrBusy = false; + if (!entry) { + return; } - } - private _copyOcrText(): void { - if (!this._ocrText) return; - const clipboard = St.Clipboard.get_default(); - clipboard.set_text(St.ClipboardType.CLIPBOARD, this._ocrText); - clipboard.set_text(St.ClipboardType.PRIMARY, this._ocrText); - this._hideOcrPanel(); - Main.notify(_('OCR'), _('Recognized text copied')); - } + const text = entry.get_text(); + entry.destroy(); - private async _searchOcrText(): Promise { - if (!this._ocrText) return; - const uri = buildWebSearchUri(this._ocrText, this._settings?.getString(WEB_SEARCH_ENGINE_KEY)); - const launchContext = global.create_app_launch_context(global.get_current_time(), -1); - this._hideOcrPanel(); - try { - await new Promise((resolve, reject) => { - Gio.app_info_launch_default_for_uri_async(uri, launchContext, null, (_source, result) => { - try { - Gio.app_info_launch_default_for_uri_finish(result); - resolve(); - } catch (error) { - reject(error); - } - }); - }); - this._ui?.close(true); - } catch (error) { - logger.warn(`[CaptureTools] Could not open OCR web search: ${String(error)}`); - Main.notifyError(_('OCR'), _('Could not open the web search')); + if (save && point && this._model && this._canvas && this._model.addText(point, text)) { + this._canvas.refresh(); } } - private _showOcrCopyAction(selection: { - x: number; - y: number; - width: number; - height: number; - }): void { - const panel = this._ocrPanel; - if (!panel) return; - const [, naturalWidth] = panel.get_preferred_width(-1); - const [, naturalHeight] = panel.get_preferred_height(naturalWidth); - const position = placeOcrActionBelow( - selection, - { width: naturalWidth, height: naturalHeight }, - { width: global.stage.width, height: global.stage.height }, - ); - panel.set_position(position.x, position.y); - panel.show(); - } - - private _clearOcr(): void { - this._ocrController?.cancel(); - this._ocrText = ''; - this._canvas?.setOcrWords([]); - this._hideOcrPanel(); - } - - private _hideOcrPanel(): void { - this._ocrPanel?.hide(); - for (const tooltip of this._ocrActionTooltips) tooltip.close(); - } - private _undo(): void { this._commitText(false); - if (this._model?.undo()) this._canvas?.refresh(); + + if (!this._model || !this._canvas || !this._model.undo()) return; + + this._canvas.refresh(); } private _clearAnnotations(): void { this._commitText(false); - if (this._model?.clear()) this._canvas?.refresh(); + + if (!this._model || !this._canvas || !this._model.clear()) return; + + this._canvas.refresh(); } private _onKeyPress(event: Clutter.Event): boolean { + if (!this._model || !this._ocr) return Clutter.EVENT_PROPAGATE; + const symbol = event.get_key_symbol(); const control = Boolean(event.get_state() & Clutter.ModifierType.CONTROL_MASK); if (control && (symbol === Clutter.KEY_z || symbol === Clutter.KEY_Z)) { this._undo(); return Clutter.EVENT_STOP; } + if (control && (symbol === Clutter.KEY_e || symbol === Clutter.KEY_E)) { - void this._runOcr(); + void this._ocr.run(); return Clutter.EVENT_STOP; } - if (symbol === Clutter.KEY_Escape && this._model?.tool !== 'select') { + + if (symbol === Clutter.KEY_Escape && this._model.tool !== 'select') { this._selectTool('select'); return Clutter.EVENT_STOP; } + return Clutter.EVENT_PROPAGATE; } private _syncVisibility(): void { - const visible = !this._portalMode && this._ui?._shotButton.checked === true; - if (visible) this._toolbar?.show(); - else this._toolbar?.hide(); - if (!visible) { - this._canvas?.setDrawingEnabled(false); - this._hideOcrPanel(); - this._ocrTooltip?.close(); - } else { - this._canvas?.setDrawingEnabled(this._model ? this._usesCanvas(this._model.tool) : false); + if ( + !this._ui || + !this._toolbar || + !this._canvas || + !this._model || + !this._ocr || + !this._toolbarPositioner + ) { + return; } - this._syncOcrButton(); - if (visible) this._syncToolbarPlacement(); - } - private _syncToolbarPlacement(): void { - const toolbar = this._toolbar; - const ui = this._ui; - if (!toolbar || !ui || !toolbar.visible || !toolbar.mapped) return; - // A position picked manually through the drag handle wins for the session. - if (this._toolbarDraggedByUser) return; - - let selection: Geometry | null = null; - if (!this._portalMode && ui._shotButton.checked && ui._selectionButton.checked) { - try { - const [x, y, width, height] = ui._areaSelector.getGeometry(); - if (width > 0 && height > 0) selection = [x, y, width, height]; - } catch { - // The selector may not have geometry before its first allocation. - } - } + const visible = !this._portalMode && this._ui._shotButton.checked; - if (!selection) { - toolbar.translation_x = 0; - toolbar.translation_y = 0; - return; + if (visible) { + this._toolbar.show(); + } else { + this._toolbar.hide(); } - const [x, y, width, height] = selection; - const selectionRectangle = { x, y, width, height }; - const monitor = findMonitorForSelection( - selectionRectangle, - Main.layoutManager.monitors ?? [], - Main.layoutManager.primaryIndex, - ); - if (!monitor) { - toolbar.translation_x = 0; - toolbar.translation_y = 0; - return; + if (!visible) { + this._canvas.setDrawingEnabled(false); + this._ocr.hidePanel(); + } else { + this._canvas.setDrawingEnabled(this._usesCanvas(this._model.tool)); } - const [stageX, stageY] = toolbar.get_transformed_position(); - const translation = calculateToolbarTranslation({ - monitor, - selection: selectionRectangle, - toolbar: { - width: toolbar.width, - height: toolbar.height, - stageX, - stageY, - translationX: toolbar.translation_x, - translationY: toolbar.translation_y, - }, - margin: 12, - }); - if (!translation) return; - - toolbar.translation_x = translation.x; - toolbar.translation_y = translation.y; + this._ocr.syncButton(this._portalMode); + if (visible) this._toolbarPositioner.sync(this._portalMode); } private _setControlsOpacity(opacity: number): void { @@ -825,45 +404,25 @@ export class CaptureTools extends Module { this._devInteraction = 'idle'; } - private _syncOcrButton(): void { - if (!this._ocrButton) return; - const available = this._devOcrAvailable ?? this._ocrController?.available; - const enabled = - this._devOcrAvailable !== null || (this._settings?.getBoolean(OCR_ENABLED_KEY) ?? false); - this._ocrButton.visible = - enabled && available === true && !this._portalMode && this._ui?._shotButton.checked === true; - if (!this._ocrButton.visible) this._ocrTooltip?.close(); - } - private _resetSession(): void { + if (!this._toolbarPositioner || !this._ocr || !this._model || !this._canvas) return; + this._commitText(false); - this._endToolbarDrag(); - this._clearOcr(); - this._model?.clear(); - this._canvas?.refresh(); + this._toolbarPositioner.reset(); + this._ocr.clear(); + this._model.clear(); + this._canvas.refresh(); this._portalMode = false; this._selectTool('select'); this._resetControlsOpacity(); - this._toolbarDraggedByUser = false; - if (this._toolbar) { - this._toolbar.translation_x = 0; - this._toolbar.translation_y = 0; - } - } - - private async _probeOcr(): Promise { - try { - await this._ocrController?.probe(); - } catch (error) { - logger.warn(`[CaptureTools] Tesseract probe failed: ${String(error)}`); - } finally { - this._syncOcrButton(); - } } async openDevPreview(): Promise { const ui = this._ui; - if (!ui) return false; + if (!ui) { + return false; + } + await ui.open(); this._portalMode = false; this._syncVisibility(); @@ -871,135 +430,146 @@ export class CaptureTools extends Module { } setDevTool(tool: AnnotationTool): boolean { - if (!this._model) return false; + if (!this._model) { + return false; + } + this._selectTool(tool); return true; } setDevColor(color: string): boolean { - if (!this._model || !COLORS.includes(color as (typeof COLORS)[number])) return false; + if (!this._model || !COLORS.includes(color as (typeof COLORS)[number])) { + return false; + } + this._selectColor(color); return true; } setDevWidth(width: number): boolean { - const model = this._model; - if (!model || !Number.isFinite(width)) return false; + if (!this._model || !this._settings || !Number.isFinite(width)) { + return false; + } + const next = Math.max(LINE_WIDTH_MIN, Math.min(LINE_WIDTH_MAX, Math.round(width))); - model.setWidth(next); - this._settings?.setInt(WIDTH_KEY, model.width); - if (this._widthSlider) - this._widthSlider.value = (model.width - LINE_WIDTH_MIN) / (LINE_WIDTH_MAX - LINE_WIDTH_MIN); + this._model.setWidth(next); + this._settings.setInt(WIDTH_KEY, this._model.width); + if (this._widthSlider) { + this._widthSlider.value = + (this._model.width - LINE_WIDTH_MIN) / (LINE_WIDTH_MAX - LINE_WIDTH_MIN); + } + return true; } simulateDevInteraction(interaction: CaptureToolsDevInteraction): boolean { - if (!this._toolbar || !this._ui) return false; + if (!this._toolbar || !this._ui) { + return false; + } + this._setInteractionState(interaction); return true; } setDevOcrAvailable(available: boolean | null): boolean { - if (!this._ocrButton) return false; - this._devOcrAvailable = available; - this._syncOcrButton(); + if (!this._ocr) { + return false; + } + + this._ocr.setAvailabilityOverride(available); + this._ocr.syncButton(this._portalMode); return true; } injectDevOcrResult(text = 'Aurora simulated OCR result'): boolean { - const ui = this._ui; - if (!ui || !this._canvas || !this._ocrPanel) return false; - - const selection = this._devSelectionGeometry(); - const words: OcrWord[] = text - .split(/\s+/) - .filter(Boolean) - .slice(0, 6) - .map((word, index) => ({ - text: word, - confidence: 96, - lineKey: 'devtool:1', - bounds: { - x: selection.x + 18 + index * 72, - y: selection.y + 20, - width: Math.max(36, Math.min(68, word.length * 9)), - height: 24, - }, - })); - this._ocrText = text; - this._canvas.setOcrWords(words); - this._showOcrCopyAction(selection); + if (!this._ui || !this._ocr) { + return false; + } + + const selection = this._devSelectionGeometry(this._ui); + this._ocr.injectResult(text, selection); return true; } copyDevOcrText(): boolean { - if (!this._ocrText) return false; - this._copyOcrText(); - return true; + if (!this._ocr) { + return false; + } + + return this._ocr.copy(); } searchDevOcrText(): boolean { - if (!this._ocrText) return false; - void this._searchOcrText(); - return true; + if (!this._ocr) { + return false; + } + + return this._ocr.search(); } clearDevAnnotations(): boolean { - if (!this._model) return false; + if (!this._model) { + return false; + } + this._clearAnnotations(); return true; } resetDevState(): boolean { - if (!this._model) return false; - this._devOcrAvailable = null; + if (!this._model || !this._ocr) { + return false; + } + + this._ocr.setAvailabilityOverride(null); this._resetSession(); - this._syncOcrButton(); + this._ocr.syncButton(this._portalMode); return true; } get devState(): CaptureToolsDevState | null { - const model = this._model; - if (!model) return null; - const toolbar = this._toolbar; - let toolbarGeometry: CaptureToolsDevState['toolbarGeometry'] = null; - if (toolbar) { - const [x, y] = toolbar.get_transformed_position(); - toolbarGeometry = { - x: Math.round(x), - y: Math.round(y), - width: Math.round(toolbar.width), - height: Math.round(toolbar.height), - }; + if (!this._ui || !this._model || !this._toolbar || !this._ocr) { + return null; } - const searchUri = this._ocrText - ? buildWebSearchUri(this._ocrText, this._settings?.getString(WEB_SEARCH_ENGINE_KEY)) - : null; + + const [toolbarX, toolbarY] = this._toolbar.get_transformed_position(); + const toolbarGeometry = { + x: Math.round(toolbarX), + y: Math.round(toolbarY), + width: Math.round(this._toolbar.width), + height: Math.round(this._toolbar.height), + }; + return { - captureVisible: this._ui?.visible ?? false, - toolbarVisible: toolbar?.visible ?? false, + captureVisible: this._ui.visible, + toolbarVisible: this._toolbar.visible, toolbarGeometry, - tool: model.tool, - color: model.color, - width: model.width, - annotationCount: model.annotations.length, - controlsOpacity: toolbar?.opacity ?? 255, + tool: this._model.tool, + color: this._model.color, + width: this._model.width, + annotationCount: this._model.annotations.length, + controlsOpacity: this._toolbar.opacity, interaction: this._devInteraction, - ocrAvailable: this._devOcrAvailable ?? this._ocrController?.available ?? null, - ocrAvailabilityOverridden: this._devOcrAvailable !== null, - ocrHasResult: this._ocrText.length > 0, - ocrPanelVisible: this._ocrPanel?.visible ?? false, - searchUri, + ocrAvailable: this._ocr.available, + ocrAvailabilityOverridden: this._ocr.availabilityOverridden, + ocrHasResult: this._ocr.hasResult, + ocrPanelVisible: this._ocr.panelVisible, + searchUri: this._ocr.searchUri, }; } - private _devSelectionGeometry(): { x: number; y: number; width: number; height: number } { - try { - const [x, y, width, height] = this._ui?._areaSelector.getGeometry() ?? [0, 0, 0, 0]; - if (width > 0 && height > 0) return { x, y, width, height }; - } catch { - // The selector may not have geometry before its first allocation. + private _devSelectionGeometry(ui: ScreenshotUi): { + x: number; + y: number; + width: number; + height: number; + } { + const [x, y, width, height] = ui._areaSelector.getGeometry(); + if (width > 0 && height > 0) { + return { x, y, width, height }; } + const monitor = Main.layoutManager.primaryMonitor; return monitor ? { diff --git a/src/capture/captureTooltip.ts b/src/capture/captureTooltip.ts index 322ed8f..45e87cb 100644 --- a/src/capture/captureTooltip.ts +++ b/src/capture/captureTooltip.ts @@ -3,58 +3,65 @@ import GLib from '@girs/glib-2.0'; import GObject from '@girs/gobject-2.0'; import St from '@girs/st-18'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; + const SHOW_DELAY_MS = 300; export const CaptureTooltip = GObject.registerClass( class CaptureTooltip extends St.Label { private _anchor: St.Widget | null = null; - private _hoverId = 0; - private _timeoutId = 0; + declare private _lifecycle: LifecycleScope; + declare private _showTimeout: ManagedSource; override _init(): void { super._init({ style_class: 'screenshot-ui-tooltip capture-tools-ocr-tooltip', visible: false, }); + this._lifecycle = new LifecycleScope(); + this._showTimeout = createManagedSource(this._lifecycle); } configure(anchor: St.Widget, text: string): void { this._anchor = anchor; this.text = text; - this._hoverId = anchor.connect('notify::hover', () => { + this._lifecycle.connect(anchor, 'notify::hover', () => { if (anchor.hover) this.open(); else this.close(); }); } open(): void { - if (this._timeoutId !== 0 || !this._anchor) return; - this._timeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, SHOW_DELAY_MS, () => { - this._timeoutId = 0; - const anchor = this._anchor; - if (!anchor?.hover) return GLib.SOURCE_REMOVE; + if (this._showTimeout.active || !this._anchor) return; + this._showTimeout.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, SHOW_DELAY_MS, () => { + this._showTimeout.complete(); + const anchor = this._anchor; + if (!anchor?.hover) return GLib.SOURCE_REMOVE; - this.opacity = 0; - this.show(); - const extents = anchor.get_transformed_extents(); - const xOffset = Math.floor((extents.get_width() - this.width) / 2); - const x = Math.max(0, Math.min(extents.get_x() + xOffset, global.stage.width - this.width)); - const yOffset = this.get_theme_node().get_length('-y-offset'); - this.set_position(x, extents.get_y() - this.height - yOffset); - this.ease({ - opacity: 255, - duration: 150, - mode: Clutter.AnimationMode.EASE_OUT_QUAD, - }); - return GLib.SOURCE_REMOVE; - }); + this.opacity = 0; + this.show(); + const extents = anchor.get_transformed_extents(); + const xOffset = Math.floor((extents.get_width() - this.width) / 2); + const x = Math.max( + 0, + Math.min(extents.get_x() + xOffset, global.stage.width - this.width), + ); + const yOffset = this.get_theme_node().get_length('-y-offset'); + this.set_position(x, extents.get_y() - this.height - yOffset); + this.ease({ + opacity: 255, + duration: 150, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + return GLib.SOURCE_REMOVE; + }), + ); } close(): void { - if (this._timeoutId !== 0) { - GLib.source_remove(this._timeoutId); - this._timeoutId = 0; - } + this._showTimeout.clear(); if (!this.visible) return; this.remove_all_transitions(); this.ease({ @@ -66,10 +73,7 @@ export const CaptureTooltip = GObject.registerClass( } override destroy(): void { - if (this._timeoutId !== 0) GLib.source_remove(this._timeoutId); - this._timeoutId = 0; - if (this._anchor && this._hoverId !== 0) this._anchor.disconnect(this._hoverId); - this._hoverId = 0; + this._lifecycle.dispose(); this._anchor = null; super.destroy(); } diff --git a/src/capture/ocrController.ts b/src/capture/ocrController.ts index 55b4599..5f1f14a 100644 --- a/src/capture/ocrController.ts +++ b/src/capture/ocrController.ts @@ -17,6 +17,7 @@ export class OcrUnavailableError extends Error {} export class OcrController { private _installedLanguages: string[] | null = null; private _probePromise: Promise | null = null; + private _probeSubprocess: Gio.Subprocess | null = null; private _subprocess: Gio.Subprocess | null = null; private _runId = 0; @@ -29,11 +30,15 @@ export class OcrController { async probe(): Promise { if (this._probePromise) return this._probePromise; - this._probePromise = this._probe(); + + const probePromise = this._probe(); + this._probePromise = probePromise; try { - return await this._probePromise; + return await probePromise; } finally { - this._probePromise = null; + if (this._probePromise === probePromise) { + this._probePromise = null; + } } } @@ -75,27 +80,30 @@ export class OcrController { return parseTesseractTsv(stdout ?? '', scale, origin); } finally { if (this._subprocess && runId === this._runId) this._subprocess = null; - try { - GLib.unlink(path); - } catch { - // The file may not have been created if capture failed early. - } + GLib.unlink(path); } } cancel(): void { this._runId++; - if (!this._subprocess) return; - try { - this._subprocess.force_exit(); - } catch { - // The process may already have exited between the check and force_exit(). + const subprocess = this._subprocess; + if (!subprocess) { + return; } + + subprocess.force_exit(); this._subprocess = null; } destroy(): void { this.cancel(); + + if (this._probeSubprocess) { + this._probeSubprocess.force_exit(); + this._probeSubprocess = null; + } + + this._probePromise = null; this._installedLanguages = null; } @@ -109,11 +117,23 @@ export class OcrController { ['tesseract', '--list-langs'], Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE, ); - const [, stdout] = await communicate(subprocess); - this._installedLanguages = subprocess.get_successful() - ? parseTesseractLanguages(stdout ?? '') - : []; - return this._installedLanguages.length > 0; + this._probeSubprocess = subprocess; + + try { + const [, stdout] = await communicate(subprocess); + if (this._probeSubprocess !== subprocess) { + throw new Error('OCR probe was cancelled'); + } + + this._installedLanguages = subprocess.get_successful() + ? parseTesseractLanguages(stdout ?? '') + : []; + return this._installedLanguages.length > 0; + } finally { + if (this._probeSubprocess === subprocess) { + this._probeSubprocess = null; + } + } } } diff --git a/src/capture/screenshotHooks.ts b/src/capture/screenshotHooks.ts new file mode 100644 index 0000000..7135357 --- /dev/null +++ b/src/capture/screenshotHooks.ts @@ -0,0 +1,117 @@ +import * as Main from '@girs/gnome-shell/ui/main'; + +import { gettext as _ } from '~/shared/i18n.ts'; +import type { Annotation } from '~/capture/annotationModel.ts'; +import { captureScreenshot, exportAnnotatedScreenshot } from '~/capture/screenshotCapture.ts'; +import type { ScreenshotUi } from '~/capture/screenshotUiAdapter.ts'; +import { logger } from '~/core/logger.ts'; + +type ScreenshotOpen = (mode?: number, ...args: unknown[]) => Promise; +type ScreenshotSave = () => Promise; + +export type ScreenshotHookCallbacks = { + setPortalMode(portalMode: boolean): void; + syncVisibility(): void; + commitText(): void; + getAnnotations(): readonly Annotation[]; +}; + +export class ScreenshotHooks { + private _originalOpen: ScreenshotOpen; + private _openWrapper: ScreenshotOpen; + private _originalSave: ScreenshotSave; + private _saveWrapper: ScreenshotSave; + + constructor( + private _ui: ScreenshotUi, + private _callbacks: ScreenshotHookCallbacks, + ) { + this._originalOpen = _ui.open; + this._originalSave = _ui._saveScreenshot; + this._openWrapper = this._createOpenWrapper(); + this._saveWrapper = this._createSaveWrapper(); + + _ui.open = this._openWrapper; + _ui._saveScreenshot = this._saveWrapper; + } + + destroy(): void { + if (this._ui.open === this._openWrapper) { + this._ui.open = this._originalOpen; + } else { + logger.warn('[CaptureTools] Screenshot open hook changed externally; leaving it untouched'); + } + + if (this._ui._saveScreenshot === this._saveWrapper) { + this._ui._saveScreenshot = this._originalSave; + } else { + logger.warn('[CaptureTools] Screenshot save hook changed externally; leaving it untouched'); + } + } + + private _createOpenWrapper(): ScreenshotOpen { + return async (mode = 0, ...args: unknown[]) => { + const portalMode = mode === 2; + this._callbacks.setPortalMode(portalMode); + + const result = await this._originalOpen.call(this._ui, mode, ...args); + + this._callbacks.syncVisibility(); + logger.debug(`Native screenshot UI opened; toolbar visible=${!portalMode} (mode=${mode})`, { + prefix: 'CaptureTools', + }); + + return result; + }; + } + + private _createSaveWrapper(): ScreenshotSave { + return async () => { + this._callbacks.commitText(); + + const annotations = this._callbacks.getAnnotations(); + if (annotations.length === 0) { + return this._originalSave.call(this._ui); + } + + try { + const capture = await captureScreenshot(this._ui); + + void this._export(capture.pixbuf, capture.origin, capture.scale, annotations); + } catch (error) { + this._reportExportFailure(error); + } + }; + } + + private async _export( + pixbuf: Parameters[0], + origin: { x: number; y: number }, + scale: number, + annotations: readonly Annotation[], + ): Promise { + try { + const file = await exportAnnotatedScreenshot( + pixbuf, + annotations, + { origin, scale }, + { copy: true, save: true }, + ); + + if (file) { + this._ui.emit('screenshot-taken', file); + } + + logger.debug(`Saved screenshot with ${annotations.length} annotation(s)`, { + prefix: 'CaptureTools', + }); + } catch (error) { + this._reportExportFailure(error); + } + } + + private _reportExportFailure(error: unknown): void { + logger.warn(`[CaptureTools] Annotated screenshot export failed: ${String(error)}`); + Main.notify(_('Screenshot failed'), _('Could not export the annotated screenshot')); + } +} diff --git a/src/capture/screenshotUiAdapter.ts b/src/capture/screenshotUiAdapter.ts index 1a29e47..3030373 100644 --- a/src/capture/screenshotUiAdapter.ts +++ b/src/capture/screenshotUiAdapter.ts @@ -44,10 +44,8 @@ export type ScreenshotUi = St.Widget & { open(mode?: number, ...args: unknown[]): Promise; }; -export function getScreenshotUi(): ScreenshotUi | null { - const candidate: unknown = Main.screenshotUI; - if (!isScreenshotUi(candidate)) return null; - return candidate; +export function getScreenshotUi(): ScreenshotUi { + return Main.screenshotUI as ScreenshotUi; } export function selectedWindow(ui: ScreenshotUi): ScreenshotWindow | null { @@ -58,25 +56,7 @@ export function selectedWindow(ui: ScreenshotUi): ScreenshotWindow | null { } export function textureFromContent(content: Clutter.Content | null): Cogl.Texture | null { - if (!content || typeof (content as { get_texture?: unknown }).get_texture !== 'function') - return null; - return (content as TextureContent).get_texture(); -} + if (!content) return null; -function isScreenshotUi(value: unknown): value is ScreenshotUi { - if (!value || typeof value !== 'object') return false; - const candidate = value as Record; - return ( - typeof candidate['_saveScreenshot'] === 'function' && - typeof candidate['open'] === 'function' && - typeof candidate['connect'] === 'function' && - candidate['_panel'] !== undefined && - candidate['_stageScreenshot'] !== undefined && - candidate['_areaSelector'] !== undefined && - candidate['_selectionButton'] !== undefined && - candidate['_showPointerButton'] !== undefined && - candidate['_showPointerButtonContainer'] !== undefined && - candidate['_screenButton'] !== undefined && - candidate['_windowButton'] !== undefined - ); + return (content as TextureContent).get_texture(); } diff --git a/src/clipboard/clipboardCardBuilders.ts b/src/clipboard/clipboardCardBuilders.ts new file mode 100644 index 0000000..d2fb526 --- /dev/null +++ b/src/clipboard/clipboardCardBuilders.ts @@ -0,0 +1,187 @@ +import '@girs/gjs'; +import { gettext as _ } from '~/shared/i18n.ts'; + +import Clutter from '@girs/clutter-18'; +import St from '@girs/st-18'; + +import type { ClipboardEntry } from './clipboardStore.ts'; +import { highlightCodeMarkup } from './codeHighlight.ts'; +import { truncateClipboardText } from './clipboardCardState.ts'; +import { CodeCardOverlayLayout, FloatingActionsLayout } from './clipboardCardLayouts.ts'; + +const MAX_LABEL_CHARS = 360; +const MAX_CODE_LINES = 5; + +export function buildImageCard(entry: ClipboardEntry, actions: St.BoxLayout): St.Widget { + const overlay = new St.Widget({ + layout_manager: new FloatingActionsLayout(), + x_expand: true, + y_expand: true, + style_class: 'aurora-clipboard-image-overlay', + }); + + const content = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + + if (!entry.filePath) { + content.add_child( + new St.Icon({ + icon_name: 'image-missing-symbolic', + icon_size: 28, + style_class: 'aurora-clipboard-image-missing', + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + }), + ); + } + + actions.add_style_class_name('aurora-clipboard-image-actions'); + overlay.add_child(content); + overlay.add_child(actions); + + return overlay; +} + +export function buildLinkCard( + parsed: { host: string; path: string }, + actions: St.BoxLayout, +): St.Widget { + const root = new St.Widget({ + layout_manager: new FloatingActionsLayout(), + x_expand: true, + x_align: Clutter.ActorAlign.FILL, + style_class: 'aurora-clipboard-item-link-overlay', + }); + + const row = new St.BoxLayout({ + orientation: Clutter.Orientation.HORIZONTAL, + x_expand: true, + style_class: 'aurora-clipboard-item-content', + }); + + const body = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + style_class: 'aurora-clipboard-item-body', + }); + + const title = new St.Label({ + text: parsed.host, + style_class: 'aurora-clipboard-item-link-title', + x_expand: true, + }); + title.clutter_text.ellipsize = 3; + body.add_child(title); + + const path = parsed.path && parsed.path !== '/' ? parsed.path : ''; + const urlLabel = new St.Label({ + text: parsed.host + path, + style_class: 'aurora-clipboard-item-meta', + x_expand: true, + }); + urlLabel.clutter_text.ellipsize = 3; + body.add_child(urlLabel); + + row.add_child(body); + root.add_child(row); + root.add_child(actions); + + return root; +} + +export function buildCodeCard(entry: ClipboardEntry, actions: St.BoxLayout): St.Widget { + const overlay = new St.Widget({ + layout_manager: new CodeCardOverlayLayout(), + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.FILL, + y_align: Clutter.ActorAlign.FILL, + style_class: 'aurora-clipboard-item-code-overlay', + }); + + const content = new St.BoxLayout({ + orientation: Clutter.Orientation.HORIZONTAL, + x_expand: true, + y_expand: true, + x_align: Clutter.ActorAlign.FILL, + y_align: Clutter.ActorAlign.FILL, + style_class: 'aurora-clipboard-item-content', + }); + + const allLines = entry.text.split('\n'); + const shownLines = allLines.slice(0, MAX_CODE_LINES); + const codeRow = new St.BoxLayout({ + orientation: Clutter.Orientation.HORIZONTAL, + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + style_class: 'aurora-clipboard-item-code', + }); + + const gutter = new St.Label({ + text: shownLines.map((_line, index) => String(index + 1)).join('\n'), + style_class: 'aurora-clipboard-item-code-gutter', + y_align: Clutter.ActorAlign.START, + }); + codeRow.add_child(gutter); + + const code = new St.Label({ + style_class: 'aurora-clipboard-item-code-label', + x_expand: true, + y_align: Clutter.ActorAlign.START, + }); + code.clutter_text.set_line_wrap(false); + code.clutter_text.ellipsize = 3; + code.clutter_text.set_markup(highlightCodeMarkup(shownLines.join('\n'))); + codeRow.add_child(code); + + content.add_child(codeRow); + overlay.add_child(content); + overlay.add_child(actions); + + if (allLines.length > MAX_CODE_LINES) { + overlay.add_child( + new St.Label({ + text: _('%d lines').format(allLines.length), + style_class: 'aurora-clipboard-item-code-badge', + }), + ); + } + + return overlay; +} + +export function buildTextCard(entry: ClipboardEntry, actions: St.BoxLayout): St.Widget { + const overlay = new St.Widget({ + layout_manager: new FloatingActionsLayout(), + request_mode: Clutter.RequestMode.HEIGHT_FOR_WIDTH, + x_expand: true, + x_align: Clutter.ActorAlign.FILL, + style_class: 'aurora-clipboard-item-text-overlay', + }); + + const textBody = new St.Bin({ + x_align: Clutter.ActorAlign.START, + style_class: 'aurora-clipboard-item-text-body', + }); + const normalizedText = entry.text.replace(/\s+/g, ' ').trim(); + const label = new St.Label({ + text: truncateClipboardText(normalizedText, MAX_LABEL_CHARS), + style_class: 'aurora-clipboard-item-label', + x_expand: true, + x_align: Clutter.ActorAlign.FILL, + y_align: Clutter.ActorAlign.START, + }); + label.clutter_text.set_line_wrap(true); + label.clutter_text.set_single_line_mode(false); + label.clutter_text.ellipsize = 0; + + textBody.set_child(label); + overlay.add_child(textBody); + overlay.add_child(actions); + + return overlay; +} diff --git a/src/clipboard/clipboardCardLayouts.ts b/src/clipboard/clipboardCardLayouts.ts new file mode 100644 index 0000000..de4c562 --- /dev/null +++ b/src/clipboard/clipboardCardLayouts.ts @@ -0,0 +1,75 @@ +import '@girs/gjs'; + +import Clutter from '@girs/clutter-18'; +import GObject from '@girs/gobject-2.0'; + +function getOverlayPreferredHeight(container: Clutter.Actor, forWidth: number): [number, number] { + const [content, actions] = container.get_children(); + const [contentMin, contentNatural] = content ? content.get_preferred_height(forWidth) : [0, 0]; + const [actionsMin, actionsNatural] = + actions && actions.visible ? actions.get_preferred_height(forWidth) : [0, 0]; + + return [Math.max(contentMin, actionsMin), Math.max(contentNatural, actionsNatural)]; +} + +function allocateTopRight(actor: Clutter.Actor | undefined, allocation: Clutter.ActorBox): void { + if (!actor || !actor.visible) return; + + const [, width] = actor.get_preferred_width(-1); + const [, height] = actor.get_preferred_height(width); + actor.allocate( + new Clutter.ActorBox({ + x1: allocation.x2 - width, + y1: allocation.y1, + x2: allocation.x2, + y2: allocation.y1 + height, + }), + ); +} + +@GObject.registerClass +export class FloatingActionsLayout extends Clutter.LayoutManager { + override vfunc_get_preferred_width( + container: Clutter.Actor, + forHeight: number, + ): [number, number] { + const content = container.first_child; + if (!content) return [0, 0]; + + return content.get_preferred_width(forHeight); + } + + override vfunc_get_preferred_height( + container: Clutter.Actor, + forWidth: number, + ): [number, number] { + return getOverlayPreferredHeight(container, forWidth); + } + + override vfunc_allocate(container: Clutter.Actor, allocation: Clutter.ActorBox): void { + const [content, actions] = container.get_children(); + if (content) content.allocate(allocation); + + allocateTopRight(actions, allocation); + } +} + +@GObject.registerClass +export class CodeCardOverlayLayout extends FloatingActionsLayout { + override vfunc_allocate(container: Clutter.Actor, allocation: Clutter.ActorBox): void { + super.vfunc_allocate(container, allocation); + const badge = container.get_children()[2]; + if (!badge) return; + + const [, width] = badge.get_preferred_width(-1); + const [, height] = badge.get_preferred_height(width); + badge.allocate( + new Clutter.ActorBox({ + x1: allocation.x2 - width, + y1: allocation.y2 - height, + x2: allocation.x2, + y2: allocation.y2, + }), + ); + } +} diff --git a/src/clipboard/clipboardCardState.ts b/src/clipboard/clipboardCardState.ts new file mode 100644 index 0000000..cfb8eef --- /dev/null +++ b/src/clipboard/clipboardCardState.ts @@ -0,0 +1,66 @@ +export type ClipboardCardKind = 'image' | 'link' | 'code' | 'text'; +export type ParsedClipboardUrl = { host: string; path: string }; + +export function parseClipboardUrl(text: string): ParsedClipboardUrl | null { + const trimmed = text.trim(); + if (trimmed.includes('\n') || trimmed.includes(' ') || trimmed.length > 2048) return null; + if (!/^https?:\/\//.test(trimmed)) return null; + + const withoutScheme = trimmed.replace(/^https?:\/\//, ''); + const slashIndex = withoutScheme.indexOf('/'); + const host = slashIndex === -1 ? withoutScheme : withoutScheme.slice(0, slashIndex); + const rawPath = slashIndex === -1 ? '' : withoutScheme.slice(slashIndex); + if (!host || !host.includes('.')) return null; + + return { host, path: rawPath.split('?')[0] ?? '' }; +} + +export function isClipboardCode(text: string): boolean { + const lines = text.split('\n'); + if (lines.length < 2) return false; + + let score = 0; + for (const line of lines) { + const trimmed = line.trim(); + + if (/^\s{2,}/.test(line)) { + score++; + } + if (/[{};]\s*$/.test(line)) { + score++; + } + if (/\\$/.test(trimmed)) { + score++; + } + if (/^\s*(\/\/|#|\/\*|\*)/.test(line)) { + score++; + } + if (/^(curl|wget|git|npm|yarn|pnpm|just|docker|kubectl|ssh|sudo)\b/.test(trimmed)) { + score += 2; + } + if (/^-[A-Za-z]/.test(trimmed)) { + score++; + } + if (/^(https?:\/\/|\/[\w.-]+|\w+=)/.test(trimmed)) { + score++; + } + if ( + /^\s*(function|class|def|import|export|const|let|var|return|if|else|for|while|try|catch|async|await|public|private|protected)\b/.test( + line, + ) + ) + score += 2; + } + return score >= 3; +} + +export function classifyClipboardCard(kind: string, text: string): ClipboardCardKind { + if (kind === 'image') return 'image'; + if (parseClipboardUrl(text)) return 'link'; + + return isClipboardCode(text) ? 'code' : 'text'; +} + +export function truncateClipboardText(text: string, maxChars: number): string { + return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text; +} diff --git a/src/clipboard/clipboardHistory.ts b/src/clipboard/clipboardHistory.ts index 3d5be71..22f5444 100644 --- a/src/clipboard/clipboardHistory.ts +++ b/src/clipboard/clipboardHistory.ts @@ -10,8 +10,9 @@ import Shell from '@girs/shell-18'; import * as Main from '@girs/gnome-shell/ui/main'; import type { ExtensionContext } from '~/core/context.ts'; -import { LifecycleScope } from '~/core/lifecycleScope.ts'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; import { Module } from '~/module.ts'; import type { ClipboardEntry, ClipboardImagePayload } from '~/clipboard/clipboardStore.ts'; @@ -32,8 +33,7 @@ export class ClipboardHistory extends Module { private _monitor: ClipboardMonitor | null = null; private _panel: ClipboardPanel | null = null; private _lifecycle: LifecycleScope | null = null; - private _startupIdleId: number = 0; - private _autoPasteTimeoutId: number = 0; + private _autoPasteTimeout: ManagedSource | null = null; private _pasteTargetWindow: Meta.Window | null = null; private _pasteTargetInputFocus: Clutter.InputFocus | null = null; @@ -42,84 +42,79 @@ export class ClipboardHistory extends Module { } override enable(): void { - this._lifecycle = new LifecycleScope(); + const lifecycle = new LifecycleScope(); + const startupIdle = createManagedSource(lifecycle); + const autoPasteTimeout = createManagedSource(lifecycle); + this._lifecycle = lifecycle; + this._autoPasteTimeout = autoPasteTimeout; const sessionDir = GLib.get_user_runtime_dir() + '/aurora-shell/' + this.context.uuid; const filePath = sessionDir + '/clipboard-history.log'; const mediaDir = sessionDir + '/clipboard-media'; const rawSettings = this.context.settings.getRawSettings(); const pollMs = rawSettings.get_int('clipboard-history-poll-interval'); - this._store = new ClipboardStore(filePath, mediaDir); + const store = new ClipboardStore(filePath, mediaDir); + this._store = store; - this._panel = new (ClipboardPanel as unknown as new ( + const panel = new (ClipboardPanel as unknown as new ( store: ClipboardStore, callbacks: { onActivate: (entry: ClipboardEntry) => void; onRemove: (id: string) => void; onTogglePin: (id: string) => void; }, - ) => ClipboardPanel)(this._store, { + ) => ClipboardPanel)(store, { onActivate: (entry) => this._onActivate(entry), onRemove: (id) => this._onRemove(id), onTogglePin: (id) => this._onTogglePin(id), }); + this._panel = panel; - this._monitor = new ClipboardMonitor(pollMs, { + const monitor = new ClipboardMonitor(pollMs, { onText: (text) => this.addText(text), onImage: (payload) => void this.addImage(payload), }); + this._monitor = monitor; - void this._store.load().then(() => this._panel?.refresh()); - - this._startupIdleId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - this._startupIdleId = 0; - this._monitor?.start(); - return GLib.SOURCE_REMOVE; - }); - const startupIdleId = this._startupIdleId; - this._lifecycle.onDispose(() => { - if (this._startupIdleId !== startupIdleId) return; - GLib.source_remove(startupIdleId); - this._startupIdleId = 0; + void store.load().then(() => { + if (this._panel === panel) panel.refresh(); }); - try { - Main.wm.addKeybinding( - KEYBINDING_KEY, - rawSettings, - Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, - Shell.ActionMode.ALL, - () => this.togglePanel(), - ); - this._lifecycle.onDispose(() => { - try { - Main.wm.removeKeybinding(KEYBINDING_KEY); - } catch { - // The Shell may already have removed it during shutdown. - } - }); - } catch (e) { - logger.error('Failed to register keybinding:', { prefix: LOG_PREFIX }, e as Error); - } - - this._lifecycle.connect(rawSettings, 'changed::clipboard-history-poll-interval', () => { - this._monitor?.setInterval(rawSettings.get_int('clipboard-history-poll-interval')); + startupIdle.replace(() => + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + startupIdle.complete(); + monitor.start(); + return GLib.SOURCE_REMOVE; + }), + ); + + Main.wm.addKeybinding( + KEYBINDING_KEY, + rawSettings, + Meta.KeyBindingFlags.IGNORE_AUTOREPEAT, + Shell.ActionMode.ALL, + () => this.togglePanel(), + ); + lifecycle.onDispose(() => Main.wm.removeKeybinding(KEYBINDING_KEY)); + + lifecycle.connect(rawSettings, 'changed::clipboard-history-poll-interval', () => { + monitor.setInterval(rawSettings.get_int('clipboard-history-poll-interval')); }); } override disable(): void { - this._cancelScheduledAutoPaste(); this._pasteTargetWindow = null; this._pasteTargetInputFocus = null; this._lifecycle?.dispose(); this._lifecycle = null; + this._autoPasteTimeout = null; this._panel?.close(); this._panel?.destroy(); this._panel = null; - this._monitor?.stop(); + this._monitor?.destroy(); this._monitor = null; this._store?.save(); @@ -149,29 +144,52 @@ export class ClipboardHistory extends Module { } addText(text: string): boolean { - const added = this._store?.addText(text) ?? false; - if (added) this._panel?.refresh(); + if (!this._store || !this._panel) return false; + + const added = this._store.addText(text); + if (added) { + this._panel.refresh(); + } + return added; } async addImage(payload: ClipboardImagePayload): Promise { - const added = (await this._store?.addImage(payload)) ?? false; - if (added) this._panel?.refresh(); + const store = this._store; + const panel = this._panel; + if (!store || !panel) { + return false; + } + + const added = await store.addImage(payload); + if (added && this._panel === panel) { + panel.refresh(); + } + return added; } clearHistory(): boolean { - const cleared = this._store?.clear() ?? false; - if (cleared) this._panel?.refresh(); + if (!this._store || !this._panel) return false; + + const cleared = this._store.clear(); + if (cleared) { + this._panel.refresh(); + } + return cleared; } get entryCount(): number { - return (this._store?.getPinned().length ?? 0) + (this._store?.getHistory().length ?? 0); + if (!this._store) return 0; + + return this._store.getPinned().length + this._store.getHistory().length; } get isPanelOpen(): boolean { - return this._panel?.isOpen ?? false; + if (!this._panel) return false; + + return this._panel.isOpen; } private _onActivate(entry: ClipboardEntry): void { @@ -209,9 +227,10 @@ export class ClipboardHistory extends Module { targetInputFocus: Clutter.InputFocus, text: string, ): void { - if (!this._lifecycle) return; + const autoPasteTimeout = this._autoPasteTimeout; + if (!this._lifecycle || !autoPasteTimeout) return; - this._cancelScheduledAutoPaste(); + autoPasteTimeout.clear(); try { targetWindow.activate(global.get_current_time()); } catch (e) { @@ -223,37 +242,36 @@ export class ClipboardHistory extends Module { return; } - this._autoPasteTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, AUTO_PASTE_DELAY_MS, () => { - this._autoPasteTimeoutId = 0; - if (!this._lifecycle) return GLib.SOURCE_REMOVE; - if (!targetWindow.has_focus()) return GLib.SOURCE_REMOVE; - - // Restore the Wayland input focus stolen by the panel search entry - Main.inputMethod.focus_in(targetInputFocus); - if (Main.inputMethod.currentFocus === targetInputFocus) Main.inputMethod.commit(text); - return GLib.SOURCE_REMOVE; - }); - } - - private _cancelScheduledAutoPaste(): void { - if (this._autoPasteTimeoutId === 0) return; - GLib.source_remove(this._autoPasteTimeoutId); - this._autoPasteTimeoutId = 0; + autoPasteTimeout.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, AUTO_PASTE_DELAY_MS, () => { + autoPasteTimeout.complete(); + if (!this._lifecycle) return GLib.SOURCE_REMOVE; + if (!targetWindow.has_focus()) return GLib.SOURCE_REMOVE; + + // Restore the Wayland input focus stolen by the panel search entry + Main.inputMethod.focus_in(targetInputFocus); + if (Main.inputMethod.currentFocus === targetInputFocus) Main.inputMethod.commit(text); + return GLib.SOURCE_REMOVE; + }), + ); } private _onRemove(id: string): void { - this._store?.remove(id); - this._panel?.refresh(); + if (!this._store || !this._panel) return; + + this._store.remove(id); + this._panel.refresh(); } private _onTogglePin(id: string): void { - if (!this._store) return; + if (!this._store || !this._panel) return; + const pinned = this._store.getPinned(); if (pinned.find((e) => e.id === id)) { this._store.unpin(id); } else { this._store.pin(id); } - this._panel?.refresh(); + this._panel.refresh(); } } diff --git a/src/clipboard/clipboardItem.ts b/src/clipboard/clipboardItem.ts index 7e47b7b..7534d76 100644 --- a/src/clipboard/clipboardItem.ts +++ b/src/clipboard/clipboardItem.ts @@ -8,14 +8,13 @@ import * as Main from '@girs/gnome-shell/ui/main'; import * as PopupMenu from '@girs/gnome-shell/ui/popupMenu'; import type { ClipboardEntry } from '~/clipboard/clipboardStore.ts'; -import { highlightCodeMarkup } from '~/clipboard/codeHighlight.ts'; -import { fetchLinkMetadata } from '~/clipboard/linkMetadata.ts'; - -const MAX_LABEL_CHARS = 360; -const MAX_DESCRIPTION_CHARS = 240; -// Code lines shown in the preview (with a line-number gutter); the full count is -// still reported in the footer. -const MAX_CODE_LINES = 5; +import { classifyClipboardCard, parseClipboardUrl } from '~/clipboard/clipboardCardState.ts'; +import { + buildCodeCard, + buildImageCard, + buildLinkCard, + buildTextCard, +} from '~/clipboard/clipboardCardBuilders.ts'; export type ClipboardItemCallbacks = { onActivate: (entry: ClipboardEntry) => void; @@ -25,88 +24,6 @@ export type ClipboardItemCallbacks = { const _menuManagers = new WeakMap(); -function _getOverlayPreferredHeight(container: Clutter.Actor, forWidth: number): [number, number] { - const [content, actions] = container.get_children(); - const [contentMin, contentNatural] = content?.get_preferred_height(forWidth) ?? [0, 0]; - const [actionsMin, actionsNatural] = actions?.visible - ? actions.get_preferred_height(forWidth) - : [0, 0]; - return [Math.max(contentMin, actionsMin), Math.max(contentNatural, actionsNatural)]; -} - -function _allocateTopRight(actor: Clutter.Actor | undefined, allocation: Clutter.ActorBox): void { - if (!actor?.visible) return; - - const [, width] = actor.get_preferred_width(-1); - const [, height] = actor.get_preferred_height(width); - actor.allocate( - new Clutter.ActorBox({ - x1: allocation.x2 - width, - y1: allocation.y1, - x2: allocation.x2, - y2: allocation.y1 + height, - }), - ); -} - -@GObject.registerClass -class FloatingActionsLayout extends Clutter.LayoutManager { - override vfunc_get_preferred_width( - container: Clutter.Actor, - forHeight: number, - ): [number, number] { - return container.first_child?.get_preferred_width(forHeight) ?? [0, 0]; - } - - override vfunc_get_preferred_height( - container: Clutter.Actor, - forWidth: number, - ): [number, number] { - return _getOverlayPreferredHeight(container, forWidth); - } - - override vfunc_allocate(container: Clutter.Actor, allocation: Clutter.ActorBox): void { - const [content, actions] = container.get_children(); - content?.allocate(allocation); - _allocateTopRight(actions, allocation); - } -} - -@GObject.registerClass -class CodeCardOverlayLayout extends Clutter.LayoutManager { - override vfunc_get_preferred_width( - container: Clutter.Actor, - forHeight: number, - ): [number, number] { - return container.first_child?.get_preferred_width(forHeight) ?? [0, 0]; - } - - override vfunc_get_preferred_height( - container: Clutter.Actor, - forWidth: number, - ): [number, number] { - return _getOverlayPreferredHeight(container, forWidth); - } - - override vfunc_allocate(container: Clutter.Actor, allocation: Clutter.ActorBox): void { - const [content, actions, badge] = container.get_children(); - content?.allocate(allocation); - _allocateTopRight(actions, allocation); - if (!badge) return; - - const [, badgeWidth] = badge.get_preferred_width(-1); - const [, badgeHeight] = badge.get_preferred_height(badgeWidth); - badge.allocate( - new Clutter.ActorBox({ - x1: allocation.x2 - badgeWidth, - y1: allocation.y2 - badgeHeight, - x2: allocation.x2, - y2: allocation.y2, - }), - ); - } -} - @GObject.registerClass export class ClipboardItem extends St.Button { declare private _entry: ClipboardEntry; @@ -116,9 +33,6 @@ export class ClipboardItem extends St.Button { declare private _removeButton: St.Button; declare private _menuButton: St.Button; declare private _menu: PopupMenu.PopupMenu | null; - declare private _linkTitle: St.Label | null; - declare private _linkDescription: St.Label | null; - declare private _linkThumb: St.Widget | null; override _init(entry: ClipboardEntry, callbacks: ClipboardItemCallbacks): void { super._init({ @@ -139,9 +53,6 @@ export class ClipboardItem extends St.Button { this._entry = entry; this._callbacks = callbacks; this._menu = null; - this._linkTitle = null; - this._linkDescription = null; - this._linkThumb = null; this._actions = new St.BoxLayout({ orientation: Clutter.Orientation.HORIZONTAL, @@ -171,18 +82,7 @@ export class ClipboardItem extends St.Button { this._actions.add_child(this._menuButton); this.setActionsVisible(false); - if (entry.kind === 'image') { - this._initImageCard(entry); - } else { - const url = this._parseUrl(entry.text); - if (url) { - this._initLinkCard(entry.text.trim(), url); - } else if (this._isCode(entry.text)) { - this._initCodeCard(entry); - } else { - this._initTextCard(entry); - } - } + this._buildCard(); } get entry(): ClipboardEntry { @@ -191,9 +91,6 @@ export class ClipboardItem extends St.Button { override destroy(): void { this._destroyMenu(); - this._linkTitle = null; - this._linkDescription = null; - this._linkThumb = null; super.destroy(); } @@ -212,224 +109,43 @@ export class ClipboardItem extends St.Button { } } - private _initImageCard(entry: ClipboardEntry): void { - this.add_style_class_name('aurora-clipboard-item--image'); + private _buildCard(): void { + const cardKind = classifyClipboardCard(this._entry.kind, this._entry.text); - if (entry.filePath) { - this.style = `background-image: url("file://${entry.filePath}"); background-size: cover;`; + if (cardKind === 'image') { + this._buildImageCard(); + return; } - const overlay = new St.Widget({ - layout_manager: new FloatingActionsLayout(), - x_expand: true, - y_expand: true, - style_class: 'aurora-clipboard-image-overlay', - }); - - const content = new St.Widget({ - layout_manager: new Clutter.BinLayout(), - x_expand: true, - y_expand: true, - }); - if (!entry.filePath) { - content.add_child( - new St.Icon({ - icon_name: 'image-missing-symbolic', - icon_size: 28, - style_class: 'aurora-clipboard-image-missing', - x_align: Clutter.ActorAlign.CENTER, - y_align: Clutter.ActorAlign.CENTER, - }), - ); + if (cardKind === 'link') { + this._buildLinkCard(); + return; } - overlay.add_child(content); - - this._actions.add_style_class_name('aurora-clipboard-image-actions'); - overlay.add_child(this._actions); - this.set_child(overlay); + const card = + cardKind === 'code' + ? buildCodeCard(this._entry, this._actions) + : buildTextCard(this._entry, this._actions); + this.set_child(card); } - private _initLinkCard(url: string, parsed: { host: string; path: string }): void { - this.add_style_class_name('aurora-clipboard-item--link'); - - const overlay = new St.Widget({ - layout_manager: new FloatingActionsLayout(), - x_expand: true, - x_align: Clutter.ActorAlign.FILL, - style_class: 'aurora-clipboard-item-link-overlay', - }); - - const box = new St.BoxLayout({ - orientation: Clutter.Orientation.HORIZONTAL, - x_expand: true, - style_class: 'aurora-clipboard-item-content', - }); - - this._linkThumb = new St.Widget({ - style_class: 'aurora-clipboard-item-link-thumb', - y_align: Clutter.ActorAlign.CENTER, - visible: false, - }); - box.add_child(this._linkThumb); - - const body = new St.BoxLayout({ - orientation: Clutter.Orientation.VERTICAL, - x_expand: true, - y_align: Clutter.ActorAlign.CENTER, - style_class: 'aurora-clipboard-item-body', - }); - - // The title starts as the host and is replaced by the page title once the - // metadata fetch resolves. - this._linkTitle = new St.Label({ - text: parsed.host, - style_class: 'aurora-clipboard-item-link-title', - x_expand: true, - }); - this._linkTitle.clutter_text.ellipsize = 3; - body.add_child(this._linkTitle); - - this._linkDescription = new St.Label({ - style_class: 'aurora-clipboard-item-link-desc', - x_expand: true, - visible: false, - }); - this._linkDescription.clutter_text.set_line_wrap(true); - this._linkDescription.clutter_text.ellipsize = 3; - body.add_child(this._linkDescription); - - const urlLabel = new St.Label({ - text: parsed.host + (parsed.path && parsed.path !== '/' ? parsed.path : ''), - style_class: 'aurora-clipboard-item-meta', - x_expand: true, - }); - urlLabel.clutter_text.ellipsize = 3; - body.add_child(urlLabel); - - box.add_child(body); - overlay.add_child(box); - overlay.add_child(this._actions); - this.set_child(overlay); - - void this._loadLinkPreview(url); - } - - private async _loadLinkPreview(url: string): Promise { - const meta = await fetchLinkMetadata(url); - if (!this._linkTitle) return; - - if (meta.title && this._linkTitle) { - this._linkTitle.text = meta.title; - } - - if (meta.description && this._linkDescription) { - this._linkDescription.text = this._truncate(meta.description, MAX_DESCRIPTION_CHARS); - this._linkDescription.visible = true; - } - - if (meta.imagePath && this._linkThumb) { - this._linkThumb.style = `background-image: url("file://${meta.imagePath}"); background-size: cover;`; - this._linkThumb.visible = true; - } - } - - private _initCodeCard(entry: ClipboardEntry): void { - const overlay = new St.Widget({ - layout_manager: new CodeCardOverlayLayout(), - x_expand: true, - y_expand: true, - x_align: Clutter.ActorAlign.FILL, - y_align: Clutter.ActorAlign.FILL, - style_class: 'aurora-clipboard-item-code-overlay', - }); - - const box = new St.BoxLayout({ - orientation: Clutter.Orientation.HORIZONTAL, - x_expand: true, - y_expand: true, - x_align: Clutter.ActorAlign.FILL, - y_align: Clutter.ActorAlign.FILL, - style_class: 'aurora-clipboard-item-content', - }); - - const allLines = entry.text.split('\n'); - const shownLines = allLines.slice(0, MAX_CODE_LINES); - const snippet = shownLines.join('\n'); - - const codeRow = new St.BoxLayout({ - orientation: Clutter.Orientation.HORIZONTAL, - x_expand: true, - y_align: Clutter.ActorAlign.CENTER, - style_class: 'aurora-clipboard-item-code', - }); - - const gutter = new St.Label({ - text: shownLines.map((_line, i) => String(i + 1)).join('\n'), - style_class: 'aurora-clipboard-item-code-gutter', - y_align: Clutter.ActorAlign.START, - }); - codeRow.add_child(gutter); + private _buildImageCard(): void { + this.add_style_class_name('aurora-clipboard-item--image'); - // No line wrap: each source line stays on its own visual row so the gutter - // numbers line up 1:1. Over-long lines ellipsize per line, which also keeps - // the label's minimum width small so one long line can't widen the panel. - const code = new St.Label({ - style_class: 'aurora-clipboard-item-code-label', - x_expand: true, - y_align: Clutter.ActorAlign.START, - }); - code.clutter_text.set_line_wrap(false); - code.clutter_text.ellipsize = 3; - code.clutter_text.set_markup(highlightCodeMarkup(snippet)); - codeRow.add_child(code); - - box.add_child(codeRow); - overlay.add_child(box); - overlay.add_child(this._actions); - - if (allLines.length > MAX_CODE_LINES) { - overlay.add_child( - new St.Label({ - text: _('%d lines').format(allLines.length), - style_class: 'aurora-clipboard-item-code-badge', - }), - ); + if (this._entry.filePath) { + this.style = `background-image: url("file://${this._entry.filePath}"); background-size: cover;`; } - this.set_child(overlay); + this.set_child(buildImageCard(this._entry, this._actions)); } - private _initTextCard(entry: ClipboardEntry): void { - const overlay = new St.Widget({ - layout_manager: new FloatingActionsLayout(), - request_mode: Clutter.RequestMode.HEIGHT_FOR_WIDTH, - x_expand: true, - x_align: Clutter.ActorAlign.FILL, - style_class: 'aurora-clipboard-item-text-overlay', - }); - - const textBody = new St.Bin({ - x_align: Clutter.ActorAlign.START, - style_class: 'aurora-clipboard-item-text-body', - }); - - const label = new St.Label({ - text: this._truncate(entry.text.replace(/\s+/g, ' ').trim(), MAX_LABEL_CHARS), - style_class: 'aurora-clipboard-item-label', - x_expand: true, - x_align: Clutter.ActorAlign.FILL, - y_align: Clutter.ActorAlign.START, - }); - label.clutter_text.set_line_wrap(true); - label.clutter_text.set_single_line_mode(false); - label.clutter_text.ellipsize = 0; - - textBody.set_child(label); - overlay.add_child(textBody); + private _buildLinkCard(): void { + const url = this._entry.text.trim(); + const parsed = parseClipboardUrl(url); + if (!parsed) return; - overlay.add_child(this._actions); - this.set_child(overlay); + this.add_style_class_name('aurora-clipboard-item--link'); + this.set_child(buildLinkCard(parsed, this._actions)); } private _createActionButton(iconName: string, styleClass: string, action: () => void): St.Button { @@ -479,58 +195,10 @@ export class ClipboardItem extends St.Button { private _destroyMenu(): void { if (!this._menu) return; - const manager = _menuManagers.get(this._menu); - manager?.removeMenu(this._menu); + const manager = _menuManagers.get(this._menu)!; + manager.removeMenu(this._menu); _menuManagers.delete(this._menu); this._menu.destroy(); this._menu = null; } - - private _parseUrl(text: string): { host: string; path: string } | null { - const trimmed = text.trim(); - if (trimmed.includes('\n') || trimmed.includes(' ') || trimmed.length > 2048) return null; - if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) return null; - - try { - const withoutScheme = trimmed.replace(/^https?:\/\//, ''); - const slashIdx = withoutScheme.indexOf('/'); - const host = slashIdx === -1 ? withoutScheme : withoutScheme.slice(0, slashIdx); - const rawPath = slashIdx === -1 ? '' : withoutScheme.slice(slashIdx); - const path = rawPath.split('?')[0]!; - - if (!host || !host.includes('.')) return null; - return { host, path }; - } catch { - return null; - } - } - - private _isCode(text: string): boolean { - const lines = text.split('\n'); - if (lines.length < 2) return false; - - let score = 0; - for (const line of lines) { - const trimmed = line.trim(); - if (/^\s{2,}/.test(line)) score++; - if (/[{};]\s*$/.test(line)) score++; - if (/\\$/.test(trimmed)) score++; - if (/^\s*(\/\/|#|\/\*|\*)/.test(line)) score++; - if (/^(curl|wget|git|npm|yarn|pnpm|just|docker|kubectl|ssh|sudo)\b/.test(trimmed)) score += 2; - if (/^-[A-Za-z]/.test(trimmed)) score++; - if (/^(https?:\/\/|\/[\w.-]+|\w+=)/.test(trimmed)) score++; - if ( - /^\s*(function|class|def|import|export|const|let|var|return|if|else|for|while|try|catch|async|await|public|private|protected)\b/.test( - line, - ) - ) - score += 2; - } - - return score >= 3; - } - - private _truncate(text: string, maxChars: number): string { - return text.length > maxChars ? text.slice(0, maxChars) + '…' : text; - } } diff --git a/src/clipboard/clipboardMonitor.ts b/src/clipboard/clipboardMonitor.ts index c46bd2e..3fe553d 100644 --- a/src/clipboard/clipboardMonitor.ts +++ b/src/clipboard/clipboardMonitor.ts @@ -2,6 +2,8 @@ import GLib from '@girs/glib-2.0'; import St from '@girs/st-18'; import type { ClipboardImagePayload } from '~/clipboard/clipboardStore.ts'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; const IMAGE_MIME_TYPES = [ 'image/png', @@ -17,7 +19,8 @@ export class ClipboardMonitor { private _intervalMs: number; private _onText: (text: string) => void; private _onImage: (payload: ClipboardImagePayload) => void; - private _sourceId: number = 0; + private _lifecycle = new LifecycleScope(); + private _pollSource: ManagedSource = createManagedSource(this._lifecycle); private _lastContentKey: string | null = null; constructor( @@ -33,28 +36,31 @@ export class ClipboardMonitor { } start(): void { - if (this._sourceId !== 0) return; - this._sourceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, this._intervalMs, () => { - this._tick(); - return GLib.SOURCE_CONTINUE; - }); + if (this._pollSource.active) return; + this._pollSource.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, this._intervalMs, () => { + this._tick(); + return GLib.SOURCE_CONTINUE; + }), + ); } stop(): void { - if (this._sourceId !== 0) { - GLib.source_remove(this._sourceId); - this._sourceId = 0; - } + this._pollSource.clear(); } setInterval(ms: number): void { this._intervalMs = ms; - if (this._sourceId !== 0) { + if (this._pollSource.active) { this.stop(); this.start(); } } + destroy(): void { + this._lifecycle.dispose(); + } + private _tick(): void { const clipboard = St.Clipboard.get_default(); const imageMimeType = this._findImageMimeType( diff --git a/src/clipboard/clipboardPanel.ts b/src/clipboard/clipboardPanel.ts index cac3fa6..2bfce9b 100644 --- a/src/clipboard/clipboardPanel.ts +++ b/src/clipboard/clipboardPanel.ts @@ -8,6 +8,7 @@ import * as Main from '@girs/gnome-shell/ui/main'; import type { ClipboardEntry, ClipboardStore } from '~/clipboard/clipboardStore.ts'; import { ClipboardList } from '~/clipboard/clipboardList.ts'; +import { LifecycleScope } from '~/core/lifecycleScope.ts'; import { UnredirectInhibitor } from '~/core/unredirectInhibitor.ts'; import { placeClipboardPanelNearPointer, @@ -37,10 +38,8 @@ export class ClipboardPanel extends St.BoxLayout { private _overlay: St.Bin | null = null; declare private _unredirectInhibitor: UnredirectInhibitor; - private _capturedEventId: number = 0; - private _searchChangedId: number = 0; - private _monitorsChangedId: number = 0; - private _sessionModeId: number = 0; + declare private _lifecycle: LifecycleScope; + private _openLifecycle: LifecycleScope | null = null; override _init(store: ClipboardStore, callbacks: PanelCallbacks): void { super._init({ @@ -52,8 +51,11 @@ export class ClipboardPanel extends St.BoxLayout { this._store = store; this._callbacks = callbacks; + this._lifecycle = new LifecycleScope(); this._unredirectInhibitor = new UnredirectInhibitor(global.compositor); - this.connect('notify::mapped', () => this._unredirectInhibitor.setInhibited(this.mapped)); + this._lifecycle.connect(this, 'notify::mapped', () => + this._unredirectInhibitor.setInhibited(this.mapped), + ); this._searchEntry = new St.Entry({ style_class: 'aurora-clipboard-search', @@ -92,6 +94,7 @@ export class ClipboardPanel extends St.BoxLayout { open(): void { if (this._isOpen) return; + this._openLifecycle = new LifecycleScope(); // Semi-transparent click-away overlay — no modal grab needed this._overlay = new St.Bin({ reactive: true }); @@ -112,17 +115,18 @@ export class ClipboardPanel extends St.BoxLayout { // captured-event fires in the CAPTURE phase — before ClutterText (St.Entry // internals) has a chance to consume Escape, Up, Down, Enter, etc. - this._capturedEventId = global.stage.connect( + this._openLifecycle.connect( + global.stage, 'captured-event', (_actor: Clutter.Actor, event: Clutter.Event) => this._onCapturedEvent(event), ); - this._searchChangedId = this._searchEntry.clutter_text.connect('text-changed', () => + this._openLifecycle.connect(this._searchEntry.clutter_text, 'text-changed', () => this._syncList(this._searchEntry.get_text()), ); - this._monitorsChangedId = Main.layoutManager.connect('monitors-changed', () => this.close()); - this._sessionModeId = Main.sessionMode.connect('updated', () => this.close()); + this._openLifecycle.connect(Main.layoutManager, 'monitors-changed', () => this.close()); + this._openLifecycle.connect(Main.sessionMode, 'updated', () => this.close()); this._searchEntry.set_text(''); this._syncList(''); @@ -132,22 +136,8 @@ export class ClipboardPanel extends St.BoxLayout { close(): void { if (!this._isOpen) return; - if (this._capturedEventId !== 0) { - global.stage.disconnect(this._capturedEventId); - this._capturedEventId = 0; - } - if (this._searchChangedId !== 0) { - this._searchEntry.clutter_text.disconnect(this._searchChangedId); - this._searchChangedId = 0; - } - if (this._monitorsChangedId !== 0) { - Main.layoutManager.disconnect(this._monitorsChangedId); - this._monitorsChangedId = 0; - } - if (this._sessionModeId !== 0) { - Main.sessionMode.disconnect(this._sessionModeId); - this._sessionModeId = 0; - } + this._openLifecycle?.dispose(); + this._openLifecycle = null; if (this._overlay) { Main.layoutManager.removeChrome(this._overlay); @@ -162,6 +152,7 @@ export class ClipboardPanel extends St.BoxLayout { override destroy(): void { this.close(); + this._lifecycle.dispose(); this._unredirectInhibitor.release(); super.destroy(); } diff --git a/src/clipboard/linkMetadata.ts b/src/clipboard/linkMetadata.ts deleted file mode 100644 index d02019f..0000000 --- a/src/clipboard/linkMetadata.ts +++ /dev/null @@ -1,187 +0,0 @@ -import '@girs/gjs'; - -import GLib from '@girs/glib-2.0'; -import Gio from '@girs/gio-2.0'; -import Soup from '@girs/soup-3.0'; - -import { logger } from '~/core/logger.ts'; - -const LOG_PREFIX = 'ClipboardLink'; -const USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AuroraShell/1.0'; -const REQUEST_TIMEOUT_S = 8; -const MAX_HTML_BYTES = 512 * 1024; - -export type LinkMetadata = { - title: string | null; - description: string | null; - imagePath: string | null; -}; - -const EMPTY: LinkMetadata = { title: null, description: null, imagePath: null }; - -// Session-scoped cache so reopening the panel never re-hits the network for a URL -// that was already resolved (or failed) during this Shell session. -const _cache = new Map>(); - -/** - * Fetches Open Graph / HTML metadata for a URL, downloading and caching the - * preview image to disk. Network failures resolve to empty metadata rather than - * rejecting, so callers can treat the result uniformly. - */ -export function fetchLinkMetadata(url: string): Promise { - let pending = _cache.get(url); - if (!pending) { - pending = _fetch(url); - _cache.set(url, pending); - } - return pending; -} - -async function _fetch(url: string): Promise { - let session: Soup.Session | null = null; - try { - const uri = GLib.Uri.parse(url, GLib.UriFlags.NONE); - - session = new Soup.Session({ user_agent: USER_AGENT }); - session.timeout = REQUEST_TIMEOUT_S; - - const message = Soup.Message.new_from_uri('GET', uri); - message.request_headers.append( - 'Accept', - 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - ); - - const body = await session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null); - if (!body) return EMPTY; - - const [contentType] = message.response_headers.get_content_type(); - if (!contentType || !contentType.includes('html')) return EMPTY; - - const data = body.get_data(); - if (!data || data.length === 0) return EMPTY; - - const slice = data.length > MAX_HTML_BYTES ? data.subarray(0, MAX_HTML_BYTES) : data; - const html = new TextDecoder('utf-8').decode(slice); - const meta = _parseMeta(html); - - let imagePath: string | null = null; - if (meta.image) { - const absolute = _resolveImageUrl(uri, meta.image); - if (absolute) imagePath = await _downloadImage(session, absolute); - } - - return { title: meta.title, description: meta.description, imagePath }; - } catch (e) { - // Best-effort preview: unreachable hosts, DNS failures and timeouts are - // routine (e.g. internal URLs). Log at debug so we don't spam CRITICAL + - // stack traces; the card just renders without a preview. - logger.debug('Failed to fetch link metadata', { prefix: LOG_PREFIX }, e as Error); - return EMPTY; - } finally { - session?.abort(); - } -} - -type RawMeta = { title: string | null; description: string | null; image: string | null }; - -function _parseMeta(html: string): RawMeta { - const meta: Record = {}; - const metaRegex = /]*>\s*([\s\S]*?)\s*<\/title>/i); - title = titleMatch ? titleMatch[1]! : null; - } - - const description = - meta['og:description'] ?? meta['twitter:description'] ?? meta['description'] ?? null; - - const image = - meta['og:image'] ?? - meta['og:image:url'] ?? - meta['og:image:secure_url'] ?? - meta['twitter:image'] ?? - meta['image'] ?? - null; - - return { - title: title ? _decodeHtml(title).trim() || null : null, - description: description ? _decodeHtml(description).trim() || null : null, - image: image ? image.trim() || null : null, - }; -} - -function _resolveImageUrl(base: GLib.Uri, image: string): string | null { - try { - return base.parse_relative(image, GLib.UriFlags.NONE).to_string(); - } catch { - return image.startsWith('http://') || image.startsWith('https://') ? image : null; - } -} - -async function _downloadImage(session: Soup.Session, url: string): Promise { - try { - const path = _cacheImagePath(url); - if (!path) return null; - - const file = Gio.File.new_for_path(path); - if (file.query_exists(null)) return path; - - const uri = GLib.Uri.parse(url, GLib.UriFlags.NONE); - const message = Soup.Message.new_from_uri('GET', uri); - message.request_headers.append( - 'Accept', - 'image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,*/*;q=0.5', - ); - - const body = await session.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null); - if (!body) return null; - - const [contentType] = message.response_headers.get_content_type(); - if (!contentType || !contentType.startsWith('image/')) return null; - - const data = body.get_data(); - if (!data || data.length === 0) return null; - - file.replace_contents(data, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); - return path; - } catch (e) { - // Same best-effort policy as _fetch: a failed image download just means no - // thumbnail, not an error worth a CRITICAL log. - logger.debug('Failed to cache link image', { prefix: LOG_PREFIX }, e as Error); - return null; - } -} - -function _cacheImagePath(url: string): string | null { - const checksum = GLib.compute_checksum_for_string(GLib.ChecksumType.MD5, url, -1); - if (!checksum) return null; - - const dir = GLib.build_filenamev([GLib.get_user_cache_dir(), 'aurora-shell', 'link-previews']); - GLib.mkdir_with_parents(dir, 0o700); - return GLib.build_filenamev([dir, checksum + '.img']); -} - -const HTML_ENTITIES: Record = { - lt: '<', - gt: '>', - amp: '&', - quot: '"', - apos: "'", - mdash: '—', - ndash: '–', - hellip: '…', - nbsp: ' ', -}; - -function _decodeHtml(html: string): string { - return html - .replace(/&/g, '&') - .replace(/&#(\d+);/g, (_, n: string) => String.fromCharCode(Number(n))) - .replace(/&#x([0-9a-fA-F]+);/g, (_, n: string) => String.fromCharCode(parseInt(n, 16))) - .replace(/&([a-z]+);/gi, (whole, name: string) => HTML_ENTITIES[name.toLowerCase()] ?? whole); -} diff --git a/src/core/extensionBase.ts b/src/core/extensionBase.ts index c0f4289..dbc7ba3 100644 --- a/src/core/extensionBase.ts +++ b/src/core/extensionBase.ts @@ -17,7 +17,12 @@ export class AuroraShellExtensionBase extends Extension { protected _context: ExtensionContext | null = null; get _modules(): ReadonlyMap { - return this._manager?.modules ?? new Map(); + const manager = this._manager; + if (!manager) { + return new Map(); + } + + return manager.modules; } override enable(): void { diff --git a/src/core/lifecycleScope.ts b/src/core/lifecycleScope.ts index 76d60ed..9f5cde4 100644 --- a/src/core/lifecycleScope.ts +++ b/src/core/lifecycleScope.ts @@ -1,10 +1,53 @@ export type Teardown = () => void; +export type SourceRemover = (sourceId: number) => void; + +export interface ManagedSource { + readonly active: boolean; + replace(createSource: () => number): void; + clear(): void; + complete(): void; +} type SignalTarget = { connect(signal: string, callback: (...args: Args) => void): number; disconnect(id: number): void; }; +class ManagedSourceImpl implements ManagedSource { + private _sourceId: number | null = 0; + + constructor(private readonly _remove: SourceRemover) {} + + get active(): boolean { + return this._sourceId !== null && this._sourceId !== 0; + } + + replace(createSource: () => number): void { + this.clear(); + if (this._sourceId === null) return; + + this._sourceId = createSource(); + } + + clear(): void { + if (!this._sourceId) return; + const sourceId = this._sourceId; + this._sourceId = 0; + this._remove(sourceId); + } + + complete(): void { + if (this._sourceId === null) return; + this._sourceId = 0; + } + + dispose(): void { + if (this._sourceId === null) return; + this.clear(); + this._sourceId = null; + } +} + export class LifecycleScope { private _teardowns: Teardown[] | null = []; @@ -20,21 +63,24 @@ export class LifecycleScope { target: SignalTarget, signal: string, callback: (...args: Args) => void, - ): void { + ): number { const id = target.connect(signal, callback); this.onDispose(() => target.disconnect(id)); + return id; + } + + manageSource(remove: SourceRemover): ManagedSource { + const source = new ManagedSourceImpl(remove); + this.onDispose(() => source.dispose()); + return source; } dispose(): void { if (!this._teardowns) return; const teardowns = this._teardowns; this._teardowns = null; - for (let index = teardowns.length - 1; index >= 0; index--) { - try { - teardowns[index]?.(); - } catch { - // Teardown is best-effort; remaining resources must still be released. - } + for (const teardown of teardowns.reverse()) { + teardown(); } } } diff --git a/src/core/mainLoop.ts b/src/core/mainLoop.ts new file mode 100644 index 0000000..e2e11ef --- /dev/null +++ b/src/core/mainLoop.ts @@ -0,0 +1,12 @@ +import GLib from '@girs/glib-2.0'; + +import type { LifecycleScope, ManagedSource } from '~/core/lifecycleScope.ts'; + +export function removeSource(sourceId: number): 0 { + if (sourceId !== 0) GLib.source_remove(sourceId); + return 0; +} + +export function createManagedSource(scope: LifecycleScope): ManagedSource { + return scope.manageSource(removeSource); +} diff --git a/src/desktop/trayIcons/appIdentity.ts b/src/desktop/trayIcons/appIdentity.ts index d1ca7fc..48d6703 100644 --- a/src/desktop/trayIcons/appIdentity.ts +++ b/src/desktop/trayIcons/appIdentity.ts @@ -17,3 +17,42 @@ export function appIdCandidates(appIds: readonly string[]): Set { return candidates; } + +const GENERIC_COMPONENTS = new Set([ + 'app', + 'application', + 'desktop', + 'indicator', + 'status', + 'statusicon', + 'status_icon', + 'tray', +]); + +export type SniIdentity = { desktopEntry: string; sniId: string }; + +export function sniIdentityMatchesAppId(identity: SniIdentity, appId: string): boolean { + const appIds = appIdCandidates([appId]); + const appComponents = new Set( + [...appIds] + .map((candidate) => candidate.split('.').at(-1) ?? candidate) + .filter(isSpecificAppComponent), + ); + const desktopEntry = identity.desktopEntry.toLowerCase(); + const unsuffixed = desktopEntry.replace(/\.desktop$/, ''); + if ( + [...appIds].some( + (id) => desktopEntry === id || desktopEntry === `${id}.desktop` || unsuffixed === id, + ) + ) + return true; + const sniId = identity.sniId.toLowerCase(); + if (!sniId) return false; + if (appIds.has(sniId)) return true; + const component = sniId.split('.').at(-1) ?? sniId; + return isSpecificAppComponent(component) && appComponents.has(component); +} + +function isSpecificAppComponent(component: string): boolean { + return component.length >= 4 && !GENERIC_COMPONENTS.has(component); +} diff --git a/src/desktop/trayIcons/backgroundAppsSource.ts b/src/desktop/trayIcons/backgroundAppsSource.ts index efb7403..9026936 100644 --- a/src/desktop/trayIcons/backgroundAppsSource.ts +++ b/src/desktop/trayIcons/backgroundAppsSource.ts @@ -44,22 +44,25 @@ export class BackgroundAppsSource { async start(): Promise { this._cancellable = new Gio.Cancellable(); + + let proxy: Gio.DBusProxy; try { - this._proxy = new (BackgroundMonitorProxy as any)( + proxy = new (BackgroundMonitorProxy as any)( Gio.DBus.session, DBUS_NAME, DBUS_OBJECT, this._cancellable, ) as Gio.DBusProxy; - - this._proxyChangedId = this._proxy.connect('g-properties-changed', () => this._sync()); - this._sync(); } catch (e) { - if (!(e as any)?.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) { + if (!(e instanceof GLib.Error && e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))) { logger.warn(`BackgroundApps proxy unavailable: ${e}`, { prefix: LOG_PREFIX }); } - this._proxy = null; + return; } + + this._proxy = proxy; + this._proxyChangedId = proxy.connect('g-properties-changed', () => this._sync()); + this._sync(); } private _sync(): void { @@ -101,7 +104,6 @@ export class BackgroundAppsSource { } } - // Remove gone apps for (const [id] of this._knownIds) { if (!currentApps.has(id)) { logger.debug(`BG app removed from monitor: ${id}`, { prefix: LOG_PREFIX }); @@ -110,7 +112,6 @@ export class BackgroundAppsSource { } } - // Add new apps for (const [appId, { app, message }] of currentApps) { if (!this._knownIds.has(appId)) { logger.debug(`BG app found in monitor: ${appId}`, { prefix: LOG_PREFIX }); diff --git a/src/desktop/trayIcons/dbusMenu.ts b/src/desktop/trayIcons/dbusMenu.ts index 7067165..bd4d2d9 100644 --- a/src/desktop/trayIcons/dbusMenu.ts +++ b/src/desktop/trayIcons/dbusMenu.ts @@ -1,4 +1,3 @@ -// src/desktop/trayIcons/dbusMenu.ts import '@girs/gjs'; import Gio from '@girs/gio-2.0'; import GLib from '@girs/glib-2.0'; @@ -229,8 +228,9 @@ export class DBusMenuClient { } private _eventTimestamp(event: Clutter.Event | null): number { - const timestamp = event?.get_time() ?? Clutter.get_current_event_time(); + const timestamp = event ? event.get_time() : Clutter.get_current_event_time(); if (!Number.isFinite(timestamp) || timestamp < 0 || timestamp > 0xffffffff) return 0; + return Math.round(timestamp); } diff --git a/src/desktop/trayIcons/sniEntry.ts b/src/desktop/trayIcons/sniEntry.ts new file mode 100644 index 0000000..70ba641 --- /dev/null +++ b/src/desktop/trayIcons/sniEntry.ts @@ -0,0 +1,29 @@ +import Gio from '@girs/gio-2.0'; +import type { TrayItem } from './trayState.ts'; + +export class SniEntry { + constructor( + public readonly proxy: Gio.DBusProxy, + public readonly item: TrayItem, + public readonly sniId: string, + public readonly desktopEntry: string, + private _signalId: number, + private _nameWatchId: number, + private _cancellable: Gio.Cancellable, + ) {} + + destroy(): void { + this._cancellable.cancel(); + + if (this._signalId) { + this.proxy.disconnect(this._signalId); + } + + if (this._nameWatchId) { + Gio.DBus.session.unwatch_name(this._nameWatchId); + } + + this._signalId = 0; + this._nameWatchId = 0; + } +} diff --git a/src/desktop/trayIcons/sniHost.ts b/src/desktop/trayIcons/sniHost.ts index 28f76a2..8bbae72 100644 --- a/src/desktop/trayIcons/sniHost.ts +++ b/src/desktop/trayIcons/sniHost.ts @@ -7,7 +7,9 @@ import St from '@girs/st-18'; import type { TrayItem, TrayItemStatus } from './trayState.ts'; import type { SniWatcher } from './sniWatcher.ts'; -import { appIdCandidates } from './appIdentity.ts'; +import { sniIdentityMatchesAppId } from './appIdentity.ts'; +import { isSymbolicSniArgb } from './sniIconState.ts'; +import { SniEntry } from './sniEntry.ts'; import { logger } from '~/core/logger.ts'; const SNI_ITEM_XML = ` @@ -42,16 +44,6 @@ const SYMBOLIC_REQUIRED_RATIO = 0.92; const LIGHT_PANEL_ICON = [48, 48, 48] as const; const DARK_PANEL_ICON = [250, 250, 251] as const; const LOG_PREFIX = 'AuroraTray'; -const GENERIC_APP_ID_COMPONENTS = new Set([ - 'app', - 'application', - 'desktop', - 'indicator', - 'status', - 'statusicon', - 'status_icon', - 'tray', -]); // @ts-ignore — _promisify is a GJS extension not reflected in .d.ts Gio._promisify(Gio.DBusProxy.prototype, 'init_async'); @@ -70,18 +62,9 @@ type SniHostOptions = { shouldRecolorSymbolicPixmaps?: () => boolean; }; -type SniEntry = { - proxy: Gio.DBusProxy; - item: TrayItem; - sniId: string; - desktopEntry: string; - signalId: number; - nameWatchId: number; - cancellable: Gio.Cancellable; -}; - export class SniHost { private _entries = new Map(); + private _pendingRegistrations = new Map(); private _callbacks: HostCallbacks; private _watcher: SniWatcher; private _getColorScheme: () => string; @@ -96,9 +79,10 @@ export class SniHost { async registerItem(busName: string, objectPath: string): Promise { const id = `${busName}${objectPath}`; - if (this._entries.has(id)) return; + if (this._entries.has(id) || this._pendingRegistrations.has(id)) return; const cancellable = new Gio.Cancellable(); + this._pendingRegistrations.set(id, cancellable); const proxy = new Gio.DBusProxy({ g_connection: Gio.DBus.session, g_interface_name: 'org.kde.StatusNotifierItem', @@ -111,12 +95,19 @@ export class SniHost { try { await proxy.init_async(GLib.PRIORITY_DEFAULT, cancellable); } catch (e) { - if (!(e as any)?.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) { + if (this._pendingRegistrations.get(id) === cancellable) { + this._pendingRegistrations.delete(id); + } + + if (!(e instanceof GLib.Error && e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))) { logger.warn(`Failed to create SNI proxy for ${id}: ${e}`, { prefix: LOG_PREFIX }); } return; } + if (this._pendingRegistrations.get(id) !== cancellable) return; + this._pendingRegistrations.delete(id); + const item = this._makeItem(id, proxy); const sniId = (proxy.get_cached_property('Id')?.unpack() as string | undefined) ?? ''; const desktopEntry = @@ -136,9 +127,11 @@ export class SniHost { } else if (signalName === 'NewIcon' || signalName === 'NewAttentionIcon') { // Electron/Discord emits NewIcon without PropertiesChanged, leaving the // proxy cache stale. Re-fetch icon properties before resolving. - this._refetchIconProperties(proxy) + this._refetchIconProperties(proxy, cancellable) .then(() => { - if (!this._entries.has(id)) return; + const currentEntry = this._entries.get(id); + if (!currentEntry || currentEntry.proxy !== proxy) return; + item.icon = this._resolveIcon(proxy, signalName); this._callbacks.onIconChanged(id); }) @@ -160,11 +153,17 @@ export class SniHost { }) as unknown as never, ); - this._entries.set(id, { proxy, item, sniId, desktopEntry, signalId, nameWatchId, cancellable }); + this._entries.set( + id, + new SniEntry(proxy, item, sniId, desktopEntry, signalId, nameWatchId, cancellable), + ); this._callbacks.onItemAdded(item); } - private async _refetchIconProperties(proxy: Gio.DBusProxy): Promise { + private async _refetchIconProperties( + proxy: Gio.DBusProxy, + cancellable: Gio.Cancellable, + ): Promise { const props = [ 'IconName', 'IconThemePath', @@ -180,7 +179,7 @@ export class SniHost { new GLib.Variant('(ss)', ['org.kde.StatusNotifierItem', prop]), Gio.DBusCallFlags.NONE, -1, - null, + cancellable, ); proxy.set_cached_property(prop, result.get_child_value(0).get_variant()); } catch { @@ -255,8 +254,9 @@ export class SniHost { const theme = St.IconTheme.new(); theme.append_search_path(iconThemePath); const iconInfo = theme.lookup_icon(iconName, 24, St.IconLookupFlags.FORCE_SIZE); - const filename = - iconInfo?.get_filename() ?? this._findIconThemePathFile(iconThemePath, iconName); + const filename = iconInfo + ? iconInfo.get_filename() + : this._findIconThemePathFile(iconThemePath, iconName); if (!filename) return null; // SVGs go through GTK's symbolic pipeline via St.Icon; return as-is. @@ -410,25 +410,11 @@ export class SniHost { } private _isSymbolicPixmap(data: Uint8Array, width: number, height: number): boolean { - let opaquePixels = 0; - let monochromePixels = 0; - const expectedLength = width * height * 4; - - for (let i = 0; i < expectedLength; i += 4) { - const a = data[i]!; - if (a < 16) continue; - - opaquePixels++; - const r = data[i + 1]!; - const g = data[i + 2]!; - const b = data[i + 3]!; - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - if (max - min <= SYMBOLIC_CHANNEL_TOLERANCE) monochromePixels++; - } - - if (opaquePixels === 0) return false; - return monochromePixels / opaquePixels >= SYMBOLIC_REQUIRED_RATIO; + return isSymbolicSniArgb( + data.slice(0, width * height * 4), + SYMBOLIC_CHANNEL_TOLERANCE, + SYMBOLIC_REQUIRED_RATIO, + ); } private _makeItem(id: string, proxy: Gio.DBusProxy): TrayItem { @@ -462,9 +448,9 @@ export class SniHost { Gio.DBusCallFlags.NONE, -1, null, - (p, res) => { + (_source, res) => { try { - p?.call_finish(res); + proxy.call_finish(res); } catch (e) { logger.warn(`Activate failed for ${id}: ${e}`, { prefix: LOG_PREFIX }); } @@ -478,9 +464,9 @@ export class SniHost { Gio.DBusCallFlags.NONE, -1, null, - (p, res) => { + (_source, res) => { try { - p?.call_finish(res); + proxy.call_finish(res); } catch (e) { logger.warn(`SecondaryActivate failed for ${id}: ${e}`, { prefix: LOG_PREFIX }); } @@ -494,9 +480,9 @@ export class SniHost { Gio.DBusCallFlags.NONE, -1, null, - (p, res) => { + (_source, res) => { try { - p?.call_finish(res); + proxy.call_finish(res); } catch (e) { logger.warn(`ContextMenu failed for ${id}: ${e}`, { prefix: LOG_PREFIX }); } @@ -514,9 +500,7 @@ export class SniHost { if (!entry) return; this._entries.delete(id); - entry.cancellable.cancel(); - entry.proxy.disconnect(entry.signalId); - Gio.DBus.session.unwatch_name(entry.nameWatchId); + entry.destroy(); this._callbacks.onItemRemoved(id); } @@ -544,44 +528,15 @@ export class SniHost { // Used when the app doesn't own a D-Bus well-known name matching its app ID // (common for Flatpak apps that register SNI under a unique bus name). hasSniForAppId(appId: string): boolean { - const appIds = appIdCandidates([appId]); - const appComponents = new Set( - [...appIds] - .map((candidate) => candidate.split('.').at(-1) ?? candidate) - .filter((component) => this._isSpecificAppComponent(component)), - ); - - for (const entry of this._entries.values()) { - if (entry.desktopEntry && this._desktopEntryMatchesAppIds(entry.desktopEntry, appIds)) - return true; - - if (!entry.sniId) continue; - const sniLower = entry.sniId.toLowerCase(); - if (appIds.has(sniLower)) return true; - - const sniLast = sniLower.split('.').at(-1) ?? sniLower; - if (this._isSpecificAppComponent(sniLast) && appComponents.has(sniLast)) return true; - } - return false; + return [...this._entries.values()].some((entry) => sniIdentityMatchesAppId(entry, appId)); } - private _desktopEntryMatchesAppIds(desktopEntry: string, appIds: Set): boolean { - const entry = desktopEntry.toLowerCase(); - const entryWithoutSuffix = entry.replace(/\.desktop$/, ''); - - for (const appId of appIds) { - if (entry === appId || entry === `${appId}.desktop` || entryWithoutSuffix === appId) - return true; + destroy(): void { + for (const cancellable of this._pendingRegistrations.values()) { + cancellable.cancel(); } + this._pendingRegistrations.clear(); - return false; - } - - private _isSpecificAppComponent(component: string): boolean { - return component.length >= 4 && !GENERIC_APP_ID_COMPONENTS.has(component); - } - - destroy(): void { for (const id of [...this._entries.keys()]) { this._removeEntry(id); } diff --git a/src/desktop/trayIcons/sniIconState.ts b/src/desktop/trayIcons/sniIconState.ts new file mode 100644 index 0000000..773132f --- /dev/null +++ b/src/desktop/trayIcons/sniIconState.ts @@ -0,0 +1,55 @@ +export type SniPixmap = { width: number; height: number; bytes: ArrayLike }; + +export function selectSniPixmap>( + pixmaps: readonly T[], + targetSize: number, + minimumSize = 8, +): T | null { + const usable = pixmaps.filter((p) => p.width >= minimumSize && p.height >= minimumSize); + if (usable.length === 0) return null; + + return ( + [...usable].sort((a, b) => { + const aDistance = Math.abs(Math.min(a.width, a.height) - targetSize); + const bDistance = Math.abs(Math.min(b.width, b.height) - targetSize); + return aDistance - bDistance || b.width * b.height - a.width * a.height; + })[0] ?? null + ); +} + +export function isSymbolicSniPixels( + rgba: ArrayLike, + tolerance = 18, + requiredRatio = 0.92, +): boolean { + let opaque = 0; + let neutral = 0; + + for (let index = 0; index + 3 < rgba.length; index += 4) { + if ((rgba[index + 3] ?? 0) === 0) continue; + + opaque++; + const red = rgba[index] ?? 0; + const green = rgba[index + 1] ?? 0; + const blue = rgba[index + 2] ?? 0; + if (Math.max(red, green, blue) - Math.min(red, green, blue) <= tolerance) { + neutral++; + } + } + + return opaque > 0 && neutral / opaque >= requiredRatio; +} + +export function isSymbolicSniArgb( + argb: ArrayLike, + tolerance = 18, + requiredRatio = 0.92, +): boolean { + const rgba: number[] = []; + + for (let index = 0; index + 3 < argb.length; index += 4) { + rgba.push(argb[index + 1] ?? 0, argb[index + 2] ?? 0, argb[index + 3] ?? 0, argb[index] ?? 0); + } + + return isSymbolicSniPixels(rgba, tolerance, requiredRatio); +} diff --git a/src/desktop/trayIcons/sniWatcher.ts b/src/desktop/trayIcons/sniWatcher.ts index 4dcffb1..c934908 100644 --- a/src/desktop/trayIcons/sniWatcher.ts +++ b/src/desktop/trayIcons/sniWatcher.ts @@ -176,11 +176,7 @@ export class SniWatcher { this._ownNameId = 0; } if (this._registrationId) { - try { - Gio.DBus.session.unregister_object(this._registrationId); - } catch { - // may fail if already unregistered - } + Gio.DBus.session.unregister_object(this._registrationId); this._registrationId = 0; } this._registeredItems = []; diff --git a/src/desktop/trayIcons/trayClipArea.ts b/src/desktop/trayIcons/trayClipArea.ts new file mode 100644 index 0000000..986e461 --- /dev/null +++ b/src/desktop/trayIcons/trayClipArea.ts @@ -0,0 +1,126 @@ +import '@girs/gjs'; + +import Clutter from '@girs/clutter-18'; +import GLib from '@girs/glib-2.0'; +import GObject from '@girs/gobject-2.0'; + +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; + +@GObject.registerClass +export class TrayClipArea extends Clutter.Actor { + public fullWidth = 0; + public reservedWidth = 0; + private _childOffsetX = 0; + private _viewportWidth = 0; + private _clipStart = 0; + declare private _lifecycle: LifecycleScope; + declare private _viewportTimeout: ManagedSource; + + override _init(params = {}) { + super._init({ clip_to_allocation: false, x_expand: false, y_expand: true, ...params }); + this._lifecycle = new LifecycleScope(); + this._viewportTimeout = createManagedSource(this._lifecycle); + } + + override vfunc_allocate(box: Clutter.ActorBox): void { + super.vfunc_allocate(box); + const childBox = new Clutter.ActorBox(); + childBox.set_origin(Math.round(this._childOffsetX), 0); + childBox.set_size(Math.round(this.fullWidth), Math.round(box.y2 - box.y1)); + this._syncClip(); + for (const child of this.get_children()) { + child.allocate(childBox); + } + } + + setViewport( + fullWidth: number, + viewportWidth: number, + clipStart: number, + reservedWidth = fullWidth, + ): void { + this.fullWidth = Math.round(fullWidth); + this.reservedWidth = Math.max(0, Math.round(reservedWidth)); + this._childOffsetX = Math.min(0, this.reservedWidth - this.fullWidth); + this._viewportWidth = viewportWidth; + this._clipStart = clipStart; + this.set_width(this.reservedWidth); + this._syncClip(); + } + + animateViewport( + fromViewportWidth: number, + fromClipStart: number, + toViewportWidth: number, + toClipStart: number, + durationMs: number, + onFrame: (viewportWidth: number, clipStart: number) => void, + onComplete: () => void, + ): void { + const startUs = GLib.get_monotonic_time(); + const durationUs = durationMs * 1000; + + this._viewportTimeout.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 16, () => { + const progress = Math.min(1, (GLib.get_monotonic_time() - startUs) / durationUs); + const eased = 1 - Math.pow(1 - progress, 3); + this._viewportWidth = fromViewportWidth + (toViewportWidth - fromViewportWidth) * eased; + this._clipStart = fromClipStart + (toClipStart - fromClipStart) * eased; + this._syncClip(); + onFrame(this._viewportWidth, this._clipStart); + if (progress < 1) return GLib.SOURCE_CONTINUE; + + this._viewportTimeout.complete(); + this._viewportWidth = toViewportWidth; + this._clipStart = toClipStart; + this._syncClip(); + onFrame(toViewportWidth, toClipStart); + onComplete(); + return GLib.SOURCE_REMOVE; + }), + ); + } + + cancelViewportAnimation(): void { + this._viewportTimeout.clear(); + } + + get viewportWidth(): number { + return this._viewportWidth; + } + + get clipStart(): number { + return this._clipStart; + } + + layoutSnapshot(): string { + const child = this.get_first_child(); + return [ + `reservedWidth=${Math.round(this.reservedWidth)}`, + `actorWidth=${Math.round(this.width)}`, + `viewportWidth=${Math.round(this._viewportWidth)}`, + `clipStart=${Math.round(this._clipStart)}`, + `allocated=${Math.round(this.allocation.x2 - this.allocation.x1)}`, + `fullWidth=${Math.round(this.fullWidth)}`, + `childOffsetX=${Math.round(this._childOffsetX)}`, + `childX=${child ? Math.round(child.x) : 'none'}`, + `childWidth=${child ? Math.round(child.width) : 'none'}`, + ].join(' '); + } + + override destroy(): void { + this._lifecycle.dispose(); + super.destroy(); + } + + private _syncClip(): void { + const reservedWidth = Math.round(this.reservedWidth); + const clipStart = Math.min(reservedWidth, Math.max(0, Math.round(this._clipStart))); + const visibleWidth = Math.min( + reservedWidth - clipStart, + Math.max(0, Math.round(this._viewportWidth)), + ); + this.set_clip(clipStart, 0, visibleWidth, Math.max(0, Math.round(this.height))); + } +} diff --git a/src/desktop/trayIcons/trayContainer.ts b/src/desktop/trayIcons/trayContainer.ts index 0150343..38f22e4 100644 --- a/src/desktop/trayIcons/trayContainer.ts +++ b/src/desktop/trayIcons/trayContainer.ts @@ -6,11 +6,15 @@ import GObject from '@girs/gobject-2.0'; import GLib from '@girs/glib-2.0'; import * as PanelMenu from '@girs/gnome-shell/ui/panelMenu'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; import { createTrayState, toggleCollapsed, addAttention, clearAttention } from './trayState.ts'; import type { TrayState, TrayItem } from './trayState.ts'; import { TrayIconItem, destroyTooltip } from './trayIconItem.ts'; +import { TrayClipArea } from './trayClipArea.ts'; +import { calculateTrayLayout, getEffectiveTrayLimit, visibleTrayIndexes } from './trayLayout.ts'; const ICON_GAP = 3; const ITEM_PADDING = 3; // Must match .aurora-tray-icon-item padding in SCSS @@ -18,134 +22,6 @@ const ANIM_DURATION = 600; const PANEL_SAFETY_GAP = 8; const LOG_PREFIX = 'AuroraTray'; -@GObject.registerClass -class TrayClipArea extends Clutter.Actor { - public fullWidth = 0; - public reservedWidth = 0; - private _childOffsetX = 0; - private _viewportWidth = 0; - private _clipStart = 0; - private _viewportTimeoutId = 0; - - override _init(params = {}) { - super._init({ - clip_to_allocation: false, - x_expand: false, - y_expand: true, - ...params, - }); - } - - override vfunc_allocate(box: Clutter.ActorBox): void { - super.vfunc_allocate(box); - const ownH = Math.round(box.y2 - box.y1); - const childW = Math.round(this.fullWidth); - const childX = Math.round(this._childOffsetX); - - this._syncClip(); - - const childBox = new Clutter.ActorBox(); - childBox.set_origin(childX, 0); - childBox.set_size(childW, ownH); - for (const child of this.get_children()) { - child.allocate(childBox); - } - } - - private _syncClip(): void { - const reservedWidth = Math.round(this.reservedWidth); - const clipStart = Math.min(reservedWidth, Math.max(0, Math.round(this._clipStart))); - const visibleWidth = Math.min( - reservedWidth - clipStart, - Math.max(0, Math.round(this._viewportWidth)), - ); - const height = Math.max(0, Math.round(this.height)); - this.set_clip(clipStart, 0, visibleWidth, height); - } - - setViewport( - fullWidth: number, - viewportWidth: number, - clipStart: number, - reservedWidth = fullWidth, - ): void { - this.fullWidth = Math.round(fullWidth); - this.reservedWidth = Math.max(0, Math.round(reservedWidth)); - this._childOffsetX = Math.min(0, this.reservedWidth - this.fullWidth); - this._viewportWidth = viewportWidth; - this._clipStart = clipStart; - this.set_width(this.reservedWidth); - this._syncClip(); - } - - animateViewport( - fromViewportWidth: number, - fromClipStart: number, - toViewportWidth: number, - toClipStart: number, - durationMs: number, - onFrame: (viewportWidth: number, clipStart: number) => void, - onComplete: () => void, - ): void { - this.cancelViewportAnimation(); - const startUs = GLib.get_monotonic_time(); - const durationUs = durationMs * 1000; - const viewportDelta = toViewportWidth - fromViewportWidth; - const clipStartDelta = toClipStart - fromClipStart; - - this._viewportTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 16, () => { - const elapsedUs = GLib.get_monotonic_time() - startUs; - const progress = Math.min(1, elapsedUs / durationUs); - const eased = 1 - Math.pow(1 - progress, 3); - - this._viewportWidth = fromViewportWidth + viewportDelta * eased; - this._clipStart = fromClipStart + clipStartDelta * eased; - this._syncClip(); - onFrame(this._viewportWidth, this._clipStart); - - if (progress < 1) return GLib.SOURCE_CONTINUE; - - this._viewportTimeoutId = 0; - this._viewportWidth = toViewportWidth; - this._clipStart = toClipStart; - this._syncClip(); - onFrame(toViewportWidth, toClipStart); - onComplete(); - return GLib.SOURCE_REMOVE; - }); - } - - cancelViewportAnimation(): void { - if (this._viewportTimeoutId > 0) { - GLib.Source.remove(this._viewportTimeoutId); - this._viewportTimeoutId = 0; - } - } - - get viewportWidth(): number { - return this._viewportWidth; - } - - get clipStart(): number { - return this._clipStart; - } - - layoutSnapshot(): string { - const child = this.get_first_child(); - return [ - `reservedWidth=${Math.round(this.reservedWidth)}`, - `actorWidth=${Math.round(this.width)}`, - `viewportWidth=${Math.round(this._viewportWidth)}`, - `clipStart=${Math.round(this._clipStart)}`, - `allocated=${Math.round(this.allocation.x2 - this.allocation.x1)}`, - `fullWidth=${Math.round(this.fullWidth)}`, - `childOffsetX=${Math.round(this._childOffsetX)}`, - `childX=${child ? Math.round(child.x) : 'none'}`, - `childWidth=${child ? Math.round(child.width) : 'none'}`, - ].join(' '); - } -} - @GObject.registerClass export class TrayContainer extends PanelMenu.Button { static [GObject.properties] = { @@ -171,9 +47,10 @@ export class TrayContainer extends PanelMenu.Button { declare private _outerBox: St.BoxLayout; declare private _userInteracted: boolean; declare private _attentionTimeoutSeconds: number; - declare private _autoCollapseTimeoutId: number; - declare private _debugPostAllocateId: number; - declare private _postAllocateRelayoutId: number; + declare private _lifecycle: LifecycleScope; + declare private _autoCollapseTimeout: ManagedSource; + declare private _debugPostAllocate: ManagedSource; + declare private _postAllocateRelayout: ManagedSource; declare private _opacityTargets: WeakMap; declare private _scrollTarget: number; declare private _smoothScrollAccumulator: number; @@ -206,6 +83,10 @@ export class TrayContainer extends PanelMenu.Button { // which is the standard GJS GObject subclassing pattern when using custom constructor args. override _init(iconSize: number, limit: number): void { super._init(0.0, 'aurora-tray-icons', true); // dontCreateMenu = true + this._lifecycle = new LifecycleScope(); + this._autoCollapseTimeout = createManagedSource(this._lifecycle); + this._debugPostAllocate = createManagedSource(this._lifecycle); + this._postAllocateRelayout = createManagedSource(this._lifecycle); this.add_style_class_name('aurora-tray-button'); this.track_hover = false; // highlight only individual icon items, not the whole button area this._state = createTrayState(); @@ -214,14 +95,10 @@ export class TrayContainer extends PanelMenu.Button { this._items = new Map(); this._userInteracted = false; this._attentionTimeoutSeconds = 5; - this._autoCollapseTimeoutId = 0; this._opacityTargets = new WeakMap(); this._scrollTarget = 0; this._smoothScrollAccumulator = 0; - this._debugPostAllocateId = 0; - this._postAllocateRelayoutId = 0; - // Chevron button (collapse/expand toggle) this._chevronIcon = new St.Icon({ icon_name: 'pan-end-symbolic', icon_size: 14, @@ -250,7 +127,6 @@ export class TrayContainer extends PanelMenu.Button { this._clipArea = new TrayClipArea(); this._clipArea.add_child(this._iconRow); - // Outer layout this._outerBox = new St.BoxLayout({ style_class: 'aurora-tray-container', }); @@ -258,7 +134,6 @@ export class TrayContainer extends PanelMenu.Button { this._outerBox.add_child(this._clipArea); this.add_child(this._outerBox); - // Scroll to peek this.connect('scroll-event', (_actor: Clutter.Actor, event: Clutter.Event) => { if (!this._canScrollIcons()) return Clutter.EVENT_PROPAGATE; const direction = event.get_scroll_direction(); @@ -299,16 +174,11 @@ export class TrayContainer extends PanelMenu.Button { } private _effectiveLimit(maxClipWidth = this._availableClipWidth(true)): number { - if (maxClipWidth === null) return this._limit; - - const itemStride = this._itemWidth() + ICON_GAP; - const maxVisibleByWidth = Math.max(1, Math.floor((maxClipWidth + ICON_GAP) / itemStride)); - return Math.max(1, Math.min(this._limit, maxVisibleByWidth)); + return getEffectiveTrayLimit(this._limit, this._itemWidth(), ICON_GAP, maxClipWidth); } private _availableClipWidth(includeChevron: boolean): number | null { - const panelContainer = - (this as unknown as { container?: Clutter.Actor }).container ?? (this as Clutter.Actor); + const panelContainer = (this as unknown as { container: Clutter.Actor }).container; const parent = panelContainer.get_parent(); if (!parent) return null; @@ -334,9 +204,10 @@ export class TrayContainer extends PanelMenu.Button { private _availablePanelSideWidth(parent: Clutter.Actor): number { const fallbackWidth = Math.round(parent.allocation.x2 - parent.allocation.x1); - const panel = parent.get_parent() as (Clutter.Actor & { _centerBox?: Clutter.Actor }) | null; - const centerBox = panel?._centerBox; - if (!centerBox) return fallbackWidth; + const panel = parent.get_parent() as (Clutter.Actor & { _centerBox: Clutter.Actor }) | null; + if (!panel) return fallbackWidth; + + const centerBox = panel._centerBox; if (this.get_text_direction() === Clutter.TextDirection.RTL) { const sideWidth = Math.round(centerBox.allocation.x1 - parent.allocation.x1); @@ -430,7 +301,6 @@ export class TrayContainer extends PanelMenu.Button { addAttention(this._state, id); const widget = this._items.get(id); - // Auto-expand if the item is not visible. const isHidden = !this._visibleIds().has(id); if (isHidden && this._state.collapsed) { this._state.collapsed = false; @@ -465,48 +335,50 @@ export class TrayContainer extends PanelMenu.Button { } private _scheduleAutoCollapse(): void { - if (this._autoCollapseTimeoutId) { - GLib.Source.remove(this._autoCollapseTimeoutId); - this._autoCollapseTimeoutId = 0; - } - this._autoCollapseTimeoutId = GLib.timeout_add_seconds( - GLib.PRIORITY_DEFAULT, - this._attentionTimeoutSeconds, - () => { - this._autoCollapseTimeoutId = 0; + this._autoCollapseTimeout.replace(() => + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, this._attentionTimeoutSeconds, () => { + this._autoCollapseTimeout.complete(); if (!this._userInteracted && !this._state.collapsed) { this._state.collapsed = true; this._syncLayout(true); } this._userInteracted = false; // reset for next attention cycle return GLib.SOURCE_REMOVE; - }, + }), ); } private _visibleIds(): Set { const keys = [...this._items.keys()]; const limit = this._effectiveLimit(); - const hiddenCount = Math.max(0, keys.length - limit); - const itemStride = this._itemWidth() + ICON_GAP; - const startIndex = - itemStride > 0 - ? Math.max(0, Math.min(hiddenCount, Math.round(this._state.scrollOffset / itemStride))) - : hiddenCount; - return new Set(keys.slice(startIndex, startIndex + limit)); + const { start, end } = visibleTrayIndexes( + keys.length, + limit, + this._state.scrollOffset, + this._itemWidth(), + ICON_GAP, + ); + return new Set(keys.slice(start, end)); } private _syncLayout(animated = false): void { const count = this._items.size; this.visible = count > 0; const itemW = this._itemWidth(); - const fullWidth = count * itemW + Math.max(0, count - 1) * ICON_GAP; const availableClipWidthWithoutChevron = this._availableClipWidth(false); const effectiveLimitWithoutChevron = this._effectiveLimit(availableClipWidthWithoutChevron); const shouldReserveChevron = count > effectiveLimitWithoutChevron; const availableClipWidth = this._availableClipWidth(shouldReserveChevron); const effectiveLimit = this._effectiveLimit(availableClipWidth); - const hasOverflow = count > effectiveLimit; + const layout = calculateTrayLayout({ + count, + itemWidth: itemW, + gap: ICON_GAP, + configuredLimit: effectiveLimit, + availableWidth: availableClipWidth, + collapsed: this._state.collapsed, + }); + const { fullWidth, hasOverflow, reservedWidth } = layout; this._chevron.visible = hasOverflow; // Chevron rotation: 0° = expanded (points right), 180° = collapsed (points left). @@ -517,19 +389,13 @@ export class TrayContainer extends PanelMenu.Button { }); const visibleCount = Math.min(count, effectiveLimit); - const naturalCollapsedWidth = visibleCount * itemW + Math.max(0, visibleCount - 1) * ICON_GAP; - const reservedWidth = - availableClipWidth === null ? fullWidth : Math.min(fullWidth, availableClipWidth); - const collapsedWidth = Math.min(naturalCollapsedWidth, reservedWidth); - const collapsedClipStart = Math.max(0, reservedWidth - collapsedWidth); - // collapsed -> maxScroll anchors the row to newest icons (right-aligned in clip). // expanded -> 0 resets any manual scroll. this._state.scrollOffset = this._state.collapsed ? this._maxScrollForLimit(effectiveLimit) : 0; if (!this._state.collapsed) this._smoothScrollAccumulator = 0; - const targetViewportWidth = Math.round(this._state.collapsed ? collapsedWidth : reservedWidth); - const targetClipStart = Math.round(this._state.collapsed ? collapsedClipStart : 0); + const targetViewportWidth = layout.viewportWidth; + const targetClipStart = layout.clipStart; const startViewportWidth = Math.round(this._clipArea.viewportWidth || targetViewportWidth); const startClipStart = Math.round(this._clipArea.clipStart); @@ -600,15 +466,16 @@ export class TrayContainer extends PanelMenu.Button { ); }, ); - if (this._debugPostAllocateId > 0) GLib.Source.remove(this._debugPostAllocateId); - this._debugPostAllocateId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - this._debugPostAllocateId = 0; - logger.debug( - `Viewport post-allocate chevronX=${Math.round(this._chevron.translationX)} ${this._clipArea.layoutSnapshot()}`, - { prefix: LOG_PREFIX }, - ); - return GLib.SOURCE_REMOVE; - }); + this._debugPostAllocate.replace(() => + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + this._debugPostAllocate.complete(); + logger.debug( + `Viewport post-allocate chevronX=${Math.round(this._chevron.translationX)} ${this._clipArea.layoutSnapshot()}`, + { prefix: LOG_PREFIX }, + ); + return GLib.SOURCE_REMOVE; + }), + ); } else { this._clipArea.setViewport(fullWidth, targetViewportWidth, targetClipStart, reservedWidth); this._setChevronAnchor(targetClipStart); @@ -630,16 +497,18 @@ export class TrayContainer extends PanelMenu.Button { if ( availableClipWidth === null || Math.round(this._clipArea.reservedWidth) <= availableClipWidth || - this._postAllocateRelayoutId > 0 + this._postAllocateRelayout.active ) { return; } - this._postAllocateRelayoutId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - this._postAllocateRelayoutId = 0; - this._syncLayout(false); - return GLib.SOURCE_REMOVE; - }); + this._postAllocateRelayout.replace(() => + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + this._postAllocateRelayout.complete(); + this._syncLayout(false); + return GLib.SOURCE_REMOVE; + }), + ); } private _applyIconOpacity(): void { @@ -684,18 +553,7 @@ export class TrayContainer extends PanelMenu.Button { override destroy(): void { this._clipArea.cancelViewportAnimation(); - if (this._autoCollapseTimeoutId > 0) { - GLib.Source.remove(this._autoCollapseTimeoutId); - this._autoCollapseTimeoutId = 0; - } - if (this._debugPostAllocateId > 0) { - GLib.Source.remove(this._debugPostAllocateId); - this._debugPostAllocateId = 0; - } - if (this._postAllocateRelayoutId > 0) { - GLib.Source.remove(this._postAllocateRelayoutId); - this._postAllocateRelayoutId = 0; - } + this._lifecycle.dispose(); destroyTooltip(); for (const widget of this._items.values()) widget.destroy(); this._items.clear(); diff --git a/src/desktop/trayIcons/trayIconItem.ts b/src/desktop/trayIcons/trayIconItem.ts index 5ec04bc..8d938e4 100644 --- a/src/desktop/trayIcons/trayIconItem.ts +++ b/src/desktop/trayIcons/trayIconItem.ts @@ -1,4 +1,3 @@ -// src/desktop/trayIcons/trayIconItem.ts import '@girs/gjs'; import St from '@girs/st-18'; diff --git a/src/desktop/trayIcons/trayIcons.ts b/src/desktop/trayIcons/trayIcons.ts index 633a913..ca1ce60 100644 --- a/src/desktop/trayIcons/trayIcons.ts +++ b/src/desktop/trayIcons/trayIcons.ts @@ -3,6 +3,7 @@ import { gettext as _ } from '~/shared/i18n.ts'; import Gio from '@girs/gio-2.0'; import GLib from '@girs/glib-2.0'; +import St from '@girs/st-18'; import * as Main from '@girs/gnome-shell/ui/main'; import Shell from '@girs/shell-18'; import type { Button as PanelMenuButton } from '@girs/gnome-shell/ui/panelMenu'; @@ -16,7 +17,6 @@ import type { ExtensionContext } from '~/core/context.ts'; import { LifecycleScope } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import type { SettingsManager } from '~/core/settings.ts'; import { getQuickSettingsGrid } from '~/shared/quickSettings.ts'; import { appIdCandidates } from './appIdentity.ts'; @@ -44,7 +44,6 @@ export class TrayIcons extends Module { private _dedupBgApps = true; private _bgAppsToggle: any = null; private _bgAppsGrid: any = null; - private _desktopSettings: SettingsManager | null = null; constructor(context: ExtensionContext) { super(context); @@ -53,7 +52,7 @@ export class TrayIcons extends Module { override enable(): void { this._lifecycle = new LifecycleScope(); const settings = this.context.settings.getRawSettings(); - this._desktopSettings = this.context.settings.getSchema('org.gnome.desktop.interface'); + const desktopSettings = this.context.settings.getSchema('org.gnome.desktop.interface'); const iconSize = settings.get_int('tray-icons-icon-size'); const limit = settings.get_int('tray-icons-limit'); const attentionTimeout = settings.get_int('tray-icons-attention-timeout'); @@ -62,52 +61,61 @@ export class TrayIcons extends Module { this._hideBgAppsQuickSettings(); } - this._container = new (TrayContainer as unknown as new ( + const container = new (TrayContainer as unknown as new ( iconSize: number, limit: number, ) => TrayContainer)(iconSize, limit); - this._container.setAttentionTimeout(attentionTimeout); + this._container = container; + container.setAttentionTimeout(attentionTimeout); Main.panel.addToStatusArea( PANEL_INDICATOR_ID, - this._container as unknown as PanelMenuButton, + container as unknown as PanelMenuButton, 0, 'right', ); - this._sniWatcher = new SniWatcher( + const sniWatcher = new SniWatcher( (busName, objectPath) => { - this._sniHost - ?.registerItem(busName, objectPath) - ?.catch((e) => logger.warn(`registerItem failed: ${e}`, { prefix: LOG_PREFIX })); + sniHost + .registerItem(busName, objectPath) + .catch((e) => logger.warn(`registerItem failed: ${e}`, { prefix: LOG_PREFIX })); }, (_busName, _objectPath) => {}, ); - this._sniHost = new SniHost( - this._sniWatcher, + this._sniWatcher = sniWatcher; + const sniHost = new SniHost( + sniWatcher, { onItemAdded: (item) => this._onSniItemAdded(item), onItemRemoved: (id) => this._onItemRemoved(id), onStatusChanged: (id, status) => this._onStatusChanged(id, status), - onIconChanged: (id) => this._container?.updateItemIcon(id), + onIconChanged: (id) => container.updateItemIcon(id), }, { - getColorScheme: () => this._desktopSettings?.getString('color-scheme') ?? 'prefer-dark', + getColorScheme: () => desktopSettings.getString('color-scheme'), shouldRecolorSymbolicPixmaps: () => settings.get_boolean('tray-icons-recolor-symbolic-pixmaps'), }, ); - this._sniWatcher.start(); - this._lifecycle.connect(this._desktopSettings, 'changed::color-scheme', () => { - const scheme = this._desktopSettings?.getString('color-scheme') ?? 'unknown'; + this._sniHost = sniHost; + sniWatcher.start(); + this._lifecycle.connect(desktopSettings, 'changed::color-scheme', () => { + const scheme = desktopSettings.getString('color-scheme'); logger.debug(`Color scheme changed to ${scheme}; refreshing SNI icons`, { prefix: LOG_PREFIX, }); - this._sniHost?.refreshIcons('color-scheme'); + sniHost.refreshIcons('color-scheme'); }); this._bgSource = new BackgroundAppsSource({ - onItemAdded: (item, appId, app) => this._onBgItemAdded(item, appId, app).catch(() => {}), + onItemAdded: (item, appId, app) => { + this._onBgItemAdded(item, appId, app).catch((error) => { + logger.warn(`Failed to add background app ${appId}: ${error}`, { + prefix: LOG_PREFIX, + }); + }); + }, onItemRemoved: (id) => this._onItemRemoved(id), }); this._bgSource @@ -134,7 +142,11 @@ export class TrayIcons extends Module { this._container?.removeItem(entry.itemId); } }) - .catch(() => {}); + .catch((error) => { + logger.warn(`Failed to reconcile background app ${appId}: ${error}`, { + prefix: LOG_PREFIX, + }); + }); } } }); @@ -157,7 +169,6 @@ export class TrayIcons extends Module { override disable(): void { this._lifecycle?.dispose(); this._lifecycle = null; - this._desktopSettings = null; this._restoreBgAppsQuickSettings(); @@ -239,8 +250,13 @@ export class TrayIcons extends Module { private async _sniCoversApp(appId: string, app: Shell.App): Promise { const owner = await this._getUniqueName(appId); + const sniHost = this._sniHost; + if (!sniHost) { + return false; + } + if (owner) { - const covered = this._sniHost?.hasItemForBus(owner) ?? false; + const covered = sniHost.hasItemForBus(owner); logger.debug(`SNI covers ${appId}? owner=${owner} covered=${covered}`, { prefix: LOG_PREFIX, }); @@ -252,7 +268,7 @@ export class TrayIcons extends Module { if (candidate === appId) continue; const candidateOwner = await this._getUniqueName(candidate); if (!candidateOwner) continue; - const covered = this._sniHost?.hasItemForBus(candidateOwner) ?? false; + const covered = sniHost.hasItemForBus(candidateOwner); logger.debug( `SNI covers ${appId}? candidate=${candidate} owner=${candidateOwner} covered=${covered}`, { @@ -262,26 +278,31 @@ export class TrayIcons extends Module { if (covered) return true; } - const coveredByPid = await this._sniCoversAppPid(appId, app); + const coveredByPid = await this._sniCoversAppPid(appId, app, sniHost); if (coveredByPid) return true; - const coveredByMetadata = - [...candidates].some((candidate) => this._sniHost?.hasSniForAppId(candidate)) ?? false; + const coveredByMetadata = [...candidates].some((candidate) => + sniHost.hasSniForAppId(candidate), + ); logger.debug(`SNI covers ${appId}? owner=none, metadata-match=${coveredByMetadata}`, { prefix: LOG_PREFIX, }); return coveredByMetadata; } - private async _sniCoversAppPid(appId: string, app: Shell.App): Promise { + private async _sniCoversAppPid( + appId: string, + app: Shell.App, + sniHost: SniHost, + ): Promise { const candidates = appIdCandidates([appId, app.get_id()]); - const appPids = app.get_pids?.() ?? []; + const appPids = app.get_pids(); if (appPids.length === 0) { logger.debug(`SNI covers ${appId}? pid-match=false app-pids=[]`, { prefix: LOG_PREFIX }); } const appPidSet = new Set(appPids); - for (const busName of this._sniHost?.getBusNames() ?? []) { + for (const busName of sniHost.getBusNames()) { const sniPid = await this._getConnectionPid(busName); if (!sniPid) continue; @@ -319,14 +340,11 @@ export class TrayIcons extends Module { } private _trackedPidMatchesApp(pid: number, appId: string, app: Shell.App): boolean { - try { - const trackedApp = Shell.WindowTracker.get_default().get_app_from_pid(pid); - if (!trackedApp) return false; - const trackedId = trackedApp.get_id(); - return appIdCandidates([appId, app.get_id()]).has(trackedId.toLowerCase()); - } catch { - return false; - } + const trackedApp = Shell.WindowTracker.get_default().get_app_from_pid(pid); + if (!trackedApp) return false; + + const trackedId = trackedApp.get_id(); + return appIdCandidates([appId, app.get_id()]).has(trackedId.toLowerCase()); } private async _pidHasAncestor(pid: number, candidateAncestors: Set): Promise { @@ -422,7 +440,7 @@ export class TrayIcons extends Module { if (!actor) return null; if (this._isBgAppsQuickSettingsToggle(actor)) return actor; - for (const child of actor.get_children?.() ?? []) { + for (const child of actor.get_children()) { const match = this._findBgAppsQuickSettingsToggleInActor(child); if (match) return match; } @@ -431,7 +449,7 @@ export class TrayIcons extends Module { } private _isBgAppsQuickSettingsToggle(actor: any): boolean { - return actor?.has_style_class_name?.('background-apps-quick-toggle') === true; + return actor instanceof St.Widget && actor.has_style_class_name('background-apps-quick-toggle'); } private _watchBgAppsQuickSettingsGrid(): void { diff --git a/src/desktop/trayIcons/trayLayout.ts b/src/desktop/trayIcons/trayLayout.ts new file mode 100644 index 0000000..0ee9dc9 --- /dev/null +++ b/src/desktop/trayIcons/trayLayout.ts @@ -0,0 +1,85 @@ +export type TrayLayoutInput = { + count: number; + itemWidth: number; + gap: number; + configuredLimit: number; + availableWidth: number | null; + collapsed: boolean; +}; + +export type TrayLayout = { + fullWidth: number; + effectiveLimit: number; + hasOverflow: boolean; + reservedWidth: number; + viewportWidth: number; + clipStart: number; + maxScroll: number; +}; + +export function getEffectiveTrayLimit( + configuredLimit: number, + itemWidth: number, + gap: number, + availableWidth: number | null, +): number { + const configured = Math.max(1, Math.floor(configuredLimit)); + if (availableWidth === null) return configured; + + const stride = Math.max(1, itemWidth + gap); + + return Math.max( + 1, + Math.min(configured, Math.floor((Math.max(0, availableWidth) + gap) / stride)), + ); +} + +export function calculateTrayLayout(input: TrayLayoutInput): TrayLayout { + const count = Math.max(0, Math.floor(input.count)); + const itemWidth = Math.max(0, input.itemWidth); + const gap = Math.max(0, input.gap); + const fullWidth = count * itemWidth + Math.max(0, count - 1) * gap; + const effectiveLimit = getEffectiveTrayLimit( + input.configuredLimit, + itemWidth, + gap, + input.availableWidth, + ); + const hasOverflow = count > effectiveLimit; + const visibleCount = Math.min(count, effectiveLimit); + const naturalCollapsedWidth = visibleCount * itemWidth + Math.max(0, visibleCount - 1) * gap; + const reservedWidth = + input.availableWidth === null + ? fullWidth + : Math.min(fullWidth, Math.max(0, input.availableWidth)); + const collapsedWidth = Math.min(naturalCollapsedWidth, reservedWidth); + const maxScroll = Math.max(0, fullWidth - collapsedWidth); + + return { + fullWidth, + effectiveLimit, + hasOverflow, + reservedWidth, + viewportWidth: Math.round(input.collapsed ? collapsedWidth : reservedWidth), + clipStart: Math.round(input.collapsed ? Math.max(0, reservedWidth - collapsedWidth) : 0), + maxScroll, + }; +} + +export function visibleTrayIndexes( + count: number, + limit: number, + scrollOffset: number, + itemWidth: number, + gap: number, +): { start: number; end: number } { + const safeCount = Math.max(0, Math.floor(count)); + const safeLimit = Math.max(1, Math.floor(limit)); + const hiddenCount = Math.max(0, safeCount - safeLimit); + const stride = itemWidth + gap; + const start = + stride > 0 + ? Math.max(0, Math.min(hiddenCount, Math.round(Math.max(0, scrollOffset) / stride))) + : hiddenCount; + return { start, end: Math.min(safeCount, start + safeLimit) }; +} diff --git a/src/desktop/trayIcons/trayState.ts b/src/desktop/trayIcons/trayState.ts index b0549f6..7317621 100644 --- a/src/desktop/trayIcons/trayState.ts +++ b/src/desktop/trayIcons/trayState.ts @@ -1,6 +1,3 @@ -// src/desktop/trayIcons/trayState.ts - -// Type-only imports: erased at compile time, no GJS runtime dependency in unit tests. import type Gio from '@girs/gio-2.0'; import type GdkPixbuf from '@girs/gdkpixbuf-2.0'; @@ -40,7 +37,6 @@ export function createTrayState(): TrayState { export function toggleCollapsed(state: TrayState): void { state.collapsed = !state.collapsed; - // scrollOffset is managed by TrayContainer._syncLayout (needs UI metrics) } export function applyScroll(state: TrayState, delta: number, maxScroll: number): void { diff --git a/src/dev/captureToolsDevTool.ts b/src/dev/captureToolsDevTool.ts index 0b6e0d4..4d93926 100644 --- a/src/dev/captureToolsDevTool.ts +++ b/src/dev/captureToolsDevTool.ts @@ -42,7 +42,10 @@ export class CaptureToolsDevTool { buildPanel(): St.Widget { const capture = this._getCaptureTools(); - const state = capture?.devState ?? null; + const state = capture ? capture.devState : null; + const toolLabel = state ? state.tool : 'off'; + const colorLabel = state ? state.color : 'off'; + const widthLabel = state ? state.width : 0; const panel = createDevToolModulePanel(); panel.add_child(createDevToolSummary(this.iconName, this._stateSummary(state))); panel.add_child(createDevToolSummary('find-location-symbolic', this._geometrySummary(state))); @@ -65,7 +68,7 @@ export class CaptureToolsDevTool { appearanceRow.add_child( createDevToolActionButton( 'document-edit-symbolic', - `Tool: ${state?.tool ?? 'off'}`, + `Tool: ${toolLabel}`, () => this.cycleTool(), !capture, ), @@ -73,7 +76,7 @@ export class CaptureToolsDevTool { appearanceRow.add_child( createDevToolActionButton( 'applications-graphics-symbolic', - `Color: ${state?.color ?? 'off'}`, + `Color: ${colorLabel}`, () => this.cycleColor(), !capture, ), @@ -81,7 +84,7 @@ export class CaptureToolsDevTool { appearanceRow.add_child( createDevToolActionButton( 'format-text-bold-symbolic', - `Width: ${state?.width ?? 0}`, + `Width: ${widthLabel}`, () => this.cycleWidth(), !capture, ), @@ -95,7 +98,7 @@ export class CaptureToolsDevTool { 'Move Selection', () => this.toggleInteraction('selection'), !capture, - state?.interaction === 'selection', + Boolean(state && state.interaction === 'selection'), ), ); opacityRow.add_child( @@ -104,7 +107,7 @@ export class CaptureToolsDevTool { 'Draw', () => this.toggleInteraction('drawing'), !capture, - state?.interaction === 'drawing', + Boolean(state && state.interaction === 'drawing'), ), ); panel.add_child(opacityRow); @@ -116,7 +119,7 @@ export class CaptureToolsDevTool { 'Tesseract On', () => this.setTesseractAvailable(true), !capture, - state?.ocrAvailabilityOverridden === true && state.ocrAvailable === true, + Boolean(state && state.ocrAvailabilityOverridden && state.ocrAvailable === true), ), ); tesseractRow.add_child( @@ -125,7 +128,7 @@ export class CaptureToolsDevTool { 'Tesseract Off', () => this.setTesseractAvailable(false), !capture, - state?.ocrAvailabilityOverridden === true && state.ocrAvailable === false, + Boolean(state && state.ocrAvailabilityOverridden && state.ocrAvailable === false), ), ); panel.add_child(tesseractRow); @@ -139,7 +142,7 @@ export class CaptureToolsDevTool { 'edit-copy-symbolic', 'Copy OCR', () => this.copyOcr(), - !state?.ocrHasResult, + !state || !state.ocrHasResult, ), ); ocrRow.add_child( @@ -147,7 +150,7 @@ export class CaptureToolsDevTool { 'system-search-symbolic', 'Search OCR', () => this.searchOcr(), - !state?.ocrHasResult, + !state || !state.ocrHasResult, ), ); panel.add_child(ocrRow); @@ -166,11 +169,15 @@ export class CaptureToolsDevTool { } destroy(): void { - this._getCaptureTools()?.resetDevState(); + const capture = this._getCaptureTools(); + if (capture) capture.resetDevState(); } async openPreview(): Promise { - const opened = (await this._getCaptureTools()?.openDevPreview()) ?? false; + const capture = this._getCaptureTools(); + if (!capture) return false; + + const opened = await capture.openDevPreview(); this._requestMenuRebuild(); return opened; } @@ -220,41 +227,62 @@ export class CaptureToolsDevTool { } setTesseractAvailable(available: boolean): boolean { - const changed = this._getCaptureTools()?.setDevOcrAvailable(available) ?? false; + const capture = this._getCaptureTools(); + if (!capture) return false; + + const changed = capture.setDevOcrAvailable(available); if (changed) this._requestMenuRebuild(); return changed; } injectOcr(): boolean { - const changed = this._getCaptureTools()?.injectDevOcrResult() ?? false; + const capture = this._getCaptureTools(); + if (!capture) return false; + + const changed = capture.injectDevOcrResult(); if (changed) this._requestMenuRebuild(); return changed; } copyOcr(): boolean { - const changed = this._getCaptureTools()?.copyDevOcrText() ?? false; + const capture = this._getCaptureTools(); + if (!capture) return false; + + const changed = capture.copyDevOcrText(); if (changed) this._requestMenuRebuild(); return changed; } searchOcr(): boolean { - return this._getCaptureTools()?.searchDevOcrText() ?? false; + const capture = this._getCaptureTools(); + if (!capture) return false; + + return capture.searchDevOcrText(); } clearAnnotations(): boolean { - const changed = this._getCaptureTools()?.clearDevAnnotations() ?? false; + const capture = this._getCaptureTools(); + if (!capture) return false; + + const changed = capture.clearDevAnnotations(); if (changed) this._requestMenuRebuild(); return changed; } reset(): boolean { - const changed = this._getCaptureTools()?.resetDevState() ?? false; + const capture = this._getCaptureTools(); + if (!capture) return false; + + const changed = capture.resetDevState(); if (changed) this._requestMenuRebuild(); return changed; } get state(): CaptureToolsDevState | null { - return this._getCaptureTools()?.devState ?? null; + const capture = this._getCaptureTools(); + if (!capture) return null; + + return capture.devState; } private _stateSummary(state: CaptureToolsDevState | null): string { @@ -264,8 +292,9 @@ export class CaptureToolsDevTool { } private _geometrySummary(state: CaptureToolsDevState | null): string { - const geometry = state?.toolbarGeometry; - if (!state || !geometry) return 'Toolbar geometry unavailable'; + if (!state || !state.toolbarGeometry) return 'Toolbar geometry unavailable'; + + const geometry = state.toolbarGeometry; const visibility = state.toolbarVisible ? 'visible' : 'hidden'; return `Toolbar ${geometry.x},${geometry.y} · ${geometry.width}×${geometry.height} · ${visibility} · ${state.interaction}`; } diff --git a/src/dev/clipboardHistoryDevTool.ts b/src/dev/clipboardHistoryDevTool.ts index 38b440a..1f9891c 100644 --- a/src/dev/clipboardHistoryDevTool.ts +++ b/src/dev/clipboardHistoryDevTool.ts @@ -120,10 +120,11 @@ export class ClipboardHistoryDevTool { return panel; } - destroy(): void {} - openPanel(): boolean { - return this._getClipboardHistory()?.openPanel() ?? false; + const clipboard = this._getClipboardHistory(); + if (!clipboard) return false; + + return clipboard.openPanel(); } addRandomMessage(): string | null { @@ -180,18 +181,25 @@ export class ClipboardHistoryDevTool { clearHistory(): boolean { const clipboard = this._getClipboardHistory(); - if (!clipboard?.clearHistory()) return false; + if (!clipboard) return false; + if (!clipboard.clearHistory()) return false; this._requestMenuRebuild(); return true; } get entryCount(): number { - return this._getClipboardHistory()?.entryCount ?? 0; + const clipboard = this._getClipboardHistory(); + if (!clipboard) return 0; + + return clipboard.entryCount; } get isPanelOpen(): boolean { - return this._getClipboardHistory()?.isPanelOpen ?? false; + const clipboard = this._getClipboardHistory(); + if (!clipboard) return false; + + return clipboard.isPanelOpen; } private _getClipboardHistory(): ClipboardHistory | null { diff --git a/src/dev/devTool.ts b/src/dev/devTool.ts index 2d921aa..2222205 100644 --- a/src/dev/devTool.ts +++ b/src/dev/devTool.ts @@ -26,7 +26,6 @@ type DevToolSection = { title: string; iconName: string; buildPanel(): St.Widget; - destroy(): void; }; type DevToolCallbacks = { @@ -95,13 +94,18 @@ export class DevTool extends Module { } override disable(): void { - for (const section of this._sections()) { - section.destroy(); - } + const tools = this._tools; this._tools = null; + if (tools) { + tools.captureTools.destroy(); + tools.trayIcons.destroy(); + tools.weatherClock.destroy(); + tools.meetingClock.destroy(); + } + if (this._menuOpenStateId && this._button) { - this._getMenu()?.disconnect(this._menuOpenStateId); + (this._button.menu as PopupMenu.PopupMenu).disconnect(this._menuOpenStateId); this._menuOpenStateId = 0; } @@ -110,31 +114,31 @@ export class DevTool extends Module { } get trayIconsTool(): TrayIconsDevTool | null { - return this._tools?.trayIcons ?? null; + return this._tools ? this._tools.trayIcons : null; } get clipboardHistoryTool(): ClipboardHistoryDevTool | null { - return this._tools?.clipboardHistory ?? null; + return this._tools ? this._tools.clipboardHistory : null; } get captureToolsTool(): CaptureToolsDevTool | null { - return this._tools?.captureTools ?? null; + return this._tools ? this._tools.captureTools : null; } get generalTool(): GeneralDevTool | null { - return this._tools?.general ?? null; + return this._tools ? this._tools.general : null; } get dockTool(): DockDevTool | null { - return this._tools?.dock ?? null; + return this._tools ? this._tools.dock : null; } get meetingClockTool(): MeetingClockDevTool | null { - return this._tools?.meetingClock ?? null; + return this._tools ? this._tools.meetingClock : null; } get weatherClockTool(): WeatherClockDevTool | null { - return this._tools?.weatherClock ?? null; + return this._tools ? this._tools.weatherClock : null; } private _rebuildMenu(): void { @@ -320,6 +324,10 @@ export class DevTool extends Module { } private _getMenu(): PopupMenu.PopupMenu | null { - return (this._button?.menu as PopupMenu.PopupMenu | null | undefined) ?? null; + if (!this._button) { + return null; + } + + return this._button.menu as PopupMenu.PopupMenu; } } diff --git a/src/dev/dockDevTool.ts b/src/dev/dockDevTool.ts index a7d13f5..4ec5656 100644 --- a/src/dev/dockDevTool.ts +++ b/src/dev/dockDevTool.ts @@ -70,8 +70,6 @@ export class DockDevTool { return panel; } - destroy(): void {} - revealAll(): boolean { const dock = this._getDock(); if (!dock) return false; @@ -105,19 +103,28 @@ export class DockDevTool { } showMonitor(monitorIndex: number): boolean { - const changed = this._getDock()?.showMonitor(monitorIndex) ?? false; + const dock = this._getDock(); + if (!dock) return false; + + const changed = dock.showMonitor(monitorIndex); if (changed) this._requestMenuRebuild(); return changed; } hideMonitor(monitorIndex: number): boolean { - const changed = this._getDock()?.hideMonitor(monitorIndex) ?? false; + const dock = this._getDock(); + if (!dock) return false; + + const changed = dock.hideMonitor(monitorIndex); if (changed) this._requestMenuRebuild(); return changed; } triggerMonitorHotArea(monitorIndex: number): boolean { - const changed = this._getDock()?.revealMonitorFromHotArea(monitorIndex) ?? false; + const dock = this._getDock(); + if (!dock) return false; + + const changed = dock.revealMonitorFromHotArea(monitorIndex); if (changed) this._requestMenuRebuild(); return changed; } diff --git a/src/dev/generalDevTool.ts b/src/dev/generalDevTool.ts index 8a10423..7fdbf2c 100644 --- a/src/dev/generalDevTool.ts +++ b/src/dev/generalDevTool.ts @@ -31,8 +31,6 @@ export class GeneralDevTool { return panel; } - destroy(): void {} - openPreferences(): void { this._openPreferences(); } diff --git a/src/dev/meetingClockDevTool.ts b/src/dev/meetingClockDevTool.ts index d993762..86960d9 100644 --- a/src/dev/meetingClockDevTool.ts +++ b/src/dev/meetingClockDevTool.ts @@ -116,13 +116,19 @@ export class MeetingClockDevTool { } triggerAlert(): boolean { - const triggered = this._getMeetingClock()?.showAlert() ?? false; + const meetingClock = this._getMeetingClock(); + if (!meetingClock) return false; + + const triggered = meetingClock.showAlert(); this._requestMenuRebuild(); return triggered; } openCalendar(): boolean { - return this._getMeetingClock()?.openMenu() ?? false; + const meetingClock = this._getMeetingClock(); + if (!meetingClock) return false; + + return meetingClock.openMenu(); } clearMeetings(): void { @@ -136,7 +142,10 @@ export class MeetingClockDevTool { } get activeAlertEventId(): string | null { - return this._getMeetingClock()?.activeAlertEventId ?? null; + const meetingClock = this._getMeetingClock(); + if (!meetingClock) return null; + + return meetingClock.activeAlertEventId; } private _getMeetingClock(): MeetingClock | null { diff --git a/src/dev/weatherClockDevTool.ts b/src/dev/weatherClockDevTool.ts index 25ff9ef..89f4e31 100644 --- a/src/dev/weatherClockDevTool.ts +++ b/src/dev/weatherClockDevTool.ts @@ -120,12 +120,17 @@ export class WeatherClockDevTool { } clearWeather(): void { - this._getWeatherClock()?.clearWeatherSnapshot(DEVTOOL_SOURCE_KEY); + const weatherClock = this._getWeatherClock(); + if (weatherClock) weatherClock.clearWeatherSnapshot(DEVTOOL_SOURCE_KEY); + this._requestMenuRebuild(); } get isVisible(): boolean { - return this._getWeatherClock()?.isVisible ?? false; + const weatherClock = this._getWeatherClock(); + if (!weatherClock) return false; + + return weatherClock.isVisible; } private _setSnapshot(snapshot: Parameters[1]): boolean { diff --git a/src/device/device.ts b/src/device/device.ts index aafccd0..a09787a 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -228,11 +228,7 @@ export class DefaultDeviceService implements DeviceService { } private _getBooleanProperty(proxy: Gio.DBusProxy, propertyName: string): boolean { - try { - return Boolean(proxy.get_cached_property(propertyName)?.unpack()); - } catch { - return false; - } + return Boolean(proxy.get_cached_property(propertyName)?.unpack()); } private _hasDBusNameOwner(name: string): boolean { diff --git a/src/dock/contextualDragReveal.ts b/src/dock/contextualDragReveal.ts new file mode 100644 index 0000000..96d93c4 --- /dev/null +++ b/src/dock/contextualDragReveal.ts @@ -0,0 +1,109 @@ +import GLib from '@girs/glib-2.0'; +import * as DND from '@girs/gnome-shell/ui/dnd'; +import * as Main from '@girs/gnome-shell/ui/main'; + +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; +import { logger } from '~/core/logger.ts'; + +const REVEAL_DELAY = 800; +const LOG_PREFIX = 'Dock'; + +export type ContextualDragTarget = { + monitorIndex: number; + hotArea: { canStartContextualDragReveal(x: number, y: number): boolean } | null; + dash: { canAcceptContextualEdgeDrag(source: unknown): boolean }; +}; + +export class ContextualDragRevealCoordinator { + private _scope = new LifecycleScope(); + private _timer: ManagedSource = createManagedSource(this._scope); + private _target: T | null = null; + private _monitor: { dragMotion: (event: any) => number }; + + constructor( + private _targets: () => Iterable, + private _isCurrent: (target: T) => boolean, + private _reveal: (target: T) => void, + ) { + this._monitor = { + dragMotion: (event) => { + this._handleMotion(event); + return DND.DragMotionResult.CONTINUE; + }, + }; + DND.addDragMonitor(this._monitor); + Main.xdndHandler.connectObject('drag-end', () => this.clear(), this); + Main.overview.connectObject( + 'item-drag-end', + () => this.clear(), + 'item-drag-cancelled', + () => this.clear(), + 'window-drag-end', + () => this.clear(), + 'window-drag-cancelled', + () => this.clear(), + this, + ); + } + + clearTarget(target: T): void { + if (this._target === target) { + this.clear(); + } + } + + clear(): void { + this._timer.clear(); + this._target = null; + } + + destroy(): void { + this.clear(); + DND.removeDragMonitor(this._monitor); + Main.xdndHandler.disconnectObject(this); + Main.overview.disconnectObject(this); + this._scope.dispose(); + } + + private _handleMotion({ source, x, y }: any): void { + let target: T | null = null; + for (const candidate of this._targets()) { + if ( + candidate.hotArea?.canStartContextualDragReveal(x, y) && + candidate.dash.canAcceptContextualEdgeDrag(source) + ) { + target = candidate; + break; + } + } + if (target === this._target) return; + + this.clear(); + if (!target) return; + + this._target = target; + this._timer.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, REVEAL_DELAY, () => { + this._timer.complete(); + const current = this._target; + this._target = null; + + if ( + current && + this._isCurrent(current) && + current.hotArea?.canStartContextualDragReveal(x, y) && + current.dash.canAcceptContextualEdgeDrag(source) + ) { + logger.debug( + `monitor=${current.monitorIndex} contextual drag reveal after ${REVEAL_DELAY}ms`, + { prefix: LOG_PREFIX }, + ); + this._reveal(current); + } + + return GLib.SOURCE_REMOVE; + }), + ); + } +} diff --git a/src/dock/dock.ts b/src/dock/dock.ts index 9cb4973..3c2d0c9 100644 --- a/src/dock/dock.ts +++ b/src/dock/dock.ts @@ -5,7 +5,6 @@ import St from '@girs/st-18'; import GLib from '@girs/glib-2.0'; import * as Main from '@girs/gnome-shell/ui/main'; -import * as DND from '@girs/gnome-shell/ui/dnd'; import type { ExtensionContext } from '~/core/context.ts'; import { LifecycleScope } from '~/core/lifecycleScope.ts'; @@ -17,32 +16,22 @@ import { DockIntellihide, OverlapStatus } from '~/dock/intellihide.ts'; import { getDockMonitorIndexes } from '~/dock/monitorTopology.ts'; import { DEFAULT_PROFILE, getBuiltInRecipe } from '~/dock/motion/catalog.ts'; import { DashMotionIntegration } from '~/dock/motion/dashMotionIntegration.ts'; +import { ContextualDragRevealCoordinator } from '~/dock/contextualDragReveal.ts'; +import { DockConfigurationController, type DockConfiguration } from '~/dock/dockConfiguration.ts'; +import { ManagedDockBinding } from '~/dock/dockBinding.ts'; +export { ManagedDockBinding } from '~/dock/dockBinding.ts'; const HOT_AREA_REVEAL_DURATION = 1500; const HOT_AREA_STRIP_HEIGHT = 1; -const CONTEXTUAL_DRAG_REVEAL_DELAY = 800; const TRANSITION_ACTIVATION_COOLDOWN = 700; const LOG_PREFIX = 'Dock'; -export type ManagedDockBinding = { - monitorIndex: number; - mode: 'always-show' | 'always-autohide' | 'intellihide'; - container: St.Bin; - dash: AuroraDash; - intellihide: InstanceType | null; - hotArea: InstanceType | null; - strutActor: St.Widget | null; - autoHideReleaseId: number; - hotAreaEnableId: number; - hotAreaActive: boolean; - motion: DashMotionIntegration; -}; - export class Dock extends Module { private _bindings = new Map(); private _lifecycle: LifecycleScope | null = null; private _pendingRebuild = false; private _dockSettings: any = null; + private _configuration: DockConfigurationController | null = null; private _alwaysShow = false; private _intellihideEnabled = false; private _showOnAllMonitors = false; @@ -50,9 +39,8 @@ export class Dock extends Module { private _showExternalStorage = true; private _motionEnabled = true; private _motionProfile: string = DEFAULT_PROFILE; - private _edgeDragMonitor: { dragMotion: (event: any) => number } | null = null; - private _edgeDragTarget: ManagedDockBinding | null = null; - private _edgeDragRevealId = 0; + private _excludePip = false; + private _dragReveal: ContextualDragRevealCoordinator | null = null; private _sessionWasLocked = false; private _fullscreenMonitors = new Set(); @@ -62,19 +50,19 @@ export class Dock extends Module { override enable(): void { this._lifecycle = new LifecycleScope(); - this._dockSettings = this.context.settings.getRawSettings(); - this._alwaysShow = this._dockSettings?.get_boolean('dock-always-show') ?? false; - this._intellihideEnabled = this._dockSettings?.get_boolean('dock-intellihide') ?? false; - if (this._alwaysShow && this._intellihideEnabled) { - this._intellihideEnabled = false; - this._dockSettings?.set_boolean('dock-intellihide', false); + const dockSettings = this.context.settings.getRawSettings(); + this._dockSettings = dockSettings; + this._configuration = new DockConfigurationController(this._readConfiguration(dockSettings)); + const configuration = this._configuration.snapshot; + this._applyConfigurationSnapshot(configuration); + + if ( + configuration.alwaysShow && + !configuration.intellihide && + dockSettings.get_boolean('dock-intellihide') + ) { + dockSettings.set_boolean('dock-intellihide', false); } - this._showOnAllMonitors = this._dockSettings?.get_boolean('dock-show-on-all-monitors') ?? false; - this._showTrash = this._dockSettings?.get_boolean('dock-show-trash') ?? true; - this._showExternalStorage = - this._dockSettings?.get_boolean('dock-show-external-storage') ?? true; - this._motionEnabled = this._dockSettings?.get_boolean('dock-motion-enabled') ?? true; - this._motionProfile = this._dockSettings?.get_string('dock-motion-profile') ?? DEFAULT_PROFILE; logger.debug( [ `enable alwaysShow=${this._alwaysShow}`, @@ -82,7 +70,7 @@ export class Dock extends Module { `showOnAllMonitors=${this._showOnAllMonitors}`, `showTrash=${this._showTrash}`, `showExternalStorage=${this._showExternalStorage}`, - `monitors=${Main.layoutManager.monitors?.length ?? 0}`, + `monitors=${Main.layoutManager.monitors.length}`, ].join(' '), { prefix: LOG_PREFIX }, ); @@ -92,7 +80,11 @@ export class Dock extends Module { this._sessionWasLocked = Boolean(Main.sessionMode.isLocked); this._fullscreenMonitors = this._getFullscreenMonitors(); this._rebuildBindings(); - this._setupContextualDragReveal(); + this._dragReveal = new ContextualDragRevealCoordinator( + () => this._bindings.values(), + (binding) => this._bindings.get(binding.monitorIndex) === binding, + (binding) => this._revealDockFromHotArea(binding), + ); Main.layoutManager.connectObject( 'monitors-changed', () => { @@ -140,80 +132,112 @@ export class Dock extends Module { ); this._lifecycle.onDispose(() => Main.overview.disconnectObject(this)); - this._dockSettings?.connectObject( + dockSettings.connectObject( 'changed::dock-always-show', - () => { - this._alwaysShow = this._dockSettings?.get_boolean('dock-always-show') ?? false; - if (this._alwaysShow && this._intellihideEnabled) { - this._dockSettings?.set_boolean('dock-intellihide', false); - return; - } - this._rebuildBindings(); - }, + () => this._handleConfigurationChange('alwaysShow'), 'changed::dock-intellihide', - () => { - this._intellihideEnabled = this._dockSettings?.get_boolean('dock-intellihide') ?? false; - if (this._intellihideEnabled && this._alwaysShow) { - this._dockSettings?.set_boolean('dock-always-show', false); - return; - } - this._rebuildBindings(); - }, + () => this._handleConfigurationChange('intellihide'), 'changed::dock-show-on-all-monitors', - () => { - this._showOnAllMonitors = - this._dockSettings?.get_boolean('dock-show-on-all-monitors') ?? false; - this._rebuildBindings(); - }, + () => this._handleConfigurationChange('showOnAllMonitors'), 'changed::dock-show-trash', - () => { - this._showTrash = this._dockSettings?.get_boolean('dock-show-trash') ?? true; - this._rebuildBindings(); - }, + () => this._handleConfigurationChange('showTrash'), 'changed::dock-show-external-storage', - () => { - this._showExternalStorage = - this._dockSettings?.get_boolean('dock-show-external-storage') ?? true; - this._rebuildBindings(); - }, + () => this._handleConfigurationChange('showExternalStorage'), 'changed::dock-motion-enabled', - () => { - this._motionEnabled = this._dockSettings?.get_boolean('dock-motion-enabled') ?? true; - this._bindings.forEach((b) => b.motion.setEnabled(this._motionEnabled)); - }, + () => this._handleConfigurationChange('motionEnabled'), 'changed::dock-motion-profile', - () => { - this._motionProfile = - this._dockSettings?.get_string('dock-motion-profile') ?? DEFAULT_PROFILE; - const recipe = getBuiltInRecipe(this._motionProfile); - this._bindings.forEach((b) => b.motion.setRecipe(recipe)); - }, + () => this._handleConfigurationChange('motionProfile'), 'changed::module-pip-on-top', - () => { - const enabled = this._dockSettings?.get_boolean('module-pip-on-top') ?? false; - this._bindings.forEach((binding) => - binding.intellihide?.setExcludePipFromSmartReveal(enabled), - ); - }, + () => this._handleConfigurationChange('excludePip'), this, ); this.context.signals.connectObject('icons-woven', () => this._refreshBindingsLayout(), this); this._lifecycle.onDispose(() => this.context.signals.disconnectObject(this)); - this._lifecycle.onDispose(() => this._dockSettings?.disconnectObject(this)); + this._lifecycle.onDispose(() => dockSettings.disconnectObject(this)); } override disable(): void { Main.overview.dash.show(); - this._teardownContextualDragReveal(); + this._dragReveal?.destroy(); + this._dragReveal = null; this._lifecycle?.dispose(); this._lifecycle = null; this._dockSettings = null; + this._configuration = null; this._pendingRebuild = false; this._fullscreenMonitors.clear(); this._clearBindings(); } + private _readConfiguration(dockSettings: any): DockConfiguration { + return { + alwaysShow: dockSettings.get_boolean('dock-always-show'), + intellihide: dockSettings.get_boolean('dock-intellihide'), + showOnAllMonitors: dockSettings.get_boolean('dock-show-on-all-monitors'), + showTrash: dockSettings.get_boolean('dock-show-trash'), + showExternalStorage: dockSettings.get_boolean('dock-show-external-storage'), + motionEnabled: dockSettings.get_boolean('dock-motion-enabled'), + motionProfile: dockSettings.get_string('dock-motion-profile'), + excludePip: dockSettings.get_boolean('module-pip-on-top'), + }; + } + + private _handleConfigurationChange(changedKey: keyof DockConfiguration): void { + if (!this._configuration || !this._dockSettings) { + return; + } + + const transition = this._configuration.transition( + this._readConfiguration(this._dockSettings), + changedKey, + ); + + const configuration = transition.snapshot; + this._applyConfigurationSnapshot(configuration); + + if (changedKey === 'alwaysShow' && !configuration.intellihide) { + if (this._dockSettings.get_boolean('dock-intellihide')) { + this._dockSettings.set_boolean('dock-intellihide', false); + } + } else if (changedKey === 'intellihide' && !configuration.alwaysShow) { + if (this._dockSettings.get_boolean('dock-always-show')) { + this._dockSettings.set_boolean('dock-always-show', false); + } + } + + if (transition.change === 'rebuild') { + this._rebuildBindings(); + return; + } + + if (transition.change === 'motion') { + const recipe = getBuiltInRecipe(this._motionProfile); + for (const binding of this._bindings.values()) { + binding.motion.setEnabled(this._motionEnabled); + binding.motion.setRecipe(recipe); + } + return; + } + + if (transition.change === 'pip') { + for (const binding of this._bindings.values()) { + binding.intellihide?.setExcludePipFromSmartReveal(configuration.excludePip); + } + } + } + + private _applyConfigurationSnapshot(configuration: DockConfiguration): void { + this._alwaysShow = configuration.alwaysShow; + this._intellihideEnabled = configuration.intellihide; + this._showOnAllMonitors = configuration.showOnAllMonitors; + this._showTrash = configuration.showTrash; + this._showExternalStorage = configuration.showExternalStorage; + this._motionEnabled = configuration.motionEnabled; + this._motionProfile = configuration.motionProfile; + this._excludePip = configuration.excludePip; + } + get bindings(): readonly ManagedDockBinding[] { return [...this._bindings.values()]; } @@ -367,19 +391,7 @@ export class Dock extends Module { const motion = new DashMotionIntegration(getBuiltInRecipe(this._motionProfile)); motion.attach(dash, this._motionEnabled); - const binding: ManagedDockBinding = { - monitorIndex, - mode, - container, - dash, - intellihide: null, - hotArea: null, - strutActor: null, - autoHideReleaseId: 0, - hotAreaEnableId: 0, - hotAreaActive: false, - motion, - }; + const binding = new ManagedDockBinding(monitorIndex, mode, container, dash, motion); logger.debug( `monitor=${monitorIndex} binding created geometry=${monitor.x},${monitor.y} ${monitor.width}x${monitor.height} mode=${mode}`, { prefix: LOG_PREFIX }, @@ -403,10 +415,7 @@ export class Dock extends Module { return binding; } - const intellihide = new DockIntellihide( - monitorIndex, - this._dockSettings?.get_boolean('module-pip-on-top') ?? false, - ); + const intellihide = new DockIntellihide(monitorIndex, this._excludePip); binding.intellihide = intellihide; dash.setTargetBoxListener((box) => intellihide.updateTargetBox(box)); @@ -552,37 +561,9 @@ export class Dock extends Module { } private _destroyBinding(binding: ManagedDockBinding): void { - if (this._edgeDragTarget === binding) this._clearContextualDragReveal(); + this._dragReveal?.clearTarget(binding); logger.debug(`monitor=${binding.monitorIndex} binding destroyed`, { prefix: LOG_PREFIX }); - if (binding.autoHideReleaseId) { - GLib.source_remove(binding.autoHideReleaseId); - binding.autoHideReleaseId = 0; - } - this._clearHotAreaEnable(binding); - - binding.intellihide?.disconnectObject(this); - binding.hotArea?.disconnectObject(this); - binding.container.disconnectObject(this); - - if (binding.hotArea) { - Main.layoutManager.removeChrome?.(binding.hotArea); - binding.hotArea.destroy(); - binding.hotArea = null; - } - - if (binding.strutActor) { - Main.layoutManager.removeChrome?.(binding.strutActor); - binding.strutActor.destroy(); - binding.strutActor = null; - } - - binding.intellihide?.destroy(); - binding.motion.dispose(); - binding.dash.detachFromContainer(); - binding.dash.destroy(); - - Main.layoutManager.removeChrome?.(binding.container); - binding.container.destroy(); + binding.destroy(this); } private _revealDockFromHotArea(binding: ManagedDockBinding): void { @@ -609,22 +590,17 @@ export class Dock extends Module { // Clutter crossing events on the dock actor, so it stays reliable even when // the pointer moves onto a client (fullscreen/maximized) window — unlike a // stage motion watch, which never fires once the pointer is over a window. - binding.autoHideReleaseId = GLib.timeout_add( - GLib.PRIORITY_DEFAULT, - HOT_AREA_REVEAL_DURATION, - () => { - binding.autoHideReleaseId = 0; + binding.autoHideRelease.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, HOT_AREA_REVEAL_DURATION, () => { + binding.autoHideRelease.complete(); this._releaseHotAreaToAutoHide(binding); return GLib.SOURCE_REMOVE; - }, + }), ); } private _clearHotAreaReveal(binding: ManagedDockBinding): void { - if (binding.autoHideReleaseId) { - GLib.source_remove(binding.autoHideReleaseId); - binding.autoHideReleaseId = 0; - } + binding.autoHideRelease.clear(); } private _handleHotAreaActiveIntellihideChange(binding: ManagedDockBinding): void { @@ -683,110 +659,27 @@ export class Dock extends Module { private _enableHotAreaWhenDockHidden(binding: ManagedDockBinding): void { this._clearHotAreaEnable(binding); - binding.hotAreaEnableId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { - if (binding.dash.visible) return GLib.SOURCE_CONTINUE; - - binding.hotAreaEnableId = 0; - binding.hotAreaActive = false; - binding.hotArea?.setEnabled(true); - logger.debug(`monitor=${binding.monitorIndex} hot area rearmed after hide`, { - prefix: LOG_PREFIX, - }); - return GLib.SOURCE_REMOVE; - }); - } + binding.hotAreaEnable.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { + if (binding.dash.visible) return GLib.SOURCE_CONTINUE; - private _clearHotAreaEnable(binding: ManagedDockBinding): void { - if (!binding.hotAreaEnableId) return; - GLib.source_remove(binding.hotAreaEnableId); - binding.hotAreaEnableId = 0; - } - - private _setupContextualDragReveal(): void { - this._edgeDragMonitor = { - dragMotion: (event: any) => { - this._handleContextualDragMotion(event); - return DND.DragMotionResult.CONTINUE; - }, - }; - DND.addDragMonitor(this._edgeDragMonitor); - - Main.xdndHandler.connectObject('drag-end', () => this._clearContextualDragReveal(), this); - Main.overview.connectObject( - 'item-drag-end', - () => this._clearContextualDragReveal(), - 'item-drag-cancelled', - () => this._clearContextualDragReveal(), - 'window-drag-end', - () => this._clearContextualDragReveal(), - 'window-drag-cancelled', - () => this._clearContextualDragReveal(), - this, - ); - } - - private _teardownContextualDragReveal(): void { - this._clearContextualDragReveal(); - if (this._edgeDragMonitor) { - DND.removeDragMonitor(this._edgeDragMonitor); - this._edgeDragMonitor = null; - } - Main.xdndHandler.disconnectObject(this); - } - - private _handleContextualDragMotion(event: any): void { - const { source, x, y } = event; - let target: ManagedDockBinding | null = null; - for (const binding of this._bindings.values()) { - if ( - binding.hotArea?.canStartContextualDragReveal(x, y) && - binding.dash.canAcceptContextualEdgeDrag(source) - ) { - target = binding; - break; - } - } - - if (target === this._edgeDragTarget) return; - - this._clearContextualDragReveal(); - if (!target) return; - - this._edgeDragTarget = target; - this._edgeDragRevealId = GLib.timeout_add( - GLib.PRIORITY_DEFAULT, - CONTEXTUAL_DRAG_REVEAL_DELAY, - () => { - this._edgeDragRevealId = 0; - const currentTarget = this._edgeDragTarget; - this._edgeDragTarget = null; - if ( - currentTarget && - this._bindings.get(currentTarget.monitorIndex) === currentTarget && - currentTarget.hotArea?.canStartContextualDragReveal(x, y) && - currentTarget.dash.canAcceptContextualEdgeDrag(source) - ) { - logger.debug( - `monitor=${currentTarget.monitorIndex} contextual drag reveal after ${CONTEXTUAL_DRAG_REVEAL_DELAY}ms`, - { prefix: LOG_PREFIX }, - ); - this._revealDockFromHotArea(currentTarget); - } + binding.hotAreaEnable.complete(); + binding.hotAreaActive = false; + binding.hotArea?.setEnabled(true); + logger.debug(`monitor=${binding.monitorIndex} hot area rearmed after hide`, { + prefix: LOG_PREFIX, + }); return GLib.SOURCE_REMOVE; - }, + }), ); } - private _clearContextualDragReveal(): void { - if (this._edgeDragRevealId) { - GLib.source_remove(this._edgeDragRevealId); - this._edgeDragRevealId = 0; - } - this._edgeDragTarget = null; + private _clearHotAreaEnable(binding: ManagedDockBinding): void { + binding.hotAreaEnable.clear(); } private _beginActivationCooldown(reason: string): void { - this._clearContextualDragReveal(); + this._dragReveal?.clear(); this._bindings.forEach((binding) => binding.hotArea?.beginCooldown(TRANSITION_ACTIVATION_COOLDOWN, reason), ); diff --git a/src/dock/dockBinding.ts b/src/dock/dockBinding.ts new file mode 100644 index 0000000..f0a583b --- /dev/null +++ b/src/dock/dockBinding.ts @@ -0,0 +1,57 @@ +import type St from '@girs/st-18'; +import * as Main from '@girs/gnome-shell/ui/main'; + +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; +import type { AuroraDash } from '~/shared/ui/dash.ts'; +import type { DockHotArea } from '~/dock/hotArea.ts'; +import type { DockIntellihide } from '~/dock/intellihide.ts'; +import type { DashMotionIntegration } from '~/dock/motion/dashMotionIntegration.ts'; + +export type DockMode = 'always-show' | 'always-autohide' | 'intellihide'; + +export class ManagedDockBinding { + public intellihide: InstanceType | null = null; + public hotArea: InstanceType | null = null; + public strutActor: St.Widget | null = null; + public hotAreaActive = false; + public readonly lifecycle = new LifecycleScope(); + public readonly autoHideRelease: ManagedSource = createManagedSource(this.lifecycle); + public readonly hotAreaEnable: ManagedSource = createManagedSource(this.lifecycle); + + constructor( + public readonly monitorIndex: number, + public readonly mode: DockMode, + public readonly container: St.Bin, + public readonly dash: AuroraDash, + public readonly motion: DashMotionIntegration, + ) {} + + destroy(signalOwner: object): void { + this.lifecycle.dispose(); + this.intellihide?.disconnectObject(signalOwner); + this.hotArea?.disconnectObject(signalOwner); + this.container.disconnectObject(signalOwner); + + if (this.hotArea) { + Main.layoutManager.removeChrome(this.hotArea); + this.hotArea.destroy(); + this.hotArea = null; + } + + if (this.strutActor) { + Main.layoutManager.removeChrome(this.strutActor); + this.strutActor.destroy(); + this.strutActor = null; + } + + this.intellihide?.destroy(); + this.intellihide = null; + this.motion.dispose(); + this.dash.detachFromContainer(); + this.dash.destroy(); + + Main.layoutManager.removeChrome(this.container); + this.container.destroy(); + } +} diff --git a/src/dock/dockConfiguration.ts b/src/dock/dockConfiguration.ts new file mode 100644 index 0000000..c2479f8 --- /dev/null +++ b/src/dock/dockConfiguration.ts @@ -0,0 +1,77 @@ +export type DockConfiguration = { + alwaysShow: boolean; + intellihide: boolean; + showOnAllMonitors: boolean; + showTrash: boolean; + showExternalStorage: boolean; + motionEnabled: boolean; + motionProfile: string; + excludePip: boolean; +}; + +export type DockConfigurationChange = 'none' | 'motion' | 'pip' | 'rebuild'; + +export class DockConfigurationController { + private _snapshot: DockConfiguration; + + constructor(initial: DockConfiguration) { + this._snapshot = normalizeDockConfiguration(initial); + } + + get snapshot(): DockConfiguration { + return { ...this._snapshot }; + } + + transition( + next: DockConfiguration, + changedKey?: keyof DockConfiguration, + ): { snapshot: DockConfiguration; change: DockConfigurationChange } { + const normalized = normalizeDockConfiguration(next, changedKey); + const change = classifyDockConfigurationChange(this._snapshot, normalized); + + this._snapshot = normalized; + + return { snapshot: this.snapshot, change }; + } +} + +export function normalizeDockConfiguration( + value: DockConfiguration, + changedKey?: keyof DockConfiguration, +): DockConfiguration { + if (!value.alwaysShow || !value.intellihide) return { ...value }; + + return changedKey === 'intellihide' + ? { ...value, alwaysShow: false } + : { ...value, intellihide: false }; +} + +export function classifyDockConfigurationChange( + previous: DockConfiguration, + next: DockConfiguration, +): DockConfigurationChange { + const rebuildKeys: Array = [ + 'alwaysShow', + 'intellihide', + 'showOnAllMonitors', + 'showTrash', + 'showExternalStorage', + ]; + + if (rebuildKeys.some((key) => previous[key] !== next[key])) { + return 'rebuild'; + } + + if ( + previous.motionEnabled !== next.motionEnabled || + previous.motionProfile !== next.motionProfile + ) { + return 'motion'; + } + + if (previous.excludePip !== next.excludePip) { + return 'pip'; + } + + return 'none'; +} diff --git a/src/dock/externalStorageIcon.ts b/src/dock/externalStorageIcon.ts index 7ae6821..6a237c8 100644 --- a/src/dock/externalStorageIcon.ts +++ b/src/dock/externalStorageIcon.ts @@ -1,203 +1,24 @@ import '@girs/gjs'; import { gettext as _ } from '~/shared/i18n.ts'; -import Gio from '@girs/gio-2.0'; import Clutter from '@girs/clutter-18'; import GObject from '@girs/gobject-2.0'; import St from '@girs/st-18'; import * as Main from '@girs/gnome-shell/ui/main'; import * as PopupMenu from '@girs/gnome-shell/ui/popupMenu'; import * as IconGrid from '@girs/gnome-shell/ui/iconGrid'; -import * as ShellMountOperation from '@girs/gnome-shell/ui/shellMountOperation'; import { DashItemContainer } from '@girs/gnome-shell/ui/dash'; -import { logger } from '~/core/logger.ts'; -import { - selectExternalStorageEntries, - type ExternalStorageCandidate, -} from '~/dock/externalStorageModel.ts'; - -const FALLBACK_ICON = 'drive-harddisk'; -const LOG_PREFIX = 'DockStorage'; +import type { ExternalStorageItem } from '~/dock/externalStorageMonitor.ts'; +import { ExternalStorageOperations } from '~/dock/externalStorageOperations.ts'; +export { ExternalStorageMonitor } from '~/dock/externalStorageMonitor.ts'; +export type { ExternalStorageItem } from '~/dock/externalStorageMonitor.ts'; type SizableBaseIcon = InstanceType & { setIconSize(size: number): void; y_align: Clutter.ActorAlign; }; -export interface ExternalStorageItem { - id: string; - name: string; - kind: 'volume' | 'mount'; - sortKey: string | null; - icon: Gio.Icon; - volume: Gio.Volume | null; - mount: Gio.Mount | null; -} - -export class ExternalStorageMonitor { - private _volumeMonitor: Gio.VolumeMonitor; - private _items: ExternalStorageItem[] = []; - private _onChanged: (items: readonly ExternalStorageItem[]) => void; - - constructor(onChanged: (items: readonly ExternalStorageItem[]) => void) { - this._onChanged = onChanged; - this._volumeMonitor = Gio.VolumeMonitor.get(); - this._volumeMonitor.connectObject( - 'volume-added', - () => this._refresh(), - 'volume-removed', - () => this._refresh(), - 'volume-changed', - () => this._refresh(), - 'mount-added', - () => this._refresh(), - 'mount-removed', - () => this._refresh(), - 'mount-changed', - () => this._refresh(), - 'drive-connected', - () => this._refresh(), - 'drive-disconnected', - () => this._refresh(), - 'drive-changed', - () => this._refresh(), - this, - ); - this._refresh(); - } - - get items(): readonly ExternalStorageItem[] { - return this._items; - } - - destroy(): void { - this._volumeMonitor.disconnectObject(this); - this._items = []; - } - - private _refresh(): void { - const itemsById = new Map(); - const candidates: ExternalStorageCandidate[] = []; - - for (const drive of this._volumeMonitor.get_connected_drives()) { - for (const volume of drive.get_volumes()) { - this._addVolume(volume, candidates, itemsById); - } - } - - for (const volume of this._volumeMonitor.get_volumes()) { - if (volume.get_drive()) continue; - this._addVolume(volume, candidates, itemsById); - } - - for (const mount of this._volumeMonitor.get_mounts()) { - if (mount.get_volume() || mount.is_shadowed()) continue; - if (!mount.get_drive()) continue; - this._addMount(mount, candidates, itemsById); - } - - this._items = selectExternalStorageEntries(candidates) - .map((entry) => itemsById.get(entry.id)) - .filter((item): item is ExternalStorageItem => item !== undefined); - this._onChanged(this._items); - } - - private _addVolume( - volume: Gio.Volume, - candidates: ExternalStorageCandidate[], - itemsById: Map, - ): void { - const mount = volume.get_mount(); - const root = mount?.get_root() ?? volume.get_activation_root(); - const id = this._volumeId(volume); - const name = volume.get_name(); - const sortKey = volume.get_sort_key(); - - candidates.push({ - id, - name, - kind: 'volume', - sortKey, - volumeClass: volume.get_identifier(Gio.VOLUME_IDENTIFIER_KIND_CLASS), - hasDrive: volume.get_drive() !== null, - isNative: root?.is_native() ?? true, - isShadowed: mount?.is_shadowed() ?? false, - canMount: volume.can_mount(), - hasMount: mount !== null, - }); - - itemsById.set(id, { - id, - name, - kind: 'volume', - sortKey, - icon: this._safeIcon(() => volume.get_icon()), - volume, - mount, - }); - } - - private _addMount( - mount: Gio.Mount, - candidates: ExternalStorageCandidate[], - itemsById: Map, - ): void { - const id = this._mountId(mount); - const name = mount.get_name(); - const sortKey = mount.get_sort_key(); - - candidates.push({ - id, - name, - kind: 'mount', - sortKey, - volumeClass: null, - hasDrive: mount.get_drive() !== null, - isNative: mount.get_default_location().is_native(), - isShadowed: mount.is_shadowed(), - canMount: false, - hasMount: true, - }); - - itemsById.set(id, { - id, - name, - kind: 'mount', - sortKey, - icon: this._safeIcon(() => mount.get_icon()), - volume: null, - mount, - }); - } - - private _volumeId(volume: Gio.Volume): string { - const identifier = - volume.get_uuid() ?? - volume.get_identifier(Gio.VOLUME_IDENTIFIER_KIND_UUID) ?? - volume.get_identifier(Gio.VOLUME_IDENTIFIER_KIND_UNIX_DEVICE) ?? - volume.get_identifier(Gio.VOLUME_IDENTIFIER_KIND_LABEL) ?? - volume.get_sort_key() ?? - volume.get_name(); - return `volume:${identifier}`; - } - - private _mountId(mount: Gio.Mount): string { - const identifier = - mount.get_uuid() ?? mount.get_sort_key() ?? mount.get_default_location().get_uri(); - return `mount:${identifier}`; - } - - private _safeIcon(getIcon: () => Gio.Icon): Gio.Icon { - try { - return getIcon(); - } catch (error) { - logger.warn(`Failed to read storage icon: ${error}`, { prefix: LOG_PREFIX }); - return new Gio.ThemedIcon({ name: FALLBACK_ICON }); - } - } -} - export const ExternalStorageIcon = GObject.registerClass( class ExternalStorageIcon extends DashItemContainer { declare toggleButton: St.Button; @@ -208,8 +29,7 @@ export const ExternalStorageIcon = GObject.registerClass( declare private _menuManager: PopupMenu.PopupMenuManager | null; declare private _openItem: PopupMenu.PopupMenuItem | null; declare private _ejectItem: PopupMenu.PopupMenuItem | null; - declare private _operationCancellable: Gio.Cancellable | null; - declare private _busy: boolean; + declare private _operations: ExternalStorageOperations; override _init(item?: ExternalStorageItem): void { super._init(); @@ -222,8 +42,7 @@ export const ExternalStorageIcon = GObject.registerClass( this._menuManager = null; this._openItem = null; this._ejectItem = null; - this._operationCancellable = new Gio.Cancellable(); - this._busy = false; + this._operations = new ExternalStorageOperations(item, () => this._syncMenuSensitivity()); this.toggleButton = new St.Button({ style_class: 'show-apps', @@ -268,8 +87,7 @@ export const ExternalStorageIcon = GObject.registerClass( } override destroy(): void { - this._operationCancellable?.cancel(); - this._operationCancellable = null; + this._operations.destroy(); this.toggleButton.disconnectObject(this); this._menu?.destroy(); this._menu = null; @@ -281,7 +99,9 @@ export const ExternalStorageIcon = GObject.registerClass( } get menuIsOpen(): boolean { - return this._menu?.isOpen ?? false; + if (!this._menu) return false; + + return this._menu.isOpen; } private _createIcon(size: number): St.Icon { @@ -318,228 +138,20 @@ export const ExternalStorageIcon = GObject.registerClass( } private _syncMenuSensitivity(): void { - this._openItem?.setSensitive(!this._busy); - this._ejectItem?.setSensitive(!this._busy && this._canUnmountOrEject()); + this._openItem?.setSensitive(!this._operations.busy); + this._ejectItem?.setSensitive(!this._operations.busy && this._operations.canUnmountOrEject); } private _open(): void { - void this._openAsync(); - } - - private async _openAsync(): Promise { - const cancellable = this._operationCancellable; - if (this._busy || !cancellable) return; - this._setBusy(true); - - try { - const mount = await this._ensureMounted(cancellable); - if (!mount) throw new Error('Volume is not mounted'); - - const uri = mount.get_root().get_uri(); - const launchContext = global.create_app_launch_context(global.get_current_time(), -1); - await this._launchUri(uri, launchContext, cancellable); - } catch (error) { - this._reportFailure(_('Failed to open “%s”').format(this._item.name), error); - } finally { - if (this._operationCancellable === cancellable) this._setBusy(false); - } - } - - private async _ensureMounted(cancellable: Gio.Cancellable): Promise { - const currentMount = this._item.volume?.get_mount() ?? this._item.mount; - if (currentMount) return currentMount; - if (!this._item.volume?.can_mount()) return null; - - const operation = new ShellMountOperation.ShellMountOperation(this._item.volume); - try { - await this._mountVolume(this._item.volume, operation.mountOp, cancellable); - return this._item.volume.get_mount(); - } finally { - operation.close(); - } + this._operations.open(); } private _eject(): void { - void this._ejectAsync(); - } - - private async _ejectAsync(): Promise { - const cancellable = this._operationCancellable; - if (this._busy || !cancellable || !this._canUnmountOrEject()) return; - this._setBusy(true); - - try { - const mount = this._item.volume?.get_mount() ?? this._item.mount; - - if (mount?.can_eject()) { - const operation = new ShellMountOperation.ShellMountOperation(mount); - try { - await this._ejectMount(mount, operation.mountOp, cancellable); - } finally { - operation.close(); - } - } else if (mount?.can_unmount()) { - const operation = new ShellMountOperation.ShellMountOperation(mount); - try { - await this._unmountMount(mount, operation.mountOp, cancellable); - } finally { - operation.close(); - } - } else if (this._item.volume?.can_eject()) { - const operation = new ShellMountOperation.ShellMountOperation(this._item.volume); - try { - await this._ejectVolume(this._item.volume, operation.mountOp, cancellable); - } finally { - operation.close(); - } - } - } catch (error) { - this._reportFailure(_('Failed to eject “%s”').format(this._item.name), error); - } finally { - if (this._operationCancellable === cancellable) this._setBusy(false); - } - } - - private _canUnmountOrEject(): boolean { - const mount = this._item.volume?.get_mount() ?? this._item.mount; - return ( - mount?.can_eject() === true || - mount?.can_unmount() === true || - this._item.volume?.can_eject() === true - ); + this._operations.eject(); } private _canEject(): boolean { - const mount = this._item.volume?.get_mount() ?? this._item.mount; - return mount?.can_eject() === true || this._item.volume?.can_eject() === true; - } - - private _launchUri( - uri: string, - launchContext: Gio.AppLaunchContext, - cancellable: Gio.Cancellable, - ): Promise { - return new Promise((resolve, reject) => { - Gio.app_info_launch_default_for_uri_async( - uri, - launchContext, - cancellable, - (_source, result) => { - try { - Gio.app_info_launch_default_for_uri_finish(result); - resolve(); - } catch (error) { - reject(error); - } - }, - ); - }); - } - - private _mountVolume( - volume: Gio.Volume, - mountOperation: Gio.MountOperation, - cancellable: Gio.Cancellable, - ): Promise { - return new Promise((resolve, reject) => { - volume.mount(Gio.MountMountFlags.NONE, mountOperation, cancellable, (_source, result) => { - try { - volume.mount_finish(result); - resolve(); - } catch (error) { - reject(error); - } - }); - }); - } - - private _ejectMount( - mount: Gio.Mount, - mountOperation: Gio.MountOperation, - cancellable: Gio.Cancellable, - ): Promise { - return new Promise((resolve, reject) => { - mount.eject_with_operation( - Gio.MountUnmountFlags.NONE, - mountOperation, - cancellable, - (_source, result) => { - try { - mount.eject_with_operation_finish(result); - resolve(); - } catch (error) { - reject(error); - } - }, - ); - }); - } - - private _unmountMount( - mount: Gio.Mount, - mountOperation: Gio.MountOperation, - cancellable: Gio.Cancellable, - ): Promise { - return new Promise((resolve, reject) => { - mount.unmount_with_operation( - Gio.MountUnmountFlags.NONE, - mountOperation, - cancellable, - (_source, result) => { - try { - mount.unmount_with_operation_finish(result); - resolve(); - } catch (error) { - reject(error); - } - }, - ); - }); - } - - private _ejectVolume( - volume: Gio.Volume, - mountOperation: Gio.MountOperation, - cancellable: Gio.Cancellable, - ): Promise { - return new Promise((resolve, reject) => { - volume.eject_with_operation( - Gio.MountUnmountFlags.NONE, - mountOperation, - cancellable, - (_source, result) => { - try { - volume.eject_with_operation_finish(result); - resolve(); - } catch (error) { - reject(error); - } - }, - ); - }); - } - - private _setBusy(busy: boolean): void { - this._busy = busy; - this._syncMenuSensitivity(); - } - - private _reportFailure(title: string, error: unknown): void { - if (this._isHandledError(error)) return; - - const message = error instanceof Error ? error.message : String(error); - logger.warn(`${title}: ${message}`, { prefix: LOG_PREFIX }); - Main.notifyError(title, message); - } - - private _isHandledError(error: unknown): boolean { - const maybeGioError = error as { - matches?: (domain: unknown, code: unknown) => boolean; - } | null; - return ( - maybeGioError?.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED) === true || - maybeGioError?.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED) === true - ); + return this._operations.canEject; } }, ); diff --git a/src/dock/externalStorageModel.ts b/src/dock/externalStorageModel.ts index 0825c04..287c65f 100644 --- a/src/dock/externalStorageModel.ts +++ b/src/dock/externalStorageModel.ts @@ -20,7 +20,9 @@ export interface ExternalStorageEntry { } function isNetworkClass(volumeClass: string | null): boolean { - return volumeClass?.includes('network') ?? false; + if (!volumeClass) return false; + + return volumeClass.includes('network'); } function isInterestingLocalStorage(candidate: ExternalStorageCandidate): boolean { diff --git a/src/dock/externalStorageMonitor.ts b/src/dock/externalStorageMonitor.ts new file mode 100644 index 0000000..0b620f8 --- /dev/null +++ b/src/dock/externalStorageMonitor.ts @@ -0,0 +1,168 @@ +import Gio from '@girs/gio-2.0'; + +import { logger } from '~/core/logger.ts'; +import { + selectExternalStorageEntries, + type ExternalStorageCandidate, +} from '~/dock/externalStorageModel.ts'; + +const FALLBACK_ICON = 'drive-harddisk'; +const LOG_PREFIX = 'DockStorage'; + +export interface ExternalStorageItem { + id: string; + name: string; + kind: 'volume' | 'mount'; + sortKey: string | null; + icon: Gio.Icon; + volume: Gio.Volume | null; + mount: Gio.Mount | null; +} + +export class ExternalStorageMonitor { + private _volumeMonitor: Gio.VolumeMonitor; + private _items: ExternalStorageItem[] = []; + + constructor(private _onChanged: (items: readonly ExternalStorageItem[]) => void) { + this._volumeMonitor = Gio.VolumeMonitor.get(); + this._volumeMonitor.connectObject( + 'volume-added', + () => this._refresh(), + 'volume-removed', + () => this._refresh(), + 'volume-changed', + () => this._refresh(), + 'mount-added', + () => this._refresh(), + 'mount-removed', + () => this._refresh(), + 'mount-changed', + () => this._refresh(), + 'drive-connected', + () => this._refresh(), + 'drive-disconnected', + () => this._refresh(), + 'drive-changed', + () => this._refresh(), + this, + ); + this._refresh(); + } + + get items(): readonly ExternalStorageItem[] { + return this._items; + } + + destroy(): void { + this._volumeMonitor.disconnectObject(this); + this._items = []; + } + + private _refresh(): void { + const itemsById = new Map(); + const candidates: ExternalStorageCandidate[] = []; + + for (const drive of this._volumeMonitor.get_connected_drives()) { + for (const volume of drive.get_volumes()) { + this._addVolume(volume, candidates, itemsById); + } + } + + for (const volume of this._volumeMonitor.get_volumes()) { + if (!volume.get_drive()) { + this._addVolume(volume, candidates, itemsById); + } + } + + for (const mount of this._volumeMonitor.get_mounts()) { + if (!mount.get_volume() && !mount.is_shadowed() && mount.get_drive()) { + this._addMount(mount, candidates, itemsById); + } + } + + this._items = selectExternalStorageEntries(candidates) + .map((entry) => itemsById.get(entry.id)) + .filter((item): item is ExternalStorageItem => item !== undefined); + this._onChanged(this._items); + } + + private _addVolume( + volume: Gio.Volume, + candidates: ExternalStorageCandidate[], + itemsById: Map, + ): void { + const mount = volume.get_mount(); + const root = mount ? mount.get_root() : volume.get_activation_root(); + const identifier = + volume.get_uuid() ?? + volume.get_identifier(Gio.VOLUME_IDENTIFIER_KIND_UUID) ?? + volume.get_identifier(Gio.VOLUME_IDENTIFIER_KIND_UNIX_DEVICE) ?? + volume.get_identifier(Gio.VOLUME_IDENTIFIER_KIND_LABEL) ?? + volume.get_sort_key() ?? + volume.get_name(); + const id = `volume:${identifier}`; + + candidates.push({ + id, + name: volume.get_name(), + kind: 'volume', + sortKey: volume.get_sort_key(), + volumeClass: volume.get_identifier(Gio.VOLUME_IDENTIFIER_KIND_CLASS), + hasDrive: volume.get_drive() !== null, + isNative: root ? root.is_native() : true, + isShadowed: mount ? mount.is_shadowed() : false, + canMount: volume.can_mount(), + hasMount: mount !== null, + }); + itemsById.set(id, { + id, + name: volume.get_name(), + kind: 'volume', + sortKey: volume.get_sort_key(), + icon: this._safeIcon(() => volume.get_icon()), + volume, + mount, + }); + } + + private _addMount( + mount: Gio.Mount, + candidates: ExternalStorageCandidate[], + itemsById: Map, + ): void { + const identifier = + mount.get_uuid() ?? mount.get_sort_key() ?? mount.get_default_location().get_uri(); + const id = `mount:${identifier}`; + + candidates.push({ + id, + name: mount.get_name(), + kind: 'mount', + sortKey: mount.get_sort_key(), + volumeClass: null, + hasDrive: mount.get_drive() !== null, + isNative: mount.get_default_location().is_native(), + isShadowed: mount.is_shadowed(), + canMount: false, + hasMount: true, + }); + itemsById.set(id, { + id, + name: mount.get_name(), + kind: 'mount', + sortKey: mount.get_sort_key(), + icon: this._safeIcon(() => mount.get_icon()), + volume: null, + mount, + }); + } + + private _safeIcon(getIcon: () => Gio.Icon): Gio.Icon { + try { + return getIcon(); + } catch (error) { + logger.warn(`Failed to read storage icon: ${error}`, { prefix: LOG_PREFIX }); + return new Gio.ThemedIcon({ name: FALLBACK_ICON }); + } + } +} diff --git a/src/dock/externalStorageOperations.ts b/src/dock/externalStorageOperations.ts new file mode 100644 index 0000000..0e8665b --- /dev/null +++ b/src/dock/externalStorageOperations.ts @@ -0,0 +1,225 @@ +import Gio from '@girs/gio-2.0'; +import GLib from '@girs/glib-2.0'; +import * as Main from '@girs/gnome-shell/ui/main'; +import * as ShellMountOperation from '@girs/gnome-shell/ui/shellMountOperation'; + +import { gettext as _ } from '~/shared/i18n.ts'; +import { logger } from '~/core/logger.ts'; +import type { ExternalStorageItem } from '~/dock/externalStorageMonitor.ts'; + +export class ExternalStorageOperations { + private _cancellable = new Gio.Cancellable(); + private _busy = false; + private _onBusyChanged: ((busy: boolean) => void) | null; + + constructor( + private _item: ExternalStorageItem, + onBusyChanged: (busy: boolean) => void, + ) { + this._onBusyChanged = onBusyChanged; + } + + get busy(): boolean { + return this._busy; + } + + get canUnmountOrEject(): boolean { + const mount = this._currentMount(); + + return Boolean(mount?.can_eject() || mount?.can_unmount() || this._item.volume?.can_eject()); + } + + get canEject(): boolean { + return Boolean(this._currentMount()?.can_eject() || this._item.volume?.can_eject()); + } + + open(): void { + void this._open(); + } + + eject(): void { + void this._eject(); + } + + destroy(): void { + this._cancellable.cancel(); + this._onBusyChanged = null; + } + + private async _open(): Promise { + if (this._busy) return; + + this._setBusy(true); + + try { + const mount = await this._ensureMounted(); + if (!mount) throw new Error('Volume is not mounted'); + + const launchContext = global.create_app_launch_context(global.get_current_time(), -1); + await this._launchUri(mount.get_root().get_uri(), launchContext); + } catch (error) { + this._reportFailure(_('Failed to open “%s”').format(this._item.name), error); + } finally { + this._setBusy(false); + } + } + + private async _ensureMounted(): Promise { + const currentMount = this._currentMount(); + if (currentMount) return currentMount; + if (!this._item.volume?.can_mount()) return null; + + const operation = new ShellMountOperation.ShellMountOperation(this._item.volume); + + try { + await this._mountVolume(this._item.volume, operation.mountOp); + return this._item.volume.get_mount(); + } finally { + operation.close(); + } + } + + private async _eject(): Promise { + if (this._busy || !this.canUnmountOrEject) return; + + this._setBusy(true); + + try { + const mount = this._currentMount(); + + if (mount?.can_eject()) { + await this._withMountOperation(mount, (operation) => this._ejectMount(mount, operation)); + } else if (mount?.can_unmount()) { + await this._withMountOperation(mount, (operation) => this._unmountMount(mount, operation)); + } else if (this._item.volume?.can_eject()) { + const volume = this._item.volume; + await this._withMountOperation(volume, (operation) => this._ejectVolume(volume, operation)); + } + } catch (error) { + this._reportFailure(_('Failed to eject “%s”').format(this._item.name), error); + } finally { + this._setBusy(false); + } + } + + private async _withMountOperation( + owner: Gio.Mount | Gio.Volume, + operation: (mountOperation: Gio.MountOperation) => Promise, + ): Promise { + const shellOperation = new ShellMountOperation.ShellMountOperation(owner); + + try { + await operation(shellOperation.mountOp); + } finally { + shellOperation.close(); + } + } + + private _currentMount(): Gio.Mount | null { + return this._item.volume?.get_mount() ?? this._item.mount; + } + + private _launchUri(uri: string, context: Gio.AppLaunchContext): Promise { + return new Promise((resolve, reject) => { + Gio.app_info_launch_default_for_uri_async( + uri, + context, + this._cancellable, + (_source, result) => { + try { + Gio.app_info_launch_default_for_uri_finish(result); + resolve(); + } catch (error) { + reject(error); + } + }, + ); + }); + } + + private _mountVolume(volume: Gio.Volume, operation: Gio.MountOperation): Promise { + return new Promise((resolve, reject) => { + volume.mount(Gio.MountMountFlags.NONE, operation, this._cancellable, (_source, result) => { + try { + volume.mount_finish(result); + resolve(); + } catch (error) { + reject(error); + } + }); + }); + } + + private _ejectMount(mount: Gio.Mount, operation: Gio.MountOperation): Promise { + return new Promise((resolve, reject) => { + mount.eject_with_operation( + Gio.MountUnmountFlags.NONE, + operation, + this._cancellable, + (_source, result) => { + try { + mount.eject_with_operation_finish(result); + resolve(); + } catch (error) { + reject(error); + } + }, + ); + }); + } + + private _unmountMount(mount: Gio.Mount, operation: Gio.MountOperation): Promise { + return new Promise((resolve, reject) => { + mount.unmount_with_operation( + Gio.MountUnmountFlags.NONE, + operation, + this._cancellable, + (_source, result) => { + try { + mount.unmount_with_operation_finish(result); + resolve(); + } catch (error) { + reject(error); + } + }, + ); + }); + } + + private _ejectVolume(volume: Gio.Volume, operation: Gio.MountOperation): Promise { + return new Promise((resolve, reject) => { + volume.eject_with_operation( + Gio.MountUnmountFlags.NONE, + operation, + this._cancellable, + (_source, result) => { + try { + volume.eject_with_operation_finish(result); + resolve(); + } catch (error) { + reject(error); + } + }, + ); + }); + } + + private _setBusy(busy: boolean): void { + this._busy = busy; + if (this._onBusyChanged) { + this._onBusyChanged(busy); + } + } + + private _reportFailure(title: string, error: unknown): void { + const handled = + error instanceof GLib.Error && + (error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED) || + error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)); + if (handled) return; + + const message = error instanceof Error ? error.message : String(error); + logger.warn(`${title}: ${message}`, { prefix: 'DockStorage' }); + Main.notifyError(title, message); + } +} diff --git a/src/dock/hotArea.ts b/src/dock/hotArea.ts index e8b1287..5908301 100644 --- a/src/dock/hotArea.ts +++ b/src/dock/hotArea.ts @@ -9,7 +9,9 @@ import Shell from '@girs/shell-18'; import GObject from '@girs/gobject-2.0'; import * as Layout from '@girs/gnome-shell/ui/layout'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; import { EdgeGestureGuard } from '~/dock/edgeGestureGuard.ts'; import type { DashBounds } from '~/shared/ui/dash.ts'; @@ -28,17 +30,20 @@ const POINTER_BUTTON_MASK = Signals: { triggered: {} }, }) export class DockHotArea extends St.Widget { - private _pressureBarrier: Layout.PressureBarrier | null = null; + declare private _pressureBarrier: Layout.PressureBarrier; private _horizontalBarrier: Meta.Barrier | null = null; private _monitor!: DashBounds; private _active = true; private _edgeArmed = true; private _grabSuppressed = false; - private _pointerDwellTimeoutId = 0; + declare private _lifecycle: LifecycleScope; + declare private _pointerDwellTimeout: ManagedSource; private _gestureGuard = new EdgeGestureGuard(); override _init(monitor: DashBounds) { super._init({ reactive: true, visible: true, name: 'aurora-dock-hot-area' }); + this._lifecycle = new LifecycleScope(); + this._pointerDwellTimeout = createManagedSource(this._lifecycle); this._monitor = monitor; this._pressureBarrier = new Layout.PressureBarrier( @@ -66,17 +71,15 @@ export class DockHotArea extends St.Widget { if (this._canTrigger()) { this._clearDebounceTimer(); - this._pointerDwellTimeoutId = GLib.timeout_add( - GLib.PRIORITY_DEFAULT, - HOT_AREA_DEBOUNCE_TIMEOUT, - () => { + this._pointerDwellTimeout.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, HOT_AREA_DEBOUNCE_TIMEOUT, () => { logger.debug(`pointer dwell trigger geometry=${this._formatGeometry()}`, { prefix: LOG_PREFIX, }); this.emit('triggered'); - this._pointerDwellTimeoutId = 0; + this._pointerDwellTimeout.complete(); return GLib.SOURCE_REMOVE; - }, + }), ); } return Clutter.EVENT_PROPAGATE; @@ -173,20 +176,17 @@ export class DockHotArea extends St.Widget { } override destroy(): void { + this._lifecycle.dispose(); global.display.disconnectObject(this); - this._destroyBarrier(); - this._clearDebounceTimer(); + this._pressureBarrier.disconnectObject(this); - this._pressureBarrier?.disconnectObject(this); - this._pressureBarrier?.destroy(); - this._pressureBarrier = null; + this._destroyBarrier(); + this._pressureBarrier.destroy(); super.destroy(); } private _rebuildBarrier(size: number): void { - if (!this._pressureBarrier) return; - this._destroyBarrier(); const width = Number.isFinite(size) ? size : 0; @@ -209,16 +209,14 @@ export class DockHotArea extends St.Widget { private _destroyBarrier(): void { if (!this._horizontalBarrier) return; - this._pressureBarrier?.removeBarrier(this._horizontalBarrier); + + this._pressureBarrier.removeBarrier(this._horizontalBarrier); this._horizontalBarrier.destroy(); this._horizontalBarrier = null; } private _clearDebounceTimer(): void { - if (this._pointerDwellTimeoutId) { - GLib.source_remove(this._pointerDwellTimeoutId); - this._pointerDwellTimeoutId = 0; - } + this._pointerDwellTimeout.clear(); } private _canTrigger(): boolean { @@ -252,12 +250,11 @@ export class DockHotArea extends St.Widget { } private _containsPoint(pointerX: number, pointerY: number): boolean { - const monitor = this._monitor; - const bottom = monitor.y + monitor.height; + const bottom = this._monitor.y + this._monitor.height; const top = bottom - Math.max(1, this.height || 1); return ( - pointerX >= monitor.x && - pointerX <= monitor.x + monitor.width && + pointerX >= this._monitor.x && + pointerX <= this._monitor.x + this._monitor.width && pointerY >= top && pointerY <= bottom ); @@ -268,7 +265,6 @@ export class DockHotArea extends St.Widget { } private _formatGeometry(): string { - const monitor = this._monitor; - return `${monitor.x},${monitor.y} ${monitor.width}x${monitor.height}`; + return `${this._monitor.x},${this._monitor.y} ${this._monitor.width}x${this._monitor.height}`; } } diff --git a/src/dock/intellihide.ts b/src/dock/intellihide.ts index 4754af9..378144a 100644 --- a/src/dock/intellihide.ts +++ b/src/dock/intellihide.ts @@ -6,7 +6,9 @@ import Meta from '@girs/meta-18'; import * as Main from '@girs/gnome-shell/ui/main'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; import { getBlockingOverlapState, isOnActiveWorkspace, @@ -64,7 +66,8 @@ export class DockIntellihide extends GObject.Object { declare private _monitorIndex: number; private _targetBox: DashBounds | null = null; private _status: OverlapStatus | null = null; - private _settleId = 0; + declare private _lifecycle: LifecycleScope; + declare private _settle: ManagedSource; private _pendingStatus: OverlapStatus = OverlapStatus.CLEAR; private _pendingReason = ''; private _pendingRectangles: Array<{ x: number; y: number; width: number; height: number }> = []; @@ -75,6 +78,8 @@ export class DockIntellihide extends GObject.Object { override _init(monitorIndex: number, excludePipFromSmartReveal = false) { super._init(); + this._lifecycle = new LifecycleScope(); + this._settle = createManagedSource(this._lifecycle); this._monitorIndex = monitorIndex; this._excludePipFromSmartReveal = excludePipFromSmartReveal; this._trackedWindowActors = new Set(); @@ -149,6 +154,7 @@ export class DockIntellihide extends GObject.Object { destroy(): void { this._cancelPendingStatus(); + this._lifecycle.dispose(); for (const id of this._queuedRefreshIds) { GLib.source_remove(id); } @@ -285,7 +291,7 @@ export class DockIntellihide extends GObject.Object { ): void { const newStatus = overlap ? OverlapStatus.BLOCKED : OverlapStatus.CLEAR; - if (this._settleId !== 0) { + if (this._settle.active) { // A commit for the same value is already pending: keep its timer running // (so a steady stream of identical rechecks still settles on schedule), // only refreshing the details logged on commit. @@ -295,8 +301,7 @@ export class DockIntellihide extends GObject.Object { return; } // The target flipped before settling — restart the quiet period. - GLib.source_remove(this._settleId); - this._settleId = 0; + this._settle.clear(); } // The value reverted to what is already committed; nothing to do. @@ -305,21 +310,21 @@ export class DockIntellihide extends GObject.Object { this._pendingStatus = newStatus; this._pendingReason = reason; this._pendingRectangles = rectangles; - this._settleId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, STATUS_SETTLE_DELAY, () => { - this._settleId = 0; - this._commitStatus( - this._pendingStatus === OverlapStatus.BLOCKED, - this._pendingReason, - this._pendingRectangles, - ); - return GLib.SOURCE_REMOVE; - }); + this._settle.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, STATUS_SETTLE_DELAY, () => { + this._settle.complete(); + this._commitStatus( + this._pendingStatus === OverlapStatus.BLOCKED, + this._pendingReason, + this._pendingRectangles, + ); + return GLib.SOURCE_REMOVE; + }), + ); } private _cancelPendingStatus(): void { - if (!this._settleId) return; - GLib.source_remove(this._settleId); - this._settleId = 0; + this._settle.clear(); } private _commitStatus( @@ -357,38 +362,36 @@ export class DockIntellihide extends GObject.Object { for (const actor of this._trackedWindowActors) { if (nextActors.has(actor)) continue; - this._safeDisconnect(actor); + + actor.disconnectObject(this); this._trackedWindowActors.delete(actor); } for (const actor of nextActors) { if (this._trackedWindowActors.has(actor)) continue; - try { - actor.connectObject( - 'notify::allocation', - () => this._checkOverlap('window-allocation'), - this, - ); - this._trackedWindowActors.add(actor); - } catch { - // Ignore actors disposed while the global window list is being updated. - } + + this._trackedWindowActors.add(actor); + actor.connectObject( + 'notify::allocation', + () => this._checkOverlap('window-allocation'), + 'destroy', + () => this._trackedWindowActors.delete(actor), + this, + ); } for (const win of this._trackedWindows) { if (nextWindows.has(win)) continue; - this._safeDisconnect(win); + + win.disconnectObject(this); this._trackedWindows.delete(win); } for (const win of nextWindows) { if (this._trackedWindows.has(win)) continue; - try { - this._connectTrackedWindow(win); - this._trackedWindows.add(win); - } catch { - // Ignore windows unmanaged while signals are being connected. - } + + this._trackedWindows.add(win); + this._connectTrackedWindow(win); } } @@ -397,29 +400,22 @@ export class DockIntellihide extends GObject.Object { for (const signal of TRACKED_WINDOW_SIGNALS) { signalArgs.push(signal, () => this._checkOverlap(signal)); } + signalArgs.push('unmanaged', () => this._trackedWindows.delete(win)); (win as any).connectObject(...signalArgs, this); } private _clearTrackedWindows(): void { for (const actor of this._trackedWindowActors) { - this._safeDisconnect(actor); + actor.disconnectObject(this); } this._trackedWindowActors.clear(); for (const win of this._trackedWindows) { - this._safeDisconnect(win); + win.disconnectObject(this); } this._trackedWindows.clear(); } - private _safeDisconnect(target: unknown): void { - try { - (target as { disconnectObject: (object: unknown) => void }).disconnectObject(this); - } catch { - // The object may already be disposed or unmanaged by Shell. - } - } - private _onKeyboardVisibilityChanged(): void { this._checkOverlap('keyboard-visibility'); } diff --git a/src/dock/intellihideState.ts b/src/dock/intellihideState.ts index 16d5dd7..a9141da 100644 --- a/src/dock/intellihideState.ts +++ b/src/dock/intellihideState.ts @@ -52,10 +52,10 @@ export function hasOverlap(rectangles: Rectangle[], target: Rectangle): boolean * visibly above it. * * Windows excluded from the smart-reveal exception (such as PiP) are skipped - * while selecting that foreground window. The next eligible window underneath - * remains authoritative, so a fullscreen background still keeps the dock - * hidden. If every candidate is excluded, the dock remains hidden: an excluded - * foreground window must never become the reason for revealing it. + * while selecting that foreground window. A focused excluded window blocks + * smart reveal unconditionally; otherwise the next eligible window underneath + * remains authoritative. If every candidate is excluded, the dock also remains + * hidden: an excluded foreground window must never reveal it. */ export function getBlockingOverlapState( candidates: OverlapCandidate[], @@ -65,6 +65,9 @@ export function getBlockingOverlapState( const smartRevealCandidates = candidates.filter( (candidate) => candidate.excludedFromSmartReveal !== true, ); + const focusedCandidatePreventsSmartReveal = candidates.some( + (candidate) => candidate.focused === true && candidate.excludedFromSmartReveal === true, + ); const focusedCandidates = smartRevealCandidates.filter((candidate) => candidate.focused === true); const hasExplicitTopmostCandidate = candidates.some((candidate) => candidate.topmost === true); const topmostCandidate = hasExplicitTopmostCandidate ? smartRevealCandidates.at(-1) : undefined; @@ -78,6 +81,7 @@ export function getBlockingOverlapState( : candidates; const rectangles = blockingCandidates.map((candidate) => candidate.rectangle); const hasExclusiveWindow = + focusedCandidatePreventsSmartReveal || blockingCandidates.some((candidate) => candidate.fullscreen === true) || (blockingCandidates.length === 0 && monitorFullscreen) || (candidates.length > 0 && smartRevealCandidates.length === 0); diff --git a/src/dock/motion/iconMotionController.ts b/src/dock/motion/iconMotionController.ts index 0b1e6e2..7ba57e5 100644 --- a/src/dock/motion/iconMotionController.ts +++ b/src/dock/motion/iconMotionController.ts @@ -310,12 +310,13 @@ export class IconMotionController { // Measure the room between the icon and the dash's clip. private _measureBudget(): IconBudget | null { - const bin = this._bin; - if (!bin) return null; - const parent = bin.get_parent(); + if (!this._bin) return null; + + const parent = this._bin.get_parent(); if (!parent) return null; + let clipActor: Clutter.Actor | null = null; - for (let node: Clutter.Actor | null = bin; node; node = node.get_parent()) { + for (let node: Clutter.Actor | null = this._bin; node; node = node.get_parent()) { if (node.has_clip) { clipActor = node; break; @@ -323,7 +324,7 @@ export class IconMotionController { } if (!clipActor) return null; - const box = bin.get_allocation_box(); + const box = this._bin.get_allocation_box(); const clip = clipActor.get_clip(); if (!clip) return null; const [, clipY] = clipActor.get_transformed_position(); @@ -353,11 +354,11 @@ export class IconMotionController { } private _syncTextureResolution(): void { - const bin = this._bin; - const normalSize = this._baseIcon?.iconSize ?? 0; - if (!bin || !(normalSize > 0)) return; + if (!this._bin || !this._baseIcon || !(this._baseIcon.iconSize > 0)) return; + + const normalSize = this._baseIcon.iconSize; - const child = bin.child; + const child = this._bin.child; if (!(child instanceof St.Icon)) return; if (this._textureState?.actor !== child) { this._textureState = { @@ -384,11 +385,16 @@ export class IconMotionController { } private _restoreTextureResolution(): void { - const normalSize = this._baseIcon?.iconSize ?? 0; const state = this._textureState; if (!state) return; - if (normalSize > 0 && state.actor.icon_size !== normalSize) state.actor.icon_size = normalSize; + if ( + this._baseIcon && + this._baseIcon.iconSize > 0 && + state.actor.icon_size !== this._baseIcon.iconSize + ) { + state.actor.icon_size = this._baseIcon.iconSize; + } const { actor, constraints } = state; actor.min_width = constraints.minWidth; diff --git a/src/dock/trashIcon.ts b/src/dock/trashIcon.ts index fe9ed6b..c1a76ab 100644 --- a/src/dock/trashIcon.ts +++ b/src/dock/trashIcon.ts @@ -99,9 +99,10 @@ export const TrashIcon = GObject.registerClass( } override destroy(): void { - this.toggleButton.disconnectObject(this); this._refreshCancellable?.cancel(); this._refreshCancellable = null; + + this.toggleButton.disconnectObject(this); this._monitor?.disconnectObject(this); this._monitor?.cancel(); this._monitor = null; @@ -114,7 +115,9 @@ export const TrashIcon = GObject.registerClass( } get menuIsOpen(): boolean { - return this._menu?.isOpen ?? false; + if (!this._menu) return false; + + return this._menu.isOpen; } private _createIcon(size: number): St.Icon { diff --git a/src/extension.dev.ts b/src/extension.dev.ts index 4c410ea..b6a4ab1 100644 --- a/src/extension.dev.ts +++ b/src/extension.dev.ts @@ -13,28 +13,32 @@ export default class AuroraShellDevelopmentExtension extends AuroraShellExtensio override enable(): void { super.enable(); - if (GLib.getenv('AURORA_DEVTOOLS') !== '1' || !this._context || !this._manager) return; + const context = this._context; + const manager = this._manager; + if (GLib.getenv('AURORA_DEVTOOLS') !== '1' || !context || !manager) return; try { - this._devTool = new DevTool(this._context, { - getModule: (key) => this._manager?.getModule(key) ?? null, + this._devTool = new DevTool(context, { + getModule: (key) => manager.getModule(key), openPreferences: () => this.openPreferences(), }); this._devTool.enable(); } catch (error) { - this._devTool?.disable(); + const devTool = this._devTool; this._devTool = null; + + if (devTool) devTool.disable(); + logger.error(`Failed to enable DevTool: ${String(error)}`, { prefix: LOG_PREFIX }); } } override disable(): void { - try { - this._devTool?.disable(); - } catch (error) { - logger.error(`Failed to disable DevTool: ${String(error)}`, { prefix: LOG_PREFIX }); - } + const devTool = this._devTool; this._devTool = null; + + if (devTool) devTool.disable(); + super.disable(); } } diff --git a/src/moduleManager.ts b/src/moduleManager.ts index 1cdb785..9c4b2b2 100644 --- a/src/moduleManager.ts +++ b/src/moduleManager.ts @@ -12,7 +12,6 @@ export type ModuleManagerLogger = { export class ModuleManager { private readonly _modules = new Map(); private _lifecycle: LifecycleScope | null = null; - private _started = false; constructor( private readonly _definitions: readonly ModuleDefinition[], @@ -29,8 +28,8 @@ export class ModuleManager { } start(): void { - if (this._started) return; - this._started = true; + if (this._lifecycle) return; + const lifecycle = new LifecycleScope(); this._lifecycle = lifecycle; @@ -44,7 +43,8 @@ export class ModuleManager { } reconcile(): void { - if (!this._started) return; + if (!this._lifecycle) return; + const snapshot = this._context.device.current; const roles = activeDisplayRoles(snapshot); @@ -61,10 +61,11 @@ export class ModuleManager { } stop(): void { - if (!this._started) return; - this._started = false; - this._lifecycle?.dispose(); + const lifecycle = this._lifecycle; + if (!lifecycle) return; + this._lifecycle = null; + lifecycle.dispose(); for (const [key, module] of [...this._modules].reverse()) this._disable(key, module); } diff --git a/src/panel/auroraMenu.ts b/src/panel/auroraMenu.ts index ec46565..ab52994 100644 --- a/src/panel/auroraMenu.ts +++ b/src/panel/auroraMenu.ts @@ -16,11 +16,11 @@ import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; import { createIcon, loadIcon } from '~/shared/icons.ts'; import { - decodeXml, parseCustomCommand, truncateMiddle, type CustomMenuCommand, } from '~/panel/auroraMenuState.ts'; +import { readRecentMenuItems } from '~/panel/auroraRecentItems.ts'; const LOG_PREFIX = 'AuroraMenu'; const STATUS_AREA_ID = 'aurora-menu'; @@ -53,11 +53,6 @@ const MENU_ICONS = { type MenuIconKey = keyof typeof MENU_ICONS; -// @ts-ignore — _promisify is a GJS extension not reflected in .d.ts -Gio._promisify(Gio.File.prototype, 'load_bytes_async'); -// @ts-ignore -Gio._promisify(Gio.File.prototype, 'query_info_async'); - type MenuCommand = { title: string; iconName: string; @@ -65,13 +60,6 @@ type MenuCommand = { activate?: () => void; }; -type RecentItem = { - title: string; - uri: string; - modified: number; - iconName: string; -}; - type RecentSubmenuItem = PopupMenu.PopupSubMenuMenuItem & { _triangleBin?: St.Widget; }; @@ -80,6 +68,7 @@ export class AuroraMenu extends Module { private _button: PanelMenu.Button | null = null; private _panelIcon: St.Icon | null = null; private _lifecycle: LifecycleScope | null = null; + private _rebuildCancellable: Gio.Cancellable | null = null; constructor(context: ExtensionContext) { super(context); @@ -89,6 +78,28 @@ export class AuroraMenu extends Module { this.disable(); this._lifecycle = new LifecycleScope(); + this._createButton(); + this._connectSettings(); + this._registerButton(); + this._syncActivitiesButton(); + this._rebuildMenu(); + } + + override disable(): void { + this._rebuildCancellable?.cancel(); + this._rebuildCancellable = null; + + this._lifecycle?.dispose(); + this._lifecycle = null; + + this._showActivitiesButton(); + this._panelIcon?.destroy(); + this._panelIcon = null; + this._button?.destroy(); + this._button = null; + } + + private _createButton(): void { this._button = new PanelMenu.Button(0.0, 'Aurora Menu'); this._button.add_style_class_name('aurora-menu-button'); this._panelIcon = createIcon('aurora-shell-menu-symbolic', { @@ -98,17 +109,25 @@ export class AuroraMenu extends Module { this._button.add_child(this._panelIcon); const menu = this._getMenu(); - menu?.actor?.add_style_class_name('aurora-menu'); - menu?.setSourceAlignment(0.0); - if (menu) this._lockMenuWidth(menu); - - if (menu) { - const menuOpenStateId = menu.connect('open-state-changed', (_menu, open) => { - if (open) this._rebuildMenu(); - return undefined; - }); - this._lifecycle.onDispose(() => menu.disconnect(menuOpenStateId)); - } + if (!menu) return; + + menu.actor.add_style_class_name('aurora-menu'); + menu.setSourceAlignment(0.0); + this._lockMenuWidth(menu); + const menuOpenStateId = menu.connect('open-state-changed', (_menu, open) => { + if (open) { + this._rebuildMenu(); + } + + return undefined; + }); + if (!this._lifecycle) return; + + this._lifecycle.onDispose(() => menu.disconnect(menuOpenStateId)); + } + + private _connectSettings(): void { + if (!this._lifecycle) return; const settings = this.context.settings; const rebuildMenu = () => this._rebuildMenu(); @@ -128,9 +147,11 @@ export class AuroraMenu extends Module { this._lifecycle.connect(settings, `changed::${HIDE_ACTIVITIES_KEY}`, () => this._syncActivitiesButton(), ); + } + + private _registerButton(): void { + if (!this._button) return; - this._syncActivitiesButton(); - this._rebuildMenu(); Main.panel.addToStatusArea( STATUS_AREA_ID, this._button as unknown as PanelMenuButton, @@ -139,24 +160,29 @@ export class AuroraMenu extends Module { ); } - override disable(): void { - this._lifecycle?.dispose(); - this._lifecycle = null; - - this._showActivitiesButton(); - this._panelIcon?.destroy(); - this._panelIcon = null; - this._button?.destroy(); - this._button = null; - } - private _rebuildMenu(): void { - this._rebuildMenuAsync().catch((e) => - logger.warn(`Failed to rebuild Aurora Menu: ${e}`, { prefix: LOG_PREFIX }), - ); + this._rebuildCancellable?.cancel(); + + const cancellable = new Gio.Cancellable(); + this._rebuildCancellable = cancellable; + this._rebuildMenuAsync(cancellable) + .catch((error) => { + if ( + !( + error instanceof GLib.Error && error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED) + ) + ) { + logger.warn(`Failed to rebuild Aurora Menu: ${error}`, { prefix: LOG_PREFIX }); + } + }) + .finally(() => { + if (this._rebuildCancellable === cancellable) { + this._rebuildCancellable = null; + } + }); } - private async _rebuildMenuAsync(): Promise { + private async _rebuildMenuAsync(cancellable: Gio.Cancellable): Promise { const menu = this._getMenu(); if (!menu) return; @@ -169,46 +195,62 @@ export class AuroraMenu extends Module { iconName: 'help-about-symbolic', }); - const filesAdded = await this._addSection(menu, hasItems, [ - () => - this._addCommandIfVisible(menu, SHOW_HOME_KEY, { - title: _('Home Folder'), - argv: ['xdg-open', GLib.get_home_dir()], - iconName: 'user-home-symbolic', - }), - () => - this._addCommandIfVisible(menu, SHOW_DOWNLOADS_KEY, { - title: _('Downloads'), - argv: ['xdg-open', this._getDownloadsDirectory() ?? GLib.get_home_dir()], - iconName: 'folder-download-symbolic', - }), - () => this._addRecentItems(menu), - ]); + const filesAdded = await this._addSection( + menu, + hasItems, + [ + () => + this._addCommandIfVisible(menu, SHOW_HOME_KEY, { + title: _('Home Folder'), + argv: ['xdg-open', GLib.get_home_dir()], + iconName: 'user-home-symbolic', + }), + () => + this._addCommandIfVisible(menu, SHOW_DOWNLOADS_KEY, { + title: _('Downloads'), + argv: ['xdg-open', this._getDownloadsDirectory() ?? GLib.get_home_dir()], + iconName: 'folder-download-symbolic', + }), + () => this._addRecentItems(menu, cancellable), + ], + () => this._isCurrentRebuild(cancellable, menu), + ); + if (!this._isCurrentRebuild(cancellable, menu)) return; + hasItems ||= filesAdded; - const systemAdded = await this._addSection(menu, hasItems, [ - () => - this._addCommandIfVisible(menu, SHOW_SETTINGS_KEY, { - title: _('System Settings'), - argv: ['gnome-control-center'], - iconName: 'emblem-system-symbolic', - }), - () => - this._addCommandIfVisible(menu, SHOW_SOFTWARE_KEY, { - title: _('Software'), - argv: this._parseCommand(APP_STORE_COMMAND_KEY, ['gnome-software']), - iconName: 'system-software-install-symbolic', - }), - () => - this._addCommandIfVisible(menu, SHOW_EXTENSIONS_KEY, { - title: _('Extensions'), - iconName: 'application-x-addon-symbolic', - activate: () => this._openExtensionsManager(), - }), - ]); + const systemAdded = await this._addSection( + menu, + hasItems, + [ + () => + this._addCommandIfVisible(menu, SHOW_SETTINGS_KEY, { + title: _('System Settings'), + argv: ['gnome-control-center'], + iconName: 'emblem-system-symbolic', + }), + () => + this._addCommandIfVisible(menu, SHOW_SOFTWARE_KEY, { + title: _('Software'), + argv: this._parseCommand(APP_STORE_COMMAND_KEY, ['gnome-software']), + iconName: 'system-software-install-symbolic', + }), + () => + this._addCommandIfVisible(menu, SHOW_EXTENSIONS_KEY, { + title: _('Extensions'), + iconName: 'application-x-addon-symbolic', + activate: () => this._openExtensionsManager(), + }), + ], + () => this._isCurrentRebuild(cancellable, menu), + ); + if (!this._isCurrentRebuild(cancellable, menu)) return; + hasItems ||= systemAdded; - await this._addSection(menu, hasItems, [() => this._addCustomItems(menu)]); + await this._addSection(menu, hasItems, [() => this._addCustomItems(menu)], () => + this._isCurrentRebuild(cancellable, menu), + ); } private _addCommand(menu: PopupMenu.PopupMenu, command: MenuCommand): void { @@ -236,17 +278,22 @@ export class AuroraMenu extends Module { menu: PopupMenu.PopupMenu, hasPreviousItems: boolean, builders: Array<() => boolean | Promise>, + isCurrent: () => boolean, ): Promise { let separator: PopupMenu.PopupSeparatorMenuItem | null = null; let added = false; for (const build of builders) { + if (!isCurrent()) return false; + if (hasPreviousItems && !separator) { separator = new PopupMenu.PopupSeparatorMenuItem(); menu.addMenuItem(separator); } - if (await build()) added = true; + const itemAdded = await build(); + if (!isCurrent()) return false; + if (itemAdded) added = true; } if (!added && separator) separator.destroy(); @@ -272,9 +319,15 @@ export class AuroraMenu extends Module { return added; } - private async _addRecentItems(menu: PopupMenu.PopupMenu): Promise { + private async _addRecentItems( + menu: PopupMenu.PopupMenu, + cancellable: Gio.Cancellable, + ): Promise { if (!this.context.settings.getBoolean(SHOW_RECENT_KEY)) return false; + const items = await readRecentMenuItems(RECENT_LIMIT, cancellable); + if (!this._isCurrentRebuild(cancellable, menu)) return false; + const submenu = new PopupMenu.PopupSubMenuMenuItem( _('Recent Items'), true, @@ -283,8 +336,6 @@ export class AuroraMenu extends Module { this._replaceSubmenuArrow(submenu); this._lockSubmenuWidth(submenu); - const items = await this._readRecentItems(); - if (items.length === 0) { const empty = new PopupMenu.PopupMenuItem(_('No recent items')); empty.setSensitive(false); @@ -305,64 +356,6 @@ export class AuroraMenu extends Module { return true; } - private async _readRecentItems(): Promise { - const file = Gio.File.new_for_path( - GLib.build_filenamev([GLib.get_user_data_dir(), 'recently-used.xbel']), - ); - - try { - await file.query_info_async( - 'standard::type', - Gio.FileQueryInfoFlags.NONE, - GLib.PRIORITY_DEFAULT, - null, - ); - - const [bytes] = await file.load_bytes_async(null); - const data = bytes.get_data(); - if (!data) return []; - - const text = new TextDecoder().decode(data); - const items: RecentItem[] = []; - const seen = new Set(); - const bookmarkRegex = - /]*href="([^"]+)"[^>]*modified="([^"]+)"[^>]*>([\s\S]*?)<\/bookmark>/g; - - let match: RegExpExecArray | null; - while ((match = bookmarkRegex.exec(text)) !== null) { - const uri = decodeXml(match[1] ?? ''); - if (!uri || seen.has(uri)) continue; - seen.add(uri); - - const body = match[3] ?? ''; - const title = this._extractRecentTitle(body, uri); - const modified = this._parseIsoTime(match[2] ?? ''); - items.push({ - title, - uri, - modified, - iconName: uri.startsWith('file://') ? 'text-x-generic-symbolic' : 'emblem-web-symbolic', - }); - } - - return items.sort((a, b) => b.modified - a.modified).slice(0, RECENT_LIMIT); - } catch (e) { - if (this._isGioError(e, Gio.IOErrorEnum.NOT_FOUND)) return []; - - logger.warn(`Failed to read recent items: ${e}`, { prefix: LOG_PREFIX }); - return []; - } - } - - private _extractRecentTitle(bookmarkBody: string, uri: string): string { - const title = /([\s\S]*?)<\/title>/.exec(bookmarkBody)?.[1]?.trim(); - if (title) return decodeXml(title); - - const decodedUri = GLib.uri_unescape_string(uri, null) ?? uri; - if (decodedUri.startsWith('file://')) return GLib.path_get_basename(decodedUri.slice(7)); - return decodedUri; - } - private _decorateItem(item: PopupMenu.PopupMenuItem, iconName: string): void { const icon = new St.Icon({ icon_name: iconName, @@ -496,26 +489,32 @@ export class AuroraMenu extends Module { } private _showActivitiesButton(): void { - this._getActivitiesActor()?.show(); + const actor = this._getActivitiesActor(); + if (actor) actor.show(); } private _getActivitiesActor(): St.Widget | null { const statusArea = Main.panel.statusArea as Record<string, any>; - const entry = statusArea['activities'] ?? statusArea['activitiesButton']; - return (entry?.container ?? entry ?? null) as St.Widget | null; + const entry = statusArea['activities']; + if (!entry) return null; + + return entry.container as St.Widget; } private _getMenu(): PopupMenu.PopupMenu | null { - return (this._button?.menu as PopupMenu.PopupMenu | null | undefined) ?? null; - } + if (!this._button) { + return null; + } - private _parseIsoTime(value: string): number { - const dateTime = GLib.DateTime.new_from_iso8601(value, null); - return dateTime?.to_unix() ?? 0; + return this._button.menu as PopupMenu.PopupMenu; } - private _isGioError(error: unknown, code: number): boolean { - return error instanceof GLib.Error && error.matches(Gio.io_error_quark(), code); + private _isCurrentRebuild(cancellable: Gio.Cancellable, menu: PopupMenu.PopupMenu): boolean { + return ( + !cancellable.is_cancelled() && + this._rebuildCancellable === cancellable && + this._getMenu() === menu + ); } private _getDownloadsDirectory(): string | null { diff --git a/src/panel/auroraMenuState.ts b/src/panel/auroraMenuState.ts index 4c55a0a..b3e429a 100644 --- a/src/panel/auroraMenuState.ts +++ b/src/panel/auroraMenuState.ts @@ -36,3 +36,38 @@ export function truncateMiddle(value: string, limit: number): string { const edgeLength = Math.max(1, Math.floor((limit - 1) / 2)); return `${value.slice(0, edgeLength)}…${value.slice(value.length - edgeLength)}`; } + +export type RecentMenuItem = { title: string; uri: string; modified: number; iconName: string }; + +export function parseRecentXbel(text: string, limit: number): RecentMenuItem[] { + const items: RecentMenuItem[] = []; + const seen = new Set<string>(); + const bookmarks = + /<bookmark\b[^>]*href="([^"]+)"[^>]*modified="([^"]+)"[^>]*>([\s\S]*?)<\/bookmark>/g; + let match: RegExpExecArray | null; + while ((match = bookmarks.exec(text)) !== null) { + const uri = decodeXml(match[1] ?? ''); + if (!uri || seen.has(uri)) continue; + seen.add(uri); + const rawTitle = /<title>([\s\S]*?)<\/title>/.exec(match[3] ?? '')?.[1]?.trim(); + let fallback = uri; + try { + fallback = decodeURIComponent(uri); + } catch { + // Keep malformed URIs readable instead of dropping the whole recent list. + } + const title = rawTitle + ? decodeXml(rawTitle) + : fallback.startsWith('file://') + ? fallback.slice(7).split('/').pop() || fallback + : fallback; + const modified = Math.floor(Date.parse(match[2] ?? '') / 1000); + items.push({ + title, + uri, + modified: Number.isFinite(modified) ? modified : 0, + iconName: uri.startsWith('file://') ? 'text-x-generic-symbolic' : 'emblem-web-symbolic', + }); + } + return items.sort((a, b) => b.modified - a.modified).slice(0, Math.max(0, limit)); +} diff --git a/src/panel/auroraRecentItems.ts b/src/panel/auroraRecentItems.ts new file mode 100644 index 0000000..cb6066d --- /dev/null +++ b/src/panel/auroraRecentItems.ts @@ -0,0 +1,43 @@ +import Gio from '@girs/gio-2.0'; +import GLib from '@girs/glib-2.0'; + +import { logger } from '~/core/logger.ts'; +import { parseRecentXbel, type RecentMenuItem } from '~/panel/auroraMenuState.ts'; + +const LOG_PREFIX = 'AuroraMenu'; + +// @ts-ignore — _promisify is a GJS extension not reflected in .d.ts +Gio._promisify(Gio.File.prototype, 'load_bytes_async'); +// @ts-ignore +Gio._promisify(Gio.File.prototype, 'query_info_async'); + +export async function readRecentMenuItems( + limit: number, + cancellable: Gio.Cancellable | null = null, +): Promise<RecentMenuItem[]> { + const file = Gio.File.new_for_path( + GLib.build_filenamev([GLib.get_user_data_dir(), 'recently-used.xbel']), + ); + try { + await file.query_info_async( + 'standard::type', + Gio.FileQueryInfoFlags.NONE, + GLib.PRIORITY_DEFAULT, + cancellable, + ); + const [bytes] = await file.load_bytes_async(cancellable); + const data = bytes.get_data(); + return data ? parseRecentXbel(new TextDecoder().decode(data), limit) : []; + } catch (error) { + if (error instanceof GLib.Error && error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED)) { + throw error; + } + + if (error instanceof GLib.Error && error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) { + return []; + } + + logger.warn(`Failed to read recent items: ${error}`, { prefix: LOG_PREFIX }); + return []; + } +} diff --git a/src/panel/bluetoothMenu/bluetoothMenu.ts b/src/panel/bluetoothMenu/bluetoothMenu.ts index a7527d3..bce773e 100644 --- a/src/panel/bluetoothMenu/bluetoothMenu.ts +++ b/src/panel/bluetoothMenu/bluetoothMenu.ts @@ -42,7 +42,7 @@ export class BluetoothMenu extends Module { this._lifecycle = null; for (const [item, { patcher, destroyId }] of this._patchedItems) { - item?.disconnect(destroyId); + item.disconnect(destroyId); patcher.disable(); } this._patchedItems.clear(); @@ -59,11 +59,10 @@ export class BluetoothMenu extends Module { } private _attach(toggle: any): void { - const lifecycle = this._lifecycle; - if (!lifecycle) return; + if (!this._lifecycle) return; toggle.menu.actor.add_style_class_name('aurora-bt-menu'); - lifecycle.onDispose(() => toggle.menu.actor.remove_style_class_name('aurora-bt-menu')); + this._lifecycle.onDispose(() => toggle.menu.actor.remove_style_class_name('aurora-bt-menu')); for (const item of toggle._deviceItems.values()) { this._patchItem(item); @@ -77,7 +76,7 @@ export class BluetoothMenu extends Module { } }, ); - lifecycle.onDispose(() => toggle._deviceSection.actor.disconnect(actorAddedId)); + this._lifecycle.onDispose(() => toggle._deviceSection.actor.disconnect(actorAddedId)); } private _patchItem(item: any): void { diff --git a/src/panel/bluetoothMenu/deviceItem.ts b/src/panel/bluetoothMenu/deviceItem.ts index c9d2bf2..a825563 100644 --- a/src/panel/bluetoothMenu/deviceItem.ts +++ b/src/panel/bluetoothMenu/deviceItem.ts @@ -5,7 +5,9 @@ import GLib from '@girs/glib-2.0'; import St from '@girs/st-18'; import Clutter from '@girs/clutter-18'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; import { createIcon, loadIcon } from '~/shared/icons.ts'; const LOG_PREFIX = 'BluetoothMenu'; @@ -20,11 +22,9 @@ export class BluetoothDeviceItemPatcher { private _item: any; private _stateIcon: St.Icon | null = null; private _batteryLabel: St.Label | null = null; - private _spinnerNotifyId = 0; - private _batteryNotifyId = 0; - private _connectedNotifyId = 0; - private _pendingUpdateId = 0; - private _animationTimeoutId = 0; + private _lifecycle: LifecycleScope | null = null; + private _pendingUpdate: ManagedSource | null = null; + private _animationTimeout: ManagedSource | null = null; private _animationFrame = 1; private _animatingState: 'connecting' | 'disconnecting' | null = null; @@ -33,6 +33,9 @@ export class BluetoothDeviceItemPatcher { } enable(): void { + this._lifecycle = new LifecycleScope(); + this._pendingUpdate = createManagedSource(this._lifecycle); + this._animationTimeout = createManagedSource(this._lifecycle); const item = this._item; // Override activate so clicking a device doesn't close the menu. @@ -80,7 +83,7 @@ export class BluetoothDeviceItemPatcher { this._updateStateIcon(); this._updateBatteryLabel(); - this._spinnerNotifyId = item._spinner.connect('notify::visible', () => { + this._lifecycle.connect(item._spinner, 'notify::visible', () => { if (item._spinner.visible) { // Spinner starting — update immediately to begin animation. this._updateStateIcon(); @@ -97,29 +100,30 @@ export class BluetoothDeviceItemPatcher { this._updateStateIcon(); } - this._batteryNotifyId = item._device.connect('notify::battery-percentage', () => { + this._lifecycle.connect(item._device, 'notify::battery-percentage', () => { this._updateBatteryLabel(); }); - this._connectedNotifyId = item._device.connect('notify::connected', () => { + this._lifecycle.connect(item._device, 'notify::connected', () => { this._updateBatteryLabel(); this._updateStateIcon(); }); } private _scheduleUpdate(): void { - if (this._pendingUpdateId !== 0) { - GLib.source_remove(this._pendingUpdateId); - this._pendingUpdateId = 0; - } - this._pendingUpdateId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - this._pendingUpdateId = 0; - if (!this._stateIcon || !this._batteryLabel) return GLib.SOURCE_REMOVE; + const pendingUpdate = this._pendingUpdate; + if (!pendingUpdate) return; - this._updateStateIcon(); - this._updateBatteryLabel(); - return GLib.SOURCE_REMOVE; - }); + pendingUpdate.replace(() => + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + pendingUpdate.complete(); + if (!this._stateIcon || !this._batteryLabel) return GLib.SOURCE_REMOVE; + + this._updateStateIcon(); + this._updateBatteryLabel(); + return GLib.SOURCE_REMOVE; + }), + ); } private _loadIcon(name: string): Gio.Icon { @@ -135,26 +139,30 @@ export class BluetoothDeviceItemPatcher { const connected: boolean = this._item._device.connected; const isWorking: boolean = this._item._spinner.visible; + const animationTimeout = this._animationTimeout; + if (!animationTimeout) return; if (isWorking) { - if (this._animationTimeoutId === 0) { + if (!animationTimeout.active) { this._animationFrame = 1; // Latch the state when animation starts: if not connected, we are connecting. this._animatingState = connected ? 'disconnecting' : 'connecting'; - this._animationTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, () => { - if (!this._stateIcon) { - this._animationTimeoutId = 0; - return GLib.SOURCE_REMOVE; - } - - this._animationFrame = (this._animationFrame % 4) + 1; - this._stateIcon.gicon = this._loadIcon( - `bbm-bluetooth-${this._animatingState}-${this._animationFrame}-symbolic`, - ); - this._setStateIconStatus('animating'); - return GLib.SOURCE_CONTINUE; - }); + animationTimeout.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 250, () => { + if (!this._stateIcon) { + animationTimeout.complete(); + return GLib.SOURCE_REMOVE; + } + + this._animationFrame = (this._animationFrame % 4) + 1; + this._stateIcon.gicon = this._loadIcon( + `bbm-bluetooth-${this._animatingState}-${this._animationFrame}-symbolic`, + ); + this._setStateIconStatus('animating'); + return GLib.SOURCE_CONTINUE; + }), + ); } const state = this._animatingState || (connected ? 'disconnecting' : 'connecting'); this._stateIcon.gicon = this._loadIcon( @@ -163,10 +171,7 @@ export class BluetoothDeviceItemPatcher { this._setStateIconStatus('animating'); } else { this._animatingState = null; - if (this._animationTimeoutId !== 0) { - GLib.source_remove(this._animationTimeoutId); - this._animationTimeoutId = 0; - } + animationTimeout.clear(); if (connected) { this._stateIcon.gicon = this._loadIcon('bbm-bluetooth-connected-symbolic'); this._setStateIconStatus('connected'); @@ -202,28 +207,10 @@ export class BluetoothDeviceItemPatcher { disable(options: DisableOptions = {}): void { const restoreOriginalChildren = options.restoreOriginalChildren ?? true; - if (this._spinnerNotifyId) { - this._item._spinner?.disconnect(this._spinnerNotifyId); - this._spinnerNotifyId = 0; - } - if (this._batteryNotifyId) { - this._item._device?.disconnect(this._batteryNotifyId); - this._batteryNotifyId = 0; - } - if (this._connectedNotifyId) { - this._item._device?.disconnect(this._connectedNotifyId); - this._connectedNotifyId = 0; - } - - if (this._pendingUpdateId !== 0) { - GLib.source_remove(this._pendingUpdateId); - this._pendingUpdateId = 0; - } - - if (this._animationTimeoutId !== 0) { - GLib.source_remove(this._animationTimeoutId); - this._animationTimeoutId = 0; - } + this._lifecycle?.dispose(); + this._lifecycle = null; + this._pendingUpdate = null; + this._animationTimeout = null; if (restoreOriginalChildren) this._stateIcon?.destroy(); this._stateIcon = null; diff --git a/src/panel/clock/meetingClock/calendarServerBackend.ts b/src/panel/clock/meetingClock/calendarServerBackend.ts index c0128bf..d714615 100644 --- a/src/panel/clock/meetingClock/calendarServerBackend.ts +++ b/src/panel/clock/meetingClock/calendarServerBackend.ts @@ -4,6 +4,7 @@ import Gio from '@girs/gio-2.0'; import GLib from '@girs/glib-2.0'; import { logger } from '~/core/logger.ts'; +import { LifecycleScope } from '~/core/lifecycleScope.ts'; import { normalizeCalendarServerEvent, type MeetingEvent } from './meetingClockLogic.ts'; @@ -16,7 +17,7 @@ type EventsChangedCallback = (events: MeetingEvent[]) => void; export class CalendarServerBackend { private _proxy: Gio.DBusProxy | null = null; - private _signalId = 0; + private _lifecycle: LifecycleScope | null = null; private _eventsById = new Map<string, MeetingEvent>(); private _onEventsChanged: EventsChangedCallback; @@ -42,7 +43,8 @@ export class CalendarServerBackend { return; } - this._signalId = this._proxy.connect('g-signal', (_proxy, _senderName, signalName, params) => { + this._lifecycle = new LifecycleScope(); + this._lifecycle.connect(this._proxy, 'g-signal', (_proxy, _senderName, signalName, params) => { if (signalName === 'EventsAddedOrUpdated') { if (!this._proxy) return; const [rawEvents = []] = params.deepUnpack() as [unknown[]?]; @@ -56,11 +58,8 @@ export class CalendarServerBackend { } stop(): void { - if (this._signalId && this._proxy) { - this._proxy.disconnect(this._signalId); - this._signalId = 0; - } - + this._lifecycle?.dispose(); + this._lifecycle = null; this._proxy = null; this._eventsById.clear(); } diff --git a/src/panel/clock/meetingClock/meetingAlertController.ts b/src/panel/clock/meetingClock/meetingAlertController.ts new file mode 100644 index 0000000..000863d --- /dev/null +++ b/src/panel/clock/meetingClock/meetingAlertController.ts @@ -0,0 +1,265 @@ +import '@girs/gjs'; +import { gettext as _ } from '~/shared/i18n.ts'; + +import Gio from '@girs/gio-2.0'; +import GLib from '@girs/glib-2.0'; +import * as Main from '@girs/gnome-shell/ui/main'; +import * as MessageTray from '@girs/gnome-shell/ui/messageTray'; + +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { logger } from '~/core/logger.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; + +import { + formatEventTime, + getDueAlertEvents, + getNextAlertEpoch, + type MeetingClockOptions, + type MeetingEvent, +} from './meetingClockLogic.ts'; + +const LOG_PREFIX = 'MeetingClock'; + +type MeetingAlertPreferences = MeetingClockOptions & { + snoozeMinutes: number; +}; + +type MeetingAlertControllerOptions = { + getPreferences: () => MeetingAlertPreferences; + onStateChanged: () => void; + now: () => number; +}; + +export class MeetingAlertController { + private _lifecycle = new LifecycleScope(); + private _timer: ManagedSource = createManagedSource(this._lifecycle); + private _notificationSource: MessageTray.Source | null = null; + private _notificationSourceDestroyId = 0; + private _activeNotification: MessageTray.Notification | null = null; + private _activeNotificationDestroyId = 0; + private _activeEventId: string | null = null; + private _events: readonly MeetingEvent[] = []; + private _alertedEventIds = new Set<string>(); + private _ignoredEventIds = new Set<string>(); + private _snoozedUntilByEventId = new Map<string, number>(); + + constructor(private _options: MeetingAlertControllerOptions) {} + + get activeEventId(): string | null { + return this._activeEventId; + } + + setEvents(events: readonly MeetingEvent[]): void { + this._events = events; + this.schedule(); + } + + clearEventState(eventIds: ReadonlySet<string | undefined>): void { + for (const eventId of eventIds) { + if (!eventId) continue; + + this._alertedEventIds.delete(eventId); + this._ignoredEventIds.delete(eventId); + this._snoozedUntilByEventId.delete(eventId); + + if (this._activeEventId === eventId) { + this._activeEventId = null; + } + } + } + + show(eventId: string | null = null): boolean { + const preferences = this._options.getPreferences(); + const event = + (eventId ? this._events.find((candidate) => candidate.id === eventId) : null) ?? + this._events.find( + (candidate) => Boolean(candidate.meetingUrl) || preferences.alertEventsWithoutLink, + ); + + if (!event) return false; + if (!event.meetingUrl && !preferences.alertEventsWithoutLink) return false; + + this._showNotification(event); + return this._activeEventId === event.id; + } + + schedule(): void { + this._timer.clear(); + + if (this._activeEventId) return; + + const now = this._options.now(); + const preferences = this._options.getPreferences(); + const alertOptions = this._alertOptions(preferences); + const dueEvents = getDueAlertEvents(this._events, now, alertOptions); + + if (dueEvents.length > 0) { + this._showNotification(dueEvents[0]!); + return; + } + + const nextAlertAt = getNextAlertEpoch(this._events, now, alertOptions); + if (!nextAlertAt) return; + + this._timer.replace(() => + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, Math.max(1, nextAlertAt - now), () => { + this._timer.complete(); + this.schedule(); + return GLib.SOURCE_REMOVE; + }), + ); + } + + destroy(): void { + this._lifecycle.dispose(); + this._destroyActiveNotification(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + this._destroyNotificationSource(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + + this._events = []; + this._activeEventId = null; + this._alertedEventIds.clear(); + this._ignoredEventIds.clear(); + this._snoozedUntilByEventId.clear(); + } + + private _alertOptions(preferences: MeetingAlertPreferences): MeetingClockOptions { + return { + alertsEnabled: preferences.alertsEnabled, + alertMinutesBefore: preferences.alertMinutesBefore, + alertEventsWithoutLink: preferences.alertEventsWithoutLink, + excludeAllDayEvents: preferences.excludeAllDayEvents, + ignoredEventIds: this._ignoredEventIds, + alertedEventIds: this._alertedEventIds, + snoozedUntilByEventId: this._snoozedUntilByEventId, + }; + } + + private _showNotification(event: MeetingEvent): void { + if (this._activeEventId === event.id) return; + + this._activeEventId = event.id; + this._destroyActiveNotification(MessageTray.NotificationDestroyedReason.REPLACED); + + const notification = new MessageTray.Notification({ + source: this._ensureNotificationSource(), + title: _('Meeting starting soon'), + body: `${event.title}\n${formatEventTime(event)}`, + iconName: 'x-office-calendar-symbolic', + urgency: MessageTray.Urgency.HIGH, + resident: true, + isTransient: false, + }); + + if (event.meetingUrl) { + notification.addAction(_('Join'), () => this._join(event)); + } + + notification.addAction(_('Snooze'), () => this._snooze(event)); + notification.addAction(_('Dismiss'), () => this._dismiss(event)); + + if (event.meetingUrl) { + notification.addAction(_('Ignore'), () => this._ignore(event)); + } + + this._activeNotificationDestroyId = notification.connect('destroy', () => { + if (this._activeNotification === notification) { + this._activeNotification = null; + this._activeNotificationDestroyId = 0; + } + + if (this._activeEventId !== event.id) return; + + this._alertedEventIds.add(event.id); + this._activeEventId = null; + this._stateChanged(); + }); + + this._activeNotification = notification; + this._ensureNotificationSource().addNotification(notification); + this._options.onStateChanged(); + } + + private _join(event: MeetingEvent): void { + if (!event.meetingUrl) return; + + try { + Gio.AppInfo.launch_default_for_uri(event.meetingUrl, null); + } catch (error) { + logger.warn(`Failed to open meeting URL: ${error}`, { prefix: LOG_PREFIX }); + } + + this._dismiss(event); + } + + private _snooze(event: MeetingEvent): void { + const snoozeSeconds = Math.max(1, this._options.getPreferences().snoozeMinutes) * 60; + this._snoozedUntilByEventId.set(event.id, this._options.now() + snoozeSeconds); + this._activeEventId = null; + this._destroyActiveNotification(MessageTray.NotificationDestroyedReason.DISMISSED); + this._stateChanged(); + } + + private _dismiss(event: MeetingEvent): void { + this._alertedEventIds.add(event.id); + this._activeEventId = null; + this._destroyActiveNotification(MessageTray.NotificationDestroyedReason.DISMISSED); + this._stateChanged(); + } + + private _ignore(event: MeetingEvent): void { + this._ignoredEventIds.add(event.id); + this._activeEventId = null; + this._destroyActiveNotification(MessageTray.NotificationDestroyedReason.DISMISSED); + this._stateChanged(); + } + + private _stateChanged(): void { + this._options.onStateChanged(); + this.schedule(); + } + + private _ensureNotificationSource(): MessageTray.Source { + if (this._notificationSource) return this._notificationSource; + + const source = new MessageTray.Source({ + title: _('Meeting Clock'), + iconName: 'x-office-calendar-symbolic', + }); + this._notificationSourceDestroyId = source.connect('destroy', () => { + if (this._notificationSource === source) { + this._notificationSource = null; + } + + this._notificationSourceDestroyId = 0; + }); + + Main.messageTray.add(source); + this._notificationSource = source; + return source; + } + + private _destroyActiveNotification(reason: MessageTray.NotificationDestroyedReason): void { + const notification = this._activeNotification; + this._activeNotification = null; + + if (notification && this._activeNotificationDestroyId) { + notification.disconnect(this._activeNotificationDestroyId); + } + + this._activeNotificationDestroyId = 0; + notification?.destroy(reason); + } + + private _destroyNotificationSource(reason: MessageTray.NotificationDestroyedReason): void { + const source = this._notificationSource; + const destroyId = this._notificationSourceDestroyId; + this._notificationSource = null; + this._notificationSourceDestroyId = 0; + + if (source && destroyId) { + source.disconnect(destroyId); + } + + source?.destroy(reason); + } +} diff --git a/src/panel/clock/meetingClock/meetingClock.ts b/src/panel/clock/meetingClock/meetingClock.ts index 05245fd..d9aee36 100644 --- a/src/panel/clock/meetingClock/meetingClock.ts +++ b/src/panel/clock/meetingClock/meetingClock.ts @@ -1,30 +1,16 @@ import '@girs/gjs'; -import { gettext as _ } from '~/shared/i18n.ts'; - -import Clutter from '@girs/clutter-18'; -import Gio from '@girs/gio-2.0'; import GLib from '@girs/glib-2.0'; -import St from '@girs/st-18'; -import * as Main from '@girs/gnome-shell/ui/main'; -import * as MessageTray from '@girs/gnome-shell/ui/messageTray'; import type { ExtensionContext } from '~/core/context.ts'; -import { LifecycleScope } from '~/core/lifecycleScope.ts'; -import { logger } from '~/core/logger.ts'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; import { Module } from '~/module.ts'; -import { openClockMenu, type ClockPillRegistration } from '~/shared/clockPill.ts'; -import { registerClockPillWidget } from '~/shared/clockPill.ts'; import { CalendarServerBackend } from './calendarServerBackend.ts'; -import { - derivePanelPresentation, - filterDisplayEvents, - formatEventTime, - getDueAlertEvents, - type MeetingEvent, -} from './meetingClockLogic.ts'; - -const LOG_PREFIX = 'MeetingClock'; +import { MeetingAlertController } from './meetingAlertController.ts'; +import { MeetingClockPill } from './meetingClockPill.ts'; +import { derivePanelPresentation, type MeetingEvent } from './meetingClockLogic.ts'; + const ALERTS_ENABLED_KEY = 'meeting-clock-alerts-enabled'; const ALERT_MINUTES_KEY = 'meeting-clock-alert-minutes-before'; const SNOOZE_MINUTES_KEY = 'meeting-clock-snooze-minutes'; @@ -35,34 +21,16 @@ const EXCLUDE_ALL_DAY_KEY = 'meeting-clock-exclude-all-day-events'; const REFRESH_WINDOW_HOURS = 24; const REFRESH_INTERVAL_SECONDS = 180; const LABEL_REFRESH_SECONDS = 30; -const PANEL_REVEAL_VISIBLE_SECONDS = 8; -const PANEL_REVEAL_ANIMATION_MS = 260; -const PANEL_REVEAL_OFFSET = 18; const CALENDAR_SERVER_SOURCE_KEY = 'calendar-server'; -const CLOCK_PILL_ID = 'meeting-clock'; export class MeetingClock extends Module { private _backend: CalendarServerBackend | null = null; private _eventsBySource = new Map<string, MeetingEvent[]>(); private _events: MeetingEvent[] = []; - private _clockPillRegistration: ClockPillRegistration | null = null; - private _panelWidget: St.BoxLayout | null = null; - private _panelLabel: St.Label | null = null; - private _notificationSource: MessageTray.Source | null = null; - private _notificationSourceDestroyId = 0; - private _activeNotification: MessageTray.Notification | null = null; - private _activeNotificationDestroyId = 0; + private _pill: MeetingClockPill | null = null; + private _alerts: MeetingAlertController | null = null; private _lifecycle: LifecycleScope | null = null; - private _refreshTimerId = 0; - private _labelTimerId = 0; - private _alertTimerId = 0; - private _panelRevealTimerId = 0; - private _panelHideTimerId = 0; - private _lastPanelEventId = ''; - private _activeAlertEventId: string | null = null; - private _alertedEventIds = new Set<string>(); - private _ignoredEventIds = new Set<string>(); - private _snoozedUntilByEventId = new Map<string, number>(); + private _panelRevealTimer: ManagedSource | null = null; constructor(context: ExtensionContext) { super(context); @@ -70,138 +38,98 @@ export class MeetingClock extends Module { override enable(): void { this.disable(); - this._lifecycle = new LifecycleScope(); - this._installClockWidget(); + const lifecycle = new LifecycleScope(); + const refreshTimer = createManagedSource(lifecycle); + const labelTimer = createManagedSource(lifecycle); + const panelRevealTimer = createManagedSource(lifecycle); + const pill = new MeetingClockPill(lifecycle); + const alerts = new MeetingAlertController({ + getPreferences: () => this._getAlertPreferences(), + onStateChanged: () => this._render(), + now: () => this._now(), + }); + this._lifecycle = lifecycle; + this._panelRevealTimer = panelRevealTimer; + this._pill = pill; + this._alerts = alerts; - this._backend = new CalendarServerBackend((events) => { - if (!this._lifecycle) return; + const backend = new CalendarServerBackend((events) => { + if (this._backend !== backend) return; this.setSourceEvents(CALENDAR_SERVER_SOURCE_KEY, events); }); - this._backend.start(); - this._refreshEvents(); - - this._refreshTimerId = GLib.timeout_add_seconds( - GLib.PRIORITY_DEFAULT, - REFRESH_INTERVAL_SECONDS, - () => { - this._refreshEvents(); + this._backend = backend; + backend.start(); + backend.refresh(REFRESH_WINDOW_HOURS); + + refreshTimer.replace(() => + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, REFRESH_INTERVAL_SECONDS, () => { + backend.refresh(REFRESH_WINDOW_HOURS); return GLib.SOURCE_CONTINUE; - }, + }), ); - this._labelTimerId = GLib.timeout_add_seconds( - GLib.PRIORITY_DEFAULT, - LABEL_REFRESH_SECONDS, - () => { + labelTimer.replace(() => + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, LABEL_REFRESH_SECONDS, () => { this._render(); return GLib.SOURCE_CONTINUE; - }, + }), ); this._schedulePanelRevealTimer(); const settings = this.context.settings; - this._lifecycle.connect(settings, `changed::${ALERTS_ENABLED_KEY}`, () => - this._scheduleAlerts(), - ); - this._lifecycle.connect(settings, `changed::${ALERT_MINUTES_KEY}`, () => - this._scheduleAlerts(), - ); - this._lifecycle.connect(settings, `changed::${SNOOZE_MINUTES_KEY}`, () => - this._scheduleAlerts(), - ); - this._lifecycle.connect(settings, `changed::${ALERT_EVENTS_WITHOUT_LINK_KEY}`, () => - this._scheduleAlerts(), + lifecycle.connect(settings, `changed::${ALERTS_ENABLED_KEY}`, () => alerts.schedule()); + lifecycle.connect(settings, `changed::${ALERT_MINUTES_KEY}`, () => alerts.schedule()); + lifecycle.connect(settings, `changed::${SNOOZE_MINUTES_KEY}`, () => alerts.schedule()); + lifecycle.connect(settings, `changed::${ALERT_EVENTS_WITHOUT_LINK_KEY}`, () => + alerts.schedule(), ); - this._lifecycle.connect(settings, `changed::${PANEL_REVEAL_INTERVAL_MINUTES_KEY}`, () => + lifecycle.connect(settings, `changed::${PANEL_REVEAL_INTERVAL_MINUTES_KEY}`, () => this._schedulePanelRevealTimer(), ); - this._lifecycle.connect(settings, `changed::${PANEL_LOOKAHEAD_MINUTES_KEY}`, () => - this._render(), - ); - this._lifecycle.connect(settings, `changed::${EXCLUDE_ALL_DAY_KEY}`, () => { + lifecycle.connect(settings, `changed::${PANEL_LOOKAHEAD_MINUTES_KEY}`, () => this._render()); + lifecycle.connect(settings, `changed::${EXCLUDE_ALL_DAY_KEY}`, () => { this._render(); - this._scheduleAlerts(); + alerts.schedule(); }); } override disable(): void { this._lifecycle?.dispose(); this._lifecycle = null; + this._panelRevealTimer = null; - this._clearRefreshTimer(); - this._clearLabelTimer(); - this._clearAlertTimer(); - this._clearPanelRevealTimer(); - this._clearPanelHideTimer(); - - if (this._backend) this._backend.stop(); + this._backend?.stop(); this._backend = null; - this._activeAlertEventId = null; - this._destroyActiveNotification(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); - this._destroyNotificationSource(MessageTray.NotificationDestroyedReason.SOURCE_CLOSED); + + this._alerts?.destroy(); + this._alerts = null; + this._eventsBySource.clear(); this._events = []; - this._activeAlertEventId = null; - this._alertedEventIds.clear(); - this._ignoredEventIds.clear(); - this._snoozedUntilByEventId.clear(); - - if (this._clockPillRegistration) this._clockPillRegistration.unregister(); - this._clockPillRegistration = null; - if (this._panelLabel) this._panelLabel.destroy(); - this._panelLabel = null; - if (this._panelWidget) this._panelWidget.destroy(); - this._panelWidget = null; - this._lastPanelEventId = ''; - } - - private _installClockWidget(): void { - this._panelWidget = new St.BoxLayout({ - style_class: 'aurora-meeting-clock-widget', - y_align: Clutter.ActorAlign.CENTER, - y_expand: true, - visible: false, - opacity: 0, - reactive: false, - }); - const icon = new St.Icon({ - icon_name: 'x-office-calendar-symbolic', - style_class: 'aurora-meeting-clock-icon', - y_align: Clutter.ActorAlign.CENTER, - }); - this._panelLabel = new St.Label({ - style_class: 'clock-label aurora-meeting-clock-label', - y_align: Clutter.ActorAlign.CENTER, - }); - this._panelWidget.add_child(this._panelLabel); - this._panelWidget.add_child(icon); - - this._clockPillRegistration = registerClockPillWidget( - CLOCK_PILL_ID, - this._panelWidget, - 'right', - 100, - ); - } - private _refreshEvents(): void { - this._backend?.refresh(REFRESH_WINDOW_HOURS); + this._pill?.destroy(); + this._pill = null; } setSourceEvents(sourceKey: string, events: readonly MeetingEvent[]): void { - if (!this._lifecycle) return; + if (!this._lifecycle || !this._alerts) return; const previousIds = new Set(this._eventsBySource.get(sourceKey)?.map((event) => event.id)); const nextEvents = [...events]; - for (const event of nextEvents) previousIds.delete(event.id); + + for (const event of nextEvents) { + previousIds.delete(event.id); + } + this._eventsBySource.set(sourceKey, nextEvents); - this._clearAlertState(previousIds); + this._alerts.clearEventState(previousIds); this._syncEvents(); } clearSourceEvents(sourceKey: string): void { const removedIds = new Set(this._eventsBySource.get(sourceKey)?.map((event) => event.id)); this._eventsBySource.delete(sourceKey); - this._clearAlertState(removedIds); + if (this._alerts) this._alerts.clearEventState(removedIds); + this._syncEvents(); } @@ -210,22 +138,18 @@ export class MeetingClock extends Module { } showAlert(eventId: string | null = null): boolean { - const alertEventsWithoutLink = this.context.settings.getBoolean(ALERT_EVENTS_WITHOUT_LINK_KEY); - const event = - (eventId ? this._events.find((candidate) => candidate.id === eventId) : null) ?? - this._events.find((candidate) => Boolean(candidate.meetingUrl) || alertEventsWithoutLink); - if (!event) return false; - if (!event.meetingUrl && !alertEventsWithoutLink) return false; - - this._showAlert(event); - return this._activeAlertEventId === event.id; + if (!this._alerts) return false; + + return this._alerts.show(eventId); } openMenu(): boolean { - if (!this._lifecycle || !this._clockPillRegistration) return false; + if (!this._lifecycle || !this._pill) { + return false; + } this._render(); - return openClockMenu(); + return this._pill.openMenu(); } get eventCount(): number { @@ -233,11 +157,13 @@ export class MeetingClock extends Module { } get activeAlertEventId(): string | null { - return this._activeAlertEventId; + if (!this._alerts) return null; + + return this._alerts.activeEventId; } private _render(): void { - if (!this._lifecycle || !this._clockPillRegistration) return; + if (!this._lifecycle || !this._pill) return; const now = this._now(); const excludeAllDayEvents = this.context.settings.getBoolean(EXCLUDE_ALL_DAY_KEY); @@ -247,59 +173,26 @@ export class MeetingClock extends Module { }); if (!presentation) { - this._lastPanelEventId = ''; - this._hidePanelWidget(false); + this._pill.setPresentation(null); return; } - const panelEventId = presentation.event.id; - if (this._panelLabel) this._panelLabel.text = presentation.label; - if (panelEventId !== this._lastPanelEventId) { - this._lastPanelEventId = panelEventId; - this._revealPanelWidget(); - } - } - - private _scheduleAlerts(): void { - if (!this._lifecycle || !this._clockPillRegistration) return; - - this._clearAlertTimer(); - if (this._activeAlertEventId) return; - - const now = this._now(); - const dueEvents = this._getDueEvents(now); - if (dueEvents.length > 0) { - this._showAlert(dueEvents[0]!); - return; - } - - const nextAlertAt = this._getNextAlertAt(now); - if (!nextAlertAt) return; - - this._alertTimerId = GLib.timeout_add_seconds( - GLib.PRIORITY_DEFAULT, - Math.max(1, nextAlertAt - now), - () => { - this._alertTimerId = 0; - this._scheduleAlerts(); - return GLib.SOURCE_REMOVE; - }, - ); + this._pill.setPresentation(presentation.event.id, presentation.label); } private _schedulePanelRevealTimer(): void { - if (!this._lifecycle || !this._clockPillRegistration) return; + const pill = this._pill; + const panelRevealTimer = this._panelRevealTimer; + if (!this._lifecycle || !pill || !panelRevealTimer) return; - this._clearPanelRevealTimer(); const intervalSeconds = Math.max(1, this.context.settings.getInt(PANEL_REVEAL_INTERVAL_MINUTES_KEY)) * 60; - this._panelRevealTimerId = GLib.timeout_add_seconds( - GLib.PRIORITY_DEFAULT, - intervalSeconds, - () => { - this._revealPanelWidget(); + + panelRevealTimer.replace(() => + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, intervalSeconds, () => { + pill.reveal(); return GLib.SOURCE_CONTINUE; - }, + }), ); } @@ -307,214 +200,14 @@ export class MeetingClock extends Module { return Math.max(0, this.context.settings.getInt(PANEL_LOOKAHEAD_MINUTES_KEY)) * 60; } - private _getDueEvents(now: number): MeetingEvent[] { - return getDueAlertEvents(this._events, now, { + private _getAlertPreferences() { + return { alertsEnabled: this.context.settings.getBoolean(ALERTS_ENABLED_KEY), alertMinutesBefore: this.context.settings.getInt(ALERT_MINUTES_KEY), alertEventsWithoutLink: this.context.settings.getBoolean(ALERT_EVENTS_WITHOUT_LINK_KEY), excludeAllDayEvents: this.context.settings.getBoolean(EXCLUDE_ALL_DAY_KEY), - ignoredEventIds: this._ignoredEventIds, - alertedEventIds: this._alertedEventIds, - snoozedUntilByEventId: this._snoozedUntilByEventId, - }); - } - - private _getNextAlertAt(now: number): number | null { - if (!this.context.settings.getBoolean(ALERTS_ENABLED_KEY)) return null; - - const leadSeconds = this.context.settings.getInt(ALERT_MINUTES_KEY) * 60; - const excludeAllDayEvents = this.context.settings.getBoolean(EXCLUDE_ALL_DAY_KEY); - const alertEventsWithoutLink = this.context.settings.getBoolean(ALERT_EVENTS_WITHOUT_LINK_KEY); - const candidates = filterDisplayEvents(this._events, now, { excludeAllDayEvents }) - .filter((event) => event.meetingUrl || alertEventsWithoutLink) - .filter((event) => !this._ignoredEventIds.has(event.id)) - .filter((event) => !this._alertedEventIds.has(event.id)) - .map((event) => - Math.max( - event.startEpochSeconds - leadSeconds, - this._snoozedUntilByEventId.get(event.id) ?? 0, - ), - ) - .filter((time) => time > now) - .sort((a, b) => a - b); - - return candidates[0] ?? null; - } - - private _showAlert(event: MeetingEvent): void { - if (!this._lifecycle || !this._clockPillRegistration) return; - - if (this._activeAlertEventId === event.id) return; - - this._activeAlertEventId = event.id; - this._destroyActiveNotification(MessageTray.NotificationDestroyedReason.REPLACED); - - const source = this._ensureNotificationSource(); - const notification = new MessageTray.Notification({ - source, - title: _('Meeting starting soon'), - body: `${event.title}\n${formatEventTime(event)}`, - iconName: 'x-office-calendar-symbolic', - urgency: MessageTray.Urgency.HIGH, - resident: true, - isTransient: false, - }); - if (event.meetingUrl) notification.addAction(_('Join'), () => this._joinEvent(event)); - notification.addAction(_('Snooze'), () => this._snoozeEvent(event)); - notification.addAction(_('Dismiss'), () => this._dismissEvent(event)); - if (event.meetingUrl) notification.addAction(_('Ignore'), () => this._ignoreEvent(event)); - this._activeNotificationDestroyId = notification.connect('destroy', () => { - if (this._activeNotification === notification) { - this._activeNotification = null; - this._activeNotificationDestroyId = 0; - } - if (this._activeAlertEventId !== event.id) return; - - this._alertedEventIds.add(event.id); - this._activeAlertEventId = null; - this._render(); - this._scheduleAlerts(); - }); - - this._activeNotification = notification; - source.addNotification(notification); - this._render(); - } - - private _joinEvent(event: MeetingEvent): void { - if (!event.meetingUrl) return; - - try { - Gio.AppInfo.launch_default_for_uri(event.meetingUrl, null); - } catch (e) { - logger.warn(`Failed to open meeting URL: ${e}`, { prefix: LOG_PREFIX }); - } - this._dismissEvent(event); - } - - private _snoozeEvent(event: MeetingEvent): void { - const snoozeSeconds = Math.max(1, this.context.settings.getInt(SNOOZE_MINUTES_KEY)) * 60; - this._snoozedUntilByEventId.set(event.id, this._now() + snoozeSeconds); - this._activeAlertEventId = null; - this._destroyActiveNotification(MessageTray.NotificationDestroyedReason.DISMISSED); - this._render(); - this._scheduleAlerts(); - } - - private _dismissEvent(event: MeetingEvent): void { - this._alertedEventIds.add(event.id); - this._activeAlertEventId = null; - this._destroyActiveNotification(MessageTray.NotificationDestroyedReason.DISMISSED); - this._render(); - this._scheduleAlerts(); - } - - private _ignoreEvent(event: MeetingEvent): void { - this._ignoredEventIds.add(event.id); - this._activeAlertEventId = null; - this._destroyActiveNotification(MessageTray.NotificationDestroyedReason.DISMISSED); - this._render(); - this._scheduleAlerts(); - } - - private _revealPanelWidget(): void { - const widget = this._panelWidget; - if (!this._lifecycle || !this._clockPillRegistration || !widget || !this._lastPanelEventId) - return; - - this._clearPanelHideTimer(); - widget.remove_transition('opacity'); - widget.remove_transition('translation-x'); - widget.remove_transition('width'); - widget.visible = true; - widget.width = -1; - const [, naturalWidth] = widget.get_preferred_width(-1); - const targetWidth = Math.ceil(naturalWidth); - widget.width = 0; - widget.opacity = 0; - widget.translation_x = PANEL_REVEAL_OFFSET; - widget.ease({ - width: targetWidth, - opacity: 255, - translationX: 0, - duration: PANEL_REVEAL_ANIMATION_MS, - mode: Clutter.AnimationMode.EASE_OUT_CUBIC, - onComplete: () => { - widget.width = -1; - }, - }); - - this._panelHideTimerId = GLib.timeout_add_seconds( - GLib.PRIORITY_DEFAULT, - PANEL_REVEAL_VISIBLE_SECONDS, - () => { - this._panelHideTimerId = 0; - this._hidePanelWidget(true); - return GLib.SOURCE_REMOVE; - }, - ); - } - - private _hidePanelWidget(animated: boolean): void { - const widget = this._panelWidget; - if (!widget) return; - - this._clearPanelHideTimer(); - widget.remove_transition('opacity'); - widget.remove_transition('translation-x'); - widget.remove_transition('width'); - - if (!animated || !widget.visible) { - widget.opacity = 0; - widget.translation_x = PANEL_REVEAL_OFFSET; - widget.width = -1; - widget.visible = false; - return; - } - - const [, naturalWidth] = widget.get_preferred_width(-1); - widget.width = Math.ceil(naturalWidth); - widget.ease({ - width: 0, - opacity: 0, - translationX: PANEL_REVEAL_OFFSET, - duration: PANEL_REVEAL_ANIMATION_MS, - mode: Clutter.AnimationMode.EASE_IN_CUBIC, - onComplete: () => { - widget.width = -1; - widget.visible = false; - }, - }); - } - - private _clearRefreshTimer(): void { - if (!this._refreshTimerId) return; - GLib.source_remove(this._refreshTimerId); - this._refreshTimerId = 0; - } - - private _clearLabelTimer(): void { - if (!this._labelTimerId) return; - GLib.source_remove(this._labelTimerId); - this._labelTimerId = 0; - } - - private _clearAlertTimer(): void { - if (!this._alertTimerId) return; - GLib.source_remove(this._alertTimerId); - this._alertTimerId = 0; - } - - private _clearPanelRevealTimer(): void { - if (!this._panelRevealTimerId) return; - GLib.source_remove(this._panelRevealTimerId); - this._panelRevealTimerId = 0; - } - - private _clearPanelHideTimer(): void { - if (!this._panelHideTimerId) return; - GLib.source_remove(this._panelHideTimerId); - this._panelHideTimerId = 0; + snoozeMinutes: this.context.settings.getInt(SNOOZE_MINUTES_KEY), + }; } private _now(): number { @@ -526,51 +219,6 @@ export class MeetingClock extends Module { .flat() .sort((a, b) => a.startEpochSeconds - b.startEpochSeconds); this._render(); - this._scheduleAlerts(); - } - - private _ensureNotificationSource(): MessageTray.Source { - if (this._notificationSource) return this._notificationSource; - - const source = new MessageTray.Source({ - title: _('Meeting Clock'), - iconName: 'x-office-calendar-symbolic', - }); - this._notificationSourceDestroyId = source.connect('destroy', () => { - if (this._notificationSource === source) this._notificationSource = null; - this._notificationSourceDestroyId = 0; - }); - Main.messageTray.add(source); - this._notificationSource = source; - return source; - } - - private _destroyActiveNotification(reason: MessageTray.NotificationDestroyedReason): void { - const notification = this._activeNotification; - this._activeNotification = null; - if (this._activeNotificationDestroyId && notification) { - notification.disconnect(this._activeNotificationDestroyId); - } - this._activeNotificationDestroyId = 0; - if (notification) notification.destroy(reason); - } - - private _destroyNotificationSource(reason: MessageTray.NotificationDestroyedReason): void { - const source = this._notificationSource; - const destroyId = this._notificationSourceDestroyId; - this._notificationSource = null; - this._notificationSourceDestroyId = 0; - if (source && destroyId) source.disconnect(destroyId); - source?.destroy(reason); - } - - private _clearAlertState(eventIds: ReadonlySet<string | undefined>): void { - for (const eventId of eventIds) { - if (!eventId) continue; - this._alertedEventIds.delete(eventId); - this._ignoredEventIds.delete(eventId); - this._snoozedUntilByEventId.delete(eventId); - if (this._activeAlertEventId === eventId) this._activeAlertEventId = null; - } + if (this._alerts) this._alerts.setEvents(this._events); } } diff --git a/src/panel/clock/meetingClock/meetingClockLogic.ts b/src/panel/clock/meetingClock/meetingClockLogic.ts index b90be35..f3a879f 100644 --- a/src/panel/clock/meetingClock/meetingClockLogic.ts +++ b/src/panel/clock/meetingClock/meetingClockLogic.ts @@ -278,6 +278,29 @@ export function getDueAlertEvents( .sort((a, b) => a.startEpochSeconds - b.startEpochSeconds); } +export function getNextAlertEpoch( + events: readonly MeetingEvent[], + nowEpochSeconds: number, + options: MeetingClockOptions, +): number | null { + if (!options.alertsEnabled) return null; + const ignored = options.ignoredEventIds ?? new Set<string>(); + const alerted = options.alertedEventIds ?? new Set<string>(); + const snoozed = options.snoozedUntilByEventId ?? new Map<string, number>(); + const leadSeconds = Math.max(0, options.alertMinutesBefore) * 60; + const candidates: number[] = []; + + for (const event of filterDisplayEvents(events, nowEpochSeconds, options)) { + if ((!event.meetingUrl && !options.alertEventsWithoutLink) || ignored.has(event.id)) continue; + if (alerted.has(event.id)) continue; + const snoozedUntil = snoozed.get(event.id) ?? 0; + const alertAt = Math.max(event.startEpochSeconds - leadSeconds, snoozedUntil); + if (alertAt > nowEpochSeconds) candidates.push(alertAt); + } + + return candidates.length > 0 ? Math.min(...candidates) : null; +} + export function formatRelativeTime( targetEpochSeconds: number, referenceEpochSeconds: number, diff --git a/src/panel/clock/meetingClock/meetingClockPill.ts b/src/panel/clock/meetingClock/meetingClockPill.ts new file mode 100644 index 0000000..5870740 --- /dev/null +++ b/src/panel/clock/meetingClock/meetingClockPill.ts @@ -0,0 +1,147 @@ +import Clutter from '@girs/clutter-18'; +import GLib from '@girs/glib-2.0'; +import St from '@girs/st-18'; + +import type { LifecycleScope, ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; +import { + openClockMenu, + registerClockPillWidget, + type ClockPillRegistration, +} from '~/shared/clockPill.ts'; + +const CLOCK_PILL_ID = 'meeting-clock'; +const VISIBLE_SECONDS = 8; +const ANIMATION_MS = 260; +const OFFSET = 18; + +export class MeetingClockPill { + private _widget: St.BoxLayout; + private _label: St.Label; + private _registration: ClockPillRegistration | null; + private _hideTimer: ManagedSource; + private _eventId = ''; + + constructor(lifecycle: LifecycleScope) { + this._hideTimer = createManagedSource(lifecycle); + this._widget = new St.BoxLayout({ + style_class: 'aurora-meeting-clock-widget', + y_align: Clutter.ActorAlign.CENTER, + y_expand: true, + visible: false, + opacity: 0, + reactive: false, + }); + this._label = new St.Label({ + style_class: 'clock-label aurora-meeting-clock-label', + y_align: Clutter.ActorAlign.CENTER, + }); + this._widget.add_child(this._label); + this._widget.add_child( + new St.Icon({ + icon_name: 'x-office-calendar-symbolic', + style_class: 'aurora-meeting-clock-icon', + y_align: Clutter.ActorAlign.CENTER, + }), + ); + this._registration = registerClockPillWidget(CLOCK_PILL_ID, this._widget, 'right', 100); + } + + setPresentation(eventId: string | null, label = ''): void { + if (!eventId) { + this._eventId = ''; + this.hide(false); + return; + } + + this._label.text = label; + if (eventId === this._eventId) return; + + this._eventId = eventId; + this.reveal(); + } + + reveal(): void { + if (!this._eventId) return; + + this._removeTransitions(); + + const targetWidth = this._naturalWidth(); + + this._widget.visible = true; + this._widget.width = 0; + this._widget.opacity = 0; + this._widget.translation_x = OFFSET; + this._widget.ease({ + width: targetWidth, + opacity: 255, + translationX: 0, + duration: ANIMATION_MS, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onComplete: () => { + this._widget.width = -1; + }, + }); + + this._hideTimer.replace(() => + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, VISIBLE_SECONDS, () => { + this._hideTimer.complete(); + this.hide(true); + return GLib.SOURCE_REMOVE; + }), + ); + } + + hide(animated: boolean): void { + this._hideTimer.clear(); + this._removeTransitions(); + + if (!animated || !this._widget.visible) { + this._widget.opacity = 0; + this._widget.translation_x = OFFSET; + this._widget.width = -1; + this._widget.visible = false; + return; + } + + this._widget.width = this._naturalWidth(); + this._widget.ease({ + width: 0, + opacity: 0, + translationX: OFFSET, + duration: ANIMATION_MS, + mode: Clutter.AnimationMode.EASE_IN_CUBIC, + onComplete: () => { + this._widget.width = -1; + this._widget.visible = false; + }, + }); + } + + openMenu(): boolean { + return openClockMenu(); + } + + destroy(): void { + this._hideTimer.clear(); + this._removeTransitions(); + if (this._registration) { + this._registration.unregister(); + this._registration = null; + } + this._widget.destroy(); + } + + private _removeTransitions(): void { + this._widget.remove_transition('opacity'); + this._widget.remove_transition('translation-x'); + this._widget.remove_transition('width'); + } + + private _naturalWidth(): number { + this._widget.width = -1; + const [, naturalWidth] = this._widget.get_preferred_width(-1); + + return Math.ceil(naturalWidth); + } +} diff --git a/src/panel/clock/weatherClock/weatherClock.ts b/src/panel/clock/weatherClock/weatherClock.ts index eb0869d..e5435cd 100644 --- a/src/panel/clock/weatherClock/weatherClock.ts +++ b/src/panel/clock/weatherClock/weatherClock.ts @@ -9,8 +9,9 @@ import GWeather from 'gi://GWeather'; import * as Main from '@girs/gnome-shell/ui/main'; import type { ExtensionContext } from '~/core/context.ts'; -import { LifecycleScope } from '~/core/lifecycleScope.ts'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; import { Module } from '~/module.ts'; import { registerClockPillWidget, type ClockPillRegistration } from '~/shared/clockPill.ts'; @@ -64,8 +65,8 @@ export class WeatherClock extends Module { private _monitor: Gio.NetworkMonitor | null = null; private _snapshotsBySource = new Map<string, WeatherSnapshot>(); private _snapshot: WeatherSnapshot | null = null; - private _refreshTimerId = 0; - private _retryTimerId = 0; + private _refreshTimer: ManagedSource | null = null; + private _retryTimer: ManagedSource | null = null; private _retryCount = 0; constructor(context: ExtensionContext) { @@ -75,6 +76,8 @@ export class WeatherClock extends Module { override enable(): void { this.disable(); this._lifecycle = new LifecycleScope(); + this._refreshTimer = createManagedSource(this._lifecycle); + this._retryTimer = createManagedSource(this._lifecycle); this._monitor = Gio.NetworkMonitor.get_default(); this._gweatherSettings = this._createGWeatherSettings(); if (this._gweatherSettings) @@ -93,9 +96,8 @@ export class WeatherClock extends Module { override disable(): void { this._lifecycle?.dispose(); this._lifecycle = null; - - this._clearRefreshTimer(); - this._clearRetryTimer(); + this._refreshTimer = null; + this._retryTimer = null; if (this._clockPillRegistration) this._clockPillRegistration.unregister(); this._clockPillRegistration = null; @@ -207,7 +209,10 @@ export class WeatherClock extends Module { private _readWeatherClient(): WeatherClient | null { const dateMenu = Main.panel.statusArea.dateMenu as unknown as DateMenuWithWeather; - return dateMenu._weatherItem?._weatherClient ?? null; + const weatherItem = dateMenu._weatherItem; + if (!weatherItem || !weatherItem._weatherClient) return null; + + return weatherItem._weatherClient; } private _createGWeatherSettings(): Gio.Settings | null { @@ -232,8 +237,10 @@ export class WeatherClock extends Module { if (!this._lifecycle) return; if (!this._hasConnectivity()) { + const available = this._weatherClient ? this._weatherClient.available : true; + this.setWeatherSnapshot(GNOME_WEATHER_SOURCE_KEY, { - available: this._weatherClient?.available ?? true, + available, hasConnectivity: false, }); return; @@ -326,7 +333,7 @@ export class WeatherClock extends Module { } private _scheduleRetry(): void { - if (this._retryTimerId) return; + if (!this._retryTimer || this._retryTimer.active) return; this._retryCount++; if (this._retryCount > MAX_RETRIES) { @@ -338,22 +345,23 @@ export class WeatherClock extends Module { } const delay = this._retryCount <= 2 ? 5 : 30; - this._retryTimerId = GLib.timeout_add_seconds(GLib.PRIORITY_LOW, delay, () => { - this._retryTimerId = 0; - this.refreshWeather(); - return GLib.SOURCE_REMOVE; - }); + this._retryTimer.replace(() => + GLib.timeout_add_seconds(GLib.PRIORITY_LOW, delay, () => { + this._retryTimer!.complete(); + this.refreshWeather(); + return GLib.SOURCE_REMOVE; + }), + ); } private _startRefreshTimer(): void { - this._clearRefreshTimer(); - this._refreshTimerId = GLib.timeout_add_seconds( - GLib.PRIORITY_LOW, - REFRESH_INTERVAL_SECONDS, - () => { + if (!this._refreshTimer) return; + + this._refreshTimer.replace(() => + GLib.timeout_add_seconds(GLib.PRIORITY_LOW, REFRESH_INTERVAL_SECONDS, () => { if (this._hasConnectivity()) this.refreshWeather(); return GLib.SOURCE_CONTINUE; - }, + }), ); } @@ -388,19 +396,15 @@ export class WeatherClock extends Module { } private _hasConnectivity(): boolean { - return this._monitor?.connectivity !== Gio.NetworkConnectivity.LOCAL; - } + if (!this._monitor) { + return false; + } - private _clearRefreshTimer(): void { - if (!this._refreshTimerId) return; - GLib.source_remove(this._refreshTimerId); - this._refreshTimerId = 0; + return this._monitor.connectivity !== Gio.NetworkConnectivity.LOCAL; } private _clearRetryTimer(): void { - if (!this._retryTimerId) return; - GLib.source_remove(this._retryTimerId); - this._retryTimerId = 0; + this._retryTimer?.clear(); } private _now(): number { diff --git a/src/panel/lockKeyIndicators.ts b/src/panel/lockKeyIndicators.ts index bfc883d..066f186 100644 --- a/src/panel/lockKeyIndicators.ts +++ b/src/panel/lockKeyIndicators.ts @@ -8,6 +8,7 @@ import type { Button as PanelMenuButton } from '@girs/gnome-shell/ui/panelMenu'; import * as PanelMenu from '@girs/gnome-shell/ui/panelMenu'; import type { ExtensionContext } from '~/core/context.ts'; +import { LifecycleScope } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; @@ -26,7 +27,7 @@ export class LockKeyIndicators extends Module { private _capsLabel: St.Label | null = null; private _numLabel: St.Label | null = null; private _keymap: Keymap | null = null; - private _stateChangedId = 0; + private _lifecycle: LifecycleScope | null = null; constructor(context: ExtensionContext) { super(context); @@ -34,6 +35,7 @@ export class LockKeyIndicators extends Module { override enable(): void { this.disable(); + this._lifecycle = new LifecycleScope(); this._keymap = this._getKeymap(); if (!this._keymap) { @@ -55,7 +57,7 @@ export class LockKeyIndicators extends Module { box.add_child(this._numLabel); this._button.add_child(box); - this._stateChangedId = this._keymap.connect('state-changed', () => this._sync()); + this._lifecycle.connect(this._keymap, 'state-changed', () => this._sync()); this._sync(); Main.panel.addToStatusArea( @@ -67,10 +69,8 @@ export class LockKeyIndicators extends Module { } override disable(): void { - if (this._stateChangedId && this._keymap) { - this._keymap.disconnect(this._stateChangedId); - this._stateChangedId = 0; - } + this._lifecycle?.dispose(); + this._lifecycle = null; this._button?.destroy(); this._button = null; this._capsLabel = null; @@ -79,11 +79,7 @@ export class LockKeyIndicators extends Module { } private _getKeymap(): Keymap | null { - try { - return Clutter.get_default_backend().get_default_seat().get_keymap() as unknown as Keymap; - } catch { - return null; - } + return Clutter.get_default_backend().get_default_seat().get_keymap() as unknown as Keymap; } private _makeLabel(text: string): St.Label { diff --git a/src/panel/lowBatteryPercentage.ts b/src/panel/lowBatteryPercentage.ts index 3071ced..2e6e2af 100644 --- a/src/panel/lowBatteryPercentage.ts +++ b/src/panel/lowBatteryPercentage.ts @@ -4,6 +4,7 @@ import { gettext as _ } from '~/shared/i18n.ts'; import Gio from '@girs/gio-2.0'; import type { ExtensionContext } from '~/core/context.ts'; +import { LifecycleScope } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; import type { SettingsManager } from '~/core/settings.ts'; @@ -21,7 +22,7 @@ const DISCHARGING_STATE = 2; export class LowBatteryPercentage extends Module { private _desktopSettings: SettingsManager | null = null; private _proxy: Gio.DBusProxy | null = null; - private _propertiesChangedId = 0; + private _lifecycle: LifecycleScope | null = null; private _managedBatteryPercentage = false; constructor(context: ExtensionContext) { @@ -30,6 +31,7 @@ export class LowBatteryPercentage extends Module { override enable(): void { this.disable(); + this._lifecycle = new LifecycleScope(); this._desktopSettings = this.context.settings.getSchema(DESKTOP_INTERFACE_SCHEMA); @@ -39,16 +41,13 @@ export class LowBatteryPercentage extends Module { return; } - this._propertiesChangedId = this._proxy.connect('g-properties-changed', () => this._sync()); + this._lifecycle.connect(this._proxy, 'g-properties-changed', () => this._sync()); this._sync(); } override disable(): void { - if (this._propertiesChangedId && this._proxy) { - this._proxy.disconnect(this._propertiesChangedId); - this._propertiesChangedId = 0; - } - + this._lifecycle?.dispose(); + this._lifecycle = null; this._proxy = null; this._restoreManagedBatteryPercentage(); this._desktopSettings = null; @@ -87,7 +86,9 @@ export class LowBatteryPercentage extends Module { ); const result = proxy.call_sync('EnumerateDevices', null, Gio.DBusCallFlags.NONE, 500, null); - const devices = (result?.get_child_value(0).deep_unpack() as string[] | undefined) ?? []; + if (!result) return null; + + const devices = result.get_child_value(0).deep_unpack() as string[]; return devices.find((path) => /battery/i.test(path)) ?? null; } catch (e) { logger.debug(`Could not enumerate UPower devices: ${e}`, { prefix: LOG_PREFIX }); diff --git a/src/panel/volumeMixer/mixerItem.ts b/src/panel/volumeMixer/mixerItem.ts index ca6ba61..308011b 100644 --- a/src/panel/volumeMixer/mixerItem.ts +++ b/src/panel/volumeMixer/mixerItem.ts @@ -2,7 +2,6 @@ import '@girs/gjs'; import St from '@girs/st-18'; import GObject from '@girs/gobject-2.0'; -import Shell from '@girs/shell-18'; import type Gvc from 'gi://Gvc'; import Clutter from '@girs/clutter-18'; @@ -20,6 +19,10 @@ export class VolumeMixerItem extends St.BoxLayout { declare private _icon: St.Icon; declare private _label: St.Label; declare private _slider: ApplicationStreamSlider; + declare private _baseLabel: string; + declare private _identityKey: string; + declare private _duplicateIndex: number; + declare private _duplicateCount: number; override _init( context?: ExtensionContext | Partial<St.BoxLayout.ConstructorProps>, @@ -33,6 +36,10 @@ export class VolumeMixerItem extends St.BoxLayout { }); this._stream = stream!; + this._baseLabel = ''; + this._identityKey = ''; + this._duplicateIndex = 0; + this._duplicateCount = 1; const headerBox = new St.BoxLayout({ orientation: Clutter.Orientation.HORIZONTAL, @@ -66,72 +73,47 @@ export class VolumeMixerItem extends St.BoxLayout { this._updateHeader(); } - private _lookupApp(): typeof Shell.App.prototype | null { - const appSystem = Shell.AppSystem.get_default(); - - const appId = this._stream.get_application_id(); - if (appId) { - const app = appSystem.lookup_app(`${appId}.desktop`) || appSystem.lookup_app(appId); - if (app) return app; - } - - const iconName = this._stream.get_icon_name(); - if (iconName) { - const app = appSystem.lookup_app(`${iconName}.desktop`) || appSystem.lookup_app(iconName); - if (app) return app; - } - - const name = this._stream.get_name(); - if (name) { - const app = appSystem.lookup_desktop_wmclass(name) || appSystem.lookup_startup_wmclass(name); - if (app) return app; - } + get identityKey(): string { + return this._identityKey; + } - const lowerAppId = appId?.toLowerCase(); - const lowerName = name?.toLowerCase(); - const lowerIcon = iconName?.toLowerCase(); - for (const app of appSystem.get_running()) { - const id = app.get_id()?.toLowerCase(); - if (!id) continue; - if ( - (lowerAppId && id.includes(lowerAppId)) || - (lowerName && id.includes(lowerName)) || - (lowerIcon && id.includes(lowerIcon)) - ) { - return app; - } - } + setDuplicatePosition(index: number, count: number): void { + this._duplicateIndex = index; + this._duplicateCount = count; + this._renderLabel(); + } - return null; + private _renderLabel(): void { + this._label.text = + this._duplicateCount > 1 + ? `${this._baseLabel} · ${_('Audio')} ${this._duplicateIndex}` + : this._baseLabel; + this._label.show(); } private _updateHeader(): void { - const app = this._lookupApp(); const streamName = this._stream.get_name(); const description = this._stream.get_description(); + const gicon = this._stream.get_gicon(); - if (app) { - this._icon.gicon = app.get_icon(); - this._icon.show(); - } else if (this._stream.get_icon_name()) { - this._icon.icon_name = this._stream.get_icon_name(); + if (gicon) { + this._icon.gicon = gicon; this._icon.show(); } else { this._icon.hide(); } - const appName = app ? app.get_name() : streamName; - if (appName && description && description !== appName) { - this._label.text = `${appName} — ${description}`; - } else if (appName) { - this._label.text = appName; + if (streamName && description && description !== streamName) { + this._baseLabel = `${streamName} — ${description}`; + } else if (streamName) { + this._baseLabel = streamName; } else if (description) { - this._label.text = description; + this._baseLabel = description; } else { - this._label.text = _('Unknown'); + this._baseLabel = _('Unknown'); } - - this._label.show(); + this._identityKey = this._baseLabel; + this._renderLabel(); } syncStream(): void { diff --git a/src/panel/volumeMixer/mixerList.ts b/src/panel/volumeMixer/mixerList.ts index 897e254..2c2952b 100644 --- a/src/panel/volumeMixer/mixerList.ts +++ b/src/panel/volumeMixer/mixerList.ts @@ -90,6 +90,7 @@ export class VolumeMixerList extends St.BoxLayout { const slider = this._sliders.get(id); if (!slider) return; slider.syncStream(); + this._sync(); } private _streamRemoved(id: number): void { @@ -101,16 +102,16 @@ export class VolumeMixerList extends St.BoxLayout { } private _sync(): void { - if (!this._sliders.size) { - this.shouldShow = false; - return; - } - for (const slider of this._sliders.values()) { - if (slider.visible) { - this.shouldShow = true; - return; - } + const visibleSliders = [...this._sliders.values()].filter((slider) => slider.visible); + const groups = new Map<string, VolumeMixerItem[]>(); + for (const slider of visibleSliders) { + const group = groups.get(slider.identityKey) ?? []; + group.push(slider); + groups.set(slider.identityKey, group); } - this.shouldShow = false; + for (const group of groups.values()) + group.forEach((slider, index) => slider.setDuplicatePosition(index + 1, group.length)); + + this.shouldShow = visibleSliders.length > 0; } } diff --git a/src/panel/volumeMixer/mixerPanel.ts b/src/panel/volumeMixer/mixerPanel.ts index 4ec0104..63eeaf6 100644 --- a/src/panel/volumeMixer/mixerPanel.ts +++ b/src/panel/volumeMixer/mixerPanel.ts @@ -14,8 +14,19 @@ export const MAX_MIXER_HEIGHT = 300; * Scrollable panel containing the list of per-application volume mixers. * Hides itself automatically when there are no active audio streams. */ -@GObject.registerClass +@GObject.registerClass({ + Properties: { + 'should-show': GObject.ParamSpec.boolean( + 'should-show', + null, + null, + GObject.ParamFlags.READABLE, + false, + ), + }, +}) export class VolumeMixerPanel extends St.BoxLayout { + declare should_show: boolean; declare private _emptyLabel: St.Label; declare private _list: VolumeMixerList; @@ -54,10 +65,21 @@ export class VolumeMixerPanel extends St.BoxLayout { sections.add_child(this._list); this.add_child(scroll); - this._list.connectObject('notify::should-show', () => this._sync(scroll), this); + this._list.connectObject( + 'notify::should-show', + () => { + this._sync(scroll); + this.notify('should-show'); + }, + this, + ); this._sync(scroll); } + get shouldShow(): boolean { + return this._list.shouldShow; + } + private _sync(scroll: St.ScrollView): void { const hasStreams = this._list.shouldShow; scroll.visible = hasStreams; diff --git a/src/panel/volumeMixer/streamSlider.ts b/src/panel/volumeMixer/streamSlider.ts index 6041123..b56080b 100644 --- a/src/panel/volumeMixer/streamSlider.ts +++ b/src/panel/volumeMixer/streamSlider.ts @@ -7,6 +7,8 @@ import GLib from '@girs/glib-2.0'; import { QuickSlider } from '@girs/gnome-shell/ui/quickSettings'; import type { ExtensionContext } from '~/core/context.ts'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; const ALLOW_AMPLIFIED_VOLUME_KEY = 'allow-volume-above-100-percent'; @@ -22,11 +24,10 @@ const ALLOW_AMPLIFIED_VOLUME_KEY = 'allow-volume-above-100-percent'; }) export class ApplicationStreamSlider extends QuickSlider { declare private _control: Gvc.MixerControl; - declare private _notifyVolumeChangeId: number; + declare private _lifecycle: LifecycleScope; + declare private _notifyVolumeChange: ManagedSource; declare private _volumeCancellable: Gio.Cancellable | null; declare private _showIcon: boolean; - declare private _dragBeginId: number; - declare private _dragEndId: number; declare private _soundSettings: Gio.Settings; declare private _stream: Gvc.MixerStream | null; declare private _inDrag: boolean; @@ -40,12 +41,12 @@ export class ApplicationStreamSlider extends QuickSlider { showIcon?: boolean, ): void { this._control = control!; - this._notifyVolumeChangeId = 0; this._volumeCancellable = null; this._showIcon = showIcon ?? false; - this._dragBeginId = 0; - this._dragEndId = 0; super._init(); + this._lifecycle = new LifecycleScope(); + this._notifyVolumeChange = createManagedSource(this._lifecycle); + this._lifecycle.onDispose(() => this._volumeCancellable?.cancel()); this._soundSettings = (context as ExtensionContext).settings .getSchema('org.gnome.desktop.sound') @@ -58,17 +59,19 @@ export class ApplicationStreamSlider extends QuickSlider { this._updateAllowAmplified(); this.iconReactive = true; - this.connect('icon-clicked', () => { + this._lifecycle.connect(this, 'icon-clicked', () => { if (!this._stream) return; this._stream.change_is_muted(!this._stream.is_muted); }); this._inDrag = false; - this._sliderChangedId = this.slider.connect('notify::value', () => this._sliderChanged()); - this._dragBeginId = this.slider.connect('drag-begin', () => { + this._sliderChangedId = this._lifecycle.connect(this.slider, 'notify::value', () => + this._sliderChanged(), + ); + this._lifecycle.connect(this.slider, 'drag-begin', () => { this._inDrag = true; }); - this._dragEndId = this.slider.connect('drag-end', () => { + this._lifecycle.connect(this.slider, 'drag-end', () => { this._inDrag = false; }); @@ -172,12 +175,14 @@ export class ApplicationStreamSlider extends QuickSlider { } this._stream.push_volume(); - if (volumeChanged && !this._notifyVolumeChangeId && !this._inDrag) { - this._notifyVolumeChangeId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 30, () => { - this._feedbackVolumeChange(); - this._notifyVolumeChangeId = 0; - return GLib.SOURCE_REMOVE; - }); + if (volumeChanged && !this._notifyVolumeChange.active && !this._inDrag) { + this._notifyVolumeChange.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 30, () => { + this._feedbackVolumeChange(); + this._notifyVolumeChange.complete(); + return GLib.SOURCE_REMOVE; + }), + ); } } @@ -191,27 +196,12 @@ export class ApplicationStreamSlider extends QuickSlider { } override destroy(): void { - if (this._notifyVolumeChangeId) { - GLib.Source.remove(this._notifyVolumeChangeId); - this._notifyVolumeChangeId = 0; - } + this._lifecycle.dispose(); if (this._volumeCancellable) { this._volumeCancellable.cancel(); this._volumeCancellable = null; } - if (this._sliderChangedId) { - this.slider.disconnect(this._sliderChangedId); - this._sliderChangedId = 0; - } - if (this._dragBeginId) { - this.slider.disconnect(this._dragBeginId); - this._dragBeginId = 0; - } - if (this._dragEndId) { - this.slider.disconnect(this._dragEndId); - this._dragEndId = 0; - } - this._soundSettings?.disconnectObject(this); + this._soundSettings.disconnectObject(this); this._stream?.disconnectObject(this); super.destroy(); } diff --git a/src/panel/volumeMixer/volumeMixer.manifest.ts b/src/panel/volumeMixer/volumeMixer.manifest.ts index 317fb57..1a18022 100644 --- a/src/panel/volumeMixer/volumeMixer.manifest.ts +++ b/src/panel/volumeMixer/volumeMixer.manifest.ts @@ -7,4 +7,12 @@ export const manifest: ModuleManifest = { section: 'dock-panel', title: _('Volume Mixer'), subtitle: _('Per-application volume control in Quick Settings'), + options: [ + { + key: 'volume-mixer-always-show', + title: _('Always Show'), + subtitle: _('Show the Volume Mixer button even when no applications are playing audio'), + type: 'switch', + }, + ], }; diff --git a/src/panel/volumeMixer/volumeMixer.ts b/src/panel/volumeMixer/volumeMixer.ts index ab38b88..0a4f936 100644 --- a/src/panel/volumeMixer/volumeMixer.ts +++ b/src/panel/volumeMixer/volumeMixer.ts @@ -18,6 +18,7 @@ import { VolumeMixerPanel } from '~/panel/volumeMixer/mixerPanel.ts'; import { createIcon } from '~/shared/icons.ts'; const LOG_PREFIX = 'VolumeMixer'; +export const ALWAYS_SHOW_KEY = 'volume-mixer-always-show'; /** * Volume Mixer Module @@ -92,8 +93,7 @@ export class VolumeMixer extends Module { } private _attachToSlider(slider: QuickSlider): void { - const lifecycle = this._lifecycle; - if (!lifecycle) return; + if (!this._lifecycle) return; this._panel = new (VolumeMixerPanel as unknown as new ( ctx: ExtensionContext, @@ -112,7 +112,7 @@ export class VolumeMixer extends Module { } catch (e) { logger.error(`Failed to open sound settings: ${e}`, { prefix: LOG_PREFIX }); } - this._quickSettings?.menu.close(PopupAnimation.FULL); + slider.menu.close(PopupAnimation.FULL); }); this._settingsSection.addMenuItem(settingsItem); slider.menu.addMenuItem(this._settingsSection, 3); @@ -129,6 +129,19 @@ export class VolumeMixer extends Module { slider.child.add_child(this._toggleButton); + const syncToggleVisibility = () => { + if (!this._toggleButton || !this._panel) return; + this._toggleButton.visible = + this.context.settings.getBoolean(ALWAYS_SHOW_KEY) || this._panel.shouldShow; + }; + this._lifecycle.connect(this._panel, 'notify::should-show', syncToggleVisibility); + this._lifecycle.connect( + this.context.settings, + `changed::${ALWAYS_SHOW_KEY}`, + syncToggleVisibility, + ); + syncToggleVisibility(); + const toggleButton = this._toggleButton; const toggleClickedId = toggleButton.connect('clicked', () => { if (!this._panel || !this._menuSection || !this._settingsSection) return; @@ -136,21 +149,21 @@ export class VolumeMixer extends Module { this._menuSection.box.show(); this._settingsSection.box.show(); (slider as any)._deviceSection?.box.hide(); - slider.menu._setSettingsVisibility?.(false); + slider.menu._setSettingsVisibility(false); slider.menu.setHeader('audio-speakers-symbolic', _('Volume Mixer')); slider.menu.open(PopupAnimation.FULL); }); - lifecycle.onDispose(() => toggleButton.disconnect(toggleClickedId)); + this._lifecycle.onDispose(() => toggleButton.disconnect(toggleClickedId)); const menuClosedId = slider.menu.connect('menu-closed', () => { if (!this._menuSection || !this._settingsSection) return undefined; this._menuSection.box.hide(); this._settingsSection.box.hide(); (slider as any)._deviceSection?.box.show(); - slider.menu._setSettingsVisibility?.(Main.sessionMode.allowSettings); + slider.menu._setSettingsVisibility(Main.sessionMode.allowSettings); slider.menu.setHeader('audio-headphones-symbolic', _('Sound Output')); return undefined; }); - lifecycle.onDispose(() => slider.menu.disconnect(menuClosedId)); + this._lifecycle.onDispose(() => slider.menu.disconnect(menuClosedId)); } } diff --git a/src/patches/appSearchTooltip.ts b/src/patches/appSearchTooltip.ts index e32e2ee..fccad79 100644 --- a/src/patches/appSearchTooltip.ts +++ b/src/patches/appSearchTooltip.ts @@ -4,6 +4,8 @@ import GLib from '@girs/glib-2.0'; import * as Main from '@girs/gnome-shell/ui/main'; import * as Search from '@girs/gnome-shell/ui/search'; import type { ExtensionContext } from '~/core/context.ts'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; import { Module } from '~/module.ts'; const SHOW_DELAY_MS = 300; @@ -16,10 +18,11 @@ const SHOW_DELAY_MS = 300; */ export class AppSearchTooltip extends Module { private _tooltipActor: any = null; - private _showTimeoutId = 0; + private _lifecycle: LifecycleScope | null = null; + private _showTimeout: ManagedSource | null = null; private _pendingActor: any = null; - private _overviewHidingId = 0; - private _patchedSearchAddItem: any = null; + private _originalSearchAddItem: any = null; + private _searchAddItemWrapper: any = null; private _trackedActors = new Set<any>(); constructor(context: ExtensionContext) { @@ -27,36 +30,38 @@ export class AppSearchTooltip extends Module { } override enable(): void { - if (!this._patchedSearchAddItem) { - const originalAddItem = Search.GridSearchResults.prototype._addItem; - this._patchedSearchAddItem = originalAddItem; - - const connectHover = (display: any) => this._connectHover(display); - - Search.GridSearchResults.prototype._addItem = function (display: any) { - originalAddItem.call(this, display); - connectHover(display); - }; - } - - this._overviewHidingId = Main.overview.connect('hiding', () => this._hideTooltip()); + this._lifecycle = new LifecycleScope(); + this._showTimeout = createManagedSource(this._lifecycle); + const prototype = Search.GridSearchResults.prototype; + const originalAddItem = prototype._addItem; + const connectHover = (display: any) => this._connectHover(display); + const wrapper = function (this: any, display: any) { + originalAddItem.call(this, display); + connectHover(display); + }; + + this._originalSearchAddItem = originalAddItem; + this._searchAddItemWrapper = wrapper; + prototype._addItem = wrapper; + + this._lifecycle.connect(Main.overview, 'hiding', () => this._hideTooltip()); } override disable(): void { - if (this._patchedSearchAddItem) { - Search.GridSearchResults.prototype._addItem = this._patchedSearchAddItem; - this._patchedSearchAddItem = null; + const prototype = Search.GridSearchResults.prototype; + if ( + this._originalSearchAddItem && + this._searchAddItemWrapper && + prototype._addItem === this._searchAddItemWrapper + ) { + prototype._addItem = this._originalSearchAddItem; } + this._originalSearchAddItem = null; + this._searchAddItemWrapper = null; - if (this._overviewHidingId > 0) { - Main.overview.disconnect(this._overviewHidingId); - this._overviewHidingId = 0; - } - - if (this._showTimeoutId > 0) { - GLib.source_remove(this._showTimeoutId); - this._showTimeoutId = 0; - } + this._lifecycle?.dispose(); + this._lifecycle = null; + this._showTimeout = null; this._pendingActor = null; for (const actor of this._trackedActors) actor.disconnectObject(this); @@ -66,7 +71,7 @@ export class AppSearchTooltip extends Module { } private _connectHover(actor: any): void { - if (!actor || typeof actor.connect !== 'function') return; + if (!actor) return; const delegate = actor._delegate || actor; if (!delegate.metaInfo && !delegate.app) return; @@ -82,9 +87,9 @@ export class AppSearchTooltip extends Module { () => this._onHover(actor), 'destroy', () => { - if (this._pendingActor === actor && this._showTimeoutId > 0) { - GLib.source_remove(this._showTimeoutId); - this._showTimeoutId = 0; + const showTimeout = this._showTimeout; + if (this._pendingActor === actor && showTimeout && showTimeout.active) { + showTimeout.clear(); this._pendingActor = null; } this._trackedActors.delete(actor); @@ -96,6 +101,9 @@ export class AppSearchTooltip extends Module { } private _onHover(actor: any): void { + const showTimeout = this._showTimeout; + if (!showTimeout) return; + const isHovered = actor.get_hover() || actor.has_key_focus(); if (isHovered) { @@ -103,19 +111,19 @@ export class AppSearchTooltip extends Module { this._showTooltip(actor); return; } - if (this._showTimeoutId > 0) return; // already scheduled - this._showTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, SHOW_DELAY_MS, () => { - this._showTimeoutId = 0; - this._pendingActor = null; - if (actor.get_hover() || actor.has_key_focus()) this._showTooltip(actor); - return GLib.SOURCE_REMOVE; - }); + if (showTimeout.active) return; + + showTimeout.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, SHOW_DELAY_MS, () => { + showTimeout.complete(); + this._pendingActor = null; + if (actor.get_hover() || actor.has_key_focus()) this._showTooltip(actor); + return GLib.SOURCE_REMOVE; + }), + ); this._pendingActor = actor; } else { - if (this._showTimeoutId > 0) { - GLib.source_remove(this._showTimeoutId); - this._showTimeoutId = 0; - } + showTimeout.clear(); this._hideTooltip(); } } @@ -160,8 +168,9 @@ export class AppSearchTooltip extends Module { private _getActorName(actor: any): string | null { const delegate = actor._delegate || actor; - if (delegate.app) return (delegate.app.get_name() as string) ?? null; - if (delegate.metaInfo) return (delegate.metaInfo['name'] as string) ?? null; + if (delegate.app) return delegate.app.get_name() as string; + if (typeof delegate.metaInfo?.name === 'string') return delegate.metaInfo.name; + return null; } } diff --git a/src/patches/iconWeave.ts b/src/patches/iconWeave.ts index 48090d7..8b67efb 100644 --- a/src/patches/iconWeave.ts +++ b/src/patches/iconWeave.ts @@ -1,519 +1,42 @@ -import { gettext as _ } from '~/shared/i18n.ts'; -import GLib from '@girs/glib-2.0'; -import GioUnix from '@girs/giounix-2.0'; -import Shell from '@girs/shell-18'; -import Meta from '@girs/meta-18'; import type { ExtensionContext } from '~/core/context.ts'; -import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; -import { normalize, scoreIconWeaveCandidate } from '~/patches/iconWeaveScoring.ts'; -const WINDOW_INSPECT_DELAY_MS = 500; -const MIN_MATCH_SCORE = 50; -const LOG_PREFIX = 'IconWeave'; +import { IconWeaveInspector } from './iconWeaveInspector.ts'; +import { IconWeavePatches } from './iconWeavePatches.ts'; +import { IconWeaveWindowRegistry } from './iconWeaveRegistry.ts'; -const BLACKLISTED_PREFIXES = [ - 'org.gnome', - 'gnome-shell', - 'xdg', - 'org.mozilla', - 'teams-for-linux', - 'google-chrome', -]; - -const ALLOWED_WINDOW_TYPES = [ - Meta.WindowType.NORMAL, - Meta.WindowType.DIALOG, - Meta.WindowType.MODAL_DIALOG, -]; - -type TimeoutId = number; - -type ActorSignalIds = { - frameId: number; - destroyId: number; -}; - -/** - * Automatically matches untracked application windows with their corresponding - * .desktop files using an in-memory approach. - */ export class IconWeave extends Module { - private _displayConnectionId = 0; - private _processed = new Set<string>(); - private _pendingConnections = new Set<any>(); - private _actorConnections = new Map<any, ActorSignalIds>(); - private _timeoutSources = new Set<TimeoutId>(); - - // Maps a window to an app - private _windowAppMap = new Map<any, any>(); - - private _originalGetWindowApp: any = null; - private _originalAppGetWindows: any = null; - private _originalAppGetState: any = null; - private _originalGetRunning: any = null; - private _originalActivate: any = null; + private _registry: IconWeaveWindowRegistry | null = null; + private _patches: IconWeavePatches | null = null; + private _inspector: IconWeaveInspector | null = null; constructor(context: ExtensionContext) { super(context); } override enable(): void { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - - // Monkey-patch Shell.WindowTracker.get_window_app - const trackerProto = Shell.WindowTracker.prototype; - this._originalGetWindowApp = trackerProto.get_window_app; - - trackerProto.get_window_app = function (win: any) { - if (self._windowAppMap.has(win)) { - return self._windowAppMap.get(win); - } - return self._originalGetWindowApp.call(this, win); - }; - - // Monkey-patch Shell.App.prototype.get_windows - const appProto = Shell.App.prototype; - this._originalAppGetWindows = appProto.get_windows; - appProto.get_windows = function () { - const windows = self._originalAppGetWindows.call(this); - const thisId = this.get_id(); - - // Remove windows that have been re-mapped to a different app so that the - // original window-backed app (window:XXXX) becomes empty and is dropped - // from the dock, preventing a duplicate icon. - const filtered = windows.filter((win: any) => { - const mappedApp = self._windowAppMap.get(win); - return !mappedApp || mappedApp.get_id() === thisId; - }); - for (const [win, app] of self._windowAppMap.entries()) { - if (app.get_id() === thisId && !filtered.includes(win)) { - filtered.push(win); - } - } - return filtered; - }; - - // Monkey-patch Shell.App.prototype.get_state - this._originalAppGetState = appProto.get_state; - appProto.get_state = function () { - const state = self._originalAppGetState.call(this); - if (state === Shell.AppState.STOPPED) { - const thisId = this.get_id(); - for (const app of self._windowAppMap.values()) { - if (app.get_id() === thisId) return Shell.AppState.RUNNING; - } - } - return state; - }; - - // Monkey-patch Shell.App.prototype.activate so that clicking a dock icon - // for a mapped window focuses it instead of launching a new instance. - // activate() is a C-level method; it calls get_windows() at the C level, - // bypassing our JS prototype patch — so we must intercept activate() itself. - this._originalActivate = appProto.activate; - appProto.activate = function () { - const mappedWindows: any[] = []; - const thisId = this.get_id(); - for (const [win, app] of self._windowAppMap.entries()) { - if (app.get_id() === thisId) mappedWindows.push(win); - } - if (mappedWindows.length > 0) { - let best = mappedWindows[0]; - for (const w of mappedWindows) { - if (w.get_user_time() > best.get_user_time()) best = w; - } - best.activate(global.get_current_time()); - return; - } - return self._originalActivate.call(this); - }; - - // Monkey-patch Shell.AppSystem.prototype.get_running - const systemProto = Shell.AppSystem.prototype; - this._originalGetRunning = systemProto.get_running; - systemProto.get_running = function () { - const running = self._originalGetRunning.call(this); - const runningIds = new Set(running.map((r: any) => r.get_id())); - for (const app of self._windowAppMap.values()) { - const appId = app.get_id(); - if (!runningIds.has(appId)) { - running.push(app); - runningIds.add(appId); - } - } - return running; - }; - - this._displayConnectionId = global.display.connect( - 'window-created', - (_display: any, win: any) => this._scheduleInspection(win), - ); - } - - override disable(): void { - if (this._displayConnectionId) { - global.display.disconnect(this._displayConnectionId); - this._displayConnectionId = 0; - } - - if (this._originalGetWindowApp) { - Shell.WindowTracker.prototype.get_window_app = this._originalGetWindowApp; - this._originalGetWindowApp = null; - } - - if (this._originalAppGetWindows) { - Shell.App.prototype.get_windows = this._originalAppGetWindows; - this._originalAppGetWindows = null; - } - - if (this._originalAppGetState) { - Shell.App.prototype.get_state = this._originalAppGetState; - this._originalAppGetState = null; - } + this.disable(); - if (this._originalActivate) { - Shell.App.prototype.activate = this._originalActivate; - this._originalActivate = null; - } + this._registry = new IconWeaveWindowRegistry(); + this._patches = new IconWeavePatches(this._registry.mappings); - if (this._originalGetRunning) { - Shell.AppSystem.prototype.get_running = this._originalGetRunning; - this._originalGetRunning = null; - } - - for (const id of this._timeoutSources) GLib.source_remove(id); - this._timeoutSources.clear(); - - for (const win of this._pendingConnections) win.disconnectObject(this); - this._pendingConnections.clear(); - - for (const [actor, { frameId, destroyId }] of this._actorConnections) { - actor.disconnect(frameId); - actor.disconnect(destroyId); - } - this._actorConnections.clear(); - - this._processed.clear(); - this._windowAppMap.clear(); - } - - private _scheduleInspection(win: any): void { - if (!ALLOWED_WINDOW_TYPES.includes(win.get_window_type())) return; - - // Remove window from map when it is destroyed - const destroyId = win.connect('unmanaged', () => { - win.disconnect(destroyId); - this._windowAppMap.delete(win); - - // Allow re-inspection when the same app reopens after fully closing. - // If _processed retains the key, _inspectWindow's fast-path returns - // early without re-applying the icon mapping. - const wmClass: string = win.get_wm_class() ?? ''; - const appId: string = win.get_gtk_application_id() ?? ''; - const dedupeKey = wmClass || appId; - if (dedupeKey) { - const stillHasWindow = [...this._windowAppMap.keys()].some( - (w) => - (wmClass && (w.get_wm_class() ?? '') === wmClass) || - (appId && (w.get_gtk_application_id() ?? '') === appId), - ); - if (!stillHasWindow) this._processed.delete(dedupeKey); - } + const resolveNativeWindowApp = this._patches.install(); + this._inspector = new IconWeaveInspector({ + registry: this._registry, + resolveNativeWindowApp, + onMappingChanged: () => this.context.signals.emit('icons-woven'), }); - - // Attempt an immediate deterministic match — wmClass/appId are typically - // available at window-created time, before the compositor renders the first - // frame, so we can set the tracker mapping before the dock shows the icon. - this._inspectWindow(win); - - // Also hook first-frame to catch apps where the deterministic match failed - // and the title-based heuristic is still pending. - const actor = win.get_compositor_private(); - if (actor) { - this._attachFirstFrame(win, actor); - } else { - // Actor may not exist yet; wait one idle cycle and try again, then fall - // back to the timeout if the actor still isn't available. - const earlyId: TimeoutId = GLib.timeout_add(GLib.PRIORITY_HIGH, 0, () => { - this._timeoutSources.delete(earlyId); - const a = win.get_compositor_private(); - if (a) { - this._attachFirstFrame(win, a); - } else { - this._addFallbackTimeout(win); - } - return GLib.SOURCE_REMOVE; - }); - this._timeoutSources.add(earlyId); - } - } - - private _attachFirstFrame(win: any, actor: any): void { - let done = false; - let currentTimeoutId: TimeoutId = 0; - let destroyId = 0; - - const frameId = actor.connect('first-frame', () => { - if (done) return; - done = true; - this._actorConnections.delete(actor); - actor.disconnect(destroyId); - if (currentTimeoutId) { - this._timeoutSources.delete(currentTimeoutId); - GLib.source_remove(currentTimeoutId); - currentTimeoutId = 0; - } - this._inspectWindow(win); - }); - - // When the actor is destroyed, cancel any pending timeout so we never - // attempt to disconnect a disposed actor. - destroyId = actor.connect('destroy', () => { - done = true; - this._actorConnections.delete(actor); - if (currentTimeoutId) { - this._timeoutSources.delete(currentTimeoutId); - GLib.source_remove(currentTimeoutId); - currentTimeoutId = 0; - } - }); - this._actorConnections.set(actor, { frameId, destroyId }); - - // Fallback: if first-frame never fires (e.g. override-redirect windows), - // inspect after the normal delay anyway. - const timeoutId: TimeoutId = GLib.timeout_add( - GLib.PRIORITY_DEFAULT_IDLE, - WINDOW_INSPECT_DELAY_MS, - () => { - currentTimeoutId = 0; - this._timeoutSources.delete(timeoutId); - if (!done) { - done = true; - actor.disconnect(frameId); - actor.disconnect(destroyId); - this._actorConnections.delete(actor); - this._inspectWindow(win); - } - return GLib.SOURCE_REMOVE; - }, - ); - currentTimeoutId = timeoutId; - this._timeoutSources.add(timeoutId); - } - - private _addFallbackTimeout(win: any): void { - const id: TimeoutId = GLib.timeout_add( - GLib.PRIORITY_DEFAULT_IDLE, - WINDOW_INSPECT_DELAY_MS, - () => { - this._timeoutSources.delete(id); - this._inspectWindow(win); - return GLib.SOURCE_REMOVE; - }, - ); - this._timeoutSources.add(id); + this._inspector.start(); } - private _inspectWindow(win: any): void { - try { - const wmClass: string = win.get_wm_class() ?? ''; - const appId: string = win.get_gtk_application_id() ?? ''; - - if (!wmClass && !appId) { - this._waitForTitle(win); - return; - } - - const tracker = Shell.WindowTracker.get_default(); - // Temporarily use original method to check if GNOME already knows it - const currentApp = this._originalGetWindowApp.call(tracker, win); - if (this._isValidApp(currentApp) && !this._isGenericSteamApp(currentApp)) return; - - if (wmClass.toLowerCase() === appId.toLowerCase()) return; - - const dedupeKey = wmClass || appId; - if (this._processed.has(dedupeKey)) { - // We already processed this class, but this is a new window. - // Try to find if we have a mapped app for another window with same class - for (const [mappedWin, app] of this._windowAppMap.entries()) { - if ( - (wmClass && mappedWin.get_wm_class() === wmClass) || - (appId && mappedWin.get_gtk_application_id() === appId) - ) { - this._windowAppMap.set(win, app); - this.context.signals.emit('icons-woven'); - tracker.emit('tracked-windows-changed'); - return; - } - } - return; - } - - const title: string = win.get_title() ?? ''; - - logger.log(`untracked window: title="${title}" wm_class="${wmClass}" app_id="${appId}"`, { - prefix: LOG_PREFIX, - }); - - const appSystem = Shell.AppSystem.get_default(); - - // Cheap deterministic match — works with just wmClass/appId, no title needed. - // Called immediately on window-created to set the mapping before the dock - // renders, eliminating the generic-icon flash for most apps. - const deterministic = this._deterministicMatch(appSystem, wmClass, appId, title); - if (deterministic) { - logger.log(`deterministic match found: ${deterministic.get_id()} — applying`, { - prefix: LOG_PREFIX, - }); - this._windowAppMap.set(win, deterministic); - this.context.signals.emit('icons-woven'); - tracker.emit('tracked-windows-changed'); - deterministic.emit('windows-changed'); - this._processed.add(dedupeKey); - return; - } - - // Heuristic needs title for reliable scoring — defer until it's available. - if (!title) { - this._waitForTitle(win); - return; - } - - const candidate = this._heuristicMatch(appSystem, wmClass, appId, title); - if (candidate) { - logger.log(`heuristic match found: ${candidate.get_id()} — applying`, { - prefix: LOG_PREFIX, - }); - this._windowAppMap.set(win, candidate); - this.context.signals.emit('icons-woven'); - tracker.emit('tracked-windows-changed'); - candidate.emit('windows-changed'); - } else { - logger.log(`no candidate found for wm_class="${wmClass}"`, { prefix: LOG_PREFIX }); - } - - this._processed.add(dedupeKey); - } catch (e) { - logger.log(`_inspectWindow error: ${e}`, { prefix: LOG_PREFIX }); - } - } - - private _waitForTitle(win: any): void { - if (this._pendingConnections.has(win)) return; - this._pendingConnections.add(win); - win.connectObject( - 'notify::title', - () => { - win.disconnectObject(this); - this._pendingConnections.delete(win); - this._inspectWindow(win); - }, - 'unmanaged', - () => this._pendingConnections.delete(win), - this, - ); - } - - private _isValidApp(app: any): boolean { - if (!app) return false; - const id: string = app.get_id() ?? ''; - return id.length > 0 && !id.startsWith('window:'); - } - - private _isGenericSteamApp(app: any): boolean { - if (!app) return false; - const id: string = app.get_id() ?? ''; - const lowerId = id.toLowerCase(); - return lowerId === 'steam.desktop' || lowerId === 'com.valvesoftware.steam.desktop'; - } - - private _deterministicMatch(appSystem: any, wmClass: string, appId: string, title: string): any { - for (const prefix of BLACKLISTED_PREFIXES) { - if (wmClass.toLowerCase().startsWith(prefix)) return null; - } - - const candidates: string[] = []; - - if (title) { - candidates.push(`${title}.desktop`, `${title.toLowerCase()}.desktop`); - } - if (appId) { - candidates.push(`${appId}.desktop`, `${appId.toLowerCase()}.desktop`); - } - if (wmClass) { - candidates.push(`${wmClass}.desktop`, `${wmClass.toLowerCase()}.desktop`); - } - - for (const id of candidates) { - const app = appSystem.lookup_app(id); - if (app) return app; - } - - return null; - } - - private _heuristicMatch(appSystem: any, wmClass: string, appId: string, title: string): any { - for (const prefix of BLACKLISTED_PREFIXES) { - if (wmClass.toLowerCase().startsWith(prefix)) return null; - } - - let bestApp: any = null; - let bestScore = 0; - - // get_installed() returns AppInfo objects, not Shell.App — resolve each to a - // Shell.App via lookup_app so that the stored value supports get_windows(), - // create_icon_texture(), and the rest of the Shell.App API. - for (const appInfo of appSystem.get_installed()) { - const id = appInfo.get_id(); - if (!id) continue; - const app = appSystem.lookup_app(id); - if (!app) continue; - - const score = this._scoreCandidate(app, wmClass, appId, title); - if (score > bestScore) { - bestScore = score; - bestApp = app; - } - } - - if (bestScore >= MIN_MATCH_SCORE) { - logger.log(`heuristic match score=${bestScore}: ${bestApp.get_id()}`, { - prefix: LOG_PREFIX, - }); - return bestApp; - } - - return null; - } - - private _isSteamGame(app: any, wmClass: string): boolean { - const info = GioUnix.DesktopAppInfo.new(app.get_id()); - const exec: string = info?.get_string('Exec') ?? ''; - const steamMatch = exec.match(/steam:\/\/rungameid\/(\d+)/); - if (!steamMatch) return false; - - const gameId = steamMatch[1]; - const nWm = normalize(wmClass); - - if (nWm === `steamapp${gameId}`) return true; - - // Check if wmClass matches the app name abbreviation (e.g. "cs2" for "Counter-Strike 2") - const appName = String(app.get_name() ?? '').toLowerCase(); - const words = appName.split(/[^a-z0-9]/).filter((w: string) => w.length > 0); - const abbreviation = words.map((w: string) => w[0]).join(''); - if (nWm === abbreviation && abbreviation.length >= 2) return true; - - return false; - } - - private _scoreCandidate(app: any, wmClass: string, appId: string, title: string): number { - const desktopId = (app.get_id() ?? '').toLowerCase().replace(/\.desktop$/, ''); - const appName = String(app.get_name() ?? '').toLowerCase(); + override disable(): void { + this._inspector?.destroy(); + this._inspector = null; - if (this._isSteamGame(app, wmClass)) return 99; + this._patches?.destroy(); + this._patches = null; - return scoreIconWeaveCandidate({ desktopId, appName, wmClass, appId, title }); + this._registry?.clear(); + this._registry = null; } } diff --git a/src/patches/iconWeaveInspector.ts b/src/patches/iconWeaveInspector.ts new file mode 100644 index 0000000..27d036d --- /dev/null +++ b/src/patches/iconWeaveInspector.ts @@ -0,0 +1,325 @@ +import GioUnix from '@girs/giounix-2.0'; +import GLib from '@girs/glib-2.0'; +import Meta from '@girs/meta-18'; +import Shell from '@girs/shell-18'; + +import { LifecycleScope } from '~/core/lifecycleScope.ts'; +import { logger } from '~/core/logger.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; + +import { normalize, scoreIconWeaveCandidate } from './iconWeaveScoring.ts'; +import type { NativeWindowAppResolver } from './iconWeavePatches.ts'; +import type { IconWeaveWindowRegistry } from './iconWeaveRegistry.ts'; + +const WINDOW_INSPECT_DELAY_MS = 500; +const MIN_MATCH_SCORE = 50; +const LOG_PREFIX = 'IconWeave'; + +const BLACKLISTED_PREFIXES = [ + 'org.gnome', + 'gnome-shell', + 'xdg', + 'org.mozilla', + 'teams-for-linux', + 'google-chrome', +]; + +const ALLOWED_WINDOW_TYPES = [ + Meta.WindowType.NORMAL, + Meta.WindowType.DIALOG, + Meta.WindowType.MODAL_DIALOG, +]; + +type IconWeaveInspectorOptions = { + registry: IconWeaveWindowRegistry; + resolveNativeWindowApp: NativeWindowAppResolver; + onMappingChanged: () => void; +}; + +export class IconWeaveInspector { + private _lifecycle = new LifecycleScope(); + private _windowScopes = new Map<any, LifecycleScope>(); + private _titleScopes = new Map<any, LifecycleScope>(); + + constructor(private _options: IconWeaveInspectorOptions) {} + + start(): void { + this._lifecycle.connect(global.display, 'window-created', (_display: any, window: any) => { + this._schedule(window); + }); + } + + destroy(): void { + this._lifecycle.dispose(); + + for (const scope of this._titleScopes.values()) { + scope.dispose(); + } + this._titleScopes.clear(); + + for (const scope of this._windowScopes.values()) { + scope.dispose(); + } + this._windowScopes.clear(); + } + + private _schedule(window: any): void { + if (!ALLOWED_WINDOW_TYPES.includes(window.get_window_type())) return; + + const scope = new LifecycleScope(); + this._windowScopes.set(window, scope); + scope.connect(window, 'unmanaged', () => this._removeWindow(window)); + + this._matchWindow(window); + + const actor = window.get_compositor_private(); + if (actor) { + this._inspectAfterFirstFrame(window, actor, scope); + return; + } + + const idle = createManagedSource(scope); + idle.replace(() => + GLib.timeout_add(GLib.PRIORITY_HIGH, 0, () => { + idle.complete(); + + const compositorActor = window.get_compositor_private(); + if (compositorActor) { + this._inspectAfterFirstFrame(window, compositorActor, scope); + } else { + this._inspectAfterDelay(window, scope); + } + + return GLib.SOURCE_REMOVE; + }), + ); + } + + private _removeWindow(window: any): void { + const wmClass: string = window.get_wm_class() ?? ''; + const appId: string = window.get_gtk_application_id() ?? ''; + this._options.registry.remove(window, wmClass, appId); + + this._titleScopes.get(window)?.dispose(); + this._titleScopes.delete(window); + + const scope = this._windowScopes.get(window); + this._windowScopes.delete(window); + scope?.dispose(); + } + + private _inspectAfterFirstFrame(window: any, actor: any, scope: LifecycleScope): void { + const timeout = createManagedSource(scope); + let inspected = false; + + scope.connect(actor, 'first-frame', () => { + if (inspected) return; + + inspected = true; + timeout.clear(); + this._matchWindow(window); + }); + scope.connect(actor, 'destroy', () => { + inspected = true; + timeout.clear(); + }); + + timeout.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT_IDLE, WINDOW_INSPECT_DELAY_MS, () => { + timeout.complete(); + + if (!inspected) { + inspected = true; + this._matchWindow(window); + } + + return GLib.SOURCE_REMOVE; + }), + ); + } + + private _inspectAfterDelay(window: any, scope: LifecycleScope): void { + const timeout = createManagedSource(scope); + timeout.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT_IDLE, WINDOW_INSPECT_DELAY_MS, () => { + timeout.complete(); + this._matchWindow(window); + return GLib.SOURCE_REMOVE; + }), + ); + } + + private _matchWindow(window: any): void { + const wmClass: string = window.get_wm_class() ?? ''; + const appId: string = window.get_gtk_application_id() ?? ''; + + if (!wmClass && !appId) { + this._waitForTitle(window); + return; + } + + const currentApp = this._options.resolveNativeWindowApp(window); + if (this._isValidApp(currentApp) && !this._isGenericSteamApp(currentApp)) return; + if (wmClass.toLowerCase() === appId.toLowerCase()) return; + + const tracker = Shell.WindowTracker.get_default(); + const identity = wmClass || appId; + + if (this._options.registry.hasProcessed(identity)) { + const mappedApp = this._options.registry.findMappedApp(wmClass, appId); + if (mappedApp) { + this._applyMapping(window, mappedApp, tracker, false); + } + + return; + } + + const title: string = window.get_title() ?? ''; + logger.log(`untracked window: title="${title}" wm_class="${wmClass}" app_id="${appId}"`, { + prefix: LOG_PREFIX, + }); + + const appSystem = Shell.AppSystem.get_default(); + const deterministic = this._deterministicMatch(appSystem, wmClass, appId, title); + if (deterministic) { + logger.log(`deterministic match found: ${deterministic.get_id()} — applying`, { + prefix: LOG_PREFIX, + }); + this._applyMapping(window, deterministic, tracker); + this._options.registry.markProcessed(identity); + return; + } + + if (!title) { + this._waitForTitle(window); + return; + } + + const candidate = this._heuristicMatch(appSystem, wmClass, appId, title); + if (candidate) { + logger.log(`heuristic match found: ${candidate.get_id()} — applying`, { + prefix: LOG_PREFIX, + }); + this._applyMapping(window, candidate, tracker); + } else { + logger.log(`no candidate found for wm_class="${wmClass}"`, { prefix: LOG_PREFIX }); + } + + this._options.registry.markProcessed(identity); + } + + private _applyMapping(window: any, app: any, tracker: any, notifyApp = true): void { + this._options.registry.map(window, app); + this._options.onMappingChanged(); + tracker.emit('tracked-windows-changed'); + if (notifyApp) { + app.emit('windows-changed'); + } + } + + private _waitForTitle(window: any): void { + if (this._titleScopes.has(window)) return; + + const titleScope = new LifecycleScope(); + this._titleScopes.set(window, titleScope); + titleScope.connect(window, 'notify::title', () => { + this._titleScopes.delete(window); + titleScope.dispose(); + this._matchWindow(window); + }); + titleScope.connect(window, 'unmanaged', () => { + this._titleScopes.delete(window); + titleScope.dispose(); + }); + } + + private _isValidApp(app: any): boolean { + if (!app) return false; + + const id: string = app.get_id() ?? ''; + return id.length > 0 && !id.startsWith('window:'); + } + + private _isGenericSteamApp(app: any): boolean { + if (!app) return false; + + const id: string = app.get_id() ?? ''; + const lowerId = id.toLowerCase(); + return lowerId === 'steam.desktop' || lowerId === 'com.valvesoftware.steam.desktop'; + } + + private _deterministicMatch(appSystem: any, wmClass: string, appId: string, title: string): any { + if (this._isBlacklisted(wmClass)) return null; + + const identities = [title, appId, wmClass].filter(Boolean); + const candidates = identities.flatMap((identity) => [ + `${identity}.desktop`, + `${identity.toLowerCase()}.desktop`, + ]); + + for (const id of candidates) { + const app = appSystem.lookup_app(id); + if (app) return app; + } + + return null; + } + + private _heuristicMatch(appSystem: any, wmClass: string, appId: string, title: string): any { + if (this._isBlacklisted(wmClass)) return null; + + let bestApp: any = null; + let bestScore = 0; + + for (const appInfo of appSystem.get_installed()) { + const id = appInfo.get_id(); + if (!id) continue; + + const app = appSystem.lookup_app(id); + if (!app) continue; + + const score = this._scoreCandidate(app, wmClass, appId, title); + if (score <= bestScore) continue; + + bestScore = score; + bestApp = app; + } + + if (bestScore < MIN_MATCH_SCORE) return null; + + logger.log(`heuristic match score=${bestScore}: ${bestApp.get_id()}`, { + prefix: LOG_PREFIX, + }); + return bestApp; + } + + private _isBlacklisted(wmClass: string): boolean { + const normalizedClass = wmClass.toLowerCase(); + return BLACKLISTED_PREFIXES.some((prefix) => normalizedClass.startsWith(prefix)); + } + + private _scoreCandidate(app: any, wmClass: string, appId: string, title: string): number { + const desktopId = (app.get_id() ?? '').toLowerCase().replace(/\.desktop$/, ''); + const appName = String(app.get_name() ?? '').toLowerCase(); + + if (this._isSteamGame(app, wmClass)) return 99; + + return scoreIconWeaveCandidate({ desktopId, appName, wmClass, appId, title }); + } + + private _isSteamGame(app: any, wmClass: string): boolean { + const info = GioUnix.DesktopAppInfo.new(app.get_id()); + const executable: string = info?.get_string('Exec') ?? ''; + const steamMatch = executable.match(/steam:\/\/rungameid\/(\d+)/); + if (!steamMatch) return false; + + const gameId = steamMatch[1]; + const normalizedClass = normalize(wmClass); + if (normalizedClass === `steamapp${gameId}`) return true; + + const appName = String(app.get_name() ?? '').toLowerCase(); + const words = appName.split(/[^a-z0-9]/).filter((word: string) => word.length > 0); + const abbreviation = words.map((word: string) => word[0]).join(''); + return normalizedClass === abbreviation && abbreviation.length >= 2; + } +} diff --git a/src/patches/iconWeavePatches.ts b/src/patches/iconWeavePatches.ts new file mode 100644 index 0000000..5efc84b --- /dev/null +++ b/src/patches/iconWeavePatches.ts @@ -0,0 +1,97 @@ +import Shell from '@girs/shell-18'; + +type Patch = { prototype: any; name: string; original: any; wrapper: any }; +export type NativeWindowAppResolver = (window: any) => any; + +export class IconWeavePatches { + private _patches: Patch[] = []; + constructor(private _windowAppMap: ReadonlyMap<any, any>) {} + + install(): NativeWindowAppResolver { + const map = this._windowAppMap; + const tracker = Shell.WindowTracker.get_default(); + const originalGetWindowApp = Shell.WindowTracker.prototype.get_window_app; + + this._install(Shell.WindowTracker.prototype, 'get_window_app', function (original, win: any) { + return map.get(win) ?? original.call(this, win); + }); + + this._install(Shell.App.prototype, 'get_windows', function (original) { + const windows = original.call(this); + const id = this.get_id(); + const filtered = windows.filter((win: any) => { + const mapped = map.get(win); + return !mapped || mapped.get_id() === id; + }); + + for (const [win, app] of map) { + if (app.get_id() === id && !filtered.includes(win)) { + filtered.push(win); + } + } + + return filtered; + }); + + this._install(Shell.App.prototype, 'get_state', function (original) { + const state = original.call(this); + if (state !== Shell.AppState.STOPPED) return state; + + const id = this.get_id(); + for (const app of map.values()) { + if (app.get_id() === id) return Shell.AppState.RUNNING; + } + + return state; + }); + + this._install(Shell.App.prototype, 'activate', function (original) { + const id = this.get_id(); + const windows = [...map].filter(([, app]) => app.get_id() === id).map(([win]) => win); + if (windows.length === 0) return original.call(this); + + const best = windows.reduce((latest, win) => + win.get_user_time() > latest.get_user_time() ? win : latest, + ); + best.activate(global.get_current_time()); + }); + + this._install(Shell.AppSystem.prototype, 'get_running', function (original) { + const running = original.call(this); + const ids = new Set(running.map((app: any) => app.get_id())); + for (const app of map.values()) { + if (ids.has(app.get_id())) continue; + running.push(app); + ids.add(app.get_id()); + } + + return running; + }); + + return (window) => originalGetWindowApp.call(tracker, window); + } + + destroy(): void { + for (const patch of [...this._patches].reverse()) { + if (patch.prototype[patch.name] === patch.wrapper) { + patch.prototype[patch.name] = patch.original; + } + } + + this._patches = []; + } + + private _install( + prototype: any, + name: string, + invoke: (this: any, original: any, ...args: any[]) => any, + ): void { + const original = prototype[name]; + const wrapper = function (this: any, ...args: any[]) { + return invoke.call(this, original, ...args); + }; + + prototype[name] = wrapper; + this._patches.push({ prototype, name, original, wrapper }); + } +} diff --git a/src/patches/iconWeaveRegistry.ts b/src/patches/iconWeaveRegistry.ts new file mode 100644 index 0000000..3142fd2 --- /dev/null +++ b/src/patches/iconWeaveRegistry.ts @@ -0,0 +1,53 @@ +export class IconWeaveWindowRegistry { + private _mappings = new Map<any, any>(); + private _processed = new Set<string>(); + + get mappings(): ReadonlyMap<any, any> { + return this._mappings; + } + + hasProcessed(identity: string): boolean { + return this._processed.has(identity); + } + + markProcessed(identity: string): void { + this._processed.add(identity); + } + + map(window: any, app: any): void { + this._mappings.set(window, app); + } + + findMappedApp(wmClass: string, appId: string): any | null { + for (const [window, app] of this._mappings) { + const sameWmClass = Boolean(wmClass && window.get_wm_class() === wmClass); + const sameAppId = Boolean(appId && window.get_gtk_application_id() === appId); + + if (sameWmClass || sameAppId) return app; + } + + return null; + } + + remove(window: any, wmClass: string, appId: string): void { + this._mappings.delete(window); + + const identity = wmClass || appId; + if (!identity) return; + + const stillMapped = [...this._mappings.keys()].some( + (mappedWindow) => + (wmClass && mappedWindow.get_wm_class() === wmClass) || + (appId && mappedWindow.get_gtk_application_id() === appId), + ); + + if (!stillMapped) { + this._processed.delete(identity); + } + } + + clear(): void { + this._processed.clear(); + this._mappings.clear(); + } +} diff --git a/src/patches/iconWeaveScoring.ts b/src/patches/iconWeaveScoring.ts index 5942c33..c476b81 100644 --- a/src/patches/iconWeaveScoring.ts +++ b/src/patches/iconWeaveScoring.ts @@ -6,6 +6,26 @@ export type IconWeaveScoreInput = { title: string; }; +export type IconWeaveRegistration = { windowId: number; appId: string }; + +export function registerIconWeaveWindow( + registrations: ReadonlyMap<number, string>, + registration: IconWeaveRegistration, +): Map<number, string> { + const next = new Map(registrations); + next.set(registration.windowId, registration.appId); + return next; +} + +export function unregisterIconWeaveWindow( + registrations: ReadonlyMap<number, string>, + windowId: number, +): Map<number, string> { + const next = new Map(registrations); + next.delete(windowId); + return next; +} + const SHORT_ID_MIN_COVERAGE = 0.45; export function scoreIconWeaveCandidate(input: IconWeaveScoreInput): number { diff --git a/src/patches/pipOnTop.ts b/src/patches/pipOnTop.ts index c219bda..4fe80b5 100644 --- a/src/patches/pipOnTop.ts +++ b/src/patches/pipOnTop.ts @@ -58,7 +58,7 @@ export class PipOnTop extends Module { for (const [window, tracked] of this._trackedWindows) { this._restoreWindow(window, tracked); - this._safeDisconnect(window); + window.disconnectObject(this); } this._trackedWindows.clear(); } @@ -109,8 +109,6 @@ export class PipOnTop extends Module { tracked.syncing = true; try { this._restoreWindowState(toWindowState(window), tracked); - } catch { - // The window may have been unmanaged while the module was shutting down. } finally { tracked.syncing = false; } @@ -122,12 +120,4 @@ export class PipOnTop extends Module { restorePipWindow(state, tracked.ownership); tracked.ownership = null; } - - private _safeDisconnect(window: Meta.Window): void { - try { - window.disconnectObject(this); - } catch { - // The window may already have been disposed by Mutter. - } - } } diff --git a/src/patches/pipWindowPolicy.ts b/src/patches/pipWindowPolicy.ts index f627a67..5bd4a85 100644 --- a/src/patches/pipWindowPolicy.ts +++ b/src/patches/pipWindowPolicy.ts @@ -1,4 +1,4 @@ -const PIP_TITLES = new Set(['picture-in-picture', 'picture in picture']); +const PIP_TITLE_SUFFIX = /(?:^|\s[-|:]\s)(?:picture[- ]in[- ]picture|pip)$/; export interface PipWindowState { isAbove(): boolean; @@ -17,8 +17,11 @@ export interface PipWindowOwnership { export function isPipTitle(title: string | null): boolean { if (!title) return false; - const normalizedTitle = title.trim().toLowerCase(); - return PIP_TITLES.has(normalizedTitle) || normalizedTitle.endsWith(' - pip'); + const normalizedTitle = title + .trim() + .toLowerCase() + .replace(/[\u2010-\u2015]/g, '-'); + return PIP_TITLE_SUFFIX.test(normalizedTitle); } export function enforcePipWindow( diff --git a/src/patches/velaVpnQuickSettings.ts b/src/patches/velaVpnQuickSettings.ts index 926044f..7f18d52 100644 --- a/src/patches/velaVpnQuickSettings.ts +++ b/src/patches/velaVpnQuickSettings.ts @@ -6,6 +6,8 @@ import * as Main from '@girs/gnome-shell/ui/main'; import { logger } from '~/core/logger.ts'; import type { ExtensionContext } from '~/core/context.ts'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; import { Module } from '~/module.ts'; const LOG_PREFIX = 'VelaVpnQuickSettings'; @@ -20,76 +22,102 @@ export class VelaVpnQuickSettings extends Module { private _vpnToggle: any = null; private _originalActivateConnection: any = null; private _originalDeactivateConnection: any = null; - private _retryId = 0; + private _installedActivateConnection: any = null; + private _installedDeactivateConnection: any = null; + private _lifecycle: LifecycleScope | null = null; + private _retry: ManagedSource | null = null; constructor(context: ExtensionContext) { super(context); } override enable(): void { + this.disable(); + this._lifecycle = new LifecycleScope(); + this._retry = createManagedSource(this._lifecycle); this._patchWhenAvailable(); } override disable(): void { - if (this._retryId > 0) { - GLib.source_remove(this._retryId); - this._retryId = 0; - } + this._lifecycle?.dispose(); + this._lifecycle = null; + this._retry = null; + this._restorePatch(); + } + private _restorePatch(): void { if (this._vpnToggle) { - if (this._originalActivateConnection) + if (this._vpnToggle.activateConnection === this._installedActivateConnection) { this._vpnToggle.activateConnection = this._originalActivateConnection; - if (this._originalDeactivateConnection) + } + if (this._vpnToggle.deactivateConnection === this._installedDeactivateConnection) { this._vpnToggle.deactivateConnection = this._originalDeactivateConnection; + } } this._vpnToggle = null; this._originalActivateConnection = null; this._originalDeactivateConnection = null; + this._installedActivateConnection = null; + this._installedDeactivateConnection = null; } private _patchWhenAvailable(): void { + if (!this._retry) return; + const toggle = this._getVpnToggle(); if (!toggle) { - if (this._retryId === 0) { - this._retryId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { - this._retryId = 0; - this._patchWhenAvailable(); - return GLib.SOURCE_REMOVE; - }); + if (!this._retry.active) { + this._retry.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { + this._retry!.complete(); + this._patchWhenAvailable(); + return GLib.SOURCE_REMOVE; + }), + ); } return; } if (this._vpnToggle === toggle) return; - this.disable(); + this._restorePatch(); this._vpnToggle = toggle; - this._originalActivateConnection = toggle.activateConnection; - this._originalDeactivateConnection = toggle.deactivateConnection; + const originalActivateConnection = toggle.activateConnection; + const originalDeactivateConnection = toggle.deactivateConnection; + this._originalActivateConnection = originalActivateConnection; + this._originalDeactivateConnection = originalDeactivateConnection; - toggle.activateConnection = (connection: any) => { + const installedActivateConnection = (connection: any) => { this._setConnectionActive(connection, true, () => { - this._originalActivateConnection?.call(this._vpnToggle, connection); + if (this._vpnToggle === toggle) { + originalActivateConnection.call(toggle, connection); + } }); }; - toggle.deactivateConnection = (activeConnection: any) => { - const connection = activeConnection?.connection ?? activeConnection?.get_connection(); + const installedDeactivateConnection = (activeConnection: any) => { + const connection = activeConnection.connection; this._setConnectionActive(connection, false, () => { - this._originalDeactivateConnection?.call(this._vpnToggle, activeConnection); + if (this._vpnToggle === toggle) { + originalDeactivateConnection.call(toggle, activeConnection); + } }); }; + this._installedActivateConnection = installedActivateConnection; + this._installedDeactivateConnection = installedDeactivateConnection; + toggle.activateConnection = installedActivateConnection; + toggle.deactivateConnection = installedDeactivateConnection; logger.info('Routing VPN Quick Settings activation through Vela', { prefix: LOG_PREFIX }); } - private _setConnectionActive(connection: any, active: boolean, fallback?: () => void): void { - const path = connection?.get_path(); + private _setConnectionActive(connection: any, active: boolean, fallback: () => void): void { + const path = connection.get_path(); if (!path) { logger.warn('Cannot route VPN activation without a NetworkManager connection path', { prefix: LOG_PREFIX, }); - if (this.context.settings.getBoolean(SHELL_FALLBACK_KEY)) fallback?.(); + if (this.context.settings.getBoolean(SHELL_FALLBACK_KEY)) fallback(); return; } @@ -103,7 +131,7 @@ export class VelaVpnQuickSettings extends Module { if (!this._shouldFallbackToShell(remoteErrorName)) return; logger.info(`Using GNOME Shell fallback after ${remoteErrorName}`, { prefix: LOG_PREFIX }); - fallback?.(); + fallback(); }); } @@ -144,10 +172,15 @@ export class VelaVpnQuickSettings extends Module { private _getNetworkIndicator(): any { const statusArea = (Main.panel as any).statusArea; - return statusArea.quickSettings?._network ?? statusArea.aggregateMenu?._network ?? null; + if (!statusArea.quickSettings) return null; + + return statusArea.quickSettings._network ?? null; } private _getVpnToggle(): any { - return this._getNetworkIndicator()?._vpnToggle ?? null; + const indicator = this._getNetworkIndicator(); + if (!indicator) return null; + + return indicator._vpnToggle; } } diff --git a/src/patches/xwaylandIndicator.ts b/src/patches/xwaylandIndicator.ts index c9bc2bf..df16965 100644 --- a/src/patches/xwaylandIndicator.ts +++ b/src/patches/xwaylandIndicator.ts @@ -8,63 +8,57 @@ import type { ExtensionContext } from '~/core/context.ts'; import { createIcon } from '~/shared/icons.ts'; import { Module } from '~/module.ts'; +type SwitcherPatch = { + prototype: any; + original: (...args: any[]) => void; + wrapper: (...args: any[]) => void; +}; + export class XwaylandIndicator extends Module { - private _origAppPopupInit: ((...args: unknown[]) => void) | null = null; - private _origWinPopupInit: ((...args: unknown[]) => void) | null = null; + private _patches: SwitcherPatch[] = []; constructor(context: ExtensionContext) { super(context); } override enable(): void { - this._patchAppSwitcherPopup(); - this._patchWindowSwitcherPopup(); + this._installPatch(AltTab.AppSwitcherPopup.prototype, (list) => this._decorateAppItems(list)); + this._installPatch(AltTab.WindowSwitcherPopup.prototype, (list) => + this._decorateWindowItems(list), + ); } override disable(): void { - if (this._origAppPopupInit) { - AltTab.AppSwitcherPopup.prototype._init = this._origAppPopupInit; - this._origAppPopupInit = null; - } - if (this._origWinPopupInit) { - AltTab.WindowSwitcherPopup.prototype._init = this._origWinPopupInit; - this._origWinPopupInit = null; + for (const patch of [...this._patches].reverse()) { + if (patch.prototype._init === patch.wrapper) { + patch.prototype._init = patch.original; + } } - } - - private _patchAppSwitcherPopup(): void { - const origInit = AltTab.AppSwitcherPopup.prototype._init; - const decorate = this._decorateAppItems.bind(this); - this._origAppPopupInit = origInit; - AltTab.AppSwitcherPopup.prototype._init = function (...args: any[]) { - origInit.apply(this, args as []); - decorate((this as any)._switcherList); - }; + this._patches = []; } - private _patchWindowSwitcherPopup(): void { - const origInit = AltTab.WindowSwitcherPopup.prototype._init; - const decorate = this._decorateWindowItems.bind(this); - this._origWinPopupInit = origInit; - - AltTab.WindowSwitcherPopup.prototype._init = function (...args: any[]) { - origInit.apply(this, args as []); + private _installPatch(prototype: any, decorate: (list: any) => void): void { + const original = prototype._init; + const wrapper = function (this: any, ...args: any[]) { + original.apply(this, args); decorate((this as any)._switcherList); }; + + prototype._init = wrapper; + this._patches.push({ prototype, original, wrapper }); } private _decorateAppItems(list: any): void { - const icons: any[] = list?.icons ?? list?._appIcons ?? []; - const items: any[] = list?._items ?? []; + const icons: any[] = list.icons; + const items: any[] = list._items; icons.forEach((icon: any, i: number) => { - const app = icon?.app; - if (!app?.get_windows) return; + const app = icon.app; const isX11 = app .get_windows() - .some((w: any) => w.get_client_type?.() === Meta.WindowClientType.X11); + .some((window: Meta.Window) => window.get_client_type() === Meta.WindowClientType.X11); if (isX11 && items[i]) { this._addBadge(items[i]); @@ -73,22 +67,18 @@ export class XwaylandIndicator extends Module { } private _decorateWindowItems(list: any): void { - const windows: any[] = list?.windows ?? list?._windows ?? []; - const items: any[] = list?._items ?? []; - - windows.forEach((win: any, i: number) => { - try { - if (win.get_client_type?.() === Meta.WindowClientType.X11 && items[i]) { - this._addBadge(items[i]); - } - } catch { - // Window may have been closed between listing and decorating + const windows: any[] = list.windows; + const items: any[] = list._items; + + windows.forEach((window: Meta.Window, index: number) => { + if (window.get_client_type() === Meta.WindowClientType.X11 && items[index]) { + this._addBadge(items[index]); } }); } private _addBadge(item: Clutter.Actor): void { - const iconActor = item?.get_first_child(); + const iconActor = item.get_first_child(); if (!iconActor) return; const wrapper = new St.Widget({ diff --git a/src/privacy/privacyPanel.ts b/src/privacy/privacyPanel.ts index ec8c06b..16d8dd9 100644 --- a/src/privacy/privacyPanel.ts +++ b/src/privacy/privacyPanel.ts @@ -8,7 +8,6 @@ import { getSharingIndicator } from '~/privacy/sharingIndicator.ts'; const FADE_DURATION = 200; const EASE_MODE = Clutter.AnimationMode.EASE_OUT_QUAD; -// _rightBox is handled per-child; indicator restored explicitly after fade const FULL_BOXES = ['_leftBox', '_centerBox'] as const; const LOG_PREFIX = 'PrivacyPanel'; @@ -63,7 +62,7 @@ export class PrivacyPanel extends Module { this._startupCompleteId = null; } - this._indicator?.disconnectObject(this); + if (this._indicator) this._indicator.disconnectObject(this); this._indicator = null; Main.panel.disconnectObject(this); @@ -73,7 +72,7 @@ export class PrivacyPanel extends Module { } private _onSharingChanged(): void { - this._isSharing = this._indicator?.visible ?? false; + this._isSharing = this._indicator ? this._indicator.visible : false; this._fadeContent(this._isSharing ? 0 : 255); } @@ -85,24 +84,29 @@ export class PrivacyPanel extends Module { private _onPanelLeave(): void { if (!this._isSharing) return; if (Main.overview.visible) return; - if ((Main.panel.menuManager as any)?.activeMenu) return; + if ((Main.panel.menuManager as any).activeMenu) return; this._fadeContent(0); } private _fadeContent(opacity: number): void { const panelAny = Main.panel as any; for (const box of FULL_BOXES) { - panelAny[box]?.ease({ opacity, duration: FADE_DURATION, mode: EASE_MODE }); + panelAny[box].ease({ opacity, duration: FADE_DURATION, mode: EASE_MODE }); } - for (const child of panelAny._rightBox?.get_children() ?? []) { + for (const child of panelAny._rightBox.get_children()) { child.ease({ opacity, duration: FADE_DURATION, mode: EASE_MODE }); } - // Always keep the sharing indicator visible regardless of its actor wrapping if (opacity === 0) { - this._indicator?.ease({ opacity: 255, duration: FADE_DURATION, mode: EASE_MODE }); - this._indicator?.container?.ease({ opacity: 255, duration: FADE_DURATION, mode: EASE_MODE }); + if (!this._indicator) return; + + this._indicator.ease({ opacity: 255, duration: FADE_DURATION, mode: EASE_MODE }); + this._indicator.container.ease({ + opacity: 255, + duration: FADE_DURATION, + mode: EASE_MODE, + }); } } @@ -111,7 +115,7 @@ export class PrivacyPanel extends Module { for (const box of FULL_BOXES) { if (panelAny[box]) panelAny[box].opacity = 255; } - for (const child of panelAny._rightBox?.get_children() ?? []) { + for (const child of panelAny._rightBox.get_children()) { child.opacity = 255; } } diff --git a/src/privacy/sharingIndicator.ts b/src/privacy/sharingIndicator.ts index 21e9d57..34d3f9b 100644 --- a/src/privacy/sharingIndicator.ts +++ b/src/privacy/sharingIndicator.ts @@ -1,13 +1,8 @@ import * as Main from '@girs/gnome-shell/ui/main'; -/** - * Resolves the panel screen-sharing indicator, shared by the privacy submodules. - * - * GNOME Shell 49+ exposes a dedicated `screenSharing` status area indicator; - * older shells only have `quickSettings._remoteAccess`. - */ export function getSharingIndicator(): any | null { const statusArea = Main.panel.statusArea as any; - if (statusArea.screenSharing) return statusArea.screenSharing; - return statusArea.quickSettings?._remoteAccess ?? null; + if (!statusArea.screenSharing) return null; + + return statusArea.screenSharing; } diff --git a/src/shared/ui/dash.ts b/src/shared/ui/dash.ts index 2c8ae4d..de37ada 100644 --- a/src/shared/ui/dash.ts +++ b/src/shared/ui/dash.ts @@ -8,16 +8,13 @@ import * as Main from '@girs/gnome-shell/ui/main'; import * as DND from '@girs/gnome-shell/ui/dnd'; import { Dash } from '@girs/gnome-shell/ui/dash'; -import { logger } from '~/core/logger.ts'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; import { UnredirectInhibitor } from '~/core/unredirectInhibitor.ts'; -import { TrashIcon, type TrashIconInstance } from '~/dock/trashIcon.ts'; -import { canLaunchTrash, NAUTILUS_APP_ID } from '~/dock/trashLauncher.ts'; -import { - ExternalStorageMonitor, - createExternalStorageIcon, - type ExternalStorageIconInstance, - type ExternalStorageItem, -} from '~/dock/externalStorageIcon.ts'; +import { DashFixedItems } from '~/shared/ui/dashFixedItems.ts'; +import { DashApplicationController } from '~/shared/ui/dashApplications.ts'; +import { DashSpringLoadCoordinator } from '~/shared/ui/dashSpringLoad.ts'; +import { DashVisibilityController } from '~/shared/ui/dashVisibility.ts'; import { boundsContainPoint, boundsEqual, @@ -29,17 +26,7 @@ export type { DashBounds } from '~/shared/ui/dashLayout.ts'; type TargetBoxListener = (bounds: DashBounds | null) => void; -const AUTOHIDE_TIMEOUT = 100; -const SPRING_LOAD_DELAY = 400; const ANIMATION_TIME = 200; -const VISIBILITY_ANIMATION_TIME = 200; -const HIDE_SCALE = 0.98; -const EASE_DURATION_FACTOR = 0.8; -const FULL_OPACITY = 255; -const PIVOT_CENTER_BOTTOM: [number, number] = [0.5, 1]; -const LOG_PREFIX = 'DockDash'; - -type VisibilityTarget = 'shown' | 'hidden'; interface AuroraDashParams { monitorIndex?: number; @@ -49,8 +36,9 @@ interface AuroraDashParams { } type DashInternals = { - _dashContainer?: St.Widget; - _showAppsIcon?: St.Widget; + _dashContainer: St.Widget; + _showAppsIcon: St.Widget; + _appSystem: Shell.AppSystem; iconSize: number; _hookUpLabel(item: unknown): void; }; @@ -61,73 +49,77 @@ export class AuroraDash extends Dash { declare private _isolateMonitor: boolean; private _workArea: DashBounds | null = null; private _container: St.Bin | null = null; - private _autohideTimeoutId = 0; - private _delayEnsureAutoHideId = 0; - private _blockAutoHideDelayId = 0; - private _workAreaUpdateId = 0; - private _iconResizeTimeoutId = 0; + declare private _dashBox: St.Widget | null; + declare private _lifecycle: LifecycleScope; + declare private _workAreaUpdate: ManagedSource; + declare private _iconResizeTimeout: ManagedSource; private _targetBox: DashBounds | null = null; - private _blockAutoHide = false; - private _itemDragVisibilityHold = false; - private _dashContainerHover = false; - private _isDestroyed = false; private _flushMode = false; private _targetBoxListener: TargetBoxListener | null = null; - private _pendingShow: { animate: boolean } | null = null; - declare private _visibilityTarget: VisibilityTarget; - declare private _showCompletionCallbacks: Array<() => void>; - private _springLoadTimerId = 0; - private _springLoadTarget: any = null; - private _springLoadDragMonitor: { dragMotion: (e: any) => number } | null = null; - declare private _trashIcon: TrashIconInstance | null; - private _externalStorageMonitor: ExternalStorageMonitor | null = null; - declare private _externalStorageIcons: ExternalStorageIconInstance[]; + declare private _visibility: DashVisibilityController; + declare private _springLoad: DashSpringLoadCoordinator; + declare private _fixedItems: DashFixedItems; + declare private _applications: DashApplicationController; declare private _unredirectInhibitor: UnredirectInhibitor; override _init(params: AuroraDashParams = {}): void { super._init(); - this._visibilityTarget = this.visible ? 'shown' : 'hidden'; - this._showCompletionCallbacks = []; - this._trashIcon = null; - this._externalStorageIcons = []; + this._lifecycle = new LifecycleScope(); + this._workAreaUpdate = createManagedSource(this._lifecycle); + this._iconResizeTimeout = createManagedSource(this._lifecycle); + this._monitorIndex = params.monitorIndex ?? Main.layoutManager.primaryIndex; this._isolateMonitor = params.isolateMonitor ?? true; this._unredirectInhibitor = new UnredirectInhibitor(global.compositor); + this._visibility = new DashVisibilityController(this, { + getContentActor: () => this._dashBox, + getContainer: () => this._container, + getMonitorIndex: () => this._monitorIndex, + getWorkArea: () => this._workArea, + isMenuOpen: () => this._isMenuOpen(), + applyWorkArea: (workArea) => this.applyWorkArea(workArea), + queueTargetBoxUpdate: () => this._queueTargetBoxUpdate(), + showActor: () => Dash.prototype.show.call(this), + hideActor: () => Dash.prototype.hide.call(this), + }); this.connect('notify::mapped', () => this._unredirectInhibitor.setInhibited(this.mapped)); const button = (this as any).showAppsButton; - button?.set_toggle_mode(false); - button?.connectObject('clicked', () => Main.overview.showApps(), this); + button.set_toggle_mode(false); + button.connectObject('clicked', () => Main.overview.showApps(), this); const dashContainer = this._dashInternals._dashContainer; - dashContainer?.set_track_hover(true); - dashContainer?.set_reactive(true); - dashContainer?.connectObject( + dashContainer.set_track_hover(true); + dashContainer.set_reactive(true); + dashContainer.connectObject( 'notify::hover', () => { - this._dashContainerHover = dashContainer.get_hover(); - this._onHover(); + this._visibility.setHovered(dashContainer.get_hover()); }, 'destroy', () => this._onDashContainerDestroyed(), this, ); - this.set_x_align?.(Clutter.ActorAlign.CENTER); - this.set_y_align?.(Clutter.ActorAlign.END); - this.set_x_expand?.(false); - this.set_y_expand?.(false); + this.set_x_align(Clutter.ActorAlign.CENTER); + this.set_y_align(Clutter.ActorAlign.END); + this.set_x_expand(false); + this.set_y_expand(false); - this.connectObject?.('notify::allocation', () => this._queueTargetBoxUpdate(), this); + this.connectObject('notify::allocation', () => this._queueTargetBoxUpdate(), this); // Track _box allocation so the chrome container follows the dash's // preferred width every frame. Critical during drag: the placeholder // animates scale 0→1, so a one-shot resize would lock the container // at the half-scaled width. - (this as any)._box?.connectObject( + const box = (this as any)._box; + this._dashBox = box; + box.connectObject( 'notify::allocation', () => this._queueWorkAreaUpdate(), + 'destroy', + () => this._onDashContentDestroyed(box), this, ); @@ -145,122 +137,45 @@ export class AuroraDash extends Dash { this, ); - this._setupSpringLoadMonitor(); + this._springLoad = new DashSpringLoadCoordinator( + () => (this as any)._box, + (window) => this._applications.isWindowRelevant(window), + ); + + this._applications = new DashApplicationController({ + getContentActor: () => this._dashBox, + getMonitorIndex: () => this._monitorIndex, + getIsolateMonitor: () => this._isolateMonitor, + }); - if (params.showExternalStorage) this._setupExternalStorageIcons(); - if (params.showTrash) this._setupTrashIcon(); + this._fixedItems = new DashFixedItems( + this, + this._dashInternals, + params.showTrash ?? false, + params.showExternalStorage ?? false, + () => { + this._queueRedisplay(); + if (this._workArea) this._queueWorkAreaUpdate(); + }, + ); } private get _dashInternals(): DashInternals { return this as unknown as DashInternals; } - private _removeSource(id: number): 0 { - if (id !== 0) GLib.source_remove(id); - return 0; - } - private _onDashContainerDestroyed(): void { - this._isDestroyed = true; - this._autohideTimeoutId = this._removeSource(this._autohideTimeoutId); - this._delayEnsureAutoHideId = this._removeSource(this._delayEnsureAutoHideId); - this._blockAutoHideDelayId = this._removeSource(this._blockAutoHideDelayId); - this._itemDragVisibilityHold = false; - this._dashContainerHover = false; - delete this._dashInternals._dashContainer; + this._dashBox = null; + this._visibility.handleContainerDestroyed(); + delete (this as unknown as { _dashContainer?: St.Widget })._dashContainer; } - private _setupTrashIcon(): void { - const dash = this._dashInternals; - const dashContainer = dash._dashContainer; - if (!dashContainer) return; - - if (!this._canLaunchTrashWithNautilus()) { - logger.debug('Trash icon disabled: Nautilus is unavailable', { prefix: LOG_PREFIX }); - return; - } - - const trashIcon = new TrashIcon() as TrashIconInstance; - this._trashIcon = trashIcon; - trashIcon.show(false); - trashIcon.setIconSize(dash.iconSize); - dash._hookUpLabel(trashIcon); - - const showAppsIcon = dash._showAppsIcon; - const showAppsIndex = showAppsIcon ? dashContainer.get_children().indexOf(showAppsIcon) : -1; - if (showAppsIndex >= 0) { - dashContainer.insert_child_at_index(trashIcon, showAppsIndex); - } else { - dashContainer.add_child(trashIcon); + private _onDashContentDestroyed(box: St.Widget): void { + if (this._dashBox === box) { + this._dashBox = null; } - this.connectObject?.('icon-size-changed', () => trashIcon.setIconSize(dash.iconSize), this); - } - - private _setupExternalStorageIcons(): void { - const dash = this._dashInternals; - if (!dash._dashContainer) return; - - this._externalStorageMonitor = new ExternalStorageMonitor((items) => { - this._syncExternalStorageIcons(items); - }); - this._syncExternalStorageIcons(this._externalStorageMonitor.items); - this.connectObject?.( - 'icon-size-changed', - () => { - for (const icon of this._externalStorageIcons) { - icon.setIconSize(dash.iconSize); - } - }, - this, - ); - } - - private _syncExternalStorageIcons(items: readonly ExternalStorageItem[]): void { - if (this._isDestroyed) return; - - for (const icon of this._externalStorageIcons) { - icon.destroy(); - } - this._externalStorageIcons = []; - - const dash = this._dashInternals; - const dashContainer = dash._dashContainer; - if (!dashContainer) return; - - for (const item of items) { - const storageIcon = createExternalStorageIcon(item); - storageIcon.show(false); - storageIcon.setIconSize(dash.iconSize); - dash._hookUpLabel(storageIcon); - this._externalStorageIcons.push(storageIcon); - } - - const fixedAnchor = this._trashIcon ?? dash._showAppsIcon; - const fixedAnchorIndex = fixedAnchor ? dashContainer.get_children().indexOf(fixedAnchor) : -1; - - for (const [offset, icon] of this._externalStorageIcons.entries()) { - if (fixedAnchorIndex >= 0) { - dashContainer.insert_child_at_index(icon, fixedAnchorIndex + offset); - } else { - dashContainer.add_child(icon); - } - } - - // Adding/removing storage icons changes how many icons must share the - // work-area width, but setMaxSize sees the same bounds and skips its - // redisplay. Queue one explicitly so _adjustIconSize reruns on hotplug. - this._queueRedisplay(); - if (this._workArea) this._queueWorkAreaUpdate(); - } - - private _canLaunchTrashWithNautilus(): boolean { - return canLaunchTrash({ - getNautilusExecutable: () => { - const app = Shell.AppSystem.get_default().lookup_app(NAUTILUS_APP_ID); - return app?.get_app_info().get_executable() ?? null; - }, - }); + this._visibility.handleContainerDestroyed(); } get monitorIndex(): number { @@ -289,7 +204,7 @@ export class AuroraDash extends Dash { } get pointerInsideDock(): boolean { - return this._dashContainerHasHover(); + return this._visibility.hovered; } containsStagePoint(x: number, y: number): boolean { @@ -301,14 +216,9 @@ export class AuroraDash extends Dash { } override destroy(): void { - this._isDestroyed = true; - this._unredirectInhibitor.release(); - this._autohideTimeoutId = this._removeSource(this._autohideTimeoutId); - this._delayEnsureAutoHideId = this._removeSource(this._delayEnsureAutoHideId); - this._blockAutoHideDelayId = this._removeSource(this._blockAutoHideDelayId); - this._workAreaUpdateId = this._removeSource(this._workAreaUpdateId); - this._iconResizeTimeoutId = this._removeSource(this._iconResizeTimeoutId); - this._springLoadTimerId = this._removeSource(this._springLoadTimerId); + this._lifecycle.dispose(); + this._visibility.destroy(); + this._springLoad.destroy(); // Remove the global DND drag monitor so its captured `this` doesn't // keep firing against a disposed AuroraDash if the dash is destroyed @@ -316,49 +226,35 @@ export class AuroraDash extends Dash { // removes it on drag end but never on early disposal. const dashAny = this as any; if (dashAny._dragMonitor) { - try { - DND.removeDragMonitor(dashAny._dragMonitor); - } catch { - // Already removed by base _endItemDrag — ignore. - } + DND.removeDragMonitor(dashAny._dragMonitor); dashAny._dragMonitor = null; } - if (this._springLoadDragMonitor) { - try { - DND.removeDragMonitor(this._springLoadDragMonitor); - } catch (_e) { - /* already removed */ - } - this._springLoadDragMonitor = null; - } - this._springLoadTarget = null; - this._externalStorageMonitor?.destroy(); - this._externalStorageMonitor = null; - for (const icon of this._externalStorageIcons) { - icon.destroy(); + (this as any).showAppsButton.disconnectObject(this); + + if (this._dashBox) { + this._dashBox.disconnectObject(this); + this._dashBox = null; } - this._externalStorageIcons = []; - (this as any).showAppsButton?.disconnectObject(this); - (this as any)._box?.disconnectObject(this); Main.overview.disconnectObject(this); global.display.disconnectObject(this); global.workspace_manager.disconnectObject(this); - (this as any)._dashContainer?.disconnectObject(this); + (this as any)._dashContainer.disconnectObject(this); this._container?.disconnectObject(this); (global.backend as any).get_dnd().disconnectObject(this); + this._fixedItems.destroy(); + this._unredirectInhibitor.release(); this._container = null; this._targetBox = null; - this._pendingShow = null; - this._showCompletionCallbacks = []; super.destroy(); } override _queueRedisplay(): void { - if (this._isDestroyed) return; + if (!this._dashBox) return; + super._queueRedisplay(); } @@ -379,17 +275,14 @@ export class AuroraDash extends Dash { } private _syncLabelFlushMode(): void { - const items: any[] = [...this._getDashChildren(), (this as any)._showAppsIcon]; + const items: any[] = [...this._applications.getChildren(), (this as any)._showAppsIcon]; for (const item of items) { - try { - if (!item?.label) continue; - if (this._flushMode) { - item.label.add_style_class_name('flush-mode'); - } else { - item.label.remove_style_class_name('flush-mode'); - } - } catch { - // Ignore children disposed during shell shutdown. + if (!item?.label) continue; + + if (this._flushMode) { + item.label.add_style_class_name('flush-mode'); + } else { + item.label.remove_style_class_name('flush-mode'); } } } @@ -405,7 +298,7 @@ export class AuroraDash extends Dash { this._container?.disconnectObject(this); this._container = container; - (container as any).connectObject?.( + container.connectObject( 'notify::allocation', () => this._queueTargetBoxUpdate(), 'destroy', @@ -423,276 +316,75 @@ export class AuroraDash extends Dash { this._container = null; this._targetBox = null; this._targetBoxListener?.(null); - this._pendingShow = null; + this._visibility.handleContainerDetached(); } applyWorkArea(workArea: DashBounds): void { - if (this._isDestroyed) return; + if (!this._dashBox) return; + this._workArea = workArea; if (!this._container) return; - try { - // Provide the dash with its maximum bounds so it can automatically - // shrink the iconSize when there are too many apps to fit. - this.setMaxSize(workArea.width, workArea.height); + // Provide the dash with its maximum bounds so it can automatically + // shrink the iconSize when there are too many apps to fit. + this.setMaxSize(workArea.width, workArea.height); - const [, prefW] = this.get_preferred_width(workArea.width); - const width = Math.min(Math.max(prefW, 0), workArea.width); + const [, prefW] = this.get_preferred_width(workArea.width); + const width = Math.min(Math.max(prefW, 0), workArea.width); - const [, prefH] = this.get_preferred_height(width || workArea.width); - const placement = calculateDashPlacement(workArea, prefW, prefH, this._getMarginBottom()); + const [, prefH] = this.get_preferred_height(width || workArea.width); + const placement = calculateDashPlacement(workArea, prefW, prefH, this._getMarginBottom()); - this._container.set_size(placement.width, placement.height); - this._container.set_position(placement.x, placement.y); - this._queueTargetBoxUpdate(); - } catch (_e) { - if (!this._isDestroyed) throw _e; - } + this._container.set_size(placement.width, placement.height); + this._container.set_position(placement.x, placement.y); + this._queueTargetBoxUpdate(); } blockAutoHide(block: boolean): void { - this._blockAutoHide = block; - if (block && !Main.overview.visible) { - this.show(true); - } else if (!block) { - this._ensureHoverState(); - } - this._onHover(); + this._visibility.blockAutoHide(block); } forceAutoHide(animate = true): void { - this._blockAutoHide = false; - - this._blockAutoHideDelayId = this._removeSource(this._blockAutoHideDelayId); - this._autohideTimeoutId = this._removeSource(this._autohideTimeoutId); - - this.hide(animate); + this._visibility.forceAutoHide(animate); } ensureAutoHide(): void { - this._delayEnsureAutoHideId = this._removeSource(this._delayEnsureAutoHideId); - this._delayEnsureAutoHideId = GLib.timeout_add( - GLib.PRIORITY_DEFAULT, - VISIBILITY_ANIMATION_TIME, - () => { - this._onHover(); - this._delayEnsureAutoHideId = 0; - return GLib.SOURCE_REMOVE; - }, - ); + this._visibility.ensureAutoHide(); } override show(animate = true, onComplete?: () => void): void { - this._container?.show(); - this._setContainerInputEnabled(true); - if (onComplete) this._showCompletionCallbacks.push(onComplete); - - // Monitor topology changes can emit several allocation/work-area and - // intellihide updates for the same visible state. Restarting an active - // show transition from the hidden pose on every update makes the dock - // flash rapidly, especially on rotated external monitors. - if (this._visibilityTarget === 'shown') { - if (this._isFullyShown()) this._flushShowCompletionCallbacks(); - return; - } - - logger.debug( - `monitor=${this._monitorIndex} visibility ${this._visibilityTarget}->shown animate=${animate}`, - { prefix: LOG_PREFIX }, - ); - this._visibilityTarget = 'shown'; - - if (!this._hasValidAllocation()) { - this._pendingShow = { animate }; - return; - } - this._pendingShow = null; - this._performShow(animate); + this._visibility.show(animate, onComplete); } override hide(animate = true): void { - if (this._itemDragVisibilityHold) { - this.show(false); - return; - } - - // The chrome container remains allocated while the dash is hidden. If it - // stays reactive, its transparent bounds win Clutter picking and create a - // dead area over controls in windows underneath the dock. - this._setContainerInputEnabled(false); - - if (this._visibilityTarget === 'hidden') { - if (this._isFullyHidden()) return; - logger.debug(`monitor=${this._monitorIndex} visibility hidden resync animate=${animate}`, { - prefix: LOG_PREFIX, - }); - } else { - logger.debug( - `monitor=${this._monitorIndex} visibility ${this._visibilityTarget}->hidden animate=${animate}`, - { prefix: LOG_PREFIX }, - ); - } - this._visibilityTarget = 'hidden'; - this._pendingShow = null; - this._showCompletionCallbacks = []; - - if (this._isFullyHidden()) return; - - this.remove_all_transitions(); - this.set_pivot_point(...PIVOT_CENTER_BOTTOM); - - if (!animate) { - this._applyHiddenState(); - super.hide(); - return; - } - - this.ease({ - opacity: 0, - scaleX: HIDE_SCALE, - scaleY: HIDE_SCALE, - duration: VISIBILITY_ANIMATION_TIME * EASE_DURATION_FACTOR, - mode: Clutter.AnimationMode.EASE_OUT_CUBIC, - onComplete: () => { - if (this._visibilityTarget === 'hidden') { - super.hide(); - logger.debug(`monitor=${this._monitorIndex} hidden transition completed`, { - prefix: LOG_PREFIX, - }); - } - }, - }); - - this.ease_property('translation-y', this.height, { - duration: VISIBILITY_ANIMATION_TIME, - mode: Clutter.AnimationMode.LINEAR, - }); + this._visibility.hide(animate); } private _isMenuOpen(): boolean { - const dashAny = this as any; - const children = this._getDashChildren(); + const children = this._applications.getChildren(); for (const child of children) { - let appIcon: any; - try { - appIcon = child.child?._delegate; - } catch { - continue; - } + const appIcon = child.child?._delegate; if (appIcon?._menu?.isOpen) { return true; } } - const showApps = dashAny.showAppsButton || dashAny._showAppsIcon?._delegate; - if (showApps?._menu?.isOpen) { - return true; - } - - if (this._trashIcon?.menuIsOpen) { - return true; - } - - if (this._externalStorageIcons.some((icon) => icon.menuIsOpen)) { - return true; - } + if (this._fixedItems.menuOpen) return true; return false; } - private _dashContainerHasHover(): boolean { - return this._dashContainerHover; - } - - private _performShow(animate = true): void { - if (this._visibilityTarget !== 'shown') return; - - if (this._isFullyShown()) { - this._flushShowCompletionCallbacks(); - return; - } - - // Recalculate the container size to account for icon changes that - // occurred while the dock was hidden (e.g. apps opened/closed during - // IntelliHide BLOCKED state). Without this, the container can retain - // a stale width and clip the last icons or overlap the show-apps button. - if (this._workArea) { - this.applyWorkArea(this._workArea); - } - - const wasVisible = this.visible; - this.remove_all_transitions(); - this.set_pivot_point(...PIVOT_CENTER_BOTTOM); - - if (!animate) { - this._applyShownState(); - super.show(); - this._queueTargetBoxUpdate(); - this._flushShowCompletionCallbacks(); - return; - } - - if (!wasVisible) this._applyHiddenState(); - super.show(); - - this.ease({ - opacity: FULL_OPACITY, - scaleX: 1, - scaleY: 1, - duration: VISIBILITY_ANIMATION_TIME, - mode: Clutter.AnimationMode.EASE_IN_CUBIC, - onComplete: () => { - if (this._visibilityTarget !== 'shown') return; - this._applyShownState(); - this._queueTargetBoxUpdate(); - logger.debug(`monitor=${this._monitorIndex} shown transition completed`, { - prefix: LOG_PREFIX, - }); - this._flushShowCompletionCallbacks(); - }, - }); - - this.ease_property('translation-y', 0, { - duration: VISIBILITY_ANIMATION_TIME * EASE_DURATION_FACTOR, - mode: Clutter.AnimationMode.LINEAR, - }); - } - - private _applyShownState(): void { - this.translation_y = 0; - this.opacity = FULL_OPACITY; - this.set_scale(1, 1); - } - - private _applyHiddenState(): void { - this.translation_y = this.height; - this.opacity = 0; - this.set_scale(HIDE_SCALE, HIDE_SCALE); - } - - private _flushShowCompletionCallbacks(): void { - const callbacks = this._showCompletionCallbacks; - this._showCompletionCallbacks = []; - for (const callback of callbacks) callback(); - } - - private _setContainerInputEnabled(enabled: boolean): void { - this._container?.set_reactive(enabled); - } - - private _getActorStageBounds(actor: any): DashBounds | null { + private _getActorStageBounds(actor: Clutter.Actor | null): DashBounds | null { if (!actor?.visible) return null; - const allocation = actor.get_allocation_box?.(); - if (!allocation) return null; - - const width = Math.max(0, (allocation.x2 ?? 0) - (allocation.x1 ?? 0)); - const height = Math.max(0, (allocation.y2 ?? 0) - (allocation.y1 ?? 0)); + const allocation = actor.get_allocation_box(); + const width = Math.max(0, allocation.x2 - allocation.x1); + const height = Math.max(0, allocation.y2 - allocation.y1); if (width <= 0 || height <= 0) return null; - const [x, y] = actor.get_transformed_position?.() ?? [actor.x, actor.y]; + const [x, y] = actor.get_transformed_position(); if (!Number.isFinite(x) || !Number.isFinite(y)) return null; return { x, y, width, height }; @@ -703,27 +395,26 @@ export class AuroraDash extends Dash { // GObject is disposed. The shared super call guards disposal; item dragging // additionally holds visibility while the placeholder changes hover state. private _guardedSuper(method: string, args: any[] = []): void { - if (this._isDestroyed) return; + if (!this._dashBox) return; + (Dash.prototype as any)[method].call(this, ...args); } override _onItemDragBegin(): void { - this._itemDragVisibilityHold = - !Main.overview.visible && (this.visible || this._visibilityTarget === 'shown'); - if (this._itemDragVisibilityHold) { - this._autohideTimeoutId = this._removeSource(this._autohideTimeoutId); - this.show(false); - } + this._visibility.beginItemDrag(); this._guardedSuper('_onItemDragBegin'); } + override _onItemDragEnd(): void { this._guardedSuper('_onItemDragEnd'); - this._releaseItemDragVisibilityHold(); + this._visibility.endItemDrag(); } + override _onItemDragCancelled(): void { this._guardedSuper('_onItemDragCancelled'); - this._releaseItemDragVisibilityHold(); + this._visibility.endItemDrag(); } + override _onWindowDragBegin(...a: any[]): void { this._guardedSuper('_onWindowDragBegin', a); } @@ -732,7 +423,7 @@ export class AuroraDash extends Dash { } override _syncLabel(item: any, appIcon: any): void { - if (this._isDestroyed) return; + if (!this._dashBox) return; if (item && !item._auroraShowLabelPatched) { item._auroraShowLabelPatched = true; @@ -743,7 +434,7 @@ export class AuroraDash extends Dash { }; } - (Dash.prototype as any)._syncLabel?.call(this, item, appIcon); + (Dash.prototype as any)._syncLabel.call(this, item, appIcon); } override _createAppItem(app: any): any { @@ -757,7 +448,8 @@ export class AuroraDash extends Dash { const originalDestroy = item.destroy.bind(item); item.destroy = () => { const globalIds = dashAny._globallyRunningIds as Set<string> | undefined; - const appId = (item.child as any)?._delegate?.app?.get_id?.() as string | undefined; + const app = (item.child as any)?._delegate?.app; + const appId = app ? app.get_id() : undefined; const appActuallyClosed = globalIds !== undefined && appId !== undefined && !globalIds.has(appId); @@ -778,12 +470,10 @@ export class AuroraDash extends Dash { } override _adjustIconSize(): void { - if (this._isDestroyed) return; + if (!this._dashBox) return; + const box = (this as any)._box; - const extraIcons: Array<TrashIconInstance | ExternalStorageIconInstance> = [ - ...this._externalStorageIcons, - ]; - if (this._trashIcon) extraIcons.push(this._trashIcon); + const extraIcons = this._fixedItems.icons; if (!box || extraIcons.length === 0) { (Dash.prototype as any)._adjustIconSize.call(this); @@ -812,20 +502,17 @@ export class AuroraDash extends Dash { } override _redisplay(): void { - if (this._isDestroyed) return; + if (!this._dashBox) return; + const dashAny = this as any; const oldIconSize = dashAny.iconSize; const shouldAnimate = this.visible && this.opacity > 0; const isFirstDisplay = !dashAny._shownInitially; const existingApps = new Set<any>(); - for (const child of this._getDashChildren()) { - try { - const app = child.child?._delegate?.app; - if (app && !child.animatingOut) existingApps.add(app); - } catch { - // Ignore children disposed during shell shutdown. - } + for (const child of this._applications.getChildren()) { + const app = child.child?._delegate?.app; + if (app && !child.animatingOut) existingApps.add(app); } // Temporarily patch get_running() so the base Dash only sees apps in the @@ -834,378 +521,87 @@ export class AuroraDash extends Dash { // _globallyRunningIds lets the _createAppItem.destroy patch distinguish // actual closes from scope-filter removals (instant destroy, no ghost icons). const appSystem = dashAny._appSystem; - const origGetRunning = appSystem?.get_running; - if (appSystem && origGetRunning) { - const hadOwnProp = Object.prototype.hasOwnProperty.call(appSystem, 'get_running'); - const allApps: any[] = origGetRunning.call(appSystem); - dashAny._globallyRunningIds = new Set<string>(allApps.map((a: any) => a.get_id())); - - const isRelevant = (w: any) => this._isWindowRelevant(w); - appSystem.get_running = () => { - const apps = allApps.filter((app: any) => { - if (app.get_state?.() === Shell.AppState.STARTING) return true; - return app.get_windows().some(isRelevant); - }); - - return apps.sort((a: any, b: any) => { - const minSeq = (app: any): number => { - const wins: any[] = app.get_windows(); - if (wins.length === 0) return Number.MAX_SAFE_INTEGER; - return wins.reduce( - (m: number, w: any) => Math.min(m, (w.get_stable_sequence?.() ?? 0) as number), - Number.MAX_SAFE_INTEGER, - ); - }; - return minSeq(a) - minSeq(b); - }); - }; + const originalGetRunning = appSystem.get_running; + const hadOwnGetRunning = Object.prototype.hasOwnProperty.call(appSystem, 'get_running'); + const allApps: any[] = originalGetRunning.call(appSystem); + dashAny._globallyRunningIds = new Set<string>(allApps.map((app: any) => app.get_id())); + + const isRelevant = (window: any) => this._applications.isWindowRelevant(window); + appSystem.get_running = () => { + const apps = allApps.filter((app: any) => { + if (app.get_state() === Shell.AppState.STARTING) return true; + return app.get_windows().some(isRelevant); + }); - try { - Dash.prototype._redisplay.call(this); - } finally { - if (hadOwnProp) { - appSystem.get_running = origGetRunning; - } else { - delete appSystem.get_running; - } - delete dashAny._globallyRunningIds; - } - } else { + return apps.sort((a: any, b: any) => { + const minimumSequence = (app: any): number => { + const windows: any[] = app.get_windows(); + if (windows.length === 0) return Number.MAX_SAFE_INTEGER; + + return windows.reduce( + (minimum: number, window: any) => Math.min(minimum, window.get_stable_sequence()), + Number.MAX_SAFE_INTEGER, + ); + }; + + return minimumSequence(a) - minimumSequence(b); + }); + }; + + try { Dash.prototype._redisplay.call(this); + } finally { + if (hadOwnGetRunning) { + appSystem.get_running = originalGetRunning; + } else { + delete appSystem.get_running; + } + delete dashAny._globallyRunningIds; } if (shouldAnimate && !isFirstDisplay) { - for (const child of this._getDashChildren()) { - try { - const childApp = child.child?._delegate?.app; - if (childApp && !existingApps.has(childApp) && !child.animatingOut) { - child.remove_all_transitions(); - child.scale_x = 0; - child.scale_y = 0; - child.opacity = 0; - child.ease({ - scale_x: 1, - scale_y: 1, - opacity: 255, - duration: ANIMATION_TIME, - mode: Clutter.AnimationMode.EASE_OUT_QUAD, - }); - } - } catch { - // Ignore children disposed during shell shutdown. + for (const child of this._applications.getChildren()) { + const childApp = child.child?._delegate?.app; + if (childApp && !existingApps.has(childApp) && !child.animatingOut) { + child.remove_all_transitions(); + child.scale_x = 0; + child.scale_y = 0; + child.opacity = 0; + child.ease({ + scale_x: 1, + scale_y: 1, + opacity: 255, + duration: ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); } } } - this._updatePerMonitorRunningDots(); + this._applications.updateRunningDots(); this._syncLabelFlushMode(); - this._overrideIconActivation(); + this._applications.installActivationOverrides(); if (dashAny.iconSize !== oldIconSize) { - this._iconResizeTimeoutId = this._removeSource(this._iconResizeTimeoutId); - this._iconResizeTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ANIMATION_TIME, () => { - this._iconResizeTimeoutId = 0; - if (this._workArea) this.applyWorkArea(this._workArea); - return GLib.SOURCE_REMOVE; - }); + this._iconResizeTimeout.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, ANIMATION_TIME, () => { + this._iconResizeTimeout.complete(); + if (this._workArea) this.applyWorkArea(this._workArea); + return GLib.SOURCE_REMOVE; + }), + ); } else if (this._workArea) { this._queueWorkAreaUpdate(); } } - private _isWindowRelevant(w: any): boolean { - return ( - (!this._isolateMonitor || w.get_monitor() === this._monitorIndex) && - (w.is_on_all_workspaces?.() || - w.get_workspace() === global.workspace_manager.get_active_workspace()) - ); - } - - private _updatePerMonitorRunningDots(): void { - for (const child of this._getDashChildren()) { - try { - const icon = child.child?._delegate; - if (!icon?.app) continue; - const hasWindowHere = icon.app.get_windows().some((w: any) => this._isWindowRelevant(w)); - const dot = icon._dot; - if (dot) dot.visible = hasWindowHere; - } catch { - // Ignore children disposed during shell shutdown. - } - } - } - - /** - * Override app icon activation so clicking an app with multiple windows - * on this monitor and active workspace cycles through them in MRU - * (Most Recently Used) order. A snapshot of the MRU list is taken on the - * first click and reused for subsequent clicks so the order stays stable - * while cycling. The snapshot resets automatically when the focused - * window no longer matches the last cycled-to window (e.g. the user - * clicked a window directly or switched apps). - */ - private _overrideIconActivation(): void { - for (const child of this._getDashChildren()) { - let appIcon: any; - try { - appIcon = child.child?._delegate; - } catch { - continue; - } - if (!appIcon?.app || appIcon._auroraActivatePatched) continue; - - appIcon._auroraActivatePatched = true; - const originalActivate = appIcon.activate.bind(appIcon); - const isRelevant = (w: any) => this._isWindowRelevant(w); - - appIcon.activate = function (button: number) { - // `this` is appIcon here — _cycleState is stored per icon, not on AuroraDash. - // Ctrl+click or middle-click opens a new window — keep default behavior - const event = Clutter.get_current_event(); - const modifiers = event ? event.get_state() : 0; - const isMiddleButton = button && button === Clutter.BUTTON_MIDDLE; - const isCtrlPressed = (modifiers & Clutter.ModifierType.CONTROL_MASK) !== 0; - - if (isCtrlPressed || isMiddleButton) { - this._cycleState = null; - originalActivate(button); - return; - } - - const windows = appIcon.app.get_windows().filter(isRelevant); - - if (windows.length === 0 && appIcon.app.get_state() === Shell.AppState.RUNNING) { - this._cycleState = null; - // App has windows on other monitors/workspaces — open a new window in the current workspace - appIcon.app.open_new_window(-1); - return; - } - - if (windows.length <= 1) { - this._cycleState = null; - if (windows.length === 1) { - // Activate the window directly to avoid DashIcon.activate() checking the - // C-level app.state. IconWeave-mapped apps have C-level state STOPPED - // (the process isn't natively tracked), so DashIcon.activate() would call - // animateLaunch() and show the "opening new instance" bounce animation - // even though we're just switching to an existing window. - const win = windows[0]; - if (win.minimized) win.unminimize(); - Main.activateWindow(win); - } else { - // No windows on this monitor/workspace and app is not "running" per our - // patched get_state() — treat as a stopped app and let the default - // DashIcon behavior launch it. - originalActivate(button); - } - return; - } - - const focusedWindow = global.display.focus_window; - const isFocused = windows.some((w: any) => w === focusedWindow); - const appId = appIcon.app.get_id(); - - if (!isFocused) { - // App not focused: activate the most recently used window - this._cycleState = null; - const win = windows[0]; - if (win.minimized) win.unminimize(); - Main.activateWindow(win); - return; - } - - // Check if we can continue an existing MRU cycle: the focused - // window must match the last window we cycled to. - if ( - this._cycleState?.appId === appId && - this._cycleState.windows[this._cycleState.index] === focusedWindow - ) { - // Advance to the next window in the snapshot - const nextIndex = (this._cycleState.index + 1) % this._cycleState.windows.length; - const next = this._cycleState.windows[nextIndex]; - - // Validate the window still exists on this monitor/workspace - if (windows.some((w: any) => w === next)) { - this._cycleState.index = nextIndex; - if (next.minimized) next.unminimize(); - Main.activateWindow(next); - return; - } - // Window was closed — fall through to start a fresh cycle - } - - // Start a new MRU cycle: snapshot the current order and activate - // the second entry (the first is the already-focused window). - this._cycleState = { appId, windows: [...windows], index: 1 }; - const next = windows[1]; - if (next.minimized) next.unminimize(); - Main.activateWindow(next); - }; - } - } - - private _getDashChildren(): any[] { - if (this._isDestroyed) return []; - try { - return (this as any)._box?.get_children?.() ?? []; - } catch { - return []; - } - } - - private _setupSpringLoadMonitor(): void { - this._springLoadDragMonitor = { - dragMotion: (dragEvent: any) => { - if (this._isDestroyed) return DND.DragMotionResult.CONTINUE; - - if (dragEvent.source?.app) { - this._clearSpringLoad(); - return DND.DragMotionResult.CONTINUE; - } - - const { x, y } = dragEvent; - let actor: Clutter.Actor | null | undefined = global.stage.get_actor_at_pos?.( - Clutter.PickMode.REACTIVE, - x, - y, - ); - const box = (this as any)._box; - let target: any = null; - - while (actor) { - if (actor.get_parent?.() === box) { - target = actor; - break; - } - actor = actor.get_parent?.(); - } - - if (target !== this._springLoadTarget) { - this._clearSpringLoad(); - this._springLoadTarget = target; - - const appIcon = target?.child?._delegate; - if (appIcon?.app) { - target.add_style_class_name?.('aurora-drag-hover'); - const isRelevant = (w: any) => this._isWindowRelevant(w); - this._springLoadTimerId = GLib.timeout_add( - GLib.PRIORITY_DEFAULT, - SPRING_LOAD_DELAY, - () => { - this._springLoadTimerId = 0; - if (this._isDestroyed) return GLib.SOURCE_REMOVE; - const windows = appIcon.app?.get_windows?.()?.filter?.(isRelevant) ?? []; - if (windows.length > 0) { - const win = windows[0]; - if (win?.minimized) win.unminimize(); - Main.activateWindow(win); - } - return GLib.SOURCE_REMOVE; - }, - ); - } - } - - return DND.DragMotionResult.CONTINUE; - }, - }; - - DND.addDragMonitor(this._springLoadDragMonitor); - - (global.backend as any) - .get_dnd?.() - ?.connectObject('dnd-leave', () => this._clearSpringLoad(), this); - } - - private _clearSpringLoad(): void { - try { - this._springLoadTarget?.remove_style_class_name('aurora-drag-hover'); - } catch { - // The target may already be disposed during shell shutdown. - } - if (this._springLoadTimerId !== 0) { - this._springLoadTimerId = this._removeSource(this._springLoadTimerId); - } - this._springLoadTarget = null; - } - - private _onHover(): void { - if (this._isDestroyed) return; - if (this._autohideTimeoutId !== 0) { - this._autohideTimeoutId = this._removeSource(this._autohideTimeoutId); - } - if (this._itemDragVisibilityHold) { - this.show(false); - return; - } - this._autohideTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, AUTOHIDE_TIMEOUT, () => { - if (this._isDestroyed) { - this._autohideTimeoutId = 0; - return GLib.SOURCE_REMOVE; - } - // `notify::hover` calls _onHover() again. Stop polling so transient - // hover loss during relayout cannot hide the dock. - if (this._dashContainerHasHover()) { - this._autohideTimeoutId = 0; - return GLib.SOURCE_REMOVE; - } - if (this._blockAutoHide || this._isMenuOpen()) { - return GLib.SOURCE_CONTINUE; - } - - this.hide(true); - this._autohideTimeoutId = 0; - return GLib.SOURCE_REMOVE; - }); - } - - private _releaseItemDragVisibilityHold(): void { - if (!this._itemDragVisibilityHold) return; - this._itemDragVisibilityHold = false; - this._onHover(); - } - - private _ensureHoverState(): void { - if (this._isDestroyed) return; - if (this._blockAutoHideDelayId !== 0) { - this._blockAutoHideDelayId = this._removeSource(this._blockAutoHideDelayId); - } - this._blockAutoHideDelayId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { - if (!this._isDestroyed) { - if (this._dashContainerHasHover()) this.show(false); - } - this._blockAutoHideDelayId = 0; - return GLib.SOURCE_REMOVE; - }); - } - - private _isFullyShown(): boolean { - return ( - this.visible && - this.translation_y === 0 && - this.scale_x === 1 && - this.scale_y === 1 && - this.opacity === FULL_OPACITY - ); - } - - private _isFullyHidden(): boolean { - return !this.visible && this.opacity === 0; - } - private _getAllocationSize(): { width: number; height: number } | null { - const alloc = this.get_allocation_box?.(); - if (!alloc) return null; - - const width = Math.max(0, (alloc.x2 ?? 0) - (alloc.x1 ?? 0)); - const height = Math.max(0, (alloc.y2 ?? 0) - (alloc.y1 ?? 0)); + const allocation = this.get_allocation_box(); + const width = Math.max(0, allocation.x2 - allocation.x1); + const height = Math.max(0, allocation.y2 - allocation.y1); return width > 0 && height > 0 ? { width, height } : null; } - private _hasValidAllocation(): boolean { - return this._getAllocationSize() !== null; - } - /** * Compute the dash bounds in stage coordinates and notify the intellihide * listener. Only reads `get_transformed_position` when the dash is visible @@ -1220,7 +616,7 @@ export class AuroraDash extends Dash { if (!this.visible || this.translation_y !== 0) return; - const [stageX, stageY] = (this as any).get_transformed_position?.() ?? [0, 0]; + const [stageX, stageY] = this.get_transformed_position(); const bounds: DashBounds = { x: stageX, @@ -1234,24 +630,13 @@ export class AuroraDash extends Dash { this._targetBoxListener?.(this._targetBox); } - this._flushPendingShow(); - } - - private _flushPendingShow(): void { - if (!this._pendingShow || !this._hasValidAllocation()) return; - - const { animate } = this._pendingShow; - this._pendingShow = null; - this._performShow(animate); + this._visibility.flushPendingShow(); } private _getMarginBottom(): number { if (this._flushMode) return 0; - try { - return (this as unknown as St.Widget).get_theme_node().get_length('margin-bottom'); - } catch { - return 0; - } + + return (this as unknown as St.Widget).get_theme_node().get_length('margin-bottom'); } /** @@ -1261,13 +646,15 @@ export class AuroraDash extends Dash { * container never resizes mid-drag while the placeholder is in/out. */ private _queueWorkAreaUpdate(): void { - if (this._isDestroyed || this._workAreaUpdateId) return; - this._workAreaUpdateId = GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { - this._workAreaUpdateId = 0; - if (this._workArea) { - this.applyWorkArea(this._workArea); - } - return GLib.SOURCE_REMOVE; - }); + if (!this._dashBox || this._workAreaUpdate.active) return; + this._workAreaUpdate.replace(() => + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + this._workAreaUpdate.complete(); + if (this._workArea) { + this.applyWorkArea(this._workArea); + } + return GLib.SOURCE_REMOVE; + }), + ); } } diff --git a/src/shared/ui/dashApplications.ts b/src/shared/ui/dashApplications.ts new file mode 100644 index 0000000..b82977f --- /dev/null +++ b/src/shared/ui/dashApplications.ts @@ -0,0 +1,141 @@ +import Clutter from '@girs/clutter-18'; +import Shell from '@girs/shell-18'; +import type St from '@girs/st-18'; +import * as Main from '@girs/gnome-shell/ui/main'; + +import { isDashWindowRelevant } from './dashState.ts'; + +type DashApplicationOptions = { + getContentActor: () => St.Widget | null; + getMonitorIndex: () => number; + getIsolateMonitor: () => boolean; +}; + +export class DashApplicationController { + constructor(private _options: DashApplicationOptions) {} + + getChildren(): any[] { + const contentActor = this._options.getContentActor(); + if (!contentActor) { + return []; + } + + return contentActor.get_children(); + } + + isWindowRelevant(window: any): boolean { + const workspace = global.workspace_manager.get_active_workspace(); + return isDashWindowRelevant( + { + monitor: window.get_monitor(), + workspace: window.get_workspace() === workspace ? 0 : 1, + sticky: window.is_on_all_workspaces(), + skipTaskbar: false, + }, + this._options.getMonitorIndex(), + 0, + this._options.getIsolateMonitor(), + ); + } + + updateRunningDots(): void { + for (const child of this.getChildren()) { + const icon = child.child?._delegate; + if (!icon?.app) continue; + + const hasWindowHere = icon.app + .get_windows() + .some((window: any) => this.isWindowRelevant(window)); + if (icon._dot) { + icon._dot.visible = hasWindowHere; + } + } + } + + installActivationOverrides(): void { + for (const child of this.getChildren()) { + const appIcon = child.child?._delegate; + + if (!appIcon?.app || appIcon._auroraActivatePatched) continue; + + appIcon._auroraActivatePatched = true; + this._installActivationOverride(appIcon); + } + } + + private _installActivationOverride(appIcon: any): void { + const originalActivate = appIcon.activate.bind(appIcon); + const isRelevant = (window: any) => this.isWindowRelevant(window); + const activateWindow = (window: any) => this._activateWindow(window); + + appIcon.activate = function (button: number) { + const event = Clutter.get_current_event(); + const modifiers = event ? event.get_state() : 0; + const opensNewWindow = + button === Clutter.BUTTON_MIDDLE || (modifiers & Clutter.ModifierType.CONTROL_MASK) !== 0; + + if (opensNewWindow) { + this._cycleState = null; + originalActivate(button); + return; + } + + const windows = appIcon.app.get_windows().filter(isRelevant); + if (windows.length === 0 && appIcon.app.get_state() === Shell.AppState.RUNNING) { + this._cycleState = null; + appIcon.app.open_new_window(-1); + return; + } + + if (windows.length <= 1) { + this._cycleState = null; + + if (windows.length === 1) { + const window = windows[0]; + if (window.minimized) { + window.unminimize(); + } + Main.activateWindow(window); + } else { + originalActivate(button); + } + + return; + } + + const focusedWindow = global.display.focus_window; + const isFocused = windows.some((window: any) => window === focusedWindow); + const appId = appIcon.app.get_id(); + + if (!isFocused) { + this._cycleState = null; + activateWindow(windows[0]); + return; + } + + const cycle = this._cycleState; + const continuesCycle = cycle?.appId === appId && cycle.windows[cycle.index] === focusedWindow; + if (continuesCycle) { + const nextIndex = (cycle.index + 1) % cycle.windows.length; + const nextWindow = cycle.windows[nextIndex]; + + if (windows.some((window: any) => window === nextWindow)) { + cycle.index = nextIndex; + activateWindow(nextWindow); + return; + } + } + + this._cycleState = { appId, windows: [...windows], index: 1 }; + activateWindow(windows[1]); + }; + } + + private _activateWindow(window: any): void { + if (window.minimized) { + window.unminimize(); + } + + Main.activateWindow(window); + } +} diff --git a/src/shared/ui/dashFixedItems.ts b/src/shared/ui/dashFixedItems.ts new file mode 100644 index 0000000..ae5012e --- /dev/null +++ b/src/shared/ui/dashFixedItems.ts @@ -0,0 +1,137 @@ +import Shell from '@girs/shell-18'; +import { logger } from '~/core/logger.ts'; +import { TrashIcon, type TrashIconInstance } from '~/dock/trashIcon.ts'; +import { canLaunchTrash, NAUTILUS_APP_ID } from '~/dock/trashLauncher.ts'; +import { + createExternalStorageIcon, + ExternalStorageMonitor, + type ExternalStorageIconInstance, + type ExternalStorageItem, +} from '~/dock/externalStorageIcon.ts'; + +type FixedItemDash = { + iconSize: number; + _dashContainer: any; + _showAppsIcon: any; + _hookUpLabel(item: unknown): void; +}; + +type FixedItemOwner = { + connectObject(...args: any[]): void; +}; + +export class DashFixedItems { + private _trash: TrashIconInstance | null = null; + private _monitor: ExternalStorageMonitor | null = null; + private _storage: ExternalStorageIconInstance[] = []; + + constructor( + private _owner: FixedItemOwner, + private _dash: FixedItemDash, + showTrash: boolean, + showExternalStorage: boolean, + private _onLayoutChanged: () => void, + ) { + if (showExternalStorage) { + this._setupStorage(); + } + + if (showTrash) { + this._setupTrash(); + } + } + + destroy(): void { + this._monitor?.destroy(); + this._monitor = null; + for (const icon of this._storage) { + icon.destroy(); + } + + this._storage = []; + this._trash?.destroy(); + this._trash = null; + } + + get icons(): any[] { + return [...this._storage, ...(this._trash ? [this._trash] : [])]; + } + + get menuOpen(): boolean { + return Boolean(this._trash?.menuIsOpen || this._storage.some((icon) => icon.menuIsOpen)); + } + + private _setupTrash(): void { + const container = this._dash._dashContainer; + + const app = Shell.AppSystem.get_default().lookup_app(NAUTILUS_APP_ID); + if ( + !canLaunchTrash({ getNautilusExecutable: () => app?.get_app_info().get_executable() ?? null }) + ) { + logger.debug('Trash icon disabled: Nautilus is unavailable', { prefix: 'DockDash' }); + return; + } + + this._trash = new TrashIcon() as TrashIconInstance; + this._trash.show(false); + this._trash.setIconSize(this._dash.iconSize); + this._dash._hookUpLabel(this._trash); + const anchorIndex = container.get_children().indexOf(this._dash._showAppsIcon); + if (anchorIndex >= 0) { + container.insert_child_at_index(this._trash, anchorIndex); + } else { + container.add_child(this._trash); + } + + const trash = this._trash; + this._owner.connectObject( + 'icon-size-changed', + () => trash.setIconSize(this._dash.iconSize), + this._owner, + ); + } + + private _setupStorage(): void { + this._monitor = new ExternalStorageMonitor((items) => this._syncStorage(items)); + this._syncStorage(this._monitor.items); + this._owner.connectObject( + 'icon-size-changed', + () => { + for (const icon of this._storage) { + icon.setIconSize(this._dash.iconSize); + } + }, + this._owner, + ); + } + + private _syncStorage(items: readonly ExternalStorageItem[]): void { + for (const icon of this._storage) { + icon.destroy(); + } + + this._storage = []; + const container = this._dash._dashContainer; + if (!container) return; + + for (const item of items) { + const icon = createExternalStorageIcon(item); + icon.show(false); + icon.setIconSize(this._dash.iconSize); + this._dash._hookUpLabel(icon); + this._storage.push(icon); + } + const anchor = this._trash ?? this._dash._showAppsIcon; + const anchorIndex = anchor ? container.get_children().indexOf(anchor) : -1; + + for (const [offset, icon] of this._storage.entries()) { + if (anchorIndex >= 0) { + container.insert_child_at_index(icon, anchorIndex + offset); + } else { + container.add_child(icon); + } + } + + this._onLayoutChanged(); + } +} diff --git a/src/shared/ui/dashSpringLoad.ts b/src/shared/ui/dashSpringLoad.ts new file mode 100644 index 0000000..6ab9be6 --- /dev/null +++ b/src/shared/ui/dashSpringLoad.ts @@ -0,0 +1,91 @@ +import Clutter from '@girs/clutter-18'; +import GLib from '@girs/glib-2.0'; +import * as DND from '@girs/gnome-shell/ui/dnd'; +import * as Main from '@girs/gnome-shell/ui/main'; + +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; + +const SPRING_LOAD_DELAY = 400; + +export class DashSpringLoadCoordinator { + private _scope = new LifecycleScope(); + private _timer: ManagedSource = createManagedSource(this._scope); + private _target: any = null; + private _monitor: { dragMotion: (event: any) => number }; + + constructor( + private _getBox: () => any, + private _isWindowRelevant: (window: any) => boolean, + ) { + this._monitor = { + dragMotion: (event) => this._handleMotion(event), + }; + + DND.addDragMonitor(this._monitor); + (global.backend as any).get_dnd().connectObject('dnd-leave', () => this.clear(), this); + } + + clear(): void { + this._target?.remove_style_class_name('aurora-drag-hover'); + this._timer.clear(); + this._target = null; + } + + destroy(): void { + this.clear(); + this._scope.dispose(); + DND.removeDragMonitor(this._monitor); + (global.backend as any).get_dnd().disconnectObject(this); + } + + private _handleMotion(event: any): number { + if (event.source?.app) { + this.clear(); + return DND.DragMotionResult.CONTINUE; + } + + const target = this._findTarget(event.x, event.y); + if (target === this._target) return DND.DragMotionResult.CONTINUE; + + this.clear(); + this._target = target; + + const app = target?.child?._delegate?.app; + if (!app) return DND.DragMotionResult.CONTINUE; + + target.add_style_class_name('aurora-drag-hover'); + this._timer.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, SPRING_LOAD_DELAY, () => { + this._timer.complete(); + + const windows = app.get_windows().filter(this._isWindowRelevant); + const window = windows[0]; + if (window) { + if (window.minimized) window.unminimize(); + Main.activateWindow(window); + } + + return GLib.SOURCE_REMOVE; + }), + ); + + return DND.DragMotionResult.CONTINUE; + } + + private _findTarget(x: number, y: number): any | null { + const box = this._getBox(); + let actor: Clutter.Actor | null = global.stage.get_actor_at_pos( + Clutter.PickMode.REACTIVE, + x, + y, + ); + + while (actor) { + if (actor.get_parent() === box) return actor; + actor = actor.get_parent(); + } + + return null; + } +} diff --git a/src/shared/ui/dashState.ts b/src/shared/ui/dashState.ts new file mode 100644 index 0000000..834347e --- /dev/null +++ b/src/shared/ui/dashState.ts @@ -0,0 +1,42 @@ +export type DashVisibilityState = { + target: 'shown' | 'hidden'; + blocked: boolean; + hovered: boolean; + menuOpen: boolean; + dragHeld: boolean; +}; + +export function shouldHideDash(state: DashVisibilityState): boolean { + return !state.blocked && !state.hovered && !state.menuOpen && !state.dragHeld; +} + +export type DashWindow = { + monitor: number; + workspace: number; + sticky?: boolean; + skipTaskbar?: boolean; +}; + +export function selectDashWindows<T extends DashWindow>( + windows: readonly T[], + monitor: number, + workspace: number, + isolateMonitor: boolean, +): T[] { + return windows.filter((window) => + isDashWindowRelevant(window, monitor, workspace, isolateMonitor), + ); +} + +export function isDashWindowRelevant( + window: DashWindow, + monitor: number, + workspace: number, + isolateMonitor: boolean, +): boolean { + return ( + !window.skipTaskbar && + (window.sticky || window.workspace === workspace) && + (!isolateMonitor || window.monitor === monitor) + ); +} diff --git a/src/shared/ui/dashVisibility.ts b/src/shared/ui/dashVisibility.ts new file mode 100644 index 0000000..8253aed --- /dev/null +++ b/src/shared/ui/dashVisibility.ts @@ -0,0 +1,379 @@ +import Clutter from '@girs/clutter-18'; +import GLib from '@girs/glib-2.0'; +import type St from '@girs/st-18'; +import * as Main from '@girs/gnome-shell/ui/main'; + +import { LifecycleScope } from '~/core/lifecycleScope.ts'; +import { logger } from '~/core/logger.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; + +import type { DashBounds } from './dashLayout.ts'; +import { shouldHideDash } from './dashState.ts'; + +const AUTOHIDE_TIMEOUT = 100; +const VISIBILITY_ANIMATION_TIME = 200; +const HIDE_SCALE = 0.98; +const EASE_DURATION_FACTOR = 0.8; +const FULL_OPACITY = 255; +const PIVOT_CENTER_BOTTOM: [number, number] = [0.5, 1]; +const LOG_PREFIX = 'DockDash'; + +type VisibilityTarget = 'shown' | 'hidden'; + +type DashVisibilityOptions = { + getContentActor: () => St.Widget | null; + getContainer: () => St.Bin | null; + getMonitorIndex: () => number; + getWorkArea: () => DashBounds | null; + isMenuOpen: () => boolean; + applyWorkArea: (workArea: DashBounds) => void; + queueTargetBoxUpdate: () => void; + showActor: () => void; + hideActor: () => void; +}; + +export class DashVisibilityController { + private _lifecycle = new LifecycleScope(); + private _autohideTimeout = createManagedSource(this._lifecycle); + private _delayEnsureAutoHide = createManagedSource(this._lifecycle); + private _blockAutoHideDelay = createManagedSource(this._lifecycle); + private _target: VisibilityTarget; + private _showCompletionCallbacks: Array<() => void> = []; + private _pendingShow: { animate: boolean } | null = null; + private _blockAutoHide = false; + private _itemDragHold = false; + private _hovered = false; + + constructor( + private _dash: any, + private _options: DashVisibilityOptions, + ) { + this._target = _dash.visible ? 'shown' : 'hidden'; + } + + get hovered(): boolean { + return this._hovered; + } + + setHovered(hovered: boolean): void { + this._hovered = hovered; + this.updateAutoHide(); + } + + blockAutoHide(block: boolean): void { + this._blockAutoHide = block; + + if (block && !Main.overview.visible) { + this.show(true); + } else if (!block) { + this._ensureHoverState(); + } + + this.updateAutoHide(); + } + + forceAutoHide(animate = true): void { + this._blockAutoHide = false; + this._blockAutoHideDelay.clear(); + this._autohideTimeout.clear(); + this.hide(animate); + } + + ensureAutoHide(): void { + this._delayEnsureAutoHide.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, VISIBILITY_ANIMATION_TIME, () => { + this._delayEnsureAutoHide.complete(); + this.updateAutoHide(); + return GLib.SOURCE_REMOVE; + }), + ); + } + + show(animate = true, onComplete?: () => void): void { + this._options.getContainer()?.show(); + this._setContainerInputEnabled(true); + + if (onComplete) { + this._showCompletionCallbacks.push(onComplete); + } + + if (this._target === 'shown') { + if (this._isFullyShown()) { + this._flushShowCompletionCallbacks(); + } + return; + } + + logger.debug( + `monitor=${this._options.getMonitorIndex()} visibility ${this._target}->shown animate=${animate}`, + { prefix: LOG_PREFIX }, + ); + this._target = 'shown'; + + if (!this._hasValidAllocation()) { + this._pendingShow = { animate }; + return; + } + + this._pendingShow = null; + this._performShow(animate); + } + + hide(animate = true): void { + if (this._itemDragHold) { + this.show(false); + return; + } + + this._setContainerInputEnabled(false); + + if (this._target === 'hidden') { + if (this._isFullyHidden()) return; + + logger.debug( + `monitor=${this._options.getMonitorIndex()} visibility hidden resync animate=${animate}`, + { prefix: LOG_PREFIX }, + ); + } else { + logger.debug( + `monitor=${this._options.getMonitorIndex()} visibility ${this._target}->hidden animate=${animate}`, + { prefix: LOG_PREFIX }, + ); + } + + this._target = 'hidden'; + this._pendingShow = null; + this._showCompletionCallbacks = []; + + if (this._isFullyHidden()) return; + + this._dash.remove_all_transitions(); + this._dash.set_pivot_point(...PIVOT_CENTER_BOTTOM); + + if (!animate) { + this._applyHiddenState(); + this._options.hideActor(); + return; + } + + this._dash.ease({ + opacity: 0, + scaleX: HIDE_SCALE, + scaleY: HIDE_SCALE, + duration: VISIBILITY_ANIMATION_TIME * EASE_DURATION_FACTOR, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onComplete: () => { + if (this._target !== 'hidden') return; + + this._options.hideActor(); + logger.debug(`monitor=${this._options.getMonitorIndex()} hidden transition completed`, { + prefix: LOG_PREFIX, + }); + }, + }); + this._dash.ease_property('translation-y', this._dash.height, { + duration: VISIBILITY_ANIMATION_TIME, + mode: Clutter.AnimationMode.LINEAR, + }); + } + + beginItemDrag(): void { + this._itemDragHold = !Main.overview.visible && (this._dash.visible || this._target === 'shown'); + + if (this._itemDragHold) { + this._autohideTimeout.clear(); + this.show(false); + } + } + + endItemDrag(): void { + if (!this._itemDragHold) return; + + this._itemDragHold = false; + this.updateAutoHide(); + } + + updateAutoHide(): void { + if (!this._options.getContentActor()) return; + + if (this._itemDragHold) { + this._autohideTimeout.clear(); + this.show(false); + return; + } + + this._autohideTimeout.replace(() => + GLib.timeout_add(GLib.PRIORITY_DEFAULT, AUTOHIDE_TIMEOUT, () => { + if (!this._options.getContentActor()) { + this._autohideTimeout.complete(); + return GLib.SOURCE_REMOVE; + } + + if (this._hovered) { + this._autohideTimeout.complete(); + return GLib.SOURCE_REMOVE; + } + + const shouldHide = shouldHideDash({ + target: this._target, + blocked: this._blockAutoHide, + hovered: this._hovered, + menuOpen: this._options.isMenuOpen(), + dragHeld: this._itemDragHold, + }); + if (!shouldHide) { + return GLib.SOURCE_CONTINUE; + } + + this.hide(true); + this._autohideTimeout.complete(); + return GLib.SOURCE_REMOVE; + }), + ); + } + + flushPendingShow(): void { + if (!this._pendingShow || !this._hasValidAllocation()) return; + + const { animate } = this._pendingShow; + this._pendingShow = null; + this._performShow(animate); + } + + handleContainerDestroyed(): void { + this._clearSources(); + this._itemDragHold = false; + this._hovered = false; + } + + handleContainerDetached(): void { + this._pendingShow = null; + } + + destroy(): void { + this._lifecycle.dispose(); + this._dash.remove_all_transitions(); + this._pendingShow = null; + this._showCompletionCallbacks = []; + } + + private _performShow(animate: boolean): void { + if (this._target !== 'shown') return; + + if (this._isFullyShown()) { + this._flushShowCompletionCallbacks(); + return; + } + + const workArea = this._options.getWorkArea(); + if (workArea) { + this._options.applyWorkArea(workArea); + } + + const wasVisible = this._dash.visible; + this._dash.remove_all_transitions(); + this._dash.set_pivot_point(...PIVOT_CENTER_BOTTOM); + + if (!animate) { + this._applyShownState(); + this._options.showActor(); + this._options.queueTargetBoxUpdate(); + this._flushShowCompletionCallbacks(); + return; + } + + if (!wasVisible) { + this._applyHiddenState(); + } + this._options.showActor(); + + this._dash.ease({ + opacity: FULL_OPACITY, + scaleX: 1, + scaleY: 1, + duration: VISIBILITY_ANIMATION_TIME, + mode: Clutter.AnimationMode.EASE_IN_CUBIC, + onComplete: () => { + if (this._target !== 'shown') return; + + this._applyShownState(); + this._options.queueTargetBoxUpdate(); + logger.debug(`monitor=${this._options.getMonitorIndex()} shown transition completed`, { + prefix: LOG_PREFIX, + }); + this._flushShowCompletionCallbacks(); + }, + }); + this._dash.ease_property('translation-y', 0, { + duration: VISIBILITY_ANIMATION_TIME * EASE_DURATION_FACTOR, + mode: Clutter.AnimationMode.LINEAR, + }); + } + + private _applyShownState(): void { + this._dash.translation_y = 0; + this._dash.opacity = FULL_OPACITY; + this._dash.set_scale(1, 1); + } + + private _applyHiddenState(): void { + this._dash.translation_y = this._dash.height; + this._dash.opacity = 0; + this._dash.set_scale(HIDE_SCALE, HIDE_SCALE); + } + + private _flushShowCompletionCallbacks(): void { + const callbacks = this._showCompletionCallbacks; + this._showCompletionCallbacks = []; + + for (const callback of callbacks) { + callback(); + } + } + + private _ensureHoverState(): void { + if (!this._options.getContentActor()) return; + + this._blockAutoHideDelay.replace(() => + GLib.idle_add(GLib.PRIORITY_DEFAULT, () => { + if (this._options.getContentActor() && this._hovered) { + this.show(false); + } + + this._blockAutoHideDelay.complete(); + return GLib.SOURCE_REMOVE; + }), + ); + } + + private _isFullyShown(): boolean { + return ( + this._dash.visible && + this._dash.translation_y === 0 && + this._dash.scale_x === 1 && + this._dash.scale_y === 1 && + this._dash.opacity === FULL_OPACITY + ); + } + + private _isFullyHidden(): boolean { + return !this._dash.visible && this._dash.opacity === 0; + } + + private _hasValidAllocation(): boolean { + const allocation = this._dash.get_allocation_box(); + const width = Math.max(0, allocation.x2 - allocation.x1); + const height = Math.max(0, allocation.y2 - allocation.y1); + return width > 0 && height > 0; + } + + private _setContainerInputEnabled(enabled: boolean): void { + this._options.getContainer()?.set_reactive(enabled); + } + + private _clearSources(): void { + this._autohideTimeout.clear(); + this._delayEnsureAutoHide.clear(); + this._blockAutoHideDelay.clear(); + } +} diff --git a/src/theme/autoThemeSwitcher.ts b/src/theme/autoThemeSwitcher.ts index e015a31..7b33dfa 100644 --- a/src/theme/autoThemeSwitcher.ts +++ b/src/theme/autoThemeSwitcher.ts @@ -4,8 +4,9 @@ import GLib from '@girs/glib-2.0'; import Gio from '@girs/gio-2.0'; import type { ExtensionContext } from '~/core/context.ts'; -import { LifecycleScope } from '~/core/lifecycleScope.ts'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; +import { createManagedSource } from '~/core/mainLoop.ts'; import { Module } from '~/module.ts'; import type { SettingsManager } from '~/core/settings.ts'; @@ -25,7 +26,7 @@ const DARK_MINUTES_KEY = 'auto-theme-switcher-dark-minutes'; const LOG_PREFIX = 'AutoThemeSwitcher'; export class AutoThemeSwitcher extends Module { - private _sourceId: number | null = null; + private _scheduledTick: ManagedSource | null = null; private _lifecycle: LifecycleScope | null = null; private _desktopSettings: SettingsManager | null = null; @@ -35,44 +36,42 @@ export class AutoThemeSwitcher extends Module { override enable(): void { this.disable(); - try { - this._lifecycle = new LifecycleScope(); - this._desktopSettings = this.context.settings.getSchema('org.gnome.desktop.interface'); - const settings = this.context.settings; - this._lifecycle.connect(settings, `changed::${LIGHT_HOURS_KEY}`, () => this._tick()); - this._lifecycle.connect(settings, `changed::${LIGHT_MINUTES_KEY}`, () => this._tick()); - this._lifecycle.connect(settings, `changed::${DARK_HOURS_KEY}`, () => this._tick()); - this._lifecycle.connect(settings, `changed::${DARK_MINUTES_KEY}`, () => this._tick()); - this._lifecycle.onDispose(() => this._cancelScheduledTick()); - const subscriptionId = Gio.DBus.system.signal_subscribe( - 'org.freedesktop.login1', - 'org.freedesktop.login1.Manager', - 'PrepareForSleep', - '/org/freedesktop/login1', - null, - Gio.DBusSignalFlags.NONE, - (_conn, _sender, _path, _iface, _signal, params) => { - const [sleeping] = params.deep_unpack() as [boolean]; - if (!sleeping) this._tick(); - }, - ); - this._lifecycle.onDispose(() => Gio.DBus.system.signal_unsubscribe(subscriptionId)); - this._tick(); - } catch (error) { - logger.error('Failed to enable:', { prefix: LOG_PREFIX }, error); - this.disable(); - } + this._lifecycle = new LifecycleScope(); + this._scheduledTick = createManagedSource(this._lifecycle); + this._desktopSettings = this.context.settings.getSchema('org.gnome.desktop.interface'); + + const settings = this.context.settings; + this._lifecycle.connect(settings, `changed::${LIGHT_HOURS_KEY}`, () => this._tick()); + this._lifecycle.connect(settings, `changed::${LIGHT_MINUTES_KEY}`, () => this._tick()); + this._lifecycle.connect(settings, `changed::${DARK_HOURS_KEY}`, () => this._tick()); + this._lifecycle.connect(settings, `changed::${DARK_MINUTES_KEY}`, () => this._tick()); + + const subscriptionId = Gio.DBus.system.signal_subscribe( + 'org.freedesktop.login1', + 'org.freedesktop.login1.Manager', + 'PrepareForSleep', + '/org/freedesktop/login1', + null, + Gio.DBusSignalFlags.NONE, + (_conn, _sender, _path, _iface, _signal, params) => { + const [sleeping] = params.deep_unpack() as [boolean]; + if (!sleeping) this._tick(); + }, + ); + this._lifecycle.onDispose(() => Gio.DBus.system.signal_unsubscribe(subscriptionId)); + this._tick(); } override disable(): void { this._lifecycle?.dispose(); this._lifecycle = null; + this._scheduledTick = null; this._desktopSettings = null; } private _tick(): void { - this._cancelScheduledTick(); - if (!this._desktopSettings) return; + const scheduledTick = this._scheduledTick; + if (!scheduledTick || !this._desktopSettings) return; const now = new Date(); const current = now.getHours() * 60 + now.getMinutes(); @@ -100,16 +99,12 @@ export class AutoThemeSwitcher extends Module { if (next <= current) next += 1440; const delay = (next - current) * 60 - now.getSeconds(); - this._sourceId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, delay, () => { - this._sourceId = null; - this._tick(); - return GLib.SOURCE_REMOVE; - }); - } - - private _cancelScheduledTick(): void { - if (this._sourceId === null) return; - GLib.source_remove(this._sourceId); - this._sourceId = null; + scheduledTick.replace(() => + GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, delay, () => { + scheduledTick.complete(); + this._tick(); + return GLib.SOURCE_REMOVE; + }), + ); } } diff --git a/src/theme/themeChanger.ts b/src/theme/themeChanger.ts index 1388831..235da14 100644 --- a/src/theme/themeChanger.ts +++ b/src/theme/themeChanger.ts @@ -2,6 +2,7 @@ import '@girs/gjs'; import { gettext as _ } from '~/shared/i18n.ts'; import type { ExtensionContext } from '~/core/context.ts'; +import { LifecycleScope } from '~/core/lifecycleScope.ts'; import { logger } from '~/core/logger.ts'; import { Module } from '~/module.ts'; import type { SettingsManager } from '~/core/settings.ts'; @@ -19,39 +20,34 @@ const LOG_PREFIX = 'ThemeChanger'; */ export class ThemeChanger extends Module { private _settings: SettingsManager | null = null; - private _signalId: number | null = null; + private _lifecycle: LifecycleScope | null = null; constructor(context: ExtensionContext) { super(context); } public enable(): void { + this.disable(); + this._lifecycle = new LifecycleScope(); logger.debug('Initializing theme monitor', { prefix: LOG_PREFIX }); - try { - this._settings = this.context.settings.getSchema('org.gnome.desktop.interface'); + this._settings = this.context.settings.getSchema('org.gnome.desktop.interface'); - const currentScheme = this._settings.getString('color-scheme'); - logger.debug(`Current color-scheme: ${currentScheme}`, { prefix: LOG_PREFIX }); + const currentScheme = this._settings.getString('color-scheme'); + logger.debug(`Current color-scheme: ${currentScheme}`, { prefix: LOG_PREFIX }); - this._signalId = this._settings.connect('changed::color-scheme', () => { - this._onColorSchemeChanged(); - }); + this._lifecycle.connect(this._settings, 'changed::color-scheme', () => { + this._onColorSchemeChanged(); + }); - logger.debug('Theme monitor active', { prefix: LOG_PREFIX }); - } catch (error) { - logger.error('Failed to initialize:', { prefix: LOG_PREFIX }, error); - } + logger.debug('Theme monitor active', { prefix: LOG_PREFIX }); } override disable(): void { logger.debug('Disabling theme monitor', { prefix: LOG_PREFIX }); - if (this._signalId && this._settings) { - this._settings.disconnect(this._signalId); - this._signalId = null; - } - + this._lifecycle?.dispose(); + this._lifecycle = null; this._settings = null; } diff --git a/tests/shell/auroraDock.js b/tests/shell/auroraDock.js index 8c7b5b3..b4e7ee7 100644 --- a/tests/shell/auroraDock.js +++ b/tests/shell/auroraDock.js @@ -126,6 +126,10 @@ export function init() { 'alwaysAutoHideIndependent', 'Always auto-hide remains hidden without relying on window overlap', ); + Scripting.defineScriptEvent( + 'dashContentDisposalSafe', + 'Dash content destruction cancels callbacks before actors are disposed', + ); Scripting.defineScriptEvent( 'externalStorageDisabled', 'External storage dock icons are absent when disabled', @@ -209,7 +213,7 @@ export async function run() { is_on_all_workspaces: () => false, get_workspace: () => global.workspace_manager.get_active_workspace(), }; - if (!dock.bindings[0].dash._isWindowRelevant(externalWorkspaceWindow)) + if (!dock.bindings[0].dash._applications.isWindowRelevant(externalWorkspaceWindow)) throw new Error('Primary-only Dock excluded an app from an external monitor'); Scripting.scriptEvent('primaryMonitorOnly'); @@ -229,9 +233,9 @@ export async function run() { ); if (!primaryBinding || !externalBinding) throw new Error('All-monitors Dock did not create the bindings needed for scope validation'); - if (primaryBinding.dash._isWindowRelevant(externalWorkspaceWindow)) + if (primaryBinding.dash._applications.isWindowRelevant(externalWorkspaceWindow)) throw new Error('Primary per-monitor Dock included an app from an external monitor'); - if (!externalBinding.dash._isWindowRelevant(externalWorkspaceWindow)) + if (!externalBinding.dash._applications.isWindowRelevant(externalWorkspaceWindow)) throw new Error('External per-monitor Dock excluded an app from its own monitor'); Scripting.scriptEvent('allMonitorsEnabled'); @@ -261,7 +265,7 @@ export async function run() { ); if (dash._box?.contains?.(trashIcon)) throw new Error('Trash icon is inside the app list instead of being a fixed dock item'); - if (dash._trashIcon !== trashIcon) + if (dash._fixedItems._trash !== trashIcon) throw new Error('Dash lost its fixed trash icon reference after GObject construction'); Scripting.scriptEvent('trashIconValid'); @@ -280,7 +284,7 @@ export async function run() { Scripting.scriptEvent('trashClickWired'); } - if (dash._externalStorageIcons?.length) + if (dash._fixedItems._storage.length) throw new Error( 'External storage icons were created while dock-show-external-storage is disabled', ); @@ -303,10 +307,10 @@ export async function run() { // check, consistent across every environment. const priorMaxWidth = dash._maxWidth; const priorMaxHeight = dash._maxHeight; - const injectedStorageIcons = dash._externalStorageIcons.length === 0; + const injectedStorageIcons = dash._fixedItems._storage.length === 0; try { if (injectedStorageIcons) { - dash._syncExternalStorageIcons( + dash._fixedItems._syncStorage( [1, 2, 3].map((n) => ({ id: `aurora-test-fake-storage-${n}`, name: `Aurora Test Fake Storage ${n}`, @@ -319,7 +323,7 @@ export async function run() { ); } - const customFixedIcons = [dash._trashIcon, ...(dash._externalStorageIcons ?? [])].filter( + const customFixedIcons = [dash._fixedItems._trash, ...dash._fixedItems._storage].filter( Boolean, ); if (customFixedIcons.length === 0) @@ -346,8 +350,8 @@ export async function run() { .filter((actor) => actor.child?._delegate?.icon && !actor.animatingOut); const fixedIcons = [ dash._showAppsIcon, - dash._trashIcon, - ...(dash._externalStorageIcons ?? []), + dash._fixedItems._trash, + ...dash._fixedItems._storage, ].filter(Boolean); const totalIcons = boxIconChildren.length + fixedIcons.length; @@ -383,7 +387,7 @@ export async function run() { `${totalIcons} icons (iconSize=${dash.iconSize}) — fixed icons not counted`, ); } finally { - if (injectedStorageIcons) dash._syncExternalStorageIcons([]); + if (injectedStorageIcons) dash._fixedItems._syncStorage([]); dash.setMaxSize(priorMaxWidth, priorMaxHeight); dash._adjustIconSize(); } @@ -464,10 +468,10 @@ export async function run() { await Scripting.waitLeisure(); await Scripting.sleep(1000); - const originalExternalHover = externalBinding.dash._dashContainerHasHover; + const originalExternalHover = externalBinding.dash._visibility._hovered; externalBinding.dash.hide(false); externalBinding.hotAreaActive = false; - externalBinding.dash._dashContainerHasHover = () => true; + externalBinding.dash._visibility._hovered = true; try { if (!dock.revealMonitorFromHotArea(externalBinding.monitorIndex)) { throw new Error(`Could not reveal Dock on monitor ${externalBinding.monitorIndex}`); @@ -504,7 +508,7 @@ export async function run() { await Scripting.sleep(120); } } finally { - externalBinding.dash._dashContainerHasHover = originalExternalHover; + externalBinding.dash._visibility._hovered = originalExternalHover; } await Scripting.sleep(450); } @@ -523,8 +527,8 @@ export async function run() { // animation from the hidden pose and make the dock flash. dash.hide(false); let hiddenPoseCalls = 0; - const originalApplyHiddenState = dash._applyHiddenState; - dash._applyHiddenState = function (...args) { + const originalApplyHiddenState = dash._visibility._applyHiddenState; + dash._visibility._applyHiddenState = function (...args) { hiddenPoseCalls++; return originalApplyHiddenState.apply(this, args); }; @@ -532,7 +536,7 @@ export async function run() { dash.show(true); dash.show(true); await Scripting.sleep(300); - dash._applyHiddenState = originalApplyHiddenState; + dash._visibility._applyHiddenState = originalApplyHiddenState; if (hiddenPoseCalls !== 1) throw new Error(`Repeated show requests restarted the animation ${hiddenPoseCalls} times`); if (!dash.visible || dash.opacity !== 255 || dash.translation_y !== 0) @@ -540,9 +544,9 @@ export async function run() { Scripting.scriptEvent('repeatedShowStable'); - const originalItemDragHover = dash._dashContainerHasHover; + const originalItemDragHover = dash._visibility._hovered; try { - dash._dashContainerHasHover = () => false; + dash._visibility._hovered = false; dash.blockAutoHide(false); dash.show(false); dash._onItemDragBegin(); @@ -561,8 +565,8 @@ export async function run() { await Scripting.sleep(400); if (dash.visible) throw new Error('Dock did not restore auto-hide after cancelling icon drag'); } finally { - if (dash._itemDragVisibilityHold) dash._onItemDragCancelled(); - dash._dashContainerHasHover = originalItemDragHover; + if (dash._visibility._itemDragHold) dash._onItemDragCancelled(); + dash._visibility._hovered = originalItemDragHover; dash.blockAutoHide(true); dash.show(false); } @@ -587,9 +591,9 @@ export async function run() { // fullscreen window via the dock: the dock must stay up while the pointer is // still over it and hide only after the pointer leaves. dash.show(false); - const originalDashContainerHasHover = dash._dashContainerHasHover; + const originalDashContainerHasHover = dash._visibility._hovered; try { - dash._dashContainerHasHover = () => true; + dash._visibility._hovered = true; binding.hotAreaActive = false; clearIntellihideQueuedRefreshes(binding.intellihide); binding.intellihide._status = 1; @@ -598,11 +602,11 @@ export async function run() { if (!dash.visible) throw new Error('Intellihide BLOCKED hid the dock while the pointer stayed over it'); - dash._dashContainerHasHover = () => false; - dash._onHover(); + dash._visibility._hovered = false; + dash._visibility.updateAutoHide(); await Scripting.sleep(450); } finally { - dash._dashContainerHasHover = originalDashContainerHasHover; + dash._visibility._hovered = originalDashContainerHasHover; } if (dash.visible) throw new Error('Intellihide BLOCKED did not hide after the pointer left'); @@ -622,22 +626,22 @@ export async function run() { // a window is BLOCKING. Hover is tracked via the dock actor's crossing events // (reliable over client windows), so this is what keeps the dock up while the // user switches apps; it only hides once the pointer leaves (see I10). - const originalHoldZoneDashContainerHasHover = dash._dashContainerHasHover; + const originalHoldZoneDashContainerHasHover = dash._visibility._hovered; clearIntellihideQueuedRefreshes(binding.intellihide); binding.intellihide._status = 1; // BLOCKED try { - dash._dashContainerHasHover = () => true; // pointer resting over the dock + dash._visibility._hovered = true; // pointer resting over the dock dock._clearHotAreaReveal(binding); binding.hotAreaActive = false; dock.revealFromHotArea(); await Scripting.sleep(1700); // past the reveal grace → handoff to autohide } finally { - dash._dashContainerHasHover = originalHoldZoneDashContainerHasHover; + dash._visibility._hovered = originalHoldZoneDashContainerHasHover; } if (!dash.visible) throw new Error('Native autohide hid the dock while the pointer was over it'); if (!binding.hotAreaActive) throw new Error('Hot-area reveal ended while the pointer was over the dock'); - if (binding.autoHideReleaseId !== 0) + if (binding.autoHideRelease.active) throw new Error('Hot-area reveal grace timer was left running after handoff'); Scripting.scriptEvent('hotAreaReleaseDeferred'); @@ -668,10 +672,10 @@ export async function run() { // native hover autohide, which polls the dock actor's hover state — reliable // even when the pointer moves onto a client window. This is the reported // maximized-switch bug, where a stage motion watch never saw the exit. - const originalBlockedDashContainerHasHover = dash._dashContainerHasHover; + const originalBlockedDashContainerHasHover = dash._visibility._hovered; try { binding.intellihide._status = 1; // BLOCKED - dash._dashContainerHasHover = () => true; // pointer over the dock + dash._visibility._hovered = true; // pointer over the dock binding.hotAreaActive = true; binding.dash.blockAutoHide(true); dash.show(false); @@ -684,11 +688,11 @@ export async function run() { throw new Error('Hot-area active BLOCKED ended the reveal while pointer stayed over it'); // Pointer leaves the dock: native hover autohide must now retract it. - dash._dashContainerHasHover = () => false; - dash._onHover(); + dash._visibility._hovered = false; + dash._visibility.updateAutoHide(); await Scripting.sleep(450); } finally { - dash._dashContainerHasHover = originalBlockedDashContainerHasHover; + dash._visibility._hovered = originalBlockedDashContainerHasHover; } if (dash.visible) throw new Error('Hot-area active BLOCKED did not hide the dock after the pointer left'); @@ -730,7 +734,7 @@ export async function run() { if (!binding.hotArea.canStartContextualDragReveal(edgeX, edgeY)) throw new Error('Hot area remained in transition cooldown after its deadline'); - dock._handleContextualDragMotion({ + dock._dragReveal._handleMotion({ source: Main.xdndHandler, x: edgeX, y: edgeY, @@ -781,10 +785,10 @@ export async function run() { // via the dock icons keeps the dock up while the pointer is over it and hides // it once the pointer leaves. Same contract as I10, but driven by the signal. dock._clearHotAreaReveal(binding); - const originalReassertHasHover = dash._dashContainerHasHover; + const originalReassertHasHover = dash._visibility._hovered; try { binding.intellihide._status = 1; // BLOCKED - dash._dashContainerHasHover = () => true; // pointer over the dock + dash._visibility._hovered = true; // pointer over the dock binding.hotAreaActive = true; binding.dash.blockAutoHide(true); dash.show(false); @@ -797,11 +801,11 @@ export async function run() { throw new Error('Focus reassert ended the reveal while the pointer stayed over it'); // Pointer leaves the dock: native hover autohide must retract it. - dash._dashContainerHasHover = () => false; - dash._onHover(); + dash._visibility._hovered = false; + dash._visibility.updateAutoHide(); await Scripting.sleep(450); } finally { - dash._dashContainerHasHover = originalReassertHasHover; + dash._visibility._hovered = originalReassertHasHover; } if (dash.visible) throw new Error('Focus reassert did not hide the dock after the pointer left'); if (binding.hotAreaActive) @@ -876,8 +880,8 @@ export async function run() { if (alwaysAutoHideBinding.dash.visible) throw new Error('Always auto-hide Dock was visible before an edge reveal'); - const originalAlwaysAutoHideHover = alwaysAutoHideBinding.dash._dashContainerHasHover; - alwaysAutoHideBinding.dash._dashContainerHasHover = () => false; + const originalAlwaysAutoHideHover = alwaysAutoHideBinding.dash._visibility._hovered; + alwaysAutoHideBinding.dash._visibility._hovered = false; try { if (!dock.revealMonitorFromHotArea(alwaysAutoHideBinding.monitorIndex)) throw new Error('Always auto-hide Dock could not be revealed from its hot area'); @@ -891,10 +895,36 @@ export async function run() { if (alwaysAutoHideBinding.dash.visible) throw new Error('Always auto-hide Dock stayed visible after the reveal ended'); } finally { - alwaysAutoHideBinding.dash._dashContainerHasHover = originalAlwaysAutoHideHover; + alwaysAutoHideBinding.dash._visibility._hovered = originalAlwaysAutoHideHover; } Scripting.scriptEvent('alwaysAutoHideIndependent'); + // The Shell may destroy the Dash content actor from C before the module's + // explicit teardown runs. Pending autohide callbacks must stop immediately + // instead of reading children from the disposed actor. + const disposableBinding = dock.bindings.find( + (candidate) => candidate.monitorIndex === Main.layoutManager.primaryIndex, + ); + if (!disposableBinding) + throw new Error('Could not find the current primary Dock for disposal coverage'); + + const disposableDash = disposableBinding.dash; + disposableDash._visibility.updateAutoHide(); + if (!disposableDash._visibility._autohideTimeout.active) + throw new Error('Could not schedule the autohide callback for disposal coverage'); + + const disposableBox = disposableDash._dashBox; + if (!disposableBox) throw new Error('Current Dash content was already destroyed before coverage'); + + disposableBox.destroy(); + await Scripting.sleep(200); + + if (disposableDash._dashBox) throw new Error('Dash retained its content actor after destruction'); + if (disposableDash._visibility._autohideTimeout.active) + throw new Error('Dash content destruction retained its autohide callback'); + + Scripting.scriptEvent('dashContentDisposalSafe'); + // I11 — disable dock, actor must be removed const originalValue = settings.get_boolean('module-dock'); settings.set_boolean('module-dock', false); @@ -945,6 +975,7 @@ let _externalWorkspaceActorStable = false; let _primaryMonitorOnly = false; let _allMonitorsEnabled = false; let _alwaysAutoHideIndependent = false; +let _dashContentDisposalSafe = false; /** @returns {void} */ export function script_dockPresent() { @@ -1056,6 +1087,11 @@ export function script_alwaysAutoHideIndependent() { _alwaysAutoHideIndependent = true; } +/** @returns {void} */ +export function script_dashContentDisposalSafe() { + _dashContentDisposalSafe = true; +} + /** @returns {void} */ export function finish() { if (!_dockPresent) @@ -1092,5 +1128,7 @@ export function finish() { if (!_allMonitorsEnabled) throw new Error('Dock all-monitors opt-in behavior was not verified'); if (!_alwaysAutoHideIndependent) throw new Error('Dock always auto-hide behavior was not verified'); + if (!_dashContentDisposalSafe) + throw new Error('Dash content disposal did not cancel pending actor access'); if (!_dockRemoved) throw new Error('Dock actor was not removed after module was disabled'); } diff --git a/tests/shell/auroraIconWeave.js b/tests/shell/auroraIconWeave.js index f0437e1..e3b7825 100644 --- a/tests/shell/auroraIconWeave.js +++ b/tests/shell/auroraIconWeave.js @@ -22,8 +22,14 @@ export var METRICS = {}; /** @returns {void} */ export function init() { - Scripting.defineScriptEvent('prototypePatched', 'WindowTracker.get_window_app is patched while enabled'); - Scripting.defineScriptEvent('prototypeRestored', 'WindowTracker.get_window_app restored after disable'); + Scripting.defineScriptEvent( + 'prototypePatched', + 'WindowTracker.get_window_app is patched while enabled', + ); + Scripting.defineScriptEvent( + 'prototypeRestored', + 'WindowTracker.get_window_app restored after disable', + ); } /** @returns {Promise<void>} */ @@ -49,7 +55,7 @@ export async function run() { auroraSettings.set_boolean('module-icon-weave', true); throw new Error( 'Shell.WindowTracker.prototype.get_window_app was NOT restored after icon-weave disable — ' + - 'the patched function is still in place' + 'the patched function is still in place', ); } @@ -63,22 +69,50 @@ export async function run() { const repatchedFn = Shell.WindowTracker.prototype.get_window_app; if (repatchedFn === restoredFn) - throw new Error('Shell.WindowTracker.prototype.get_window_app was not re-patched after re-enable'); + throw new Error( + 'Shell.WindowTracker.prototype.get_window_app was not re-patched after re-enable', + ); + + // A later extension owns its wrapper. IconWeave must not overwrite that + // external patch while it tears down its own resources. + const externalWrapper = function (window) { + return repatchedFn.call(this, window); + }; + Shell.WindowTracker.prototype.get_window_app = externalWrapper; + + auroraSettings.set_boolean('module-icon-weave', false); + await Scripting.waitLeisure(); + await Scripting.sleep(200); + + if (Shell.WindowTracker.prototype.get_window_app !== externalWrapper) + throw new Error('IconWeave overwrote a prototype patch installed after its own wrapper'); + + Shell.WindowTracker.prototype.get_window_app = restoredFn; + auroraSettings.set_boolean('module-icon-weave', true); + await Scripting.waitLeisure(); } let _prototypePatched = false; let _prototypeRestored = false; /** @returns {void} */ -export function script_prototypePatched() { _prototypePatched = true; } +export function script_prototypePatched() { + _prototypePatched = true; +} /** @returns {void} */ -export function script_prototypeRestored() { _prototypeRestored = true; } +export function script_prototypeRestored() { + _prototypeRestored = true; +} /** @returns {void} */ export function finish() { if (!_prototypePatched) - throw new Error('IconWeave did not patch Shell.WindowTracker.prototype.get_window_app on enable'); + throw new Error( + 'IconWeave did not patch Shell.WindowTracker.prototype.get_window_app on enable', + ); if (!_prototypeRestored) - throw new Error('IconWeave did not restore Shell.WindowTracker.prototype.get_window_app on disable'); + throw new Error( + 'IconWeave did not restore Shell.WindowTracker.prototype.get_window_app on disable', + ); } diff --git a/tests/shell/auroraVolumeMixer.js b/tests/shell/auroraVolumeMixer.js index 3dbbc39..0dfc22f 100644 --- a/tests/shell/auroraVolumeMixer.js +++ b/tests/shell/auroraVolumeMixer.js @@ -7,6 +7,7 @@ * - After enable, an actor with CSS class "aurora-volume-mixer" is attached * inside the OutputStreamSlider's menu (I15) * - After disable, the actor is destroyed and no longer present (I16) + * - "Always Show" overrides contextual toggle visibility (I17) * * If no OutputStreamSlider is present in the headless environment, the test * skips the attachment assertions gracefully and only verifies the lifecycle. @@ -26,7 +27,7 @@ const MIXER_CSS_CLASS = 'aurora-volume-mixer'; function findOutputSlider() { const grid = Main.panel.statusArea.quickSettings?.menu?._grid; if (!grid) return null; - return grid.get_children().find(c => c.constructor.name === 'OutputStreamSlider') ?? null; + return grid.get_children().find((c) => c.constructor.name === 'OutputStreamSlider') ?? null; } function findMixerPanelInSlider(slider) { @@ -42,13 +43,27 @@ function findMixerPanelInSlider(slider) { return null; } +function findMixerToggle(slider) { + return ( + slider?.child?.get_children?.().find((child) => child.accessible_name === 'Volume Mixer') ?? + null + ); +} + export var METRICS = {}; /** @returns {void} */ export function init() { - Scripting.defineScriptEvent('mixerAttached', 'aurora-volume-mixer found in OutputStreamSlider menu'); + Scripting.defineScriptEvent( + 'mixerAttached', + 'aurora-volume-mixer found in OutputStreamSlider menu', + ); Scripting.defineScriptEvent('mixerRemoved', 'aurora-volume-mixer removed after disable'); - Scripting.defineScriptEvent('lifecycleOk', 'enable/disable cycle completed (no slider in environment)'); + Scripting.defineScriptEvent('visibilityOk', 'Volume Mixer contextual visibility works'); + Scripting.defineScriptEvent( + 'lifecycleOk', + 'enable/disable cycle completed (no slider in environment)', + ); } /** @returns {Promise<void>} */ @@ -81,10 +96,28 @@ export async function run() { // I15 — mixer panel must be attached inside the slider menu const panelAfterEnable = findMixerPanelInSlider(slider); if (!panelAfterEnable) - throw new Error(`No actor with CSS class "${MIXER_CSS_CLASS}" found in OutputStreamSlider menu`); + throw new Error( + `No actor with CSS class "${MIXER_CSS_CLASS}" found in OutputStreamSlider menu`, + ); Scripting.scriptEvent('mixerAttached'); + // I17 — Always Show must reveal the toggle; contextual mode must then match + // whether the panel has any adjustable application streams. + const mixerToggle = findMixerToggle(slider); + if (!mixerToggle) throw new Error('Volume Mixer toggle was not attached to OutputStreamSlider'); + + auroraSettings.set_boolean('volume-mixer-always-show', true); + await Scripting.waitLeisure(); + if (!mixerToggle.visible) throw new Error('Always Show did not reveal the Volume Mixer toggle'); + + auroraSettings.set_boolean('volume-mixer-always-show', false); + await Scripting.waitLeisure(); + if (mixerToggle.visible !== panelAfterEnable.should_show) + throw new Error('Volume Mixer toggle visibility does not match adjustable stream availability'); + + Scripting.scriptEvent('visibilityOk'); + // I16 — disable module; panel must be gone auroraSettings.set_boolean('module-volume-mixer', false); await Scripting.waitLeisure(); @@ -105,15 +138,27 @@ export async function run() { let _mixerAttached = false; let _mixerRemoved = false; let _lifecycleOk = false; +let _visibilityOk = false; /** @returns {void} */ -export function script_mixerAttached() { _mixerAttached = true; } +export function script_mixerAttached() { + _mixerAttached = true; +} /** @returns {void} */ -export function script_mixerRemoved() { _mixerRemoved = true; } +export function script_mixerRemoved() { + _mixerRemoved = true; +} /** @returns {void} */ -export function script_lifecycleOk() { _lifecycleOk = true; } +export function script_lifecycleOk() { + _lifecycleOk = true; +} + +/** @returns {void} */ +export function script_visibilityOk() { + _visibilityOk = true; +} /** @returns {void} */ export function finish() { @@ -122,4 +167,6 @@ export function finish() { throw new Error('VolumeMixer module test did not complete — shell may have crashed'); if (_mixerAttached && !_mixerRemoved) throw new Error('aurora-volume-mixer actor was not removed after module was disabled'); + if (_mixerAttached && !_visibilityOk) + throw new Error('Volume Mixer contextual visibility was not verified'); } diff --git a/tests/unit/appIdentity.test.ts b/tests/unit/appIdentity.test.ts index 7043026..c5a2292 100644 --- a/tests/unit/appIdentity.test.ts +++ b/tests/unit/appIdentity.test.ts @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { appIdCandidates } from '../../src/desktop/trayIcons/appIdentity.ts'; +import { + appIdCandidates, + sniIdentityMatchesAppId, +} from '../../src/desktop/trayIcons/appIdentity.ts'; test('app identity — keeps suffixed and unsuffixed lowercase candidates', () => { assert.deepEqual( @@ -10,6 +13,20 @@ test('app identity — keeps suffixed and unsuffixed lowercase candidates', () = ); }); +test('app identity — matches specific SNI metadata without generic component collisions', () => { + assert.equal( + sniIdentityMatchesAppId( + { desktopEntry: 'com.discordapp.Discord.desktop', sniId: '' }, + 'com.discordapp.Discord', + ), + true, + ); + assert.equal( + sniIdentityMatchesAppId({ desktopEntry: '', sniId: 'org.example.tray' }, 'com.other.tray'), + false, + ); +}); + test('app identity — combines candidates from Shell and background-app IDs', () => { assert.deepEqual( appIdCandidates(['org.example.App', 'Com.Vendor.App.desktop']), diff --git a/tests/unit/auroraMenuState.test.ts b/tests/unit/auroraMenuState.test.ts index 4c6621a..d3a3134 100644 --- a/tests/unit/auroraMenuState.test.ts +++ b/tests/unit/auroraMenuState.test.ts @@ -3,6 +3,7 @@ import { test } from 'node:test'; import { decodeXml, + parseRecentXbel, parseCustomCommand, serializeCustomCommand, truncateMiddle, @@ -18,6 +19,18 @@ test('Aurora Menu state — parses label and command around the first separator' assert.equal(parseCustomCommand('missing command | '), null); }); +test('Aurora Menu state — parses, deduplicates and sorts recent XBEL bookmarks', () => { + const xml = `<xbel> + <bookmark href="file:///tmp/older.txt" modified="2026-01-01T10:00:00Z"><title>Older + Example & Docs + Duplicate + `; + assert.deepEqual( + parseRecentXbel(xml, 10).map((item) => item.title), + ['Example & Docs', 'Older'], + ); +}); + test('Aurora Menu state — serializes custom commands in the settings format', () => { assert.equal( serializeCustomCommand({ label: ' Terminal ', command: ' ptyxis --new-window ' }), diff --git a/tests/unit/clipboardCardState.test.ts b/tests/unit/clipboardCardState.test.ts new file mode 100644 index 0000000..809fbee --- /dev/null +++ b/tests/unit/clipboardCardState.test.ts @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + classifyClipboardCard, + parseClipboardUrl, + truncateClipboardText, +} from '../../src/clipboard/clipboardCardState.ts'; + +test('clipboard cards classify images, links, code and plain text', () => { + assert.equal(classifyClipboardCard('image', ''), 'image'); + assert.equal(classifyClipboardCard('text', 'https://example.com/path?q=1'), 'link'); + assert.equal(classifyClipboardCard('text', 'const value = 1;\nreturn value;'), 'code'); + assert.equal(classifyClipboardCard('text', 'a normal sentence'), 'text'); +}); + +test('clipboard URL parsing strips query from display path and rejects whitespace', () => { + assert.deepEqual(parseClipboardUrl('https://example.com/docs?q=1'), { + host: 'example.com', + path: '/docs', + }); + assert.equal(parseClipboardUrl('https://example.com/a b'), null); +}); + +test('clipboard truncation adds an ellipsis only beyond the limit', () => { + assert.equal(truncateClipboardText('abcd', 4), 'abcd'); + assert.equal(truncateClipboardText('abcde', 4), 'abcd…'); +}); diff --git a/tests/unit/dashState.test.ts b/tests/unit/dashState.test.ts new file mode 100644 index 0000000..d68700a --- /dev/null +++ b/tests/unit/dashState.test.ts @@ -0,0 +1,27 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { selectDashWindows, shouldHideDash } from '../../src/shared/ui/dashState.ts'; + +test('dash visibility hides only when no owner is holding it open', () => { + const idle = { + target: 'shown' as const, + blocked: false, + hovered: false, + menuOpen: false, + dragHeld: false, + }; + assert.equal(shouldHideDash(idle), true); + assert.equal(shouldHideDash({ ...idle, hovered: true }), false); + assert.equal(shouldHideDash({ ...idle, dragHeld: true }), false); +}); + +test('dash window selection filters workspace, monitor and taskbar entries', () => { + const windows = [ + { monitor: 0, workspace: 1 }, + { monitor: 1, workspace: 1 }, + { monitor: 0, workspace: 0, sticky: true }, + { monitor: 0, workspace: 1, skipTaskbar: true }, + ]; + assert.deepEqual(selectDashWindows(windows, 0, 1, true), [windows[0], windows[2]]); + assert.deepEqual(selectDashWindows(windows, 0, 1, false), [windows[0], windows[1], windows[2]]); +}); diff --git a/tests/unit/dockConfiguration.test.ts b/tests/unit/dockConfiguration.test.ts new file mode 100644 index 0000000..ca59a32 --- /dev/null +++ b/tests/unit/dockConfiguration.test.ts @@ -0,0 +1,40 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + classifyDockConfigurationChange, + DockConfigurationController, + normalizeDockConfiguration, + type DockConfiguration, +} from '../../src/dock/dockConfiguration.ts'; + +const base: DockConfiguration = { + alwaysShow: false, + intellihide: false, + showOnAllMonitors: false, + showTrash: true, + showExternalStorage: true, + motionEnabled: true, + motionProfile: 'subtle', + excludePip: false, +}; + +test('dock configuration keeps always-show and intellihide mutually exclusive', () => { + const both = { ...base, alwaysShow: true, intellihide: true }; + assert.equal(normalizeDockConfiguration(both, 'alwaysShow').intellihide, false); + assert.equal(normalizeDockConfiguration(both, 'intellihide').alwaysShow, false); +}); + +test('dock configuration controller publishes typed snapshots and transition scope', () => { + const controller = new DockConfigurationController(base); + const transition = controller.transition({ ...base, motionProfile: 'balanced' }, 'motionProfile'); + assert.equal(transition.change, 'motion'); + assert.equal(transition.snapshot.motionProfile, 'balanced'); + assert.notEqual(transition.snapshot, controller.snapshot); +}); + +test('dock configuration distinguishes rebuild, motion and PiP updates', () => { + assert.equal(classifyDockConfigurationChange(base, { ...base, showTrash: false }), 'rebuild'); + assert.equal(classifyDockConfigurationChange(base, { ...base, motionEnabled: false }), 'motion'); + assert.equal(classifyDockConfigurationChange(base, { ...base, excludePip: true }), 'pip'); + assert.equal(classifyDockConfigurationChange(base, { ...base }), 'none'); +}); diff --git a/tests/unit/iconWeaveRegistry.test.ts b/tests/unit/iconWeaveRegistry.test.ts new file mode 100644 index 0000000..c38e2cc --- /dev/null +++ b/tests/unit/iconWeaveRegistry.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { IconWeaveWindowRegistry } from '../../src/patches/iconWeaveRegistry.ts'; + +function fakeWindow(wmClass: string, appId: string) { + return { + get_wm_class: () => wmClass, + get_gtk_application_id: () => appId, + }; +} + +test('icon weave registry reuses mappings with the same application identity', () => { + const registry = new IconWeaveWindowRegistry(); + const application = { id: 'example.desktop' }; + const first = fakeWindow('Example', 'com.example.App'); + + registry.map(first, application); + + assert.equal(registry.findMappedApp('Example', ''), application); + assert.equal(registry.findMappedApp('', 'com.example.App'), application); + assert.equal(registry.findMappedApp('Different', 'different.app'), null); +}); + +test('icon weave registry retains a processed identity until its last mapping is removed', () => { + const registry = new IconWeaveWindowRegistry(); + const application = { id: 'example.desktop' }; + const first = fakeWindow('Example', 'com.example.App'); + const second = fakeWindow('Example', 'com.example.App'); + + registry.map(first, application); + registry.map(second, application); + registry.markProcessed('Example'); + + registry.remove(first, 'Example', 'com.example.App'); + assert.equal(registry.hasProcessed('Example'), true); + + registry.remove(second, 'Example', 'com.example.App'); + assert.equal(registry.hasProcessed('Example'), false); +}); + +test('icon weave registry clears mappings and processed identities together', () => { + const registry = new IconWeaveWindowRegistry(); + const window = fakeWindow('Example', 'com.example.App'); + + registry.map(window, { id: 'example.desktop' }); + registry.markProcessed('Example'); + registry.clear(); + + assert.equal(registry.mappings.size, 0); + assert.equal(registry.hasProcessed('Example'), false); +}); diff --git a/tests/unit/iconWeaveScoring.test.ts b/tests/unit/iconWeaveScoring.test.ts index 1351d27..405d15e 100644 --- a/tests/unit/iconWeaveScoring.test.ts +++ b/tests/unit/iconWeaveScoring.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { scoreIconWeaveCandidate } from '../../src/patches/iconWeaveScoring.ts'; +import { + registerIconWeaveWindow, + scoreIconWeaveCandidate, + unregisterIconWeaveWindow, +} from '../../src/patches/iconWeaveScoring.ts'; const MIN_MATCH_SCORE = 50; @@ -17,6 +21,14 @@ test('IconWeave scoring rejects helper classes that only share a generic short t assert.equal(score, 0); }); +test('IconWeave registration updates immutably and removes mapped windows', () => { + const initial = new Map([[1, 'one.desktop']]); + const registered = registerIconWeaveWindow(initial, { windowId: 2, appId: 'two.desktop' }); + assert.equal(initial.has(2), false); + assert.equal(registered.get(2), 'two.desktop'); + assert.deepEqual([...unregisterIconWeaveWindow(registered, 1)], [[2, 'two.desktop']]); +}); + test('IconWeave scoring keeps exact identity matches strong', () => { const score = scoreIconWeaveCandidate({ desktopId: 'io.ente.auth', diff --git a/tests/unit/intellihideState.test.ts b/tests/unit/intellihideState.test.ts index 66dd4ef..857ddb2 100644 --- a/tests/unit/intellihideState.test.ts +++ b/tests/unit/intellihideState.test.ts @@ -100,6 +100,28 @@ test('getBlockingOverlapState does not let a focused PiP reveal over fullscreen' assert.deepEqual(state.rectangles, [backgroundFullscreenWindow]); }); +test('getBlockingOverlapState keeps the dock hidden while PiP is focused without fullscreen', () => { + const dock = rect(700, 1030, 600, 50); + const backgroundWindow = rect(0, 0, 600, 500); + const focusedPipWindow = rect(100, 100, 500, 300); + + const state = getBlockingOverlapState( + [ + { rectangle: backgroundWindow }, + { + rectangle: focusedPipWindow, + focused: true, + topmost: true, + excludedFromSmartReveal: true, + }, + ], + dock, + false, + ); + + assert.equal(state.blocked, true); +}); + test('getBlockingOverlapState keeps ordinary small-window reveal when PiP exclusion is off', () => { const dock = rect(700, 1030, 600, 50); const backgroundFullscreenWindow = rect(0, 0, 1920, 1080); diff --git a/tests/unit/lifecycleScope.test.ts b/tests/unit/lifecycleScope.test.ts index f25d879..49eaef2 100644 --- a/tests/unit/lifecycleScope.test.ts +++ b/tests/unit/lifecycleScope.test.ts @@ -21,11 +21,87 @@ test('LifecycleScope — disconnects signals', () => { disconnect: (id: number) => disconnected.push(id), }; const scope = new LifecycleScope(); - scope.connect(target, 'changed', () => undefined); + const id = scope.connect(target, 'changed', () => undefined); + assert.equal(id, 42); scope.dispose(); assert.deepEqual(disconnected, [42]); }); +test('LifecycleScope — replaces and clears a managed source', () => { + const removed: number[] = []; + const scope = new LifecycleScope(); + const source = scope.manageSource((id) => removed.push(id)); + + source.replace(() => 1); + assert.equal(source.active, true); + source.replace(() => 2); + assert.deepEqual(removed, [1]); + source.clear(); + source.clear(); + + assert.equal(source.active, false); + assert.deepEqual(removed, [1, 2]); +}); + +test('LifecycleScope — removes the previous source before creating its replacement', () => { + const events: string[] = []; + const scope = new LifecycleScope(); + const source = scope.manageSource((id) => events.push(`remove:${id}`)); + + source.replace(() => { + events.push('create:1'); + return 1; + }); + source.replace(() => { + events.push('create:2'); + return 2; + }); + + assert.deepEqual(events, ['create:1', 'remove:1', 'create:2']); +}); + +test('LifecycleScope — completes a managed source without removing it', () => { + const removed: number[] = []; + const scope = new LifecycleScope(); + const source = scope.manageSource((id) => removed.push(id)); + + source.replace(() => 7); + source.complete(); + scope.dispose(); + + assert.equal(source.active, false); + assert.deepEqual(removed, []); +}); + +test('LifecycleScope — disposes an active managed source', () => { + const removed: number[] = []; + const scope = new LifecycleScope(); + const source = scope.manageSource((id) => removed.push(id)); + + source.replace(() => 9); + scope.dispose(); + scope.dispose(); + + assert.deepEqual(removed, [9]); +}); + +test('LifecycleScope — does not create a managed source after disposal', () => { + const removed: number[] = []; + let created = false; + const scope = new LifecycleScope(); + const source = scope.manageSource((id) => removed.push(id)); + scope.dispose(); + + source.replace(() => { + created = true; + return 11; + }); + + assert.equal(source.active, false); + assert.equal(created, false); + assert.deepEqual(removed, []); +}); + test('LifecycleScope — teardown registered after disposal runs immediately', () => { let tornDown = false; const scope = new LifecycleScope(); diff --git a/tests/unit/meetingClock.test.ts b/tests/unit/meetingClock.test.ts index bf5967f..3ddfba5 100644 --- a/tests/unit/meetingClock.test.ts +++ b/tests/unit/meetingClock.test.ts @@ -5,6 +5,7 @@ import { derivePanelPresentation, extractMeetingUrl, getDueAlertEvents, + getNextAlertEpoch, normalizeCalendarServerEvent, type MeetingEvent, } from '../../src/panel/clock/meetingClock/meetingClockLogic.ts'; @@ -48,6 +49,30 @@ test('meetingClock — normalizes CalendarServer events and detects meeting URL' assert.strictEqual(normalized.meetingUrl, 'https://meet.google.com/abc-defg-hij'); }); +test('meetingClock — calculates the next alert across lead time and snooze state', () => { + const now = 1_000; + const first = event({ + id: 'first', + startEpochSeconds: 1_900, + endEpochSeconds: 2_200, + meetingUrl: 'https://meet.example/first', + }); + const second = event({ + id: 'second', + startEpochSeconds: 2_500, + endEpochSeconds: 2_800, + meetingUrl: 'https://meet.example/second', + }); + const next = getNextAlertEpoch([second, first], now, { + alertsEnabled: true, + alertMinutesBefore: 10, + alertEventsWithoutLink: false, + excludeAllDayEvents: false, + snoozedUntilByEventId: new Map([['first', 1_450]]), + }); + assert.equal(next, 1_450); +}); + test('meetingClock — prefers video meeting URLs over generic links', () => { const url = extractMeetingUrl({ description: 'Notes: https://example.com Agenda: https://zoom.us/j/12345', diff --git a/tests/unit/pipWindowPolicy.test.ts b/tests/unit/pipWindowPolicy.test.ts index 7c7cabd..a9879e7 100644 --- a/tests/unit/pipWindowPolicy.test.ts +++ b/tests/unit/pipWindowPolicy.test.ts @@ -43,6 +43,8 @@ test('recognizes supported PiP titles without matching unrelated windows', () => assert.equal(isPipTitle('Picture-in-Picture'), true); assert.equal(isPipTitle('Picture in picture'), true); assert.equal(isPipTitle('Video title - PiP'), true); + assert.equal(isPipTitle('Video title - Picture-in-Picture'), true); + assert.equal(isPipTitle('Video title — Picture in picture'), true); assert.equal(isPipTitle(' PICTURE-IN-PICTURE '), true); assert.equal(isPipTitle(null), false); diff --git a/tests/unit/schema.test.ts b/tests/unit/schema.test.ts index 022dfe6..a82e7da 100644 --- a/tests/unit/schema.test.ts +++ b/tests/unit/schema.test.ts @@ -237,3 +237,7 @@ test('schema — Dock defaults to the primary monitor only', () => { test('schema — Dock uses always auto-hide by default', () => { assert.equal(schemaDefault('dock-intellihide'), 'false'); }); + +test('schema — Volume Mixer button is contextual by default', () => { + assert.equal(schemaDefault('volume-mixer-always-show'), 'false'); +}); diff --git a/tests/unit/sniIconState.test.ts b/tests/unit/sniIconState.test.ts new file mode 100644 index 0000000..9f624f4 --- /dev/null +++ b/tests/unit/sniIconState.test.ts @@ -0,0 +1,23 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + isSymbolicSniArgb, + isSymbolicSniPixels, + selectSniPixmap, +} from '../../src/desktop/trayIcons/sniIconState.ts'; + +test('SNI pixmap selection ignores unusable images and chooses nearest size', () => { + const pixmaps = [ + { width: 4, height: 4 }, + { width: 16, height: 16 }, + { width: 32, height: 32 }, + ]; + assert.equal(selectSniPixmap(pixmaps, 24), pixmaps[2]); + assert.equal(selectSniPixmap([pixmaps[0]!], 24), null); +}); + +test('SNI symbolic classification accepts neutral pixels and rejects colorful icons', () => { + assert.equal(isSymbolicSniPixels([40, 42, 41, 255, 250, 250, 251, 255]), true); + assert.equal(isSymbolicSniPixels([255, 0, 0, 255, 0, 255, 0, 255]), false); + assert.equal(isSymbolicSniArgb([255, 40, 42, 41]), true); +}); diff --git a/tests/unit/trayLayout.test.ts b/tests/unit/trayLayout.test.ts new file mode 100644 index 0000000..30aa9d5 --- /dev/null +++ b/tests/unit/trayLayout.test.ts @@ -0,0 +1,41 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + calculateTrayLayout, + getEffectiveTrayLimit, + visibleTrayIndexes, +} from '../../src/desktop/trayIcons/trayLayout.ts'; + +test('tray layout computes collapsed viewport, overflow and maximum scroll', () => { + const layout = calculateTrayLayout({ + count: 8, + itemWidth: 30, + gap: 3, + configuredLimit: 4, + availableWidth: null, + collapsed: true, + }); + assert.equal(layout.fullWidth, 261); + assert.equal(layout.viewportWidth, 129); + assert.equal(layout.maxScroll, 132); + assert.equal(layout.hasOverflow, true); +}); + +test('tray layout constrains effective limit and visible indexes to available width', () => { + assert.equal(getEffectiveTrayLimit(8, 30, 3, 96), 3); + assert.deepEqual(visibleTrayIndexes(8, 3, 165, 30, 3), { start: 5, end: 8 }); +}); + +test('expanded tray uses the reserved viewport and resets clip start', () => { + const layout = calculateTrayLayout({ + count: 5, + itemWidth: 30, + gap: 3, + configuredLimit: 3, + availableWidth: 120, + collapsed: false, + }); + assert.equal(layout.reservedWidth, 120); + assert.equal(layout.viewportWidth, 120); + assert.equal(layout.clipStart, 0); +});