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); +});