diff --git a/.stylelintrc.json b/.stylelintrc.json index dcfad55..2667832 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -13,7 +13,16 @@ "spacing", "icon-size", "z-position", - "icon-shadow" + "icon-shadow", + "border-radius-topleft", + "border-radius-topright", + "border-radius-bottomleft", + "border-radius-bottomright", + "background-gradient-direction", + "background-gradient-start", + "background-gradient-end", + "-arrow-rise", + "-boxpointer-gap" ] } ], diff --git a/data/po/pt_BR.po b/data/po/pt_BR.po index bc28fee..d7a8f4d 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-09-06 17:06-0300\n" +"POT-Creation-Date: 2026-09-13 18:39-0300\n" "PO-Revision-Date: 2026-07-16 09:20-0300\n" "Last-Translator: Aurora Shell Contributors\n" "Language-Team: Portuguese (Brazil)\n" @@ -226,27 +226,19 @@ msgstr "" "Insere o texto selecionado no campo que estava em foco anteriormente. " "Atenção: requer um campo Wayland ativo compatível com text-input-v3" -#: dist/clipboard/clipboardHistory.manifest.js:27 -msgid "Poll Interval (ms)" -msgstr "Intervalo de verificação (ms)" - -#: dist/clipboard/clipboardHistory.manifest.js:28 -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:148 +#: dist/clipboard/clipboardItem.js:151 msgid "Copy" msgstr "Copiar" -#: dist/clipboard/clipboardItem.js:151 +#: dist/clipboard/clipboardItem.js:154 msgid "Unpin" msgstr "Desafixar" -#: dist/clipboard/clipboardItem.js:151 +#: dist/clipboard/clipboardItem.js:154 msgid "Pin" msgstr "Fixar" -#: dist/clipboard/clipboardItem.js:154 +#: dist/clipboard/clipboardItem.js:157 dist/dev/devToolModuleSettings.js:144 msgid "Delete" msgstr "Excluir" @@ -334,6 +326,386 @@ msgstr "" "Recolore automaticamente ícones SNI monocromáticos para corresponder ao tema " "do painel" +#: dist/dev/calendarRemindersDevTool.js:50 +#, fuzzy +msgid "Fake events" +msgstr "Eventos e alertas fictícios" + +#: dist/dev/calendarRemindersDevTool.js:59 +#, fuzzy +msgid "reminder" +msgstr "Lembrete do calendário" + +#: dist/dev/calendarRemindersDevTool.js:59 +#, fuzzy +msgid "no reminder" +msgstr "Lembrete do calendário" + +#: dist/dev/clipboardHistoryDevTool.js:116 +msgid "Generated samples" +msgstr "" + +#: dist/dev/clipboardHistoryDevTool.js:118 +#, fuzzy +msgid "Added by Aurora DevTool" +msgstr "Aurora DevTool" + +#: dist/dev/clipboardHistoryDevTool.js:160 +msgid "Generated code sample" +msgstr "" + +#: dist/dev/clipboardHistoryDevTool.js:174 +msgid "Generated image sample" +msgstr "" + +#: dist/dev/devTool.js:28 +msgid "Extension tools and session info" +msgstr "Ferramentas da extensão e informações da sessão" + +#: dist/dev/devTool.js:29 +msgid "Visibility, layout and monitor bindings" +msgstr "Visibilidade, layout e vínculos de monitores" + +#: dist/dev/devTool.js:30 +msgid "Annotation overlay and OCR states" +msgstr "Sobreposição de anotação e estados de OCR" + +#: dist/dev/devTool.js:31 +msgid "Populate the history panel" +msgstr "Preenche o painel de histórico" + +#: dist/dev/devTool.js:32 +msgid "Fake SNI items and attention badges" +msgstr "Itens SNI e selos de atenção fictícios" + +#: dist/dev/devTool.js:33 +msgid "Fake weather snapshots" +msgstr "Instantâneos de clima fictícios" + +#: dist/dev/devTool.js:34 +msgid "Fake events and alerts" +msgstr "Eventos e alertas fictícios" + +#: dist/dev/devTool.js:82 +msgid "Runtime updated" +msgstr "Ambiente atualizado" + +#: dist/dev/devTool.js:346 +msgid "Back" +msgstr "Voltar" + +#: dist/dev/devTool.js:375 dist/dev/devToolModuleSettings.js:58 +msgid "Module" +msgstr "Módulo" + +#: dist/dev/devTool.js:377 +msgid "Custom Commands" +msgstr "Comandos personalizados" + +#: dist/dev/devTool.js:378 +msgid "Aurora DevTool" +msgstr "Aurora DevTool" + +#: dist/dev/devTool.js:382 dist/dev/devTool.js:394 +#: dist/dev/devToolModuleBrowser.js:107 +msgid "Modules" +msgstr "Módulos" + +#: dist/dev/devTool.js:383 dist/dev/devTool.js:447 +msgid "Developer Tools" +msgstr "Ferramentas de desenvolvedor" + +#: dist/dev/devTool.js:384 +msgid "Module Settings" +msgstr "Configurações do módulo" + +#: dist/dev/devTool.js:385 dist/panel/auroraMenu.js:342 +#: dist/panel/auroraMenu.js:355 dist/panel/auroraMenu.js:377 +#: dist/panel/auroraMenu.manifest.js:8 +msgid "Aurora Menu" +msgstr "Aurora Menu" + +#: dist/dev/devTool.js:387 +msgid "Developer Tool" +msgstr "Ferramenta de desenvolvedor" + +#: dist/dev/devTool.js:395 +msgid "Dev Tools" +msgstr "Ferramentas de desenvolvimento" + +#: dist/dev/devTool.js:448 dist/dev/devToolModuleSettings.js:95 +msgid "Simulate runtime states and trigger module actions" +msgstr "Simula estados de execução e dispara ações de módulos" + +#: dist/dev/devTool.js:455 +msgid "Inspect and simulate runtime state" +msgstr "Inspeciona e simula o estado em tempo de execução" + +#: dist/dev/devTool.js:480 +msgid "Live development controls" +msgstr "Controles de desenvolvimento em tempo real" + +#: dist/dev/devToolModuleBrowser.js:54 +msgid "Search modules" +msgstr "Buscar módulos" + +#: dist/dev/devToolModuleBrowser.js:84 dist/prefs.js:24 +msgid "Other" +msgstr "Outros" + +#: dist/dev/devToolModuleBrowser.js:111 +#, javascript-format +msgid "%d of %d modules · toggle a module or open its settings" +msgstr "%d de %d módulos · ative um módulo ou abra suas configurações" + +#: dist/dev/devToolModuleBrowser.js:143 +msgid "No Results Found" +msgstr "Nenhum resultado encontrado" + +#: dist/dev/devToolModuleBrowser.js:148 +#, javascript-format +msgid "No modules match “%s”. Try a different search." +msgstr "Nenhum módulo corresponde a “%s”. Tente uma busca diferente." + +#: dist/dev/devToolModuleBrowser.js:202 dist/dev/devToolModuleSettings.js:61 +#: dist/dev/devToolModuleSettings.js:73 dist/dev/devToolModuleSettings.js:234 +#, javascript-format +msgid "Enable %s" +msgstr "Ativar %s" + +#: dist/dev/devToolModuleSettings.js:63 +msgid "Module is running in this session" +msgstr "O módulo está em execução nesta sessão" + +#: dist/dev/devToolModuleSettings.js:65 +#, fuzzy +msgid "Module is enabled but not running in this session" +msgstr "O módulo está em execução nesta sessão" + +#: dist/dev/devToolModuleSettings.js:66 +msgid "Module is off" +msgstr "O módulo está desativado" + +#: dist/dev/devToolModuleSettings.js:80 +msgid "Settings" +msgstr "Configurações" + +#: dist/dev/devToolModuleSettings.js:89 +#, javascript-format +msgid "%s Dev Tools" +msgstr "Ferramentas de desenvolvedor de %s" + +#: dist/dev/devToolModuleSettings.js:91 +msgid "Developer" +msgstr "Desenvolvedor" + +#: dist/dev/devToolModuleSettings.js:112 dist/panel/auroraMenu.manifest.js:83 +msgid "Custom Menu Commands" +msgstr "Comandos de menu personalizados" + +#: dist/dev/devToolModuleSettings.js:116 +msgid "Shortcuts that run commands from Aurora Menu" +msgstr "Atalhos que executam comandos do Aurora Menu" + +#: dist/dev/devToolModuleSettings.js:120 +msgid "Commands" +msgstr "Comandos" + +#: dist/dev/devToolModuleSettings.js:130 +msgid "Edit" +msgstr "Editar" + +#: dist/dev/devToolModuleSettings.js:136 +#, javascript-format +msgid "Edit %s" +msgstr "Editar %s" + +#: dist/dev/devToolModuleSettings.js:156 +#, javascript-format +msgid "Delete %s" +msgstr "Excluir %s" + +#: dist/dev/devToolModuleSettings.js:165 +#: dist/preferences/commandListEditor.js:39 +msgid "No custom commands" +msgstr "Nenhum comando personalizado" + +#: dist/dev/devToolModuleSettings.js:165 +msgid "Add one below." +msgstr "Adicione um abaixo." + +#: dist/dev/devToolModuleSettings.js:175 dist/dev/devToolModuleSettings.js:462 +#: dist/preferences/commandListEditor.js:63 +#: dist/preferences/commandListEditor.js:232 +msgid "Add Command" +msgstr "Adicionar comando" + +#: dist/dev/devToolModuleSettings.js:191 +msgid "Stored in" +msgstr "Armazenados em" + +#: dist/dev/devToolModuleSettings.js:201 +#, javascript-format +msgid "· module %s" +msgstr "· módulo %s" + +#: dist/dev/devToolModuleSettings.js:219 +msgid "This module is disabled. Its settings are stored but have no effect." +msgstr "" +"Este módulo está desativado. Suas configurações ficam salvas, mas não têm " +"efeito." + +#: dist/dev/devToolModuleSettings.js:228 +msgid "Enable" +msgstr "Ativar" + +#: dist/dev/devToolModuleSettings.js:282 +msgid "Hours" +msgstr "Horas" + +#: dist/dev/devToolModuleSettings.js:296 +msgid "Minutes" +msgstr "Minutos" + +#: dist/dev/devToolModuleSettings.js:349 +msgid "Unsupported option" +msgstr "Opção não suportada" + +#: dist/dev/devToolModuleSettings.js:374 +#, fuzzy, javascript-format +msgid "Decrease %s" +msgstr "Excluir %s" + +#: dist/dev/devToolModuleSettings.js:389 +#, fuzzy, javascript-format +msgid "Increase %s" +msgstr "Ativar %s" + +#: dist/dev/devToolModuleSettings.js:402 +msgid "Reset" +msgstr "Redefinir" + +#: dist/dev/devToolModuleSettings.js:417 dist/dev/devToolModuleSettings.js:429 +#: dist/prefs.js:324 dist/prefs.js:359 dist/prefs.js:366 dist/prefs.js:376 +msgid "Disabled" +msgstr "Desativado" + +#: dist/dev/devToolModuleSettings.js:424 +msgid "Keyboard shortcut" +msgstr "Atalho de teclado" + +#: dist/dev/devToolModuleSettings.js:429 +msgid "Press shortcut…" +msgstr "Pressione o atalho…" + +#: dist/dev/devToolModuleSettings.js:462 +#: dist/preferences/commandListEditor.js:149 +#: dist/preferences/commandListEditor.js:232 +msgid "Edit Command" +msgstr "Editar comando" + +#: dist/dev/devToolModuleSettings.js:465 dist/dev/devToolModuleSettings.js:466 +#: dist/dev/devToolModuleSettings.js:483 +#: dist/preferences/commandListEditor.js:171 +msgid "Name" +msgstr "Nome" + +#: dist/dev/devToolModuleSettings.js:473 dist/dev/devToolModuleSettings.js:474 +#: dist/dev/devToolModuleSettings.js:484 +#: dist/preferences/commandListEditor.js:176 +msgid "Command" +msgstr "Comando" + +#: dist/dev/devToolModuleSettings.js:483 +msgid "Shown in the Aurora menu" +msgstr "Exibido no menu Aurora" + +#: dist/dev/devToolModuleSettings.js:484 +msgid "Executed when selected" +msgstr "Executado ao ser selecionado" + +#: dist/dev/devToolModuleSettings.js:487 +#: dist/preferences/commandListEditor.js:274 +msgid "Cancel" +msgstr "Cancelar" + +#: dist/dev/devToolModuleSettings.js:494 dist/dev/devToolModuleSettings.js:520 +#: dist/preferences/commandListEditor.js:219 +msgid "Save" +msgstr "Salvar" + +#: dist/dev/devToolModuleSettings.js:501 +msgid "Name and command are required." +msgstr "Nome e comando são obrigatórios." + +#: dist/dev/devToolModuleSettings.js:502 +msgid "Name cannot contain “|”." +msgstr "O nome não pode conter “|”." + +#: dist/dev/devToolModuleSettings.js:510 +msgid "Command list changed. Reopen this command before saving." +msgstr "" + +#: dist/dev/devToolUi.js:122 dist/dev/devToolUi.js:127 +msgid "On" +msgstr "Ligado" + +#: dist/dev/devToolUi.js:122 dist/dev/devToolUi.js:127 +msgid "Off" +msgstr "Desligado" + +#: dist/dev/generalDevTool.js:30 +msgid "Environment" +msgstr "" + +#: dist/dev/generalDevTool.js:31 +#, fuzzy +msgid "GNOME Shell" +msgstr "Usar alternativa do GNOME Shell" + +#: dist/dev/generalDevTool.js:33 +msgid "Session" +msgstr "" + +#: dist/dev/generalDevTool.js:33 dist/panel/volumeMixer/mixerItem.js:76 +msgid "Unknown" +msgstr "Desconhecido" + +#: dist/dev/generalDevTool.js:36 +#, fuzzy +msgid "Enabled modules" +msgstr "Ativar %s" + +#: dist/dev/generalDevTool.js:43 +#, fuzzy +msgid "Open Settings" +msgstr "Configurações" + +#: dist/dev/generalDevTool.js:51 +msgid "Looking Glass" +msgstr "" + +#: dist/dev/trayIconsDevTool.js:77 +msgid "Fake tray preview" +msgstr "" + +#: dist/dev/trayIconsDevTool.js:82 +msgid "Active" +msgstr "" + +#: dist/dev/weatherClockDevTool.js:21 dist/dev/weatherClockDevTool.js:27 +#: dist/dev/weatherClockDevTool.js:126 +msgid "No fake weather" +msgstr "" + +#: dist/dev/weatherClockDevTool.js:116 +msgid "Offline" +msgstr "" + +#: dist/dev/weatherClockDevTool.js:120 +msgid "Weather service unavailable" +msgstr "" + #: dist/dock/dock.manifest.js:8 msgid "Dock" msgstr "Dock" @@ -547,11 +919,6 @@ msgstr "Itens recentes" msgid "No recent items" msgstr "Nenhum item recente" -#: dist/panel/auroraMenu.js:342 dist/panel/auroraMenu.js:355 -#: dist/panel/auroraMenu.js:377 dist/panel/auroraMenu.manifest.js:8 -msgid "Aurora Menu" -msgstr "Menu Aurora" - #: dist/panel/auroraMenu.js:342 dist/panel/auroraMenu.js:355 msgid "Could not launch the selected command." msgstr "Não foi possível iniciar o comando selecionado." @@ -656,10 +1023,6 @@ msgstr "Comando de aplicativos" msgid "Command used by the Software menu item" msgstr "Comando usado pelo item Aplicativos do menu" -#: dist/panel/auroraMenu.manifest.js:83 -msgid "Custom Menu Commands" -msgstr "Comandos de menu personalizados" - #: dist/panel/auroraMenu.manifest.js:84 msgid "Add shortcuts that run commands from Aurora Menu" msgstr "Adicione atalhos que executam comandos pelo Menu Aurora" @@ -698,7 +1061,9 @@ msgstr "Lembretes do calendário" #: dist/panel/clock/calendarReminders/calendarReminders.manifest.js:9 msgid "Shows upcoming events next to the clock and notifies calendar reminders" -msgstr "Mostra os próximos eventos ao lado do relógio e notifica os lembretes do calendário" +msgstr "" +"Mostra os próximos eventos ao lado do relógio e notifica os lembretes do " +"calendário" #: dist/panel/clock/calendarReminders/calendarReminders.manifest.js:13 msgid "Notifications" @@ -714,7 +1079,9 @@ msgstr "Forçar lembrete no início do evento" #: dist/panel/clock/calendarReminders/calendarReminders.manifest.js:20 msgid "Always notify when a timed event starts, even after an earlier reminder" -msgstr "Sempre notificar quando um evento com horário começar, mesmo após um lembrete anterior" +msgstr "" +"Sempre notificar quando um evento com horário começar, mesmo após um " +"lembrete anterior" #: dist/panel/clock/calendarReminders/calendarReminders.manifest.js:25 msgid "Snooze Duration (minutes)" @@ -798,10 +1165,6 @@ msgstr "Mostra o avatar e o nome do usuário atual no menu de energia" msgid "Audio" msgstr "Áudio" -#: dist/panel/volumeMixer/mixerItem.js:76 -msgid "Unknown" -msgstr "Desconhecido" - #: dist/panel/volumeMixer/mixerPanel.js:29 msgid "No audio playing" msgstr "Nenhum áudio em reprodução" @@ -920,10 +1283,6 @@ msgstr "Mostra um emblema X11 em aplicativos XWayland no alternador Alt+Tab" msgid "Manage Commands" msgstr "Gerenciar comandos" -#: dist/preferences/commandListEditor.js:39 -msgid "No custom commands" -msgstr "Nenhum comando personalizado" - #: dist/preferences/commandListEditor.js:42 #, javascript-format msgid "%d custom command" @@ -935,11 +1294,6 @@ msgstr[1] "%d comandos personalizados" msgid "Menu Commands" msgstr "Comandos do menu" -#: dist/preferences/commandListEditor.js:63 -#: dist/preferences/commandListEditor.js:232 -msgid "Add Command" -msgstr "Adicionar comando" - #: dist/preferences/commandListEditor.js:86 msgid "No commands yet" msgstr "Nenhum comando ainda" @@ -956,23 +1310,10 @@ msgstr "Mover para cima" msgid "Move Down" msgstr "Mover para baixo" -#: dist/preferences/commandListEditor.js:149 -#: dist/preferences/commandListEditor.js:232 -msgid "Edit Command" -msgstr "Editar comando" - #: dist/preferences/commandListEditor.js:150 msgid "Remove Command" msgstr "Remover comando" -#: dist/preferences/commandListEditor.js:171 -msgid "Name" -msgstr "Nome" - -#: dist/preferences/commandListEditor.js:176 -msgid "Command" -msgstr "Comando" - #: dist/preferences/commandListEditor.js:193 msgid "The name cannot contain “|”" msgstr "O nome não pode conter “|”" @@ -981,10 +1322,6 @@ msgstr "O nome não pode conter “|”" 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:219 -msgid "Save" -msgstr "Salvar" - #: dist/preferences/commandListEditor.js:219 msgid "Add" msgstr "Adicionar" @@ -998,10 +1335,6 @@ msgstr "Remover comando?" msgid "“%s” will be removed from Aurora Menu." msgstr "“%s” será removido do Menu Aurora." -#: dist/preferences/commandListEditor.js:274 -msgid "Cancel" -msgstr "Cancelar" - #: dist/preferences/commandListEditor.js:275 msgid "Remove" msgstr "Remover" @@ -1010,10 +1343,6 @@ msgstr "Remover" msgid "General" msgstr "Geral" -#: dist/prefs.js:24 -msgid "Other" -msgstr "Outros" - #: dist/prefs.js:47 msgid "Aurora Shell logo" msgstr "Logo do Aurora Shell" @@ -1026,10 +1355,6 @@ msgstr "Acessar website" msgid "Reset to Default" msgstr "Restaurar padrão" -#: dist/prefs.js:324 dist/prefs.js:359 dist/prefs.js:366 dist/prefs.js:376 -msgid "Disabled" -msgstr "Desativado" - #: dist/prefs.js:354 msgid "Press a shortcut…" msgstr "Pressione um atalho…" @@ -1098,29 +1423,11 @@ msgstr "Alternador de Tema" msgid "Monitors and synchronizes GNOME color scheme" msgstr "Monitora e sincroniza o esquema de cores do GNOME" -#~ msgid "Dismiss" -#~ msgstr "Dispensar" - -#~ msgid "Ignore" -#~ msgstr "Ignorar" - -#~ msgid "Restore" -#~ msgstr "Restaurar" - -#~ msgid "Minimize" -#~ msgstr "Minimizar" - -#~ msgid "Save Changes" -#~ msgstr "Salvar alterações" - -#~ msgid "Manage" -#~ msgstr "Gerenciar" - -#~ msgid "One command per line, using “Label | command”" -#~ msgstr "Um comando por linha, usando “Rótulo | comando”" +#~ msgid "No configurable options" +#~ msgstr "Nenhuma opção configurável" -#~ msgid "Clear OCR result" -#~ msgstr "Limpar resultado do OCR" +#~ msgid "This module works out of the box once enabled" +#~ msgstr "Este módulo funciona de imediato após ser ativado" -#~ msgid "Recognizing text…" -#~ msgstr "Reconhecendo texto…" +#~ msgid "Choose" +#~ msgstr "Escolher" 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 5baa6b7..cacb7ba 100644 --- a/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml +++ b/data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml @@ -403,11 +403,5 @@ Insert clipboard text automatically Insert the selected clipboard text into the input that was focused before opening the panel. Warning: requires an active Wayland field that supports text-input-v3 - - - 1000 - Clipboard poll interval (ms) - How often to check the clipboard for new content in milliseconds - diff --git a/package.json b/package.json index 39359ee..2773bed 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "stylelint": "stylelint \"src/styles/**/*.scss\"", "watch:css": "tsx sass.config.ts --watch", "test:unit": "node --import tsx/esm --test \"tests/unit/**/*.test.ts\"", - "test:unit:coverage": "node --import tsx/esm --experimental-test-coverage --test \"tests/unit/**/*.test.ts\"" + "test:unit:coverage": "node --import tsx/esm --experimental-test-coverage --test \"tests/unit/**/*.test.ts\"", + "benchmark:runtime": "tsx tests/performance/runtimePerformance.ts" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/src/clipboard/clipboardHistory.manifest.ts b/src/clipboard/clipboardHistory.manifest.ts index 25d1a99..2c9b049 100644 --- a/src/clipboard/clipboardHistory.manifest.ts +++ b/src/clipboard/clipboardHistory.manifest.ts @@ -22,13 +22,5 @@ export const manifest: ModuleManifest = { ), type: 'switch', }, - { - key: 'clipboard-history-poll-interval', - title: _('Poll Interval (ms)'), - subtitle: _('How often to check the clipboard for changes'), - type: 'spin', - min: 250, - max: 5000, - }, ], }; diff --git a/src/clipboard/clipboardHistory.ts b/src/clipboard/clipboardHistory.ts index f6e2bc1..ce20b68 100644 --- a/src/clipboard/clipboardHistory.ts +++ b/src/clipboard/clipboardHistory.ts @@ -26,7 +26,7 @@ const AUTO_PASTE_DELAY_MS = 100; const LOG_PREFIX = 'ClipboardHistory'; // @ts-ignore - _promisify is a GJS extension not reflected in .d.ts -Gio._promisify(Gio.File.prototype, 'load_contents_async'); +Gio._promisify(Gio.File.prototype, 'load_bytes_async'); export class ClipboardHistory extends Module { private _store: ClipboardStore | null = null; @@ -34,6 +34,7 @@ export class ClipboardHistory extends Module { private _panel: ClipboardPanel | null = null; private _lifecycle: LifecycleScope | null = null; private _autoPasteTimeout: ManagedSource | null = null; + private _restoreCancellable: Gio.Cancellable | null = null; private _pasteTargetWindow: Meta.Window | null = null; private _pasteTargetInputFocus: Clutter.InputFocus | null = null; @@ -51,7 +52,6 @@ export class ClipboardHistory extends Module { 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'); const store = new ClipboardStore(filePath, mediaDir); this._store = store; @@ -63,23 +63,30 @@ export class ClipboardHistory extends Module { }); this._panel = panel; - const monitor = new ClipboardMonitor(pollMs, { + const monitor = new ClipboardMonitor({ onText: (text) => this.addText(text), - onImage: (payload) => void this.addImage(payload), + onImage: (payload) => this.addImage(payload), }); this._monitor = monitor; void store.load().then(() => { - if (this._panel === panel) panel.refresh(); - }); + if ( + this._lifecycle !== lifecycle || + this._store !== store || + this._panel !== panel || + this._monitor !== monitor + ) + return; - startupIdle.replace(() => - GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { - startupIdle.complete(); - monitor.start(); - return GLib.SOURCE_REMOVE; - }), - ); + panel.refresh(); + startupIdle.replace(() => + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + startupIdle.complete(); + monitor.start(); + return GLib.SOURCE_REMOVE; + }), + ); + }); Main.wm.addKeybinding( KEYBINDING_KEY, @@ -89,16 +96,15 @@ export class ClipboardHistory extends Module { () => 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._pasteTargetWindow = null; this._pasteTargetInputFocus = null; + if (this._restoreCancellable) this._restoreCancellable.cancel(); + this._restoreCancellable = null; + this._lifecycle?.dispose(); this._lifecycle = null; this._autoPasteTimeout = null; @@ -111,6 +117,7 @@ export class ClipboardHistory extends Module { this._monitor = null; this._store?.save(); + this._store?.destroy(); this._store = null; } @@ -196,6 +203,7 @@ export class ClipboardHistory extends Module { ? this._pasteTargetWindow : null; const pasteTargetInputFocus = pasteTarget ? this._pasteTargetInputFocus : null; + this._monitor?.suppressNextOwnerChange(); St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, entry.text); this.closePanel(); if (pasteTarget && pasteTargetInputFocus) @@ -204,14 +212,29 @@ export class ClipboardHistory extends Module { } private async _restoreImage(entry: ClipboardEntry): Promise { - if (!entry.filePath || !entry.mimeType) return; + if (!this._lifecycle || !entry.filePath || !entry.mimeType) return; + + if (this._restoreCancellable) this._restoreCancellable.cancel(); + const restoreCancellable = new Gio.Cancellable(); + this._restoreCancellable = restoreCancellable; try { - const [contents] = await Gio.File.new_for_path(entry.filePath).load_contents_async(null); - St.Clipboard.get_default().set_content(St.ClipboardType.CLIPBOARD, entry.mimeType, contents); + const [bytes] = await Gio.File.new_for_path(entry.filePath).load_bytes_async( + restoreCancellable, + ); + if (this._restoreCancellable !== restoreCancellable || restoreCancellable.is_cancelled()) + return; + + if (!this._monitor) return; + this._monitor.suppressNextOwnerChange(); + St.Clipboard.get_default().set_content(St.ClipboardType.CLIPBOARD, entry.mimeType, bytes); logger.debug(`Restored clipboard image: ${entry.mimeType}`, { prefix: LOG_PREFIX }); } catch (e) { + if (restoreCancellable.is_cancelled()) return; + logger.warn('Failed to restore clipboard image:', { prefix: LOG_PREFIX }, e as Error); + } finally { + if (this._restoreCancellable === restoreCancellable) this._restoreCancellable = null; } } diff --git a/src/clipboard/clipboardItem.ts b/src/clipboard/clipboardItem.ts index 7e776ee..e18b5b7 100644 --- a/src/clipboard/clipboardItem.ts +++ b/src/clipboard/clipboardItem.ts @@ -1,13 +1,14 @@ import '@girs/gjs'; import { gettext as _ } from '~/shared/i18n.ts'; +import GLib from '@girs/glib-2.0'; import St from '@girs/st-18'; import GObject from '@girs/gobject-2.0'; import Clutter from '@girs/clutter-18'; 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 { thumbnailPathFor, type ClipboardEntry } from '~/clipboard/clipboardStore.ts'; import { classifyClipboardCard, parseClipboardUrl } from '~/clipboard/clipboardCardState.ts'; import { buildCodeCard, @@ -137,8 +138,9 @@ export const ClipboardItem = GObject.registerClass( private _buildImageCard(): void { this.add_style_class_name('aurora-clipboard-item--image'); - if (this._entry.filePath) { - this.style = `background-image: url("file://${this._entry.filePath}"); background-size: cover;`; + const thumbnail = this._entry.filePath ? thumbnailPathFor(this._entry.filePath) : null; + if (thumbnail && GLib.file_test(thumbnail, GLib.FileTest.EXISTS)) { + this.style = `background-image: url("file://${thumbnail}"); background-size: cover;`; } this.set_child(buildImageCard(this._entry, this._actions)); diff --git a/src/clipboard/clipboardLog.ts b/src/clipboard/clipboardLog.ts index 076a148..7d5e069 100644 --- a/src/clipboard/clipboardLog.ts +++ b/src/clipboard/clipboardLog.ts @@ -34,10 +34,22 @@ type IdOp = { type ClipboardLogOp = AddOp | IdOp; +type ClipboardEntryNode = { + entry: ClipboardEntrySnapshot; + previous: ClipboardEntryNode | null; + next: ClipboardEntryNode | null; + list: ClipboardEntryList | null; +}; + +type ClipboardEntryList = { + first: ClipboardEntryNode | null; + last: ClipboardEntryNode | null; +}; + export function parseClipboardLog(source: string): ClipboardLogState { - const pinned: ClipboardEntrySnapshot[] = []; - const history: ClipboardEntrySnapshot[] = []; - const byId = new Map(); + const pinned: ClipboardEntryList = { first: null, last: null }; + const history: ClipboardEntryList = { first: null, last: null }; + const byId = new Map(); let nextId = 1; let wastedOps = 0; @@ -62,37 +74,42 @@ export function parseClipboardLog(source: string): ClipboardLogState { }; if (op.mimeType) entry.mimeType = op.mimeType; if (op.filePath) entry.filePath = op.filePath; - byId.set(entry.id, entry); - history.unshift(entry); + const node: ClipboardEntryNode = { entry, previous: null, next: null, list: null }; + byId.set(entry.id, node); + moveNodeToFront(history, node); nextId = Math.max(nextId, Number.parseInt(entry.id, 10) + 1 || nextId); continue; } - const entry = byId.get(op.id); - if (!entry) continue; + const node = byId.get(op.id); + if (!node) continue; + + const entry = node.entry; if (op.op === 'delete') { - removeClipboardEntry(pinned, entry); - removeClipboardEntry(history, entry); + removeNode(node); byId.delete(op.id); wastedOps += 2; } else if (op.op === 'move') { const list = entry.pinned ? pinned : history; - moveToFront(list, entry); + moveNodeToFront(list, node); wastedOps += 1; } else if (op.op === 'pin') { - removeClipboardEntry(history, entry); entry.pinned = true; - moveToFront(pinned, entry); + moveNodeToFront(pinned, node); } else if (op.op === 'unpin') { - removeClipboardEntry(pinned, entry); entry.pinned = false; - moveToFront(history, entry); + moveNodeToFront(history, node); wastedOps += 2; } } - return { pinned, history, nextId, wastedOps }; + return { + pinned: materializeClipboardEntries(pinned), + history: materializeClipboardEntries(history), + nextId, + wastedOps, + }; } export function encodeAddOp(entry: ClipboardEntrySnapshot): string { @@ -145,7 +162,37 @@ export function removeClipboardEntry( if (index !== -1) list.splice(index, 1); } -function moveToFront(list: ClipboardEntrySnapshot[], entry: ClipboardEntrySnapshot): void { - removeClipboardEntry(list, entry); - list.unshift(entry); +function removeNode(node: ClipboardEntryNode): void { + if (!node.list) return; + + const list = node.list; + if (node.previous) node.previous.next = node.next; + else list.first = node.next; + + if (node.next) node.next.previous = node.previous; + else list.last = node.previous; + + node.previous = null; + node.next = null; + node.list = null; +} + +function moveNodeToFront(list: ClipboardEntryList, node: ClipboardEntryNode): void { + removeNode(node); + + node.next = list.first; + node.list = list; + if (list.first) list.first.previous = node; + else list.last = node; + list.first = node; +} + +function materializeClipboardEntries(list: ClipboardEntryList): ClipboardEntrySnapshot[] { + const entries: ClipboardEntrySnapshot[] = []; + let node = list.first; + while (node) { + entries.push(node.entry); + node = node.next; + } + return entries; } diff --git a/src/clipboard/clipboardMonitor.ts b/src/clipboard/clipboardMonitor.ts index f563eea..724775c 100644 --- a/src/clipboard/clipboardMonitor.ts +++ b/src/clipboard/clipboardMonitor.ts @@ -1,10 +1,14 @@ +import Gio from '@girs/gio-2.0'; import GLib from '@girs/glib-2.0'; +import Meta from '@girs/meta-18'; 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'; +import { LifecycleScope } from '~/core/lifecycleScope.ts'; +import { logger } from '~/core/logger.ts'; +import { createManagedTimeout } from '~/core/mainLoop.ts'; +const LOG_PREFIX = 'ClipboardMonitor'; const IMAGE_MIME_TYPES = [ 'image/png', 'image/jpeg', @@ -14,107 +18,177 @@ const IMAGE_MIME_TYPES = [ 'image/bmp', 'image/tiff', ]; +const MAX_IMAGE_BYTES = 32 * 1024 * 1024; +const READ_DELAY_MS = 50; + +type ClipboardMonitorCallbacks = { + onText: (text: string) => void; + onImage: (payload: ClipboardImagePayload) => Promise; +}; export class ClipboardMonitor { - private _intervalMs: number; - private _onText: (text: string) => void; - private _onImage: (payload: ClipboardImagePayload) => void; + private _callbacks: ClipboardMonitorCallbacks | null; private _lifecycle = new LifecycleScope(); - private _pollSource: ManagedSource = createManagedSource(this._lifecycle); + private _readTimeout = createManagedTimeout(this._lifecycle); + private _cancellable = new Gio.Cancellable(); private _lastContentKey: string | null = null; + private _started = false; + private _suppressNextOwnerChange = false; + private _readInProgress = false; + private _readPending = false; - constructor( - intervalMs: number, - callbacks: { - onText: (text: string) => void; - onImage: (payload: ClipboardImagePayload) => void; - }, - ) { - this._intervalMs = intervalMs; - this._onText = callbacks.onText; - this._onImage = callbacks.onImage; + constructor(callbacks: ClipboardMonitorCallbacks) { + this._callbacks = callbacks; } start(): void { - if (this._pollSource.active) return; - this._pollSource.replace(() => - GLib.timeout_add(GLib.PRIORITY_DEFAULT, this._intervalMs, () => { - this._tick(); - return GLib.SOURCE_CONTINUE; - }), + if (this._started || !this._callbacks) return; + this._started = true; + + this._lifecycle.connect( + global.display.get_selection(), + 'owner-changed', + (_selection, selectionType) => { + if (selectionType !== Meta.SelectionType.SELECTION_CLIPBOARD) return; + if (this._suppressNextOwnerChange) { + this._suppressNextOwnerChange = false; + return; + } + // owner-changed is emitted synchronously from inside set_content (e.g. while the + // screenshot UI still holds its grab), and reading a shell-owned source copies the + // whole payload in mutter. Defer, and coalesce bursts of owner changes. + this._readTimeout.schedule(READ_DELAY_MS, () => this._requestRead()); + }, ); + this._requestRead(); } - stop(): void { - this._pollSource.clear(); - } - - setInterval(ms: number): void { - this._intervalMs = ms; - if (this._pollSource.active) { - this.stop(); - this.start(); - } + // Call right before setting the clipboard ourselves: the owner-changed it emits is + // synchronous, so the flag is consumed by exactly that change. + suppressNextOwnerChange(): void { + this._readTimeout.clear(); + this._readPending = false; + this._suppressNextOwnerChange = true; } destroy(): void { + this._callbacks = null; + this._cancellable.cancel(); this._lifecycle.dispose(); } - private _tick(): void { - const clipboard = St.Clipboard.get_default(); - const imageMimeType = this._findImageMimeType( - clipboard.get_mimetypes(St.ClipboardType.CLIPBOARD), - ); - - if (imageMimeType) { - clipboard.get_content(St.ClipboardType.CLIPBOARD, imageMimeType, (_clipboard, bytes) => { - if (!bytes || bytes.get_size() === 0) return; + private _requestRead(): void { + if (!this._callbacks) return; + if (this._readInProgress) { + this._readPending = true; + return; + } - const fingerprint = fingerprintBytes(bytes); - const contentKey = 'image:' + imageMimeType + ':' + fingerprint; - if (contentKey === this._lastContentKey) return; + this._readInProgress = true; + this._readClipboard(); + } - this._lastContentKey = contentKey; - this._onImage({ mimeType: imageMimeType, bytes, fingerprint }); - }); + private _readClipboard(): void { + const clipboard = St.Clipboard.get_default(); + const imageMimeType = findImageMimeType(clipboard.get_mimetypes(St.ClipboardType.CLIPBOARD)); + if (imageMimeType) { + this._readImage(imageMimeType); return; } clipboard.get_text( St.ClipboardType.CLIPBOARD, (_clipboard: St.Clipboard, text: string | null) => { - if (!text || text.trim().length === 0) return; + try { + if (!this._callbacks || !text || text.trim().length === 0) return; + + const contentKey = 'text:' + text; + if (contentKey === this._lastContentKey) return; - const contentKey = 'text:' + text; - if (contentKey === this._lastContentKey) return; + this._lastContentKey = contentKey; + this._callbacks.onText(text); + } finally { + this._finishRead(); + } + }, + ); + } - this._lastContentKey = contentKey; - this._onText(text); + // Not St.Clipboard.get_content(): its callback receives a transfer-none GBytes that GJS + // wraps without taking a reference (Boxed::NoCopy) and St unrefs right after the callback. + // When the wrapper is later finalized, GJS removes g_bytes_get_size() of the freed GBytes + // from SpiderMonkey's malloc accounting; reading reused memory underflows the counter to + // ~2^64 and every later JS allocation then triggers a full GC, crippling the shell for the + // rest of the session. Transferring into our own stream yields an owned GBytes instead. + private _readImage(mimeType: string): void { + const selection = global.display.get_selection(); + const stream = Gio.MemoryOutputStream.new_resizable(); + selection.transfer_async( + Meta.SelectionType.SELECTION_CLIPBOARD, + mimeType, + MAX_IMAGE_BYTES + 1, + stream, + this._cancellable, + (_selection, result) => { + try { + selection.transfer_finish(result); + } catch { + // Cancelled on destroy, or the owner vanished mid-transfer; the next owner-changed + // reads again. + this._finishRead(); + return; + } + + let persistenceStarted = false; + try { + if (!stream.is_closed()) stream.close(null); + const bytes = stream.steal_as_bytes(); + const size = bytes.get_size(); + if (!this._callbacks || size === 0 || size > MAX_IMAGE_BYTES) return; + + const fingerprint = fingerprintBytes(bytes); + const contentKey = 'image:' + mimeType + ':' + fingerprint; + if (contentKey === this._lastContentKey) return; + + this._lastContentKey = contentKey; + persistenceStarted = true; + void this._persistImage(this._callbacks, { mimeType, bytes, fingerprint }); + } finally { + if (!persistenceStarted) this._finishRead(); + } }, ); } - private _findImageMimeType(mimeTypes: string[]): string | null { - for (const preferred of IMAGE_MIME_TYPES) { - if (mimeTypes.includes(preferred)) return preferred; + private async _persistImage( + callbacks: ClipboardMonitorCallbacks, + payload: ClipboardImagePayload, + ): Promise { + try { + await callbacks.onImage(payload); + } catch (e) { + logger.error('Unexpected clipboard image persistence failure:', { prefix: LOG_PREFIX }, e); + } finally { + this._finishRead(); } + } - const imageMimeType = mimeTypes.find((mimeType) => mimeType.startsWith('image/')); - if (!imageMimeType) return null; + private _finishRead(): void { + this._readInProgress = false; + if (!this._readPending || !this._callbacks) return; - return imageMimeType; + this._readPending = false; + this._requestRead(); } } -export function fingerprintBytes(bytes: GLib.Bytes): string { - const data = bytes.toArray(); - let hash = 2166136261; +function findImageMimeType(mimeTypes: string[]): string | null { + const preferred = IMAGE_MIME_TYPES.find((mimeType) => mimeTypes.includes(mimeType)); + if (preferred) return preferred; - for (let i = 0; i < data.length; i++) { - hash ^= data[i]!; - hash = Math.imul(hash, 16777619); - } + return mimeTypes.find((mimeType) => mimeType.startsWith('image/')) || null; +} - return data.length + '-' + (hash >>> 0).toString(16); +export function fingerprintBytes(bytes: GLib.Bytes): string { + return `${bytes.get_size()}-${GLib.compute_checksum_for_bytes(GLib.ChecksumType.SHA256, bytes)}`; } diff --git a/src/clipboard/clipboardStore.ts b/src/clipboard/clipboardStore.ts index d1d85f1..dda162f 100644 --- a/src/clipboard/clipboardStore.ts +++ b/src/clipboard/clipboardStore.ts @@ -24,7 +24,7 @@ Gio._promisify(Gio.File.prototype, 'append_to_async', 'append_to_finish'); // @ts-ignore - _promisify is a GJS extension not reflected in .d.ts Gio._promisify(Gio.File.prototype, 'replace_contents_async', 'replace_contents_finish'); // @ts-ignore - _promisify is a GJS extension not reflected in .d.ts -Gio._promisify(Gio.OutputStream.prototype, 'write_bytes_async', 'write_bytes_finish'); +Gio._promisify(Gio.OutputStream.prototype, 'write_all_async', 'write_all_finish'); // @ts-ignore - _promisify is a GJS extension not reflected in .d.ts Gio._promisify(Gio.OutputStream.prototype, 'flush_async', 'flush_finish'); // @ts-ignore - _promisify is a GJS extension not reflected in .d.ts @@ -33,9 +33,29 @@ Gio._promisify(Gio.OutputStream.prototype, 'close_async', 'close_finish'); const LOG_PREFIX = 'ClipboardHistory'; const WRITE_PRIORITY = GLib.PRIORITY_DEFAULT_IDLE; const MAX_WASTED_OPS = 500; +const THUMBNAIL_SIZE = 640; export type ClipboardEntry = ClipboardEntrySnapshot; +export function thumbnailPathFor(filePath: string): string { + return filePath.replace(/\.[^./]+$/, '') + '.thumb.png'; +} + +function callAsync( + start: (callback: (_source: unknown, result: Gio.AsyncResult) => void) => void, + finish: (result: Gio.AsyncResult) => T, +): Promise { + return new Promise((resolve, reject) => { + start((_source, result) => { + try { + resolve(finish(result)); + } catch (e) { + reject(e); + } + }); + }); +} + export type ClipboardImagePayload = { mimeType: string; bytes: GLib.Bytes; @@ -55,6 +75,8 @@ export class ClipboardStore { private _writing: boolean = false; private _compactionRequested: boolean = false; private _writeRequestVersion = 0; + private _clearVersion = 0; + private _imageCancellable = new Gio.Cancellable(); constructor(filePath: string, mediaDir: string) { this._filePath = filePath; @@ -62,15 +84,17 @@ export class ClipboardStore { } async load(): Promise { + const clearVersion = this._clearVersion; try { const file = Gio.File.new_for_path(this._filePath); const [contents] = await file.load_contents_async(null); const decoded = new TextDecoder().decode(contents); const state = parseClipboardLog(decoded); - const { pinned, history, removed } = this._dropInvalidImageEntries( + const { pinned, history, removed } = await this._dropInvalidImageEntries( state.pinned, state.history, ); + if (this._clearVersion !== clearVersion) return; this._pinned = pinned; this._history = history; @@ -83,6 +107,8 @@ export class ClipboardStore { } this._requestCompactionIfNeeded(); } catch (_e) { + if (this._clearVersion !== clearVersion) return; + this._pinned = []; this._history = []; this._nextId = 1; @@ -95,6 +121,11 @@ export class ClipboardStore { this._requestCompactionIfNeeded(); } + destroy(): void { + this._clearVersion++; + this._imageCancellable.cancel(); + } + addText(text: string): boolean { const cleanText = text.trim(); if (!cleanText) return false; @@ -127,17 +158,6 @@ export class ClipboardStore { async addImage(payload: ClipboardImagePayload): Promise { if (payload.bytes.get_size() === 0) return false; - try { - this._validateImageBytes(payload.mimeType, payload.bytes); - } catch (e) { - logger.warn( - `Rejected invalid clipboard image: ${payload.mimeType}, ${payload.bytes.get_size()} bytes`, - { prefix: LOG_PREFIX }, - e as Error, - ); - return false; - } - const contentKey = 'image:' + payload.mimeType + ':' + payload.fingerprint; const existing = this._byContentKey.get(contentKey); if (existing) { @@ -149,8 +169,62 @@ export class ClipboardStore { } const id = String(this._nextId++); + const clearVersion = this._clearVersion; const filePath = this._mediaDir + '/' + id + this._extensionForMimeType(payload.mimeType); - await this._writeImage(filePath, payload.bytes); + const temporaryPath = `${filePath}.${GLib.uuid_string_random()}.tmp`; + let hasThumbnail = false; + try { + await this._writeImage(temporaryPath, payload.bytes); + await this._validateImageFileAsync(temporaryPath, payload.mimeType); + const pixbuf = await this._decodeThumbnail(temporaryPath); + + try { + await this._writeThumbnail(temporaryPath, pixbuf); + hasThumbnail = true; + } catch (e) { + if (this._imageCancellable.is_cancelled()) { + this._deleteMediaPath(temporaryPath); + return false; + } + + logger.warn( + 'Failed to create clipboard image thumbnail:', + { prefix: LOG_PREFIX }, + e as Error, + ); + } + + if (this._imageCancellable.is_cancelled() || this._clearVersion !== clearVersion) { + this._deleteMediaPath(temporaryPath); + return false; + } + + if (hasThumbnail) { + Gio.File.new_for_path(thumbnailPathFor(temporaryPath)).move( + Gio.File.new_for_path(thumbnailPathFor(filePath)), + Gio.FileCopyFlags.OVERWRITE, + this._imageCancellable, + null, + ); + } + Gio.File.new_for_path(temporaryPath).move( + Gio.File.new_for_path(filePath), + Gio.FileCopyFlags.OVERWRITE, + this._imageCancellable, + null, + ); + } catch (e) { + this._deleteMediaPath(temporaryPath); + if (hasThumbnail) this._deleteMediaPath(filePath); + if (this._imageCancellable.is_cancelled()) return false; + + logger.warn( + `Rejected clipboard image: ${payload.mimeType}, ${payload.bytes.get_size()} bytes`, + { prefix: LOG_PREFIX }, + e as Error, + ); + return false; + } const entry: ClipboardEntry = { id, @@ -206,8 +280,14 @@ export class ClipboardStore { } clear(): boolean { + this._clearVersion++; const entries = [...this._pinned, ...this._history]; - if (entries.length === 0) return false; + if (entries.length === 0) { + this._compactionRequested = true; + this._writeRequestVersion++; + void this._drainWrites(); + return false; + } for (const entry of entries) this._deleteMediaFile(entry); @@ -260,34 +340,50 @@ export class ClipboardStore { } } - private _dropInvalidImageEntries( + private async _dropInvalidImageEntries( pinned: ClipboardEntry[], history: ClipboardEntry[], - ): { pinned: ClipboardEntry[]; history: ClipboardEntry[]; removed: number } { - let removed = 0; - - const keepValid = (entry: ClipboardEntry): boolean => { - if (entry.kind !== 'image') return true; + ): Promise<{ pinned: ClipboardEntry[]; history: ClipboardEntry[]; removed: number }> { + const invalid = new Set(); + const images = [...pinned, ...history].filter((entry) => entry.kind === 'image'); + for (const entry of images) { + let pixbuf: GdkPixbuf.Pixbuf; try { - this._validateImageFile(entry); - return true; + if (!entry.filePath || !entry.mimeType) throw new Error('Image entry has no file'); + await this._validateImageFileAsync(entry.filePath, entry.mimeType); + pixbuf = await this._decodeThumbnail(entry.filePath); } catch (e) { - removed++; + if (this._imageCancellable.is_cancelled()) break; + + invalid.add(entry); logger.warn( `Dropped invalid clipboard image from history: id=${entry.id}, path=${entry.filePath || '(none)'}`, { prefix: LOG_PREFIX }, e as Error, ); this._deleteMediaFile(entry); - return false; + continue; } - }; + try { + await this._writeThumbnail(entry.filePath, pixbuf); + } catch (e) { + if (this._imageCancellable.is_cancelled()) break; + + logger.warn( + `Failed to refresh clipboard image thumbnail: id=${entry.id}, path=${entry.filePath}`, + { prefix: LOG_PREFIX }, + e as Error, + ); + } + } + + const keepValid = (entry: ClipboardEntry): boolean => !invalid.has(entry); return { pinned: pinned.filter(keepValid), history: history.filter(keepValid), - removed, + removed: invalid.size, }; } @@ -347,7 +443,7 @@ export class ClipboardStore { try { const bytes = new TextEncoder().encode(data); - await stream.write_bytes_async(bytes, WRITE_PRIORITY, null); + await stream.write_all_async(bytes, WRITE_PRIORITY, null); await stream.flush_async(WRITE_PRIORITY, null); } finally { await stream.close_async(WRITE_PRIORITY, null); @@ -383,23 +479,82 @@ export class ClipboardStore { private async _writeImage(filePath: string, bytes: GLib.Bytes): Promise { this._ensureMediaDirectory(); - await Gio.File.new_for_path(filePath).replace_contents_async( - bytes.toArray(), - null, - false, - Gio.FileCreateFlags.PRIVATE, - null, + const file = Gio.File.new_for_path(filePath); + await callAsync( + (callback) => + file.replace_contents_bytes_async( + bytes, + null, + false, + Gio.FileCreateFlags.PRIVATE, + this._imageCancellable, + callback, + ), + (result) => file.replace_contents_finish(result), + ); + } + + private async _decodeThumbnail(filePath: string): Promise { + const source = Gio.File.new_for_path(filePath); + const input = await callAsync( + (callback) => source.read_async(GLib.PRIORITY_DEFAULT, this._imageCancellable, callback), + (result) => source.read_finish(result), + ); + return callAsync( + (callback) => + GdkPixbuf.Pixbuf.new_from_stream_at_scale_async( + input, + THUMBNAIL_SIZE, + THUMBNAIL_SIZE, + true, + this._imageCancellable, + callback, + ), + (result) => GdkPixbuf.Pixbuf.new_from_stream_finish(result), + ).finally(() => input.close(null)); + } + + // PNG encoding runs on a GdkPixbuf worker thread. + private async _writeThumbnail(filePath: string, pixbuf: GdkPixbuf.Pixbuf): Promise { + const target = Gio.File.new_for_path(thumbnailPathFor(filePath)); + const output = await callAsync( + (callback) => + target.replace_async( + null, + false, + Gio.FileCreateFlags.PRIVATE, + GLib.PRIORITY_DEFAULT, + this._imageCancellable, + callback, + ), + (result) => target.replace_finish(result), ); + try { + await callAsync( + (callback) => + pixbuf.save_to_streamv_async(output, 'png', null, null, this._imageCancellable, callback), + (result) => GdkPixbuf.Pixbuf.save_to_stream_finish(result), + ); + } finally { + await output.close_async(GLib.PRIORITY_DEFAULT, this._imageCancellable); + } } private _deleteMediaFile(entry: ClipboardEntry): void { if (entry.kind !== 'image' || !entry.filePath) return; + this._deleteMediaPath(entry.filePath); + } - try { - const file = Gio.File.new_for_path(entry.filePath); - if (file.query_exists(null)) file.delete(null); - } catch (_e) { - // Runtime files are session-scoped; deletion here is best effort. + private _deleteMediaPath(filePath: string): void { + for (const path of [filePath, thumbnailPathFor(filePath)]) { + try { + Gio.File.new_for_path(path).delete(null); + } catch (e) { + if (e instanceof GLib.Error && e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) + continue; + + logger.warn(`Failed to delete clipboard media: ${path}`, { prefix: LOG_PREFIX }, e); + } } } @@ -412,29 +567,19 @@ export class ClipboardStore { return '.png'; } - private _validateImageBytes(mimeType: string, bytes: GLib.Bytes): void { - const loader = GdkPixbuf.PixbufLoader.new_with_mime_type(mimeType); - - try { - loader.write_bytes(bytes); - } finally { - loader.close(); - } - - if (!loader.get_pixbuf() && !loader.get_animation()) { - throw new Error('Image decoder did not produce a pixbuf or animation'); + // Reads only the image header, on a GdkPixbuf worker thread. + private async _validateImageFileAsync(filePath: string, mimeType: string): Promise { + const [format] = await callAsync( + (callback) => + GdkPixbuf.Pixbuf.get_file_info_async(filePath, this._imageCancellable, callback), + (result) => GdkPixbuf.Pixbuf.get_file_info_finish(result), + ); + const expectedMimeType = mimeType === 'image/jpg' ? 'image/jpeg' : mimeType; + if (!format?.get_mime_types()?.includes(expectedMimeType)) { + throw new Error(`Image data does not match ${mimeType}`); } } - private _validateImageFile(entry: ClipboardEntry): void { - if (!entry.filePath) throw new Error('Image entry has no file path'); - - const file = Gio.File.new_for_path(entry.filePath); - if (!file.query_exists(null)) throw new Error('Image file is missing'); - - GdkPixbuf.Pixbuf.new_from_file(entry.filePath); - } - private _searchText(entry: ClipboardEntry): string { if (entry.kind === 'image') return 'image imagem picture photo foto'; return entry.text.toLowerCase(); diff --git a/src/core/logger.ts b/src/core/logger.ts index 4a5cc77..971408f 100644 --- a/src/core/logger.ts +++ b/src/core/logger.ts @@ -5,6 +5,15 @@ type LogOptions = { prefix?: string; }; +let _cachedExtension: { metadata: { name: string }; uuid: string } | null = null; + +function getExtension(): { metadata: { name: string }; uuid: string } { + if (!_cachedExtension) { + _cachedExtension = Extension.lookupByURL(import.meta.url)!; + } + return _cachedExtension; +} + function write( level: GLib.LogLevelFlags, msg: string, @@ -13,7 +22,7 @@ function write( ): void { const prefix = options.prefix ? `[${options.prefix}] ` : ''; const suffix = args.length ? ` ${args.map(String).join(' ')}` : ''; - const extension = Extension.lookupByURL(import.meta.url)!; + const extension = getExtension(); GLib.log_structured(extension.metadata.name, level, { SYSLOG_IDENTIFIER: extension.uuid, MESSAGE: `${prefix}${msg}${suffix}`, @@ -23,10 +32,14 @@ function write( export const logger = { log: (msg: string, options: LogOptions = {}, ...args: unknown[]) => write(GLib.LogLevelFlags.LEVEL_MESSAGE, msg, options, args), - debug: (msg: string, options: LogOptions = {}, ...args: unknown[]) => - write(GLib.LogLevelFlags.LEVEL_DEBUG, msg, options, args), + debug: (_msg: string, _options?: LogOptions, ..._args: unknown[]) => {}, warn: (msg: string, options: LogOptions = {}, ...args: unknown[]) => write(GLib.LogLevelFlags.LEVEL_WARNING, msg, options, args), error: (msg: string, options: LogOptions = {}, ...args: unknown[]) => write(GLib.LogLevelFlags.LEVEL_CRITICAL, msg, options, args), }; + +export function enableDebugLogging(): void { + logger.debug = (msg: string, options: LogOptions = {}, ...args: unknown[]) => + write(GLib.LogLevelFlags.LEVEL_DEBUG, msg, options, args); +} diff --git a/src/desktop/trayIcons/dbusMenu.ts b/src/desktop/trayIcons/dbusMenu.ts index 75366bd..8f656bc 100644 --- a/src/desktop/trayIcons/dbusMenu.ts +++ b/src/desktop/trayIcons/dbusMenu.ts @@ -92,11 +92,19 @@ export class DBusMenuClient { 'AboutToShow', new GLib.Variant('(i)', [0]), Gio.DBusCallFlags.NONE, - -1, + 5000, null, ); - } catch { - // Not all apps implement AboutToShow; ignore errors silently + } catch (e) { + const remoteError = e instanceof GLib.Error ? Gio.DBusError.get_remote_error(e) : null; + if ( + !this._cancellable.is_cancelled() && + remoteError !== 'org.freedesktop.DBus.Error.UnknownMethod' + ) { + logger.warn(`DBusMenu AboutToShow failed for ${this._busName}: ${e}`, { + prefix: LOG_PREFIX, + }); + } } try { @@ -104,7 +112,7 @@ export class DBusMenuClient { 'GetLayout', new GLib.Variant('(iias)', [0, -1, []]), Gio.DBusCallFlags.NONE, - -1, + 5000, null, ); @@ -211,7 +219,7 @@ export class DBusMenuClient { 'Event', new GLib.Variant('(isvu)', [id, 'clicked', new GLib.Variant('i', 0), timestamp]), Gio.DBusCallFlags.NONE, - -1, + 5000, null, (p, res) => { try { diff --git a/src/desktop/trayIcons/sniHost.ts b/src/desktop/trayIcons/sniHost.ts index 9c9532f..869ac0c 100644 --- a/src/desktop/trayIcons/sniHost.ts +++ b/src/desktop/trayIcons/sniHost.ts @@ -178,12 +178,20 @@ export class SniHost { 'org.freedesktop.DBus.Properties.Get', new GLib.Variant('(ss)', ['org.kde.StatusNotifierItem', prop]), Gio.DBusCallFlags.NONE, - -1, + 5000, cancellable, ); proxy.set_cached_property(prop, result.get_child_value(0).get_variant()); - } catch { - // property may not be supported by this item + } catch (e) { + const remoteError = e instanceof GLib.Error ? Gio.DBusError.get_remote_error(e) : null; + if ( + !cancellable.is_cancelled() && + remoteError !== 'org.freedesktop.DBus.Error.UnknownProperty' + ) { + logger.warn(`Failed to fetch SNI property ${prop} from ${proxy.g_name}: ${e}`, { + prefix: LOG_PREFIX, + }); + } } }), ); diff --git a/src/desktop/trayIcons/trayClipArea.ts b/src/desktop/trayIcons/trayClipArea.ts index f64dba6..55252f4 100644 --- a/src/desktop/trayIcons/trayClipArea.ts +++ b/src/desktop/trayIcons/trayClipArea.ts @@ -1,26 +1,41 @@ 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'; +const VIEWPORT_TRANSITION = 'viewport-progress'; export const TrayClipArea = GObject.registerClass( class TrayClipArea extends Clutter.Actor { + static [GObject.properties]: Record = { + [VIEWPORT_TRANSITION]: GObject.ParamSpec.double( + VIEWPORT_TRANSITION, + VIEWPORT_TRANSITION, + 'Viewport animation progress', + GObject.ParamFlags.READWRITE, + 0, + 1, + 0, + ), + }; + public fullWidth = 0; public reservedWidth = 0; private _childOffsetX = 0; private _viewportWidth = 0; private _clipStart = 0; - declare private _lifecycle: LifecycleScope; - declare private _viewportTimeout: ManagedSource; + private _viewportProgress = 0; + + get viewport_progress(): number { + return this._viewportProgress; + } + + set viewport_progress(progress: number) { + this._viewportProgress = progress; + } 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 { @@ -58,32 +73,43 @@ export const TrayClipArea = GObject.registerClass( 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.cancelViewportAnimation(); + this._viewportProgress = 0; + + if (durationMs <= 0 || !this.mapped) { + this._viewportWidth = toViewportWidth; + this._clipStart = toClipStart; + this._syncClip(); + onFrame(toViewportWidth, toClipStart); + onComplete(); + return; + } - this._viewportTimeout.complete(); + this.ease_property(VIEWPORT_TRANSITION, 1, { + duration: durationMs, + mode: Clutter.AnimationMode.EASE_OUT_CUBIC, + onComplete: () => { this._viewportWidth = toViewportWidth; this._clipStart = toClipStart; this._syncClip(); onFrame(toViewportWidth, toClipStart); onComplete(); - return GLib.SOURCE_REMOVE; - }), - ); + }, + }); + const transition = this.get_transition(VIEWPORT_TRANSITION); + if (!transition) return; + + transition.connect('new-frame', () => { + this._viewportWidth = + fromViewportWidth + (toViewportWidth - fromViewportWidth) * this._viewportProgress; + this._clipStart = fromClipStart + (toClipStart - fromClipStart) * this._viewportProgress; + this._syncClip(); + onFrame(this._viewportWidth, this._clipStart); + }); } cancelViewportAnimation(): void { - this._viewportTimeout.clear(); + this.remove_transition(VIEWPORT_TRANSITION); } get viewportWidth(): number { @@ -110,7 +136,7 @@ export const TrayClipArea = GObject.registerClass( } override destroy(): void { - this._lifecycle.dispose(); + this.cancelViewportAnimation(); super.destroy(); } diff --git a/src/desktop/trayIcons/trayContainer.ts b/src/desktop/trayIcons/trayContainer.ts index 1f5894c..a4808d1 100644 --- a/src/desktop/trayIcons/trayContainer.ts +++ b/src/desktop/trayIcons/trayContainer.ts @@ -556,7 +556,6 @@ export const TrayContainer = GObject.registerClass( } override destroy(): void { - this._clipArea.cancelViewportAnimation(); this._lifecycle.dispose(); destroyTooltip(); for (const widget of this._items.values()) widget.destroy(); diff --git a/src/desktop/trayIcons/trayIcons.ts b/src/desktop/trayIcons/trayIcons.ts index f02d455..648ff2a 100644 --- a/src/desktop/trayIcons/trayIcons.ts +++ b/src/desktop/trayIcons/trayIcons.ts @@ -211,7 +211,7 @@ export class TrayIcons extends Module { GLib.Variant.new('(s)', [busName]), new GLib.VariantType('(s)'), Gio.DBusCallFlags.NONE, - -1, + 5000, null, ); return res.get_child_value(0).unpack() as string; @@ -230,7 +230,7 @@ export class TrayIcons extends Module { GLib.Variant.new('(s)', [busName]), new GLib.VariantType('(u)'), Gio.DBusCallFlags.NONE, - -1, + 5000, null, ); return res.get_child_value(0).unpack() as number; diff --git a/src/dev/calendarRemindersDevTool.ts b/src/dev/calendarRemindersDevTool.ts index a133dac..2f70bc0 100644 --- a/src/dev/calendarRemindersDevTool.ts +++ b/src/dev/calendarRemindersDevTool.ts @@ -9,12 +9,15 @@ import type { SettingsManager } from '~/core/settings.ts'; import { createDevToolActionButton, createDevToolActionRow, + createDevToolGroup, createDevToolModulePanel, + createDevToolRow, createDevToolSummary, } from '~/dev/devToolUi.ts'; import type { Module } from '~/module.ts'; import { CalendarReminders } from '~/panel/clock/calendarReminders/calendarReminders.ts'; import type { CalendarEvent } from '~/panel/clock/calendarReminders/calendarRemindersLogic.ts'; +import { gettext as _ } from '~/shared/i18n.ts'; const DEVTOOL_SOURCE_KEY = 'aurora-devtool'; @@ -53,6 +56,23 @@ export class CalendarRemindersDevTool { ), ); + if (this._events.length) { + const preview = createDevToolGroup(_('Fake events')); + for (const event of this._events) { + const time = new Date(event.startEpochSeconds * 1000).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + preview.list.add_child( + createDevToolRow( + event.title, + `${time} · ${this._withReminder ? _('reminder') : _('no reminder')}`, + ), + ); + } + panel.add_child(preview.container); + } + const firstRow = createDevToolActionRow(); firstRow.add_child( createDevToolActionButton( @@ -80,6 +100,9 @@ export class CalendarRemindersDevTool { () => this.setWithReminder(!this._withReminder), !calendarReminders, this._withReminder, + undefined, + 'calendar-with-reminder', + true, ), ); secondRow.add_child( diff --git a/src/dev/captureToolsDevTool.ts b/src/dev/captureToolsDevTool.ts index 4d93926..9b0dbc4 100644 --- a/src/dev/captureToolsDevTool.ts +++ b/src/dev/captureToolsDevTool.ts @@ -57,6 +57,8 @@ export class CaptureToolsDevTool { 'Open Preview', () => void this.openPreview(), !capture, + false, + 'suggested', ), ); sessionRow.add_child( @@ -71,6 +73,9 @@ export class CaptureToolsDevTool { `Tool: ${toolLabel}`, () => this.cycleTool(), !capture, + false, + undefined, + 'capture-tool', ), ); appearanceRow.add_child( @@ -79,6 +84,9 @@ export class CaptureToolsDevTool { `Color: ${colorLabel}`, () => this.cycleColor(), !capture, + false, + undefined, + 'capture-color', ), ); appearanceRow.add_child( @@ -87,6 +95,9 @@ export class CaptureToolsDevTool { `Width: ${widthLabel}`, () => this.cycleWidth(), !capture, + false, + undefined, + 'capture-width', ), ); panel.add_child(appearanceRow); @@ -99,6 +110,9 @@ export class CaptureToolsDevTool { () => this.toggleInteraction('selection'), !capture, Boolean(state && state.interaction === 'selection'), + undefined, + 'capture-interaction-selection', + true, ), ); opacityRow.add_child( @@ -108,6 +122,9 @@ export class CaptureToolsDevTool { () => this.toggleInteraction('drawing'), !capture, Boolean(state && state.interaction === 'drawing'), + undefined, + 'capture-interaction-drawing', + true, ), ); panel.add_child(opacityRow); @@ -120,6 +137,9 @@ export class CaptureToolsDevTool { () => this.setTesseractAvailable(true), !capture, Boolean(state && state.ocrAvailabilityOverridden && state.ocrAvailable === true), + undefined, + 'capture-tesseract-on', + true, ), ); tesseractRow.add_child( @@ -129,6 +149,9 @@ export class CaptureToolsDevTool { () => this.setTesseractAvailable(false), !capture, Boolean(state && state.ocrAvailabilityOverridden && state.ocrAvailable === false), + undefined, + 'capture-tesseract-off', + true, ), ); panel.add_child(tesseractRow); diff --git a/src/dev/clipboardHistoryDevTool.ts b/src/dev/clipboardHistoryDevTool.ts index 1f9891c..f9bd10b 100644 --- a/src/dev/clipboardHistoryDevTool.ts +++ b/src/dev/clipboardHistoryDevTool.ts @@ -8,10 +8,13 @@ import { fingerprintBytes } from '~/clipboard/clipboardMonitor.ts'; import { createDevToolActionButton, createDevToolActionRow, + createDevToolGroup, createDevToolModulePanel, + createDevToolRow, createDevToolSummary, } from '~/dev/devToolUi.ts'; import type { Module } from '~/module.ts'; +import { gettext as _ } from '~/shared/i18n.ts'; const RANDOM_MESSAGES = [ 'Aurora dev note: clipboard entry', @@ -37,6 +40,8 @@ export class ClipboardHistoryDevTool { readonly title = 'Clipboard'; readonly iconName = 'edit-paste-symbolic'; + private _generatedSamples: string[] = []; + constructor( private readonly _getModule: (key: string) => Module | null, private readonly _requestMenuRebuild: () => void, @@ -86,6 +91,8 @@ export class ClipboardHistoryDevTool { 'Clear History', () => this.clearHistory(), !clipboard || clipboard.entryCount === 0, + false, + 'destructive', ), ); panel.add_child(secondaryRow); @@ -117,6 +124,13 @@ export class ClipboardHistoryDevTool { ); panel.add_child(sampleRow); + if (this._generatedSamples.length) { + const preview = createDevToolGroup(_('Generated samples')); + for (const sample of this._generatedSamples) + preview.list.add_child(createDevToolRow(sample, _('Added by Aurora DevTool'))); + panel.add_child(preview.container); + } + return panel; } @@ -133,7 +147,7 @@ export class ClipboardHistoryDevTool { const text = this._makeRandomMessage(); if (!clipboard.addText(text)) return null; - this._requestMenuRebuild(); + this._remember(text); return text; } @@ -151,7 +165,7 @@ export class ClipboardHistoryDevTool { if (!clipboard) return false; const added = clipboard.addText(SAMPLE_LINK); - if (added) this._requestMenuRebuild(); + if (added) this._remember(SAMPLE_LINK); return added; } @@ -160,7 +174,7 @@ export class ClipboardHistoryDevTool { if (!clipboard) return false; const added = clipboard.addText(SAMPLE_CODE_SNIPPET); - if (added) this._requestMenuRebuild(); + if (added) this._remember(_('Generated code sample')); return added; } @@ -175,7 +189,7 @@ export class ClipboardHistoryDevTool { bytes, fingerprint: fingerprintBytes(bytes), }); - if (added) this._requestMenuRebuild(); + if (added) this._remember(_('Generated image sample')); return added; } @@ -184,6 +198,7 @@ export class ClipboardHistoryDevTool { if (!clipboard) return false; if (!clipboard.clearHistory()) return false; + this._generatedSamples = []; this._requestMenuRebuild(); return true; } @@ -211,4 +226,9 @@ export class ClipboardHistoryDevTool { const base = RANDOM_MESSAGES[Math.floor(Math.random() * RANDOM_MESSAGES.length)]!; return `${base} #${Date.now()}-${Math.floor(Math.random() * 1000)}`; } + + private _remember(sample: string): void { + this._generatedSamples = [sample, ...this._generatedSamples].slice(0, 5); + this._requestMenuRebuild(); + } } diff --git a/src/dev/devTool.ts b/src/dev/devTool.ts index 9fa3a7b..4899da5 100644 --- a/src/dev/devTool.ts +++ b/src/dev/devTool.ts @@ -2,19 +2,28 @@ import '@girs/gjs'; import St from '@girs/st-18'; import Clutter from '@girs/clutter-18'; +import GLib from '@girs/glib-2.0'; +import Atk from '@girs/atk-1.0'; import * as Main from '@girs/gnome-shell/ui/main'; import * as PanelMenu from '@girs/gnome-shell/ui/panelMenu'; import * as PopupMenu from '@girs/gnome-shell/ui/popupMenu'; import type { ExtensionContext } from '~/core/context.ts'; +import { LifecycleScope, type ManagedSource } from '~/core/lifecycleScope.ts'; +import { createManagedSource, createManagedTimeout, type ManagedTimeout } from '~/core/mainLoop.ts'; import { Module } from '~/module.ts'; +import { MODULE_CATALOG } from '~/moduleCatalog.ts'; +import { gettext as _ } from '~/shared/i18n.ts'; import { createIcon } from '~/shared/icons.ts'; -import { ClipboardHistoryDevTool } from './clipboardHistoryDevTool.ts'; +import { CalendarRemindersDevTool } from './calendarRemindersDevTool.ts'; import { CaptureToolsDevTool } from './captureToolsDevTool.ts'; +import { ClipboardHistoryDevTool } from './clipboardHistoryDevTool.ts'; +import { DevToolModuleBrowser, plainText } from './devToolModuleBrowser.ts'; +import { DevToolModuleSettings } from './devToolModuleSettings.ts'; +import { createDevToolGroup, createDevToolRow } from './devToolUi.ts'; import { DockDevTool } from './dockDevTool.ts'; import { GeneralDevTool } from './generalDevTool.ts'; -import { CalendarRemindersDevTool } from './calendarRemindersDevTool.ts'; import { TrayIconsDevTool } from './trayIconsDevTool.ts'; import { WeatherClockDevTool } from './weatherClockDevTool.ts'; @@ -30,6 +39,7 @@ type DevToolSection = { type DevToolCallbacks = { getModule(key: string): Module | null; openPreferences(): void; + versionName: string; }; type DevTools = { @@ -42,18 +52,57 @@ type DevTools = { calendarReminders: CalendarRemindersDevTool; }; +type Route = + | { type: 'modules' } + | { type: 'devtools' } + | { type: 'module'; key: string } + | { type: 'commands'; key: string; optionKey: string } + | { type: 'devtool'; key: string }; + +const DEVTOOL_SECTION_SUBTITLES: Record = { + general: _('Extension tools and session info'), + dock: _('Visibility, layout and monitor bindings'), + 'capture-tools': _('Annotation overlay and OCR states'), + 'clipboard-history': _('Populate the history panel'), + 'tray-icons': _('Fake SNI items and attention badges'), + 'weather-clock': _('Fake weather snapshots'), + 'calendar-reminders': _('Fake events and alerts'), +}; + export class DevTool extends Module { private _button: PanelMenu.Button | null = null; private _menuOpenStateId = 0; private _tools: DevTools | null = null; - private _activeSectionKey = 'general'; - private _sectionDropdownOpen = false; + private _routes: Route[] = [{ type: 'modules' }]; + private _toast = ''; + private _lastAnimatedRoute = ''; + private _returnFocusNames: string[] = []; + private _scrollPositions = new Map(); + private _lifecycle: LifecycleScope | null = null; + private _toastTimer: ManagedTimeout | null = null; + private _uiRestoreIdle: ManagedSource | null = null; + private readonly _moduleBrowser: DevToolModuleBrowser; + private readonly _moduleSettings: DevToolModuleSettings; constructor( context: ExtensionContext, private readonly _callbacks: DevToolCallbacks, ) { super(context); + this._moduleBrowser = new DevToolModuleBrowser(context.settings, { + openModule: (key, returnFocusName) => + this._navigate({ type: 'module', key }, returnFocusName), + }); + this._moduleSettings = new DevToolModuleSettings(context.settings, { + getModule: this._callbacks.getModule, + rebuild: (requestedFocusName) => this._rebuildMenu(requestedFocusName), + openCommands: (key, optionKey, returnFocusName) => + this._navigate({ type: 'commands', key, optionKey }, returnFocusName), + openDeveloperTool: (key, returnFocusName) => + this._navigate({ type: 'devtool', key }, returnFocusName), + hasDeveloperTool: (key) => this._section(key) !== undefined, + buildHero: (title, subtitle) => this._buildHero(title, subtitle), + }); } override enable(): void { @@ -67,27 +116,50 @@ export class DevTool extends Module { }), ); - const rebuildMenu = () => this._rebuildMenu(); + this._lifecycle = new LifecycleScope(); + this._toastTimer = createManagedTimeout(this._lifecycle); + this._uiRestoreIdle = createManagedSource(this._lifecycle); + const rebuild = () => this._showToast(_('Runtime updated')); this._tools = { - general: new GeneralDevTool(this._callbacks.openPreferences), - captureTools: new CaptureToolsDevTool(this._callbacks.getModule, rebuildMenu), - dock: new DockDevTool(this.context.settings, this._callbacks.getModule, rebuildMenu), - clipboardHistory: new ClipboardHistoryDevTool(this._callbacks.getModule, rebuildMenu), - trayIcons: new TrayIconsDevTool(rebuildMenu), - weatherClock: new WeatherClockDevTool(this._callbacks.getModule, rebuildMenu), + general: new GeneralDevTool( + this._callbacks.openPreferences, + () => { + const menu = this._getMenu(); + if (menu) menu.close(); + }, + this._callbacks.versionName, + () => + MODULE_CATALOG.filter((manifest) => + this.context.settings.getBoolean(manifest.settingsKey), + ).length, + ), + captureTools: new CaptureToolsDevTool(this._callbacks.getModule, rebuild), + dock: new DockDevTool(this.context.settings, this._callbacks.getModule, rebuild), + clipboardHistory: new ClipboardHistoryDevTool(this._callbacks.getModule, rebuild), + trayIcons: new TrayIconsDevTool(rebuild), + weatherClock: new WeatherClockDevTool(this._callbacks.getModule, rebuild), calendarReminders: new CalendarRemindersDevTool( this.context.settings, this._callbacks.getModule, - rebuildMenu, + rebuild, ), }; const menu = this._getMenu(); if (!menu) return; - menu.setSourceAlignment(1.0); + menu.setSourceAlignment(1.0); + menu.actor.add_style_class_name('aurora-devtool-menu'); this._menuOpenStateId = menu.connect('open-state-changed', (_menu, open) => { - if (open) this._rebuildMenu(); + if (open) { + this._routes = [{ type: 'modules' }]; + this._moduleBrowser.resetQuery(); + this._moduleSettings.resetEditor(); + this._lastAnimatedRoute = ''; + this._returnFocusNames = []; + this._scrollPositions.clear(); + this._rebuildMenu(); + } return undefined; }); @@ -98,20 +170,27 @@ export class DevTool extends Module { override disable(): void { const tools = this._tools; this._tools = null; - + if (this._lifecycle) this._lifecycle.dispose(); + this._lifecycle = null; + this._toastTimer = null; + this._uiRestoreIdle = null; if (tools) { tools.captureTools.destroy(); tools.trayIcons.destroy(); tools.weatherClock.destroy(); tools.calendarReminders.destroy(); } - + this._toast = ''; + this._lastAnimatedRoute = ''; + this._returnFocusNames = []; + this._scrollPositions.clear(); + this._moduleBrowser.resetQuery(); + this._moduleSettings.resetEditor(); if (this._menuOpenStateId && this._button) { (this._button.menu as PopupMenu.PopupMenu).disconnect(this._menuOpenStateId); this._menuOpenStateId = 0; } - - this._button?.destroy(); + if (this._button) this._button.destroy(); this._button = null; } @@ -143,187 +222,348 @@ export class DevTool extends Module { return this._tools ? this._tools.weatherClock : null; } - private _rebuildMenu(): void { - const menu = this._getMenu(); - if (!menu) return; + private _route(): Route { + if (this._routes.length === 0) return { type: 'modules' }; + return this._routes.at(-1)!; + } + + private _navigate(route: Route, returnFocusName: string): void { + this._routes.push(route); + this._returnFocusNames.push(returnFocusName); + this._moduleSettings.resetEditor(); + this._rebuildMenu('aurora-devtool-focus-back'); + } + + private _back(): void { + if (this._routes.length <= 1) return; + + this._routes.pop(); + const returnFocusName = this._returnFocusNames.pop(); + this._moduleSettings.resetEditor(); + this._rebuildMenu(returnFocusName); + } + + private _rebuildMenu(requestedFocusName?: string): void { + if (!this._button) return; + const menu = this._button.menu as PopupMenu.PopupMenu; + const currentScroll = this._findScrollView(menu.actor); + if (currentScroll && this._lastAnimatedRoute) + this._scrollPositions.set(this._lastAnimatedRoute, currentScroll.vadjustment.value); + + const focused = global.stage.get_key_focus(); + const focusName = requestedFocusName || (focused ? focused.name : '') || ''; menu.removeAll(); + + const route = this._route(); + const routeSignature = this._routeSignature(route); + const animatePage = routeSignature !== this._lastAnimatedRoute; + this._lastAnimatedRoute = routeSignature; + const section = new PopupMenu.PopupMenuSection(); - section.box.add_child(this._buildPanel()); + section.box.add_child(this._buildCard(animatePage, this._button)); menu.addMenuItem(section); + + const scrollPosition = this._scrollPositions.get(routeSignature) || 0; + if (!focusName && scrollPosition === 0) return; + + if (!this._uiRestoreIdle) return; + const uiRestoreIdle = this._uiRestoreIdle; + uiRestoreIdle.replace(() => + GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => { + uiRestoreIdle.complete(); + if (!this._button) return GLib.SOURCE_REMOVE; + + const rebuiltMenu = this._button.menu as PopupMenu.PopupMenu; + const scroll = this._findScrollView(rebuiltMenu.actor); + if (scroll) scroll.vadjustment.value = scrollPosition; + if (focusName) { + const focusTarget = this._findNamedActor(rebuiltMenu.actor, focusName); + if (focusTarget instanceof St.Widget && focusTarget.can_focus && focusTarget.visible) + focusTarget.grab_key_focus(); + } + return GLib.SOURCE_REMOVE; + }), + ); } - private _buildPanel(): St.Widget { + private _routeSignature(route: Route): string { + if (route.type === 'module' || route.type === 'devtool') return `${route.type}:${route.key}`; + if (route.type === 'commands') return `${route.type}:${route.key}:${route.optionKey}`; + return route.type; + } + + private _findNamedActor(actor: Clutter.Actor, name: string): Clutter.Actor | null { + if (actor.name === name) return actor; + for (const child of actor.get_children()) { + const match = this._findNamedActor(child, name); + if (match) return match; + } + return null; + } + + private _findScrollView(actor: Clutter.Actor): St.ScrollView | null { + if (actor instanceof St.ScrollView) return actor; + for (const child of actor.get_children()) { + const match = this._findScrollView(child); + if (match) return match; + } + return null; + } + + private _buildCard(animatePage: boolean, sourceActor: Clutter.Actor): St.Widget { + const monitor = Main.layoutManager.findMonitorForActor(sourceActor); + const monitorIndex = monitor ? monitor.index : Main.layoutManager.primaryIndex; + const workArea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex); + const width = Math.min(448, Math.max(1, workArea.width - 24)); + const maxHeight = Math.max(1, workArea.height - 48); const panel = new St.BoxLayout({ orientation: Clutter.Orientation.VERTICAL, style_class: 'aurora-devtool-panel', + style: `width: ${width}px; max-height: ${maxHeight}px;`, + x_expand: true, }); - panel.add_child(this._buildHeader()); - panel.add_child(this._buildBody()); - return panel; - } + const route = this._route(); + if (route.type === 'modules' || route.type === 'devtools') + panel.add_child(this._buildViewSwitcher(route.type)); + const viewport = new St.Viewport({ layout_manager: new Clutter.BinLayout() }); + const page = this._buildRoute(route); + viewport.add_child(page); + if (route.type === 'modules') panel.add_child(this._moduleBrowser.buildSearch(viewport)); + + if (animatePage) { + page.opacity = 0; + page.translation_x = 10; + page.ease({ + opacity: 255, + translationX: 0, + duration: 180, + mode: Clutter.AnimationMode.EASE_OUT_QUAD, + }); + } - private _buildHeader(): St.Widget { - const header = new St.BoxLayout({ - style_class: 'aurora-devtool-header', + const scroll = new St.ScrollView({ + style_class: 'aurora-devtool-scroll', + hscrollbar_policy: St.PolicyType.NEVER, + vscrollbar_policy: St.PolicyType.AUTOMATIC, + x_expand: true, }); + scroll.add_child(viewport); + panel.add_child(scroll); - header.add_child( - createIcon('applications-engineering-symbolic', { - icon_size: 18, - style_class: 'aurora-devtool-header-icon', - }), - ); - header.add_child( - new St.Label({ - text: 'Aurora DevTool', - y_align: Clutter.ActorAlign.CENTER, - style_class: 'aurora-devtool-title', - }), - ); + const stack = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + x_expand: true, + y_expand: true, + }); + stack.add_child(panel); + if (this._toast) + stack.add_child( + new St.Label({ + text: this._toast, + style_class: 'aurora-devtool-toast', + accessible_role: Atk.Role.NOTIFICATION, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.END, + }), + ); + return stack; + } - return header; + private _showToast(message: string): void { + if (!this._toastTimer || !this._button) return; + + this._toast = message; + this._rebuildMenu(); + this._toastTimer.schedule(2600, () => { + this._toast = ''; + this._rebuildMenu(); + }); } - private _buildBody(): St.Widget { - const body = new St.BoxLayout({ - orientation: Clutter.Orientation.VERTICAL, - style_class: 'aurora-devtool-body', + private _buildHeader(): St.Widget { + const route = this._route(); + const header = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + style_class: 'aurora-devtool-header', x_expand: true, - y_expand: true, }); + const canGoBack = this._routes.length > 1; + + const nav = new St.BoxLayout({ style_class: 'aurora-devtool-header-nav', x_expand: true }); + const back = new St.Button({ + child: new St.Icon({ icon_name: 'go-previous-symbolic', icon_size: 16 }), + style_class: 'button aurora-devtool-header-button aurora-devtool-back-button', + can_focus: canGoBack, + reactive: canGoBack, + visible: canGoBack, + y_align: Clutter.ActorAlign.CENTER, + accessible_name: _('Back'), + name: 'aurora-devtool-focus-back', + }); + back.connect('clicked', () => this._back()); + nav.add_child(back); + nav.add_child(new St.Widget({ x_expand: true })); + header.add_child(nav); - body.add_child(this._buildSectionDropdown()); - - const content = new St.BoxLayout({ + const labels = new St.BoxLayout({ orientation: Clutter.Orientation.VERTICAL, - style_class: 'aurora-devtool-content', + style_class: 'aurora-devtool-header-labels', x_expand: true, y_expand: true, + x_align: Clutter.ActorAlign.CENTER, + y_align: Clutter.ActorAlign.CENTER, + reactive: false, }); + labels.add_child( + new St.Label({ text: this._routeTitle(route), style_class: 'aurora-devtool-title' }), + ); + labels.add_child( + new St.Label({ text: this._routeSubtitle(route), style_class: 'aurora-devtool-subtitle' }), + ); + header.add_child(labels); + return header; + } - const activeSection = this._activeSection(); - if (activeSection) { - content.add_child(activeSection.buildPanel()); + private _routeTitle(route: Route): string { + if (route.type === 'module') { + const manifest = MODULE_CATALOG.find((entry) => entry.key === route.key); + return manifest ? plainText(manifest.title) : _('Module'); } - - body.add_child(content); - return body; + if (route.type === 'commands') return _('Custom Commands'); + return _('Aurora DevTool'); } - private _buildSectionDropdown(): St.Widget { - const dropdown = new St.BoxLayout({ - orientation: Clutter.Orientation.VERTICAL, - style_class: 'aurora-devtool-section-dropdown', - x_expand: true, - }); + private _routeSubtitle(route: Route): string { + if (route.type === 'modules') return _('Modules'); + if (route.type === 'devtools') return _('Developer Tools'); + if (route.type === 'module') return _('Module Settings'); + if (route.type === 'commands') return _('Aurora Menu'); - const activeSection = this._activeSection(); - if (activeSection) { - dropdown.add_child(this._buildSectionTrigger(activeSection)); - } + const section = this._section(route.key); + return section ? section.title : _('Developer Tool'); + } - if (this._sectionDropdownOpen) { - const list = new St.BoxLayout({ - orientation: Clutter.Orientation.VERTICAL, - style_class: 'aurora-devtool-section-menu', + private _buildViewSwitcher(active: 'modules' | 'devtools'): St.Widget { + const switcher = new St.BoxLayout({ style_class: 'aurora-devtool-view-switcher' }); + const track = new St.BoxLayout({ style_class: 'aurora-devtool-view-track', x_expand: true }); + for (const [key, label, iconName] of [ + ['modules', _('Modules'), 'view-app-grid-symbolic'], + ['devtools', _('Dev Tools'), 'applications-engineering-symbolic'], + ] as const) { + const content = new St.BoxLayout({ + style_class: 'aurora-devtool-view-button-content', + x_align: Clutter.ActorAlign.CENTER, + }); + content.add_child(new St.Icon({ icon_name: iconName, icon_size: 14 })); + content.add_child(new St.Label({ text: label })); + const button = new St.Button({ + child: content, + style_class: + key === active + ? 'button aurora-devtool-view-button active' + : 'button aurora-devtool-view-button', + can_focus: true, x_expand: true, + toggle_mode: true, + checked: key === active, + accessible_name: label, + name: `aurora-devtool-focus-view-${key}`, }); - for (const section of this._sections()) { - list.add_child(this._buildSectionOption(section)); - } - dropdown.add_child(list); + button.connect('clicked', () => { + this._routes = [{ type: key }]; + this._returnFocusNames = []; + this._moduleBrowser.resetQuery(); + this._moduleSettings.resetEditor(); + this._rebuildMenu(`aurora-devtool-focus-view-${key}`); + }); + track.add_child(button); } - - return dropdown; + switcher.add_child(track); + return switcher; } - private _buildSectionTrigger(section: DevToolSection): St.Button { - const content = this._buildSectionButtonContent(section, 'pan-down-symbolic'); - const button = new St.Button({ - child: content, - style_class: this._sectionDropdownOpen - ? 'button aurora-devtool-section-trigger active' - : 'button aurora-devtool-section-trigger', - can_focus: true, - x_expand: true, - accessible_name: section.title, - }); - button.connect('clicked', () => { - this._sectionDropdownOpen = !this._sectionDropdownOpen; - this._rebuildMenu(); - }); - return button; - } - - private _buildSectionOption(section: DevToolSection): St.Button { - const active = section.key === this._activeSectionKey; - const content = this._buildSectionButtonContent( - section, - active ? 'object-select-symbolic' : null, - ); - const button = new St.Button({ - child: content, - style_class: active - ? 'button aurora-devtool-section-option active' - : 'button aurora-devtool-section-option', - can_focus: true, - x_expand: true, - accessible_name: section.title, - }); - button.connect('clicked', () => { - this._activeSectionKey = section.key; - this._sectionDropdownOpen = false; - this._rebuildMenu(); - }); - return button; + private _buildRoute(route: Route): St.Widget { + let page: St.Widget; + if (route.type === 'modules') page = this._moduleBrowser.buildRoot(); + else if (route.type === 'devtools') page = this._buildDevTools(); + else if (route.type === 'module') page = this._moduleSettings.buildModule(route.key); + else if (route.type === 'commands') + page = this._moduleSettings.buildCommands(route.key, route.optionKey); + else page = this._buildDevTool(route.key); + page.x_expand = true; + return page; } - private _buildSectionButtonContent( - section: DevToolSection, - suffixIconName: string | null, - ): St.Widget { + private _buildDevTools(): St.Widget { const content = new St.BoxLayout({ - style_class: 'aurora-devtool-section-button-content', - x_expand: true, + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-content', }); - - content.add_child( - new St.Icon({ - icon_name: section.iconName, - icon_size: 16, - style_class: 'aurora-devtool-section-icon', - }), + const group = createDevToolGroup( + _('Developer Tools'), + _('Simulate runtime states and trigger module actions'), ); - content.add_child( - new St.Label({ - text: section.title, - y_align: Clutter.ActorAlign.CENTER, + for (const section of this._sections()) { + const focusName = `aurora-devtool-focus-devtool-${section.key}`; + const button = new St.Button({ + child: createDevToolRow( + section.title, + DEVTOOL_SECTION_SUBTITLES[section.key] || _('Inspect and simulate runtime state'), + new St.Icon({ icon_name: 'go-next-symbolic', icon_size: 16 }), + section.iconName, + ), + style_class: 'button aurora-devtool-list-button', + can_focus: true, x_expand: true, - }), - ); - if (suffixIconName) { - content.add_child( - new St.Icon({ - icon_name: suffixIconName, - icon_size: 16, - style_class: 'aurora-devtool-section-suffix-icon', - }), + name: focusName, + }); + button.connect('clicked', () => + this._navigate({ type: 'devtool', key: section.key }, focusName), ); + group.list.add_child(button); } + content.add_child(group.container); + return content; + } + + private _buildDevTool(key: string): St.Widget { + const section = this._section(key); + const content = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-content', + }); + if (!section) return content; + content.add_child(this._buildHero(section.title, _('Live development controls'))); + content.add_child(section.buildPanel()); return content; } - private _activeSection(): DevToolSection | null { - const activeSection = this._sections().find( - (section) => section.key === this._activeSectionKey, - ); - if (!activeSection) return null; + private _buildHero(title: string, subtitle: string): St.Widget { + const hero = new St.BoxLayout({ style_class: 'aurora-devtool-hero', x_expand: true }); + const labels = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-hero-labels', + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + labels.add_child(new St.Label({ text: title, style_class: 'aurora-devtool-hero-title' })); + const subtitleLabel = new St.Label({ + text: subtitle, + style_class: 'aurora-devtool-hero-subtitle', + x_expand: true, + }); + subtitleLabel.clutter_text.line_wrap = true; + labels.add_child(subtitleLabel); + hero.add_child(labels); + return hero; + } - return activeSection; + private _section(key: string): DevToolSection | undefined { + return this._sections().find((section) => section.key === key); } private _sections(): DevToolSection[] { @@ -331,10 +571,6 @@ export class DevTool extends Module { } private _getMenu(): PopupMenu.PopupMenu | null { - if (!this._button) { - return null; - } - - return this._button.menu as PopupMenu.PopupMenu; + return this._button ? (this._button.menu as PopupMenu.PopupMenu) : null; } } diff --git a/src/dev/devToolModuleBrowser.ts b/src/dev/devToolModuleBrowser.ts new file mode 100644 index 0000000..762c80b --- /dev/null +++ b/src/dev/devToolModuleBrowser.ts @@ -0,0 +1,236 @@ +import St from '@girs/st-18'; +import Clutter from '@girs/clutter-18'; +import Pango from '@girs/pango-1.0'; + +import type { SettingsManager } from '~/core/settings.ts'; +import type { ModuleManifest } from '~/module.ts'; +import { getSections, MODULE_CATALOG } from '~/moduleCatalog.ts'; +import { gettext as _ } from '~/shared/i18n.ts'; +import { createIcon } from '~/shared/icons.ts'; + +import { createDevToolGroup, createDevToolSwitch } from './devToolUi.ts'; + +type ModuleBrowserCallbacks = { + openModule(key: string, returnFocusName: string): void; +}; + +const MODULE_ICONS: Record = { + 'no-overview': 'view-app-grid-symbolic', + 'pip-on-top': 'view-pin-symbolic', + 'focus-launched-windows': 'focus-windows-symbolic', + 'capture-tools': 'camera-photo-symbolic', + 'theme-changer': 'preferences-desktop-appearance-symbolic', + dock: 'view-app-grid-symbolic', + 'aurora-menu': 'aurora-shell-menu-symbolic', + 'power-menu-avatar': 'avatar-default-symbolic', + 'volume-mixer': 'audio-volume-high-symbolic', + 'low-battery-percentage': 'battery-level-20-symbolic', + 'lock-key-indicators': 'input-keyboard-symbolic', + 'xwayland-indicator': 'video-display-symbolic', + privacy: 'changes-prevent-symbolic', + 'icon-weave': 'applications-graphics-symbolic', + 'app-search-tooltip': 'system-search-symbolic', + 'vela-vpn-quick-settings': 'network-vpn-symbolic', + 'auto-theme-switcher': 'weather-clear-night-symbolic', + 'bluetooth-menu': 'bluetooth-symbolic', + 'weather-clock': 'weather-clear-symbolic', + 'calendar-reminders': 'x-office-calendar-symbolic', + 'tray-icons': 'view-grid-symbolic', + 'clipboard-history': 'edit-paste-symbolic', +}; + +export function plainText(text: string): string { + return text.replaceAll('&', '&'); +} + +export class DevToolModuleBrowser { + private _query = ''; + + constructor( + private readonly _settings: SettingsManager, + private readonly _callbacks: ModuleBrowserCallbacks, + ) {} + + resetQuery(): void { + this._query = ''; + } + + buildSearch(viewport: St.Viewport): St.Widget { + const box = new St.BoxLayout({ style_class: 'aurora-devtool-search-box' }); + const entry = new St.Entry({ + text: this._query, + hint_text: _('Search modules'), + can_focus: true, + style_class: 'aurora-devtool-search', + x_expand: true, + name: 'aurora-devtool-focus-search', + }); + entry.set_primary_icon( + new St.Icon({ + icon_name: 'system-search-symbolic', + icon_size: 15, + style_class: 'aurora-devtool-search-icon', + }), + ); + entry.clutter_text.connect('text-changed', () => { + this._query = entry.get_text(); + for (const child of viewport.get_children()) child.destroy(); + + const page = this.buildRoot(); + page.x_expand = true; + viewport.add_child(page); + }); + box.add_child(entry); + return box; + } + + buildRoot(): St.Widget { + const query = this._query.trim().toLocaleLowerCase(); + let matches = 0; + const groups: St.Widget[] = []; + const sections = getSections(); + const knownSections = new Set(sections.map((section) => section.id)); + for (const section of [...sections, { id: 'other', title: _('Other') }]) { + const manifests = MODULE_CATALOG.filter((manifest) => { + const belongs = + section.id === 'other' + ? !knownSections.has(manifest.section) + : manifest.section === section.id; + if (!belongs) return false; + + const haystack = + `${plainText(manifest.title)} ${plainText(manifest.subtitle)}`.toLocaleLowerCase(); + return !query || haystack.includes(query); + }); + if (manifests.length === 0) continue; + + matches += manifests.length; + const group = createDevToolGroup(plainText(section.title)); + for (const manifest of manifests) group.list.add_child(this._buildModuleRow(manifest)); + groups.push(group.container); + } + if (query && matches === 0) return this._buildSearchStatusPage(); + + const content = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-content', + }); + content.add_child( + new St.Label({ text: _('Modules'), style_class: 'aurora-devtool-page-title' }), + ); + content.add_child( + new St.Label({ + text: _('%d of %d modules · toggle a module or open its settings').format( + matches, + MODULE_CATALOG.length, + ), + style_class: 'aurora-devtool-page-subtitle', + }), + ); + for (const group of groups) content.add_child(group); + return content; + } + + private _buildSearchStatusPage(): St.Widget { + const content = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-content', + }); + const status = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-status-page', + x_expand: true, + }); + status.add_child( + new St.Icon({ + icon_name: 'system-search-symbolic', + icon_size: 72, + style_class: 'aurora-devtool-status-icon', + x_align: Clutter.ActorAlign.CENTER, + opacity: 140, + }), + ); + status.add_child( + new St.Label({ + text: _('No Results Found'), + style_class: 'aurora-devtool-status-title', + }), + ); + const description = new St.Label({ + text: _('No modules match “%s”. Try a different search.').format(this._query.trim()), + style_class: 'aurora-devtool-status-description', + x_expand: true, + }); + description.clutter_text.line_wrap = true; + status.add_child(description); + content.add_child(status); + return content; + } + + private _buildModuleRow(manifest: ModuleManifest): St.Widget { + const enabled = this._settings.getBoolean(manifest.settingsKey); + const row = new St.BoxLayout({ + style_class: 'aurora-devtool-row aurora-devtool-module-row', + x_expand: true, + }); + const openContent = new St.BoxLayout({ + style_class: 'aurora-devtool-navigation-content', + x_expand: true, + }); + openContent.add_child( + createIcon(MODULE_ICONS[manifest.key] || 'application-x-addon-symbolic', { + icon_size: 18, + style_class: 'aurora-devtool-row-icon', + }), + ); + openContent.add_child( + this._buildRowLabels(plainText(manifest.title), plainText(manifest.subtitle)), + ); + openContent.add_child( + new St.Icon({ + icon_name: 'go-next-symbolic', + icon_size: 16, + style_class: 'aurora-devtool-chevron', + y_align: Clutter.ActorAlign.CENTER, + }), + ); + const focusName = `aurora-devtool-focus-module-${manifest.key}`; + const open = new St.Button({ + child: openContent, + style_class: 'button aurora-devtool-module-open', + can_focus: true, + x_expand: true, + accessible_name: plainText(manifest.title), + name: focusName, + }); + open.connect('clicked', () => this._callbacks.openModule(manifest.key, focusName)); + row.add_child(open); + row.add_child( + createDevToolSwitch( + enabled, + (state) => this._settings.setBoolean(manifest.settingsKey, state), + _('Enable %s').format(plainText(manifest.title)), + ), + ); + return row; + } + + private _buildRowLabels(title: string, subtitle: string): St.Widget { + const labels = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-row-labels', + x_expand: true, + }); + labels.add_child(new St.Label({ text: title, style_class: 'aurora-devtool-row-title' })); + const subtitleLabel = new St.Label({ + text: subtitle, + style_class: 'aurora-devtool-row-subtitle', + x_expand: true, + }); + subtitleLabel.clutter_text.line_wrap = true; + subtitleLabel.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; + subtitleLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + labels.add_child(subtitleLabel); + return labels; + } +} diff --git a/src/dev/devToolModuleSettings.ts b/src/dev/devToolModuleSettings.ts new file mode 100644 index 0000000..aa187b4 --- /dev/null +++ b/src/dev/devToolModuleSettings.ts @@ -0,0 +1,636 @@ +import St from '@girs/st-18'; +import Clutter from '@girs/clutter-18'; +import Meta from '@girs/meta-18'; +import Pango from '@girs/pango-1.0'; +import Atk from '@girs/atk-1.0'; + +import type { SettingsManager } from '~/core/settings.ts'; +import type { Module, ModuleManifest, ModuleOption } from '~/module.ts'; +import { MODULE_CATALOG } from '~/moduleCatalog.ts'; +import { parseCustomCommand, serializeCustomCommand } from '~/panel/auroraMenuState.ts'; +import { gettext as _ } from '~/shared/i18n.ts'; +import { createIcon } from '~/shared/icons.ts'; + +import { plainText } from './devToolModuleBrowser.ts'; +import { + createDevToolButton, + createDevToolGroup, + createDevToolRow, + createDevToolSwitch, +} from './devToolUi.ts'; + +type ModuleSettingsCallbacks = { + getModule(key: string): Module | null; + rebuild(requestedFocusName?: string): void; + openCommands(key: string, optionKey: string, returnFocusName: string): void; + openDeveloperTool(key: string, returnFocusName: string): void; + hasDeveloperTool(key: string): boolean; + buildHero(title: string, subtitle: string): St.Widget; +}; + +type CommandEditor = { + index: number | null; + original: string | null; + label: string; + command: string; +}; + +const MODIFIER_KEYSYMS = new Set([ + Clutter.KEY_Alt_L, + Clutter.KEY_Alt_R, + Clutter.KEY_Control_L, + Clutter.KEY_Control_R, + Clutter.KEY_Hyper_L, + Clutter.KEY_Hyper_R, + Clutter.KEY_Meta_L, + Clutter.KEY_Meta_R, + Clutter.KEY_Shift_L, + Clutter.KEY_Shift_R, + Clutter.KEY_Super_L, + Clutter.KEY_Super_R, +]); + +export class DevToolModuleSettings { + private _commandEditor: CommandEditor | null = null; + + constructor( + private readonly _settings: SettingsManager, + private readonly _callbacks: ModuleSettingsCallbacks, + ) {} + + resetEditor(): void { + this._commandEditor = null; + } + + buildModule(key: string): St.Widget { + const manifest = MODULE_CATALOG.find((entry) => entry.key === key); + const content = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-content', + }); + if (!manifest) return content; + + const enabled = this._settings.getBoolean(manifest.settingsKey); + const running = this._callbacks.getModule(manifest.key) !== null; + content.add_child( + this._callbacks.buildHero(plainText(manifest.title), plainText(manifest.subtitle)), + ); + if (!enabled) content.add_child(this._buildDisabledBanner(manifest)); + + const moduleGroup = createDevToolGroup(_('Module')); + moduleGroup.list.add_child( + createDevToolRow( + _('Enable %s').format(plainText(manifest.title)), + running + ? _('Module is running in this session') + : enabled + ? _('Module is enabled but not running in this session') + : _('Module is off'), + createDevToolSwitch( + enabled, + (state) => { + this._settings.setBoolean(manifest.settingsKey, state); + this._callbacks.rebuild(); + }, + _('Enable %s').format(plainText(manifest.title)), + `module-enabled-${manifest.key}`, + ), + ), + ); + content.add_child(moduleGroup.container); + + if (manifest.options && manifest.options.length > 0) { + const settingsGroup = createDevToolGroup(_('Settings')); + for (const option of manifest.options) { + const row = this._buildOption(manifest, option); + if (!enabled) this._disableActor(row); + settingsGroup.list.add_child(row); + } + content.add_child(settingsGroup.container); + } + if (this._callbacks.hasDeveloperTool(key)) { + const developerTitle = _('%s Dev Tools').format(plainText(manifest.title)); + const developerFocusName = `aurora-devtool-focus-developer-${key}`; + const developer = createDevToolGroup(_('Developer')); + developer.list.add_child( + this._buildNavigationRow( + developerTitle, + _('Simulate runtime states and trigger module actions'), + () => this._callbacks.openDeveloperTool(key, developerFocusName), + 'applications-engineering-symbolic', + developerFocusName, + ), + ); + content.add_child(developer.container); + } + return content; + } + + buildCommands(key: string, optionKey: string): St.Widget { + const content = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-content', + }); + content.add_child( + new St.Label({ text: _('Custom Menu Commands'), style_class: 'aurora-devtool-page-title' }), + ); + content.add_child( + new St.Label({ + text: _('Shortcuts that run commands from Aurora Menu'), + style_class: 'aurora-devtool-page-subtitle', + }), + ); + const group = createDevToolGroup(_('Commands')); + const rawCommands = this._settings.getStrv(optionKey); + let validCommands = 0; + rawCommands.forEach((raw, index) => { + const command = parseCustomCommand(raw); + if (!command) return; + + validCommands++; + const actions = new St.BoxLayout({ style_class: 'aurora-devtool-inline-controls' }); + actions.add_child( + createDevToolButton( + _('Edit'), + () => { + this._commandEditor = { index, original: raw, ...command }; + this._callbacks.rebuild(); + }, + undefined, + _('Edit %s').format(command.label), + false, + 'flat', + `command-edit-${index}`, + ), + ); + actions.add_child( + createDevToolButton( + _('Delete'), + () => { + const currentCommands = this._settings.getStrv(optionKey); + if (currentCommands[index] !== raw) { + this._callbacks.rebuild(); + return; + } + + currentCommands.splice(index, 1); + this._settings.setStrv(optionKey, currentCommands); + this._callbacks.rebuild(); + }, + undefined, + _('Delete %s').format(command.label), + false, + 'destructive', + `command-delete-${index}`, + ), + ); + group.list.add_child(createDevToolRow(command.label, command.command, actions)); + }); + if (validCommands === 0) + group.list.add_child(createDevToolRow(_('No custom commands'), _('Add one below.'))); + + if (this._commandEditor) { + content.add_child(group.container); + content.add_child(this._buildCommandEditor(optionKey, this._commandEditor)); + } else { + const buttonRow = new St.BoxLayout({ + style_class: 'aurora-devtool-button-row', + x_expand: true, + }); + const add = createDevToolButton( + _('Add Command'), + () => { + this._commandEditor = { index: null, original: null, label: '', command: '' }; + this._callbacks.rebuild(); + }, + 'list-add-symbolic', + undefined, + false, + 'suggested', + ); + add.x_expand = true; + buttonRow.add_child(add); + group.list.add_child(buttonRow); + content.add_child(group.container); + } + + const note = new St.BoxLayout({ style_class: 'aurora-devtool-commands-note' }); + note.add_child(new St.Label({ text: _('Stored in'), y_align: Clutter.ActorAlign.CENTER })); + note.add_child( + new St.Label({ + text: optionKey, + style_class: 'aurora-devtool-kbd-chip', + y_align: Clutter.ActorAlign.CENTER, + }), + ); + note.add_child( + new St.Label({ + text: _('· module %s').format(key), + y_align: Clutter.ActorAlign.CENTER, + }), + ); + content.add_child(note); + return content; + } + + private _buildDisabledBanner(manifest: ModuleManifest): St.Widget { + const banner = new St.BoxLayout({ style_class: 'aurora-devtool-banner', x_expand: true }); + banner.add_child( + new St.Icon({ + icon_name: 'dialog-warning-symbolic', + icon_size: 16, + y_align: Clutter.ActorAlign.CENTER, + }), + ); + const bannerLabel = new St.Label({ + text: _('This module is disabled. Its settings are stored but have no effect.'), + style_class: 'aurora-devtool-banner-label', + x_expand: true, + y_align: Clutter.ActorAlign.CENTER, + }); + bannerLabel.clutter_text.line_wrap = true; + banner.add_child(bannerLabel); + banner.add_child( + createDevToolButton( + _('Enable'), + () => { + this._settings.setBoolean(manifest.settingsKey, true); + this._callbacks.rebuild(); + }, + undefined, + _('Enable %s').format(plainText(manifest.title)), + ), + ); + return banner; + } + + private _buildOption(manifest: ModuleManifest, option: ModuleOption): St.Widget { + const title = plainText(option.title); + const subtitle = plainText(option.subtitle); + if (option.type === 'switch' && option.key) { + const optionKey = option.key; + return createDevToolRow( + title, + subtitle, + createDevToolSwitch( + this._settings.getBoolean(optionKey), + (state) => this._settings.setBoolean(optionKey, state), + title, + ), + ); + } + if (option.type === 'entry' && option.key) { + const optionKey = option.key; + const entry = new St.Entry({ + text: this._settings.getString(optionKey), + can_focus: true, + accessible_name: title, + style_class: 'aurora-devtool-entry', + }); + entry.clutter_text.connect('text-changed', () => + this._settings.setString(optionKey, entry.get_text()), + ); + const entryWrap = new St.Widget({ + layout_manager: new Clutter.BinLayout(), + style_class: 'aurora-devtool-entry-wrap', + y_align: Clutter.ActorAlign.CENTER, + }); + entryWrap.add_child(entry); + return createDevToolRow(title, subtitle, entryWrap); + } + if (option.type === 'spin' && option.key) + return createDevToolRow(title, subtitle, this._buildStepper(option.key, option, title)); + if (option.type === 'time' && option.hourKey && option.minuteKey) { + const time = new St.BoxLayout({ style_class: 'aurora-devtool-inline-controls' }); + time.add_child( + this._buildStepper( + option.hourKey, + { ...option, min: 0, max: 23 }, + `${title}: ${_('Hours')}`, + ), + ); + time.add_child( + new St.Label({ + text: ':', + style_class: 'aurora-devtool-time-colon', + y_align: Clutter.ActorAlign.CENTER, + }), + ); + time.add_child( + this._buildStepper( + option.minuteKey, + { ...option, min: 0, max: 59 }, + `${title}: ${_('Minutes')}`, + ), + ); + return createDevToolRow(title, subtitle, time); + } + if ((option.type === 'select' || option.type === 'icon-select') && option.key) { + const optionKey = option.key; + const choices = option.choices || []; + const container = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + x_expand: true, + }); + container.add_child(createDevToolRow(title, subtitle)); + const segmented = new St.BoxLayout({ + style_class: 'aurora-devtool-segmented', + x_expand: true, + }); + const buttons: Array<{ value: string; button: St.Button }> = []; + for (const choice of choices) { + const button = createDevToolButton( + plainText(choice.title), + () => { + this._settings.setString(optionKey, choice.value); + for (const entry of buttons) entry.button.checked = entry.value === choice.value; + }, + choice.iconName, + `${title}: ${plainText(choice.title)}`, + choice.value === this._settings.getString(optionKey), + undefined, + `select-${optionKey}-${choice.value}`, + true, + ); + button.x_expand = true; + button.add_style_class_name('aurora-devtool-segmented-button'); + buttons.push({ value: choice.value, button }); + segmented.add_child(button); + } + container.add_child(segmented); + return container; + } + if (option.type === 'shortcut' && option.key) + return createDevToolRow(title, subtitle, this._buildShortcut(option.key)); + if (option.type === 'command-list' && option.key) { + const optionKey = option.key; + const focusName = `aurora-devtool-focus-commands-${manifest.key}`; + return this._buildNavigationRow( + title, + subtitle, + () => this._callbacks.openCommands(manifest.key, optionKey, focusName), + undefined, + focusName, + ); + } + return createDevToolRow(title, subtitle || _('Unsupported option')); + } + + private _buildStepper(key: string, option: ModuleOption, title: string): St.Widget { + const value = this._settings.getInt(key); + const control = new St.BoxLayout({ + style_class: 'aurora-devtool-stepper-control', + y_align: Clutter.ActorAlign.CENTER, + }); + control.add_child( + new St.Label({ + text: String(value).padStart(2, '0'), + style_class: 'aurora-devtool-stepper-value', + y_align: Clutter.ActorAlign.CENTER, + }), + ); + const stepper = new St.BoxLayout({ style_class: 'aurora-devtool-stepper' }); + const decrease = createDevToolButton( + '−', + () => { + const minimum = option.min === undefined ? value - 1 : option.min; + this._settings.setInt(key, Math.max(minimum, value - 1)); + this._callbacks.rebuild(); + }, + undefined, + _('Decrease %s').format(title), + false, + undefined, + `decrease-${key}`, + ); + decrease.add_style_class_name('aurora-devtool-stepper-button'); + stepper.add_child(decrease); + const increase = createDevToolButton( + '+', + () => { + const maximum = option.max === undefined ? value + 1 : option.max; + this._settings.setInt(key, Math.min(maximum, value + 1)); + this._callbacks.rebuild(); + }, + undefined, + _('Increase %s').format(title), + false, + undefined, + `increase-${key}`, + ); + increase.add_style_class_name('aurora-devtool-stepper-button'); + stepper.add_child(increase); + control.add_child(stepper); + if (option.resettable) { + const reset = new St.Button({ + child: new St.Icon({ icon_name: 'edit-undo-symbolic', icon_size: 16 }), + style_class: 'button aurora-devtool-header-button aurora-devtool-stepper-reset', + can_focus: true, + accessible_name: _('Reset'), + name: `aurora-devtool-focus-Reset ${key}`, + }); + reset.connect('clicked', () => { + this._settings.getRawSettings().reset(key); + this._callbacks.rebuild(); + }); + control.add_child(reset); + } + return control; + } + + private _buildShortcut(key: string): St.Widget { + const shortcut = this._settings.getStrv(key)[0] || ''; + let capturing = false; + const label = new St.Label({ text: shortcut || _('Disabled') }); + const content = new St.BoxLayout({ style_class: 'aurora-devtool-button-content' }); + content.add_child(label); + const button = new St.Button({ + child: content, + style_class: 'button aurora-devtool-button aurora-devtool-kbd', + can_focus: true, + accessible_name: _('Keyboard shortcut'), + name: `aurora-devtool-focus-shortcut-${key}`, + }); + button.connect('clicked', () => { + capturing = !capturing; + label.text = capturing ? _('Press shortcut…') : shortcut || _('Disabled'); + if (capturing) button.grab_key_focus(); + }); + button.connect('event', (_actor, event) => { + if (!capturing || event.type() !== Clutter.EventType.KEY_PRESS) + return Clutter.EVENT_PROPAGATE; + + const keyval = event.get_key_symbol(); + if (keyval === Clutter.KEY_Escape) { + this._callbacks.rebuild(); + return Clutter.EVENT_STOP; + } + if (keyval === Clutter.KEY_BackSpace || keyval === Clutter.KEY_Delete) { + this._settings.setStrv(key, []); + this._callbacks.rebuild(); + return Clutter.EVENT_STOP; + } + const supportedModifiers = + Clutter.ModifierType.CONTROL_MASK | + Clutter.ModifierType.MOD1_MASK | + Clutter.ModifierType.SHIFT_MASK | + Clutter.ModifierType.SUPER_MASK; + const modifiers = event.get_state() & supportedModifiers; + if (modifiers === 0 || MODIFIER_KEYSYMS.has(keyval)) return Clutter.EVENT_STOP; + + const accelerator = Meta.accelerator_name(modifiers, keyval); + if (!accelerator) return Clutter.EVENT_STOP; + + this._settings.setStrv(key, [accelerator]); + this._callbacks.rebuild(); + return Clutter.EVENT_STOP; + }); + return button; + } + + private _buildCommandEditor(optionKey: string, editor: CommandEditor): St.Widget { + const group = createDevToolGroup(editor.index === null ? _('Add Command') : _('Edit Command')); + const name = new St.Entry({ + text: editor.label, + hint_text: _('Name'), + accessible_name: _('Name'), + can_focus: true, + style_class: 'aurora-devtool-entry', + x_expand: true, + }); + const command = new St.Entry({ + text: editor.command, + hint_text: _('Command'), + accessible_name: _('Command'), + can_focus: true, + style_class: 'aurora-devtool-entry', + x_expand: true, + }); + const error = new St.Label({ + style_class: 'aurora-devtool-error', + accessible_role: Atk.Role.NOTIFICATION, + }); + group.list.add_child(createDevToolRow(_('Name'), _('Shown in the Aurora menu'), name)); + group.list.add_child(createDevToolRow(_('Command'), _('Executed when selected'), command)); + + const actions = new St.BoxLayout({ style_class: 'aurora-devtool-inline-controls' }); + actions.add_child( + createDevToolButton(_('Cancel'), () => { + this._commandEditor = null; + this._callbacks.rebuild(); + }), + ); + actions.add_child( + createDevToolButton( + _('Save'), + () => { + const label = name.get_text().trim(); + const value = command.get_text().trim(); + if (!label || !value || label.includes('|')) { + error.text = + !label || !value + ? _('Name and command are required.') + : _('Name cannot contain “|”.'); + return; + } + + const serialized = serializeCustomCommand({ label, command: value }); + const next = this._settings.getStrv(optionKey); + if (editor.index === null) next.push(serialized); + else { + if (next[editor.index] !== editor.original) { + error.text = _('Command list changed. Reopen this command before saving.'); + return; + } + next[editor.index] = serialized; + } + this._settings.setStrv(optionKey, next); + this._commandEditor = null; + this._callbacks.rebuild(); + }, + undefined, + _('Save'), + false, + 'suggested', + ), + ); + + const footer = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-group-footer', + x_expand: true, + }); + footer.add_child(error); + footer.add_child(actions); + group.container.add_child(footer); + return group.container; + } + + private _buildNavigationRow( + title: string, + subtitle: string, + onClick: () => void, + iconName?: string, + focusName?: string, + ): St.Button { + const content = new St.BoxLayout({ + style_class: 'aurora-devtool-navigation-content', + x_expand: true, + }); + if (iconName) + content.add_child( + createIcon(iconName, { + icon_size: 18, + style_class: 'aurora-devtool-row-icon', + }), + ); + content.add_child(this._buildRowLabels(title, subtitle)); + content.add_child( + new St.Icon({ + icon_name: 'go-next-symbolic', + icon_size: 16, + style_class: 'aurora-devtool-chevron', + y_align: Clutter.ActorAlign.CENTER, + }), + ); + const button = new St.Button({ + child: content, + style_class: 'button aurora-devtool-row aurora-devtool-list-button', + can_focus: true, + x_expand: true, + accessible_name: title, + name: focusName || null, + }); + button.connect('clicked', onClick); + return button; + } + + private _buildRowLabels(title: string, subtitle: string): St.Widget { + const labels = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-row-labels', + x_expand: true, + }); + labels.add_child(new St.Label({ text: title, style_class: 'aurora-devtool-row-title' })); + const subtitleLabel = new St.Label({ + text: subtitle, + style_class: 'aurora-devtool-row-subtitle', + x_expand: true, + }); + subtitleLabel.clutter_text.line_wrap = true; + subtitleLabel.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; + subtitleLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + labels.add_child(subtitleLabel); + return labels; + } + + private _disableActor(actor: Clutter.Actor): void { + actor.opacity = 128; + const disable = (child: Clutter.Actor): void => { + child.reactive = false; + if (child instanceof St.Widget) child.can_focus = false; + for (const descendant of child.get_children()) disable(descendant); + }; + disable(actor); + } +} diff --git a/src/dev/devToolUi.ts b/src/dev/devToolUi.ts index 3b44ffc..6ad39f2 100644 --- a/src/dev/devToolUi.ts +++ b/src/dev/devToolUi.ts @@ -1,5 +1,17 @@ import St from '@girs/st-18'; import Clutter from '@girs/clutter-18'; +import Pango from '@girs/pango-1.0'; +import * as PopupMenu from '@girs/gnome-shell/ui/popupMenu'; + +import { gettext as _ } from '~/shared/i18n.ts'; +import { createIcon } from '~/shared/icons.ts'; + +export type DevToolButtonVariant = 'suggested' | 'destructive' | 'flat'; + +export type DevToolGroup = { + container: St.BoxLayout; + list: St.BoxLayout; +}; export function createDevToolModulePanel(): St.BoxLayout { return new St.BoxLayout({ @@ -35,12 +47,146 @@ export function createDevToolActionRow(): St.BoxLayout { }); } +export function createDevToolGroup(title?: string, description?: string): DevToolGroup { + const container = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-group', + x_expand: true, + }); + if (title) { + container.add_child( + new St.Label({ + text: title, + style_class: 'aurora-devtool-group-title', + }), + ); + } + if (description) { + const descriptionLabel = new St.Label({ + text: description, + style_class: 'aurora-devtool-group-description', + x_expand: true, + }); + descriptionLabel.clutter_text.line_wrap = true; + container.add_child(descriptionLabel); + } + + const list = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-group-list', + x_expand: true, + }); + container.add_child(list); + return { container, list }; +} + +export function createDevToolRow( + title: string, + subtitle = '', + suffix?: St.Widget, + iconName?: string, + activatable = false, +): St.BoxLayout { + const row = new St.BoxLayout({ + style_class: activatable ? 'aurora-devtool-row activatable' : 'aurora-devtool-row', + x_expand: true, + reactive: activatable, + track_hover: activatable, + }); + if (iconName) + row.add_child( + new St.Icon({ + icon_name: iconName, + icon_size: 18, + style_class: 'aurora-devtool-row-icon', + }), + ); + + const labels = new St.BoxLayout({ + orientation: Clutter.Orientation.VERTICAL, + style_class: 'aurora-devtool-row-labels', + x_expand: true, + }); + labels.add_child(new St.Label({ text: title, style_class: 'aurora-devtool-row-title' })); + if (subtitle) { + const subtitleLabel = new St.Label({ + text: subtitle, + style_class: 'aurora-devtool-row-subtitle', + x_expand: true, + }); + subtitleLabel.clutter_text.line_wrap = true; + subtitleLabel.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; + subtitleLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + labels.add_child(subtitleLabel); + } + row.add_child(labels); + if (suffix) row.add_child(suffix); + return row; +} + +export function createDevToolSwitch( + active: boolean, + onToggle: (active: boolean) => void, + accessibleName: string, + focusId = accessibleName, +): St.Button { + const control = new PopupMenu.Switch(active); + control.reactive = false; + const button = new St.Button({ + child: control, + style_class: 'button aurora-devtool-switch-button', + can_focus: true, + toggle_mode: true, + checked: active, + name: `aurora-devtool-focus-${focusId}`, + accessible_name: `${accessibleName}: ${active ? _('On') : _('Off')}`, + }); + button.connect('clicked', () => control.toggle()); + control.connect('notify::state', () => { + button.checked = control.state; + button.accessible_name = `${accessibleName}: ${control.state ? _('On') : _('Off')}`; + onToggle(control.state); + }); + return button; +} + +export function createDevToolButton( + label: string, + onClick: () => void, + iconName?: string, + accessibleName = label, + active = false, + variant?: DevToolButtonVariant, + focusId = accessibleName, + toggleMode = false, +): St.Button { + const content = new St.BoxLayout({ style_class: 'aurora-devtool-button-content' }); + if (iconName) content.add_child(createIcon(iconName, { icon_size: 16 })); + content.add_child(new St.Label({ text: label })); + const button = new St.Button({ + child: content, + style_class: variant + ? `button aurora-devtool-button ${variant}` + : 'button aurora-devtool-button', + can_focus: true, + accessible_name: accessibleName, + name: `aurora-devtool-focus-${focusId}`, + toggle_mode: toggleMode, + checked: active, + }); + button.connect('clicked', onClick); + return button; +} + export function createDevToolActionButton( iconName: string, label: string, onClick: () => void, disabled = false, active = false, + variant?: DevToolButtonVariant, + focusId = label, + toggleMode = false, ): St.Button { const content = new St.BoxLayout({ style_class: 'aurora-devtool-action-content', @@ -57,13 +203,18 @@ export function createDevToolActionButton( child: content, style_class: active ? 'button aurora-devtool-action-button active' - : 'button aurora-devtool-action-button', + : variant + ? `button aurora-devtool-action-button ${variant}` + : 'button aurora-devtool-action-button', can_focus: !disabled, reactive: !disabled, x_expand: true, accessible_name: label, + name: `aurora-devtool-focus-${focusId}`, + toggle_mode: toggleMode, + checked: active, }); - if (disabled) button.opacity = 120; + if (disabled) button.opacity = 115; button.connect('clicked', onClick); return button; } diff --git a/src/dev/dockDevTool.ts b/src/dev/dockDevTool.ts index 5bf1d20..819426b 100644 --- a/src/dev/dockDevTool.ts +++ b/src/dev/dockDevTool.ts @@ -61,7 +61,14 @@ export class DockDevTool { const firstRow = createDevToolActionRow(); firstRow.add_child( - createDevToolActionButton('go-up-symbolic', 'Reveal All', () => this.revealAll(), !dock), + createDevToolActionButton( + 'go-up-symbolic', + 'Reveal All', + () => this.revealAll(), + !dock, + false, + 'suggested', + ), ); firstRow.add_child( createDevToolActionButton('go-down-symbolic', 'Hide All', () => this.hideAll(), !dock), @@ -83,6 +90,9 @@ export class DockDevTool { `Always Show: ${dock?.alwaysShow ? 'On' : 'Off'}`, () => this.toggleAlwaysShow(), !dock, + false, + undefined, + 'dock-always-show', ), ); panel.add_child(secondRow); @@ -237,13 +247,25 @@ export class DockDevTool { const actions = createDevToolActionRow(); actions.add_child( - createDevToolActionButton('go-up-symbolic', 'Show', () => - this.showMonitor(binding.monitorIndex), + createDevToolActionButton( + 'go-up-symbolic', + 'Show', + () => this.showMonitor(binding.monitorIndex), + false, + false, + undefined, + `dock-monitor-${binding.monitorIndex}-show`, ), ); actions.add_child( - createDevToolActionButton('go-down-symbolic', 'Hide', () => - this.hideMonitor(binding.monitorIndex), + createDevToolActionButton( + 'go-down-symbolic', + 'Hide', + () => this.hideMonitor(binding.monitorIndex), + false, + false, + undefined, + `dock-monitor-${binding.monitorIndex}-hide`, ), ); actions.add_child( @@ -252,6 +274,9 @@ export class DockDevTool { 'Hot Area', () => this.triggerMonitorHotArea(binding.monitorIndex), !binding.hotArea, + false, + undefined, + `dock-monitor-${binding.monitorIndex}-hot-area`, ), ); panel.add_child(actions); diff --git a/src/dev/generalDevTool.ts b/src/dev/generalDevTool.ts index a82cd63..90c99f4 100644 --- a/src/dev/generalDevTool.ts +++ b/src/dev/generalDevTool.ts @@ -1,28 +1,62 @@ import '@girs/gjs'; import type St from '@girs/st-18'; +import GLib from '@girs/glib-2.0'; +import * as Config from '@girs/gnome-shell/misc/config'; +import * as Main from '@girs/gnome-shell/ui/main'; import { createDevToolActionButton, createDevToolActionRow, + createDevToolGroup, createDevToolModulePanel, + createDevToolRow, createDevToolSummary, } from '~/dev/devToolUi.ts'; +import { gettext as _ } from '~/shared/i18n.ts'; export class GeneralDevTool { readonly key = 'general'; readonly title = 'General'; readonly iconName = 'emblem-system-symbolic'; - constructor(private readonly _openPreferences: () => void) {} + constructor( + private readonly _openPreferences: () => void, + private readonly _closeDevTool: () => void, + private readonly _versionName: string, + private readonly _enabledModules: () => number, + ) {} buildPanel(): St.Widget { const panel = createDevToolModulePanel(); - panel.add_child(createDevToolSummary(this.iconName, 'Extension tools')); + panel.add_child(createDevToolSummary(this.iconName, `Aurora Shell ${this._versionName}`)); + + const environment = createDevToolGroup(_('Environment')); + environment.list.add_child(createDevToolRow(_('GNOME Shell'), Config.PACKAGE_VERSION)); + environment.list.add_child( + createDevToolRow(_('Session'), GLib.getenv('XDG_SESSION_TYPE') || _('Unknown')), + ); + environment.list.add_child( + createDevToolRow(_('Enabled modules'), String(this._enabledModules())), + ); + panel.add_child(environment.container); const row = createDevToolActionRow(); row.add_child( - createDevToolActionButton('emblem-system-symbolic', 'Open Settings', this._openPreferences), + createDevToolActionButton( + 'emblem-system-symbolic', + _('Open Settings'), + this._openPreferences, + false, + false, + 'suggested', + ), + ); + row.add_child( + createDevToolActionButton('utilities-terminal-symbolic', _('Looking Glass'), () => { + this._closeDevTool(); + Main.createLookingGlass().open(); + }), ); panel.add_child(row); diff --git a/src/dev/trayIconsDevTool.ts b/src/dev/trayIconsDevTool.ts index bb6fded..bf565e2 100644 --- a/src/dev/trayIconsDevTool.ts +++ b/src/dev/trayIconsDevTool.ts @@ -6,10 +6,13 @@ import * as Main from '@girs/gnome-shell/ui/main'; import { createDevToolActionButton, createDevToolActionRow, + createDevToolGroup, createDevToolModulePanel, + createDevToolRow, createDevToolSummary, } from '~/dev/devToolUi.ts'; import type { TrayItem } from '~/desktop/trayIcons/trayState.ts'; +import { gettext as _ } from '~/shared/i18n.ts'; const TRAY_ID = 'aurora-tray-icons'; const FAKE_ICON_NAMES = [ @@ -66,6 +69,9 @@ export class TrayIconsDevTool { () => this.toggleAttentionOnAll(), !hasFakeItems, this._attentionEnabled, + undefined, + 'tray-attention', + true, ), ); panel.add_child(primaryRow); @@ -81,6 +87,20 @@ export class TrayIconsDevTool { ); panel.add_child(secondaryRow); + if (hasFakeItems) { + const preview = createDevToolGroup(_('Fake tray preview')); + for (const item of this._fakeItems.values()) + preview.list.add_child( + createDevToolRow( + item.tooltip || item.id, + item.status || _('Active'), + undefined, + typeof item.icon === 'string' ? item.icon : undefined, + ), + ); + panel.add_child(preview.container); + } + if (!tray) { for (const row of [primaryRow, secondaryRow]) { for (const button of row.get_children()) { diff --git a/src/dev/weatherClockDevTool.ts b/src/dev/weatherClockDevTool.ts index 89f4e31..3994880 100644 --- a/src/dev/weatherClockDevTool.ts +++ b/src/dev/weatherClockDevTool.ts @@ -10,6 +10,7 @@ import { } from '~/dev/devToolUi.ts'; import type { Module } from '~/module.ts'; import { WeatherClock } from '~/panel/clock/weatherClock/weatherClock.ts'; +import { gettext as _ } from '~/shared/i18n.ts'; const DEVTOOL_SOURCE_KEY = 'aurora-devtool'; @@ -18,6 +19,9 @@ export class WeatherClockDevTool { readonly title = 'Weather Clock'; readonly iconName = 'weather-clear-symbolic'; + private _preview = _('No fake weather'); + private _previewOwner: WeatherClock | null = null; + constructor( private readonly _getModule: (key: string) => Module | null, private readonly _requestMenuRebuild: () => void, @@ -25,6 +29,10 @@ export class WeatherClockDevTool { buildPanel(): St.Widget { const weatherClock = this._getWeatherClock(); + if (weatherClock !== this._previewOwner) { + this._preview = _('No fake weather'); + this._previewOwner = null; + } const panel = createDevToolModulePanel(); panel.add_child( createDevToolSummary( @@ -34,6 +42,7 @@ export class WeatherClockDevTool { : 'Weather Clock disabled', ), ); + panel.add_child(createDevToolSummary('weather-severe-alerts-symbolic', this._preview)); const firstRow = createDevToolActionRow(); firstRow.add_child( @@ -92,37 +101,41 @@ export class WeatherClockDevTool { } showSunny(): boolean { - return this._setSnapshot({ - iconName: 'weather-clear-symbolic', - temperature: '24°', - description: 'Clear sky', - }); + return this._setSnapshot( + { + iconName: 'weather-clear-symbolic', + temperature: '24°', + description: 'Clear sky', + }, + '24° · Clear sky', + ); } showRain(): boolean { - return this._setSnapshot({ - iconName: 'weather-showers-symbolic', - temperature: '18°', - description: 'Rain showers', - }); + return this._setSnapshot( + { + iconName: 'weather-showers-symbolic', + temperature: '18°', + description: 'Rain showers', + }, + '18° · Rain showers', + ); } showOffline(): boolean { - return this._setSnapshot({ - hasConnectivity: false, - }); + return this._setSnapshot({ hasConnectivity: false }, _('Offline')); } showUnavailable(): boolean { - return this._setSnapshot({ - available: false, - }); + return this._setSnapshot({ available: false }, _('Weather service unavailable')); } clearWeather(): void { const weatherClock = this._getWeatherClock(); if (weatherClock) weatherClock.clearWeatherSnapshot(DEVTOOL_SOURCE_KEY); + this._preview = _('No fake weather'); + this._previewOwner = null; this._requestMenuRebuild(); } @@ -133,11 +146,16 @@ export class WeatherClockDevTool { return weatherClock.isVisible; } - private _setSnapshot(snapshot: Parameters[1]): boolean { + private _setSnapshot( + snapshot: Parameters[1], + preview: string, + ): boolean { const weatherClock = this._getWeatherClock(); if (!weatherClock) return false; weatherClock.setWeatherSnapshot(DEVTOOL_SOURCE_KEY, snapshot); + this._preview = preview; + this._previewOwner = weatherClock; this._requestMenuRebuild(); return true; } diff --git a/src/device/device.ts b/src/device/device.ts index 16e5e16..b3bad9e 100644 --- a/src/device/device.ts +++ b/src/device/device.ts @@ -1,7 +1,7 @@ import Clutter from '@girs/clutter-18'; import Gio from '@girs/gio-2.0'; -import GLib from '@girs/glib-2.0'; import * as Main from '@girs/gnome-shell/ui/main'; +import Meta from '@girs/meta-18'; import type { RuntimeCapability } from '~/module.ts'; import { @@ -26,8 +26,6 @@ const SENSOR_DBUS_NAME = 'net.hadess.SensorProxy'; const SENSOR_PATH = '/net/hadess/SensorProxy'; const SENSOR_IFACE = 'net.hadess.SensorProxy'; const MODEM_MANAGER_NAME = 'org.freedesktop.ModemManager1'; -const DISPLAY_CONFIG_NAME = 'org.gnome.Mutter.DisplayConfig'; -const DISPLAY_CONFIG_PATH = '/org/gnome/Mutter/DisplayConfig'; export class DefaultDeviceService implements DeviceService { private readonly _listeners = new Set(); @@ -35,22 +33,43 @@ export class DefaultDeviceService implements DeviceService { private _monitorChangedId: number | null; private _deviceAddedId: number | null; private _deviceRemovedId: number | null; - private _nameWatchIds: number[] | null; + private _sensorWatchId: number | null = null; + private _modemManagerWatchId: number | null = null; + private _sensorOwner: string | null = null; + private _modemManagerOwned = false; + private _sensorProxyRequest: Gio.Cancellable | null = null; + private _sensorProxy: Gio.DBusProxy | null = null; + private _sensorPropertiesChangedId: number | null = null; + private _refreshLaterId: number | null = null; private _snapshot: DeviceSnapshot; constructor() { this._snapshot = this._detect(); - this._monitorChangedId = Main.layoutManager.connect('monitors-changed', () => this.refresh()); - this._deviceAddedId = this._seat.connect('device-added', () => this.refresh()); - this._deviceRemovedId = this._seat.connect('device-removed', () => this.refresh()); - this._nameWatchIds = [SENSOR_DBUS_NAME, MODEM_MANAGER_NAME].map((name) => - Gio.bus_watch_name( - Gio.BusType.SYSTEM, - name, - Gio.BusNameWatcherFlags.NONE, - () => this.refresh(), - () => this.refresh(), - ), + this._monitorChangedId = Main.layoutManager.connect('monitors-changed', () => + this._queueRefresh(), + ); + this._deviceAddedId = this._seat.connect('device-added', () => this._queueRefresh()); + this._deviceRemovedId = this._seat.connect('device-removed', () => this._queueRefresh()); + + this._sensorWatchId = Gio.bus_watch_name( + Gio.BusType.SYSTEM, + SENSOR_DBUS_NAME, + Gio.BusNameWatcherFlags.NONE, + (_connection, _name, owner) => this._sensorAppeared(owner), + () => this._sensorVanished(), + ); + this._modemManagerWatchId = Gio.bus_watch_name( + Gio.BusType.SYSTEM, + MODEM_MANAGER_NAME, + Gio.BusNameWatcherFlags.NONE, + () => { + this._modemManagerOwned = true; + this._queueRefresh(); + }, + () => { + this._modemManagerOwned = false; + this._queueRefresh(); + }, ); } @@ -63,7 +82,8 @@ export class DefaultDeviceService implements DeviceService { } refresh(): DeviceSnapshot { - if (!this._nameWatchIds) return this._snapshot; + if (this._sensorWatchId === null || this._modemManagerWatchId === null) return this._snapshot; + const next = this._detect(); if (!sameDeviceSnapshot(this._snapshot, next)) { this._snapshot = next; @@ -78,15 +98,32 @@ export class DefaultDeviceService implements DeviceService { } destroy(): void { - if (!this._nameWatchIds) return; + if (this._sensorWatchId === null || this._modemManagerWatchId === null) return; + + if (this._refreshLaterId !== null) { + global.compositor.get_laters().remove(this._refreshLaterId); + this._refreshLaterId = null; + } + + Gio.bus_unwatch_name(this._sensorWatchId); + Gio.bus_unwatch_name(this._modemManagerWatchId); + this._sensorWatchId = null; + this._modemManagerWatchId = null; + + this._sensorOwner = null; + this._modemManagerOwned = false; + if (this._sensorProxyRequest) { + this._sensorProxyRequest.cancel(); + this._sensorProxyRequest = null; + } + this._clearSensorProxy(); + if (this._monitorChangedId !== null) Main.layoutManager.disconnect(this._monitorChangedId); if (this._deviceAddedId !== null) this._seat.disconnect(this._deviceAddedId); if (this._deviceRemovedId !== null) this._seat.disconnect(this._deviceRemovedId); - for (const id of this._nameWatchIds) Gio.bus_unwatch_name(id); this._monitorChangedId = null; this._deviceAddedId = null; this._deviceRemovedId = null; - this._nameWatchIds = null; this._listeners.clear(); } @@ -127,71 +164,31 @@ export class DefaultDeviceService implements DeviceService { const capabilities = new Set(); if (hasTouch) capabilities.add('touch'); if (this._hasBacklight()) capabilities.add('backlight'); - if (this._hasDBusNameOwner(MODEM_MANAGER_NAME)) capabilities.add('cellular'); + if (this._modemManagerOwned) capabilities.add('cellular'); - const sensorProxy = this._getSensorProxy(); - if (sensorProxy) { - if (this._getBooleanProperty(sensorProxy, 'HasAccelerometer')) + if (this._sensorProxy) { + if (this._getBooleanProperty(this._sensorProxy, 'HasAccelerometer')) capabilities.add('accelerometer'); - if (this._getBooleanProperty(sensorProxy, 'HasAmbientLight')) + if (this._getBooleanProperty(this._sensorProxy, 'HasAmbientLight')) capabilities.add('light-sensor'); - if (this._getBooleanProperty(sensorProxy, 'HasProximity')) + if (this._getBooleanProperty(this._sensorProxy, 'HasProximity')) capabilities.add('proximity-sensor'); } return capabilities; } private _detectBuiltinMonitorIndices(): ReadonlySet { - try { - const result = Gio.DBus.session.call_sync( - DISPLAY_CONFIG_NAME, - DISPLAY_CONFIG_PATH, - DISPLAY_CONFIG_NAME, - 'GetCurrentState', - null, - null, - Gio.DBusCallFlags.NONE, - 200, - null, - ); - return this._parseBuiltinMonitorIndices(result?.recursiveUnpack()); - } catch { - return new Set(); - } - } - - private _parseBuiltinMonitorIndices(state: unknown): ReadonlySet { - if (!Array.isArray(state) || !Array.isArray(state[1]) || !Array.isArray(state[2])) - return new Set(); - - const builtinConnectors = new Set(); - for (const physical of state[1]) { - if (!Array.isArray(physical) || !Array.isArray(physical[0])) continue; - const connector = physical[0][0]; - const properties = physical[2]; - if ( - typeof connector === 'string' && - this._isRecord(properties) && - properties['is-builtin'] === true - ) - builtinConnectors.add(connector); - } - + const logicalMonitors = global.backend.get_monitor_manager().get_logical_monitors(); const indices = new Set(); - for (const [index, logical] of state[2].entries()) { - if (!Array.isArray(logical) || !Array.isArray(logical[5])) continue; - const containsBuiltin = logical[5].some( - (spec) => Array.isArray(spec) && builtinConnectors.has(spec[0]), - ); - if (containsBuiltin) indices.add(index); + if (!logicalMonitors) return indices; + + for (const logicalMonitor of logicalMonitors) { + if (logicalMonitor.get_monitors().some((monitor) => monitor.is_builtin())) + indices.add(logicalMonitor.get_number()); } return indices; } - private _isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); - } - private _hasBacklight(): boolean { try { const dir = Gio.File.new_for_path('/sys/class/backlight'); @@ -210,43 +207,75 @@ export class DefaultDeviceService implements DeviceService { } } - private _getSensorProxy(): Gio.DBusProxy | null { - if (!this._hasDBusNameOwner(SENSOR_DBUS_NAME)) return null; - try { - return Gio.DBusProxy.new_for_bus_sync( - Gio.BusType.SYSTEM, - Gio.DBusProxyFlags.NONE, - null, - SENSOR_DBUS_NAME, - SENSOR_PATH, - SENSOR_IFACE, - null, - ); - } catch { - return null; - } + private _sensorAppeared(owner: string): void { + if (this._sensorWatchId === null) return; + + this._sensorOwner = owner; + if (this._sensorProxyRequest) this._sensorProxyRequest.cancel(); + this._sensorProxyRequest = new Gio.Cancellable(); + this._clearSensorProxy(); + this._queueRefresh(); + + const sensorProxyRequest = this._sensorProxyRequest; + Gio.DBusProxy.new_for_bus( + Gio.BusType.SYSTEM, + Gio.DBusProxyFlags.NONE, + null, + owner, + SENSOR_PATH, + SENSOR_IFACE, + sensorProxyRequest, + (_source, result) => { + let proxy: Gio.DBusProxy; + try { + proxy = Gio.DBusProxy.new_for_bus_finish(result); + } catch { + if (this._sensorProxyRequest === sensorProxyRequest) this._sensorProxyRequest = null; + return; + } + + if (this._sensorProxyRequest !== sensorProxyRequest || this._sensorOwner !== owner) return; + + this._sensorProxyRequest = null; + this._sensorProxy = proxy; + this._sensorPropertiesChangedId = proxy.connect('g-properties-changed', () => + this._queueRefresh(), + ); + this._queueRefresh(); + }, + ); } - private _getBooleanProperty(proxy: Gio.DBusProxy, propertyName: string): boolean { - return Boolean(proxy.get_cached_property(propertyName)?.unpack()); + private _sensorVanished(): void { + if (this._sensorWatchId === null) return; + + this._sensorOwner = null; + if (this._sensorProxyRequest) { + this._sensorProxyRequest.cancel(); + this._sensorProxyRequest = null; + } + this._clearSensorProxy(); + this._queueRefresh(); } - private _hasDBusNameOwner(name: string): boolean { - try { - const result = Gio.DBus.system.call_sync( - 'org.freedesktop.DBus', - '/org/freedesktop/DBus', - 'org.freedesktop.DBus', - 'NameHasOwner', - new GLib.Variant('(s)', [name]), - new GLib.VariantType('(b)'), - Gio.DBusCallFlags.NONE, - 200, - null, - ); - return Boolean(result?.get_child_value(0).unpack()); - } catch { + private _queueRefresh(): void { + if (this._sensorWatchId === null || this._refreshLaterId !== null) return; + + this._refreshLaterId = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { + this._refreshLaterId = null; + this.refresh(); return false; - } + }); + } + + private _clearSensorProxy(): void { + if (this._sensorProxy && this._sensorPropertiesChangedId !== null) + this._sensorProxy.disconnect(this._sensorPropertiesChangedId); + this._sensorProxy = null; + this._sensorPropertiesChangedId = null; + } + + private _getBooleanProperty(proxy: Gio.DBusProxy, propertyName: string): boolean { + return Boolean(proxy.get_cached_property(propertyName)?.unpack()); } } diff --git a/src/dock/dock.ts b/src/dock/dock.ts index d5392df..3a34b58 100644 --- a/src/dock/dock.ts +++ b/src/dock/dock.ts @@ -770,8 +770,9 @@ export class Dock extends Module { private _enableHotAreaWhenDockHidden(binding: ManagedDockBinding): void { this._clearHotAreaEnable(binding); + // Polls while the dash stays visible; idle priority so it can never starve redraws. binding.hotAreaEnable.replace(() => - GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { + GLib.timeout_add(GLib.PRIORITY_DEFAULT_IDLE, 100, () => { if (binding.dash.visible) return GLib.SOURCE_CONTINUE; binding.hotAreaEnable.complete(); diff --git a/src/dock/intellihide.ts b/src/dock/intellihide.ts index b171cf1..bbebf00 100644 --- a/src/dock/intellihide.ts +++ b/src/dock/intellihide.ts @@ -43,9 +43,11 @@ const OVERLAP_WINDOW_TYPES: Meta.WindowType[] = [ Meta.WindowType.SPLASHSCREEN, ]; -const TRACKED_WINDOW_SIGNALS = [ - 'position-changed', - 'size-changed', +/** High-frequency signals during interactive drag/resize — coalesced per frame. */ +const GEOMETRY_SIGNALS = ['position-changed', 'size-changed'] as const; + +/** Discrete state transitions — checked immediately. */ +const STATE_SIGNALS = [ 'workspace-changed', 'notify::fullscreen', 'notify::main-monitor', @@ -53,7 +55,6 @@ const TRACKED_WINDOW_SIGNALS = [ 'notify::maximized-vertically', 'notify::minimized', 'notify::on-all-workspaces', - 'notify::title', ] as const; export enum OverlapStatus { @@ -77,6 +78,9 @@ export const DockIntellihide = GObject.registerClass( declare private _trackedWindowActors: Set; declare private _trackedWindows: Set; declare private _queuedRefreshes: ManagedTimeoutBatch; + private _dirty = false; + private _dirtyReason = 'unspecified'; + private _dirtyLaterId: number | null = null; override _init(monitorIndex: number) { super._init(); @@ -150,6 +154,10 @@ export const DockIntellihide = GObject.registerClass( destroy(): void { this._cancelPendingStatus(); + if (this._dirtyLaterId !== null) { + global.compositor.get_laters().remove(this._dirtyLaterId); + this._dirtyLaterId = null; + } this._lifecycle.dispose(); this._clearTrackedWindows(); global.display.disconnectObject(this); @@ -159,7 +167,43 @@ export const DockIntellihide = GObject.registerClass( Main.overview.disconnectObject(this); } + /** + * Coalesce high-frequency window geometry signals into a single + * `_checkOverlap` per render frame via `Meta.LaterType.BEFORE_REDRAW`. + * + * During interactive window drag/resize, `position-changed`, + * `size-changed`, and `notify::allocation` fire on every pixel of + * movement. Without coalescing, each fires a full `_checkOverlap` + * (enumerating all window actors, filtering, querying geometries) — + * hundreds of times per second. + * + * `BEFORE_REDRAW` aligns the check with Mutter's frame boundary, + * matching how GNOME Shell coalesces region updates in `layout.js`. + */ + private _markDirty(reason: string): void { + this._dirtyReason = reason; + if (this._dirty) return; + this._dirty = true; + + if (this._dirtyLaterId !== null) return; + this._dirtyLaterId = global.compositor.get_laters().add(Meta.LaterType.BEFORE_REDRAW, () => { + this._dirtyLaterId = null; + if (this._dirty) { + this._dirty = false; + this._checkOverlap(this._dirtyReason); + } + return false; + }); + global.stage.queue_redraw(); + } + private _checkOverlap(reason = 'unspecified', force = false): void { + if (this._dirtyLaterId !== null) { + global.compositor.get_laters().remove(this._dirtyLaterId); + this._dirtyLaterId = null; + } + this._dirty = false; + if (!this._isMonitorValid()) return; if (Main.overview.visible) { @@ -365,7 +409,7 @@ export const DockIntellihide = GObject.registerClass( this._trackedWindowActors.add(actor); actor.connectObject( 'notify::allocation', - () => this._checkOverlap('window-allocation'), + () => this._markDirty('window-allocation'), 'destroy', () => this._trackedWindowActors.delete(actor), this, @@ -389,7 +433,10 @@ export const DockIntellihide = GObject.registerClass( private _connectTrackedWindow(win: Meta.Window): void { const signalArgs: any[] = []; - for (const signal of TRACKED_WINDOW_SIGNALS) { + for (const signal of GEOMETRY_SIGNALS) { + signalArgs.push(signal, () => this._markDirty(signal)); + } + for (const signal of STATE_SIGNALS) { signalArgs.push(signal, () => this._checkOverlap(signal)); } signalArgs.push('unmanaged', () => this._trackedWindows.delete(win)); diff --git a/src/extension.dev.ts b/src/extension.dev.ts index 723ac67..fe9e511 100644 --- a/src/extension.dev.ts +++ b/src/extension.dev.ts @@ -3,7 +3,7 @@ import '@girs/gjs'; import GLib from '@girs/glib-2.0'; import { Extension } from '@girs/gnome-shell/extensions/extension'; -import { logger } from '~/core/logger.ts'; +import { enableDebugLogging, logger } from '~/core/logger.ts'; import { ShellRuntime } from '~/core/shellRuntime.ts'; import { DevTool } from '~/dev/devTool.ts'; @@ -14,6 +14,8 @@ export default class AuroraShellDevelopmentExtension extends Extension { private _devTool: DevTool | null = null; override enable(): void { + enableDebugLogging(); + const runtime = new ShellRuntime(this); this._runtime = runtime; runtime.start(); @@ -25,6 +27,9 @@ export default class AuroraShellDevelopmentExtension extends Extension { this._devTool = new DevTool(context, { getModule: (key) => runtime.getModule(key), openPreferences: () => this.openPreferences(), + versionName: String( + this.metadata['version-name'] || this.metadata['version'] || 'Development', + ), }); this._devTool.enable(); } catch (error) { diff --git a/src/panel/clock/calendarReminders/calendarRemindersLogic.ts b/src/panel/clock/calendarReminders/calendarRemindersLogic.ts index bab2010..ce99457 100644 --- a/src/panel/clock/calendarReminders/calendarRemindersLogic.ts +++ b/src/panel/clock/calendarReminders/calendarRemindersLogic.ts @@ -20,6 +20,17 @@ export type CalendarPanelPresentation = { isInProgress: boolean; } | null; +export function removeCalendarEventsByPrefix( + eventsById: Map, + prefix: string, +): boolean { + let changed = false; + for (const id of eventsById.keys()) { + if (id.startsWith(prefix)) changed = eventsById.delete(id) || changed; + } + return changed; +} + const MAX_PANEL_TITLE_LENGTH = 24; function _deepUnpack(value: unknown): unknown { diff --git a/src/panel/clock/calendarReminders/calendarServerBackend.ts b/src/panel/clock/calendarReminders/calendarServerBackend.ts index b21251b..42d308f 100644 --- a/src/panel/clock/calendarReminders/calendarServerBackend.ts +++ b/src/panel/clock/calendarReminders/calendarServerBackend.ts @@ -6,18 +6,26 @@ import GLib from '@girs/glib-2.0'; import { logger } from '~/core/logger.ts'; import { LifecycleScope } from '~/core/lifecycleScope.ts'; -import { normalizeCalendarServerEvent, type CalendarEvent } from './calendarRemindersLogic.ts'; +import { + normalizeCalendarServerEvent, + removeCalendarEventsByPrefix, + type CalendarEvent, +} from './calendarRemindersLogic.ts'; const LOG_PREFIX = 'CalendarReminders'; const CALENDAR_SERVER_BUS_NAME = 'org.gnome.Shell.CalendarServer'; const CALENDAR_SERVER_OBJECT_PATH = '/org/gnome/Shell/CalendarServer'; const CALENDAR_SERVER_INTERFACE = 'org.gnome.Shell.CalendarServer'; +const DBUS_TIMEOUT_MS = 10_000; type EventsChangedCallback = (events: CalendarEvent[]) => void; export class CalendarServerBackend { private _proxy: Gio.DBusProxy | null = null; private _lifecycle: LifecycleScope | null = null; + private _cancellable: Gio.Cancellable | null = null; + private _requestCancellable: Gio.Cancellable | null = null; + private _refreshWindowHours: number | null = null; private _eventsById = new Map(); private _onEventsChanged: EventsChangedCallback; @@ -26,67 +34,143 @@ export class CalendarServerBackend { } start(): void { - if (this._proxy) return; + if (this._lifecycle) return; + + const lifecycle = new LifecycleScope(); + const cancellable = new Gio.Cancellable(); + const proxy = new Gio.DBusProxy({ + g_connection: Gio.DBus.session, + g_interface_name: CALENDAR_SERVER_INTERFACE, + g_name: CALENDAR_SERVER_BUS_NAME, + g_object_path: CALENDAR_SERVER_OBJECT_PATH, + }); + lifecycle.onDispose(() => cancellable.cancel()); + this._lifecycle = lifecycle; + this._cancellable = cancellable; + + void this._initializeProxy(proxy, lifecycle, cancellable); + } + + private async _initializeProxy( + proxy: Gio.DBusProxy, + lifecycle: LifecycleScope, + cancellable: Gio.Cancellable, + ): Promise { + let loaded = false; + try { - this._proxy = Gio.DBusProxy.new_for_bus_sync( - Gio.BusType.SESSION, - Gio.DBusProxyFlags.NONE, - null, - CALENDAR_SERVER_BUS_NAME, - CALENDAR_SERVER_OBJECT_PATH, - CALENDAR_SERVER_INTERFACE, - null, - ); + await proxy.init_async(GLib.PRIORITY_DEFAULT, cancellable); + loaded = true; } catch (e) { - logger.warn(`Failed to connect to CalendarServer: ${e}`, { prefix: LOG_PREFIX }); - this._proxy = null; - return; + if (this._lifecycle !== lifecycle) return; + + const timedOut = e instanceof GLib.Error && e.matches(Gio.DBusError, Gio.DBusError.TIMED_OUT); + if (!timedOut) { + logger.warn(`Failed to connect to CalendarServer: ${e}`, { prefix: LOG_PREFIX }); + this.stop(); + return; + } } - this._lifecycle = new LifecycleScope(); - this._lifecycle.connect(this._proxy, 'g-signal', (_proxy, _senderName, signalName, params) => { + if (this._lifecycle !== lifecycle) return; + + this._proxy = proxy; + lifecycle.connect(proxy, 'g-signal', (_proxy, _senderName, signalName, params) => { + if (this._lifecycle !== lifecycle) return; + if (signalName === 'EventsAddedOrUpdated') { - if (!this._proxy) return; const [rawEvents = []] = params.deepUnpack() as [unknown[]?]; this._onEventsAddedOrUpdated(rawEvents); } else if (signalName === 'EventsRemoved') { - if (!this._proxy) return; const [rawIds = []] = params.deepUnpack() as [string[]?]; this._onEventsRemoved(rawIds); + } else if (signalName === 'ClientDisappeared') { + const [sourceUid = ''] = params.deepUnpack() as [string?]; + this._removeMatching(sourceUid + '\n'); } }); + lifecycle.connect(proxy, 'notify::g-name-owner', () => { + if (this._lifecycle !== lifecycle) return; + + if (proxy.g_name_owner) this._handleServiceAppeared(lifecycle); + else this._handleServiceVanished(); + }); + + if (loaded) this._handleServiceAppeared(lifecycle); } stop(): void { + if (this._requestCancellable) this._requestCancellable.cancel(); + this._requestCancellable = null; this._lifecycle?.dispose(); this._lifecycle = null; + this._cancellable = null; this._proxy = null; + this._refreshWindowHours = null; this._eventsById.clear(); } refresh(windowHours: number): void { + if (!this._lifecycle) return; + + this._refreshWindowHours = windowHours; if (!this._proxy) return; + this._requestTimeRange(windowHours, this._lifecycle); + } + + private async _requestTimeRange(windowHours: number, lifecycle: LifecycleScope): Promise { + if (!this._proxy || !this._cancellable) return; + + const proxy = this._proxy; + if (this._requestCancellable) this._requestCancellable.cancel(); + const requestCancellable = new Gio.Cancellable(); + this._requestCancellable = requestCancellable; const sinceEpochSeconds = Math.floor(Date.now() / 1000) - 3600; const untilEpochSeconds = sinceEpochSeconds + Math.max(1, windowHours) * 3600; try { - this._proxy.call_sync( + await proxy.call( 'SetTimeRange', GLib.Variant.new('(xxb)', [sinceEpochSeconds, untilEpochSeconds, true]), Gio.DBusCallFlags.NONE, - -1, - null, + DBUS_TIMEOUT_MS, + requestCancellable, ); } catch (e) { + if (this._lifecycle !== lifecycle || requestCancellable.is_cancelled()) return; + logger.warn(`Failed to refresh CalendarServer events: ${e}`, { prefix: LOG_PREFIX }); + } finally { + if (this._requestCancellable === requestCancellable) this._requestCancellable = null; } } + private _handleServiceAppeared(lifecycle: LifecycleScope): void { + this._eventsById.clear(); + this._emitEventsChanged(); + + if (this._refreshWindowHours !== null) + this._requestTimeRange(this._refreshWindowHours, lifecycle); + } + + private _handleServiceVanished(): void { + this._eventsById.clear(); + this._emitEventsChanged(); + } + private _onEventsAddedOrUpdated(rawEvents: unknown[]): void { + const clearedRecurringEvents = new Set(); for (const rawEvent of rawEvents) { const event = normalizeCalendarServerEvent(rawEvent); if (!event) continue; + if (!event.id.endsWith('\n')) { + const parentId = event.id.slice(0, event.id.lastIndexOf('\n') + 1); + if (!clearedRecurringEvents.has(parentId)) { + clearedRecurringEvents.add(parentId); + this._removeMatching(parentId, false); + } + } this._eventsById.set(event.id, event); } @@ -94,11 +178,16 @@ export class CalendarServerBackend { } private _onEventsRemoved(rawIds: string[]): void { - for (const rawId of rawIds) this._eventsById.delete(String(rawId)); + for (const rawId of rawIds) this._removeMatching(String(rawId), false); this._emitEventsChanged(); } + private _removeMatching(prefix: string, emit = true): void { + const changed = removeCalendarEventsByPrefix(this._eventsById, prefix); + if (changed && emit) this._emitEventsChanged(); + } + private _emitEventsChanged(): void { if (!this._proxy) return; diff --git a/src/patches/iconWeaveInspector.ts b/src/patches/iconWeaveInspector.ts index 35bdf69..cd4252a 100644 --- a/src/patches/iconWeaveInspector.ts +++ b/src/patches/iconWeaveInspector.ts @@ -1,4 +1,4 @@ -import GioUnix from '@girs/giounix-2.0'; +import type 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'; @@ -7,9 +7,13 @@ 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 { + createIconWeaveCandidateMetadata, + scoreIconWeaveCandidate, + type IconWeaveCandidateMetadata, +} from './iconWeaveScoring.ts'; import type { NativeWindowAppResolver } from './iconWeavePatches.ts'; -import type { IconWeaveWindowRegistry } from './iconWeaveRegistry.ts'; +import { createIconWeaveResolutionKey, type IconWeaveWindowRegistry } from './iconWeaveRegistry.ts'; const WINDOW_INSPECT_DELAY_MS = 500; const MIN_MATCH_SCORE = 50; @@ -40,6 +44,7 @@ export class IconWeaveInspector { private _lifecycle = new LifecycleScope(); private _windowScopes = new Map(); private _titleScopes = new Map(); + private _candidateMetadata = new Map(); constructor(private _options: IconWeaveInspectorOptions) {} @@ -47,6 +52,9 @@ export class IconWeaveInspector { this._lifecycle.connect(global.display, 'window-created', (_display: any, window: any) => { this._schedule(window); }); + this._lifecycle.connect(Shell.AppSystem.get_default(), 'installed-changed', () => { + this._handleInstalledChanged(); + }); } destroy(): void { @@ -61,6 +69,7 @@ export class IconWeaveInspector { scope.dispose(); } this._windowScopes.clear(); + this._candidateMetadata.clear(); } private _schedule(window: any): void { @@ -95,10 +104,26 @@ export class IconWeaveInspector { ); } + private _handleInstalledChanged(): void { + this._candidateMetadata.clear(); + this._options.registry.clearResolutions(); + + const previousApps = new Set(); + for (const [window, app] of this._options.registry.mappings) { + previousApps.add(app); + this._options.registry.remove(window); + } + if (previousApps.size > 0) { + this._options.onMappingChanged(); + Shell.WindowTracker.get_default().emit('tracked-windows-changed'); + for (const app of previousApps) app.emit('windows-changed'); + } + + for (const window of this._windowScopes.keys()) this._matchWindow(window); + } + 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._options.registry.remove(window); this._titleScopes.get(window)?.dispose(); this._titleScopes.delete(window); @@ -163,18 +188,24 @@ export class IconWeaveInspector { if (wmClass.toLowerCase() === appId.toLowerCase()) return; const tracker = Shell.WindowTracker.get_default(); - const identity = wmClass || appId; + const title: string = window.get_title() || ''; + const resolutionKey = createIconWeaveResolutionKey(wmClass, appId, title); + + if (this._options.registry.hasResolved(resolutionKey)) { + const resolvedId = this._options.registry.getResolvedApp(resolutionKey); + if (resolvedId) { + const app = Shell.AppSystem.get_default().lookup_app(resolvedId); + if (app) { + this._applyMapping(window, app, tracker); + return; + } - if (this._options.registry.hasProcessed(identity)) { - const mappedApp = this._options.registry.findMappedApp(wmClass, appId); - if (mappedApp) { - this._applyMapping(window, mappedApp, tracker, false); + this._options.registry.removeResolution(resolutionKey); + } else { + return; } - - return; } - const title: string = window.get_title() || ''; logger.log(`untracked window: title="${title}" wm_class="${wmClass}" app_id="${appId}"`, { prefix: LOG_PREFIX, }); @@ -186,7 +217,7 @@ export class IconWeaveInspector { prefix: LOG_PREFIX, }); this._applyMapping(window, deterministic, tracker); - this._options.registry.markProcessed(identity); + this._options.registry.setResolvedApp(resolutionKey, deterministic.get_id()); return; } @@ -201,11 +232,11 @@ export class IconWeaveInspector { prefix: LOG_PREFIX, }); this._applyMapping(window, candidate, tracker); + this._options.registry.setResolvedApp(resolutionKey, candidate.get_id()); } else { logger.log(`no candidate found for wm_class="${wmClass}"`, { prefix: LOG_PREFIX }); + this._options.registry.setResolvedApp(resolutionKey, null); } - - this._options.registry.markProcessed(identity); } private _applyMapping(window: any, app: any, tracker: any, notifyApp = true): void { @@ -278,7 +309,8 @@ export class IconWeaveInspector { const app = appSystem.lookup_app(id); if (!app) continue; - const score = this._scoreCandidate(app, wmClass, appId, title); + const candidate = this._getCandidateMetadata(appInfo, id); + const score = this._scoreCandidate(candidate, wmClass, appId, title); if (score <= bestScore) continue; bestScore = score; @@ -298,28 +330,26 @@ export class IconWeaveInspector { 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(); + private _getCandidateMetadata(appInfo: any, id: string): IconWeaveCandidateMetadata { + const cached = this._candidateMetadata.get(id); + if (cached) return cached; - if (this._isSteamGame(app, wmClass)) return 99; - - return scoreIconWeaveCandidate({ desktopId, appName, wmClass, appId, title }); + const desktopInfo = appInfo as GioUnix.DesktopAppInfo; + const metadata = createIconWeaveCandidateMetadata( + id, + String(appInfo.get_name() || ''), + desktopInfo.get_string('Exec') || '', + ); + this._candidateMetadata.set(id, metadata); + return metadata; } - 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; + private _scoreCandidate( + candidate: IconWeaveCandidateMetadata, + wmClass: string, + appId: string, + title: string, + ): number { + return scoreIconWeaveCandidate({ candidate, wmClass, appId, title }); } } diff --git a/src/patches/iconWeaveRegistry.ts b/src/patches/iconWeaveRegistry.ts index 3142fd2..beaeb23 100644 --- a/src/patches/iconWeaveRegistry.ts +++ b/src/patches/iconWeaveRegistry.ts @@ -1,53 +1,55 @@ +export function createIconWeaveResolutionKey( + wmClass: string, + appId: string, + title: string, +): string { + return JSON.stringify([wmClass, appId, title]); +} + +const MAX_RESOLVED_APPS = 256; + export class IconWeaveWindowRegistry { private _mappings = new Map(); - private _processed = new Set(); + private _resolvedApps = new Map(); get mappings(): ReadonlyMap { return this._mappings; } - hasProcessed(identity: string): boolean { - return this._processed.has(identity); + hasResolved(key: string): boolean { + return this._resolvedApps.has(key); } - markProcessed(identity: string): void { - this._processed.add(identity); + getResolvedApp(key: string): string | null | undefined { + return this._resolvedApps.get(key); } - map(window: any, app: any): void { - this._mappings.set(window, app); + setResolvedApp(key: string, desktopId: string | null): void { + if (!this._resolvedApps.has(key) && this._resolvedApps.size >= MAX_RESOLVED_APPS) { + const oldestKey = this._resolvedApps.keys().next().value; + if (oldestKey !== undefined) this._resolvedApps.delete(oldestKey); + } + this._resolvedApps.set(key, desktopId); } - 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); + removeResolution(key: string): void { + this._resolvedApps.delete(key); + } - if (sameWmClass || sameAppId) return app; - } + clearResolutions(): void { + this._resolvedApps.clear(); + } - return null; + map(window: any, app: any): void { + this._mappings.set(window, app); } - remove(window: any, wmClass: string, appId: string): void { + remove(window: any): 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(); + this._resolvedApps.clear(); } } diff --git a/src/patches/iconWeaveScoring.ts b/src/patches/iconWeaveScoring.ts index e9dc3c7..2115084 100644 --- a/src/patches/iconWeaveScoring.ts +++ b/src/patches/iconWeaveScoring.ts @@ -1,6 +1,16 @@ -export type IconWeaveScoreInput = { +export type IconWeaveCandidateMetadata = { desktopId: string; appName: string; + shortId: string; + normalizedDesktopId: string; + normalizedAppName: string; + normalizedShortId: string; + abbreviation: string; + steamGameId: string | null; +}; + +export type IconWeaveScoreInput = { + candidate: IconWeaveCandidateMetadata; wmClass: string; appId: string; title: string; @@ -28,61 +38,101 @@ export function unregisterIconWeaveWindow( const SHORT_ID_MIN_COVERAGE = 0.45; +export function createIconWeaveCandidateMetadata( + desktopId: string, + appName: string, + executable: string, +): IconWeaveCandidateMetadata { + const normalizedId = desktopId.toLowerCase().replace(/\.desktop$/, ''); + const normalizedName = appName.toLowerCase(); + const shortId = normalizedId.split('.').pop() || normalizedId; + const words = normalizedName.split(/[^a-z0-9]/).filter((word) => word.length > 0); + const steamMatch = executable.match(/steam:\/\/rungameid\/(\d+)/); + + return { + desktopId: normalizedId, + appName: normalizedName, + shortId, + normalizedDesktopId: normalize(normalizedId), + normalizedAppName: normalize(normalizedName), + normalizedShortId: normalize(shortId), + abbreviation: words.map((word) => word[0]).join(''), + steamGameId: steamMatch && steamMatch[1] ? steamMatch[1] : null, + }; +} + export function scoreIconWeaveCandidate(input: IconWeaveScoreInput): number { - const desktopId = input.desktopId.toLowerCase().replace(/\.desktop$/, ''); - const appName = input.appName.toLowerCase(); - const shortId = desktopId.split('.').pop() || desktopId; + const candidate = input.candidate; + if (isIconWeaveSteamGame(candidate, input.wmClass)) return 99; - if (isSubprocessClass(wmClassLower(input.wmClass), desktopId, shortId)) return 0; + if (isSubprocessClass(wmClassLower(input.wmClass), candidate.desktopId, candidate.shortId)) { + return 0; + } let score = 0; - const words = appName.split(/[^a-z0-9]/).filter((w) => w.length > 0); - const abbreviation = words.map((w) => w[0]).join(''); - const nWm = normalize(input.wmClass); - const nAppName = normalize(appName); - const nDesktopId = normalize(desktopId); - const nShortId = normalize(shortId); if (input.wmClass) { const wm = wmClassLower(input.wmClass); - if (desktopId === wm) score = Math.max(score, 93); - if (desktopId.includes(wm) && wm.length >= 3) score = Math.max(score, 80); - if (wm.includes(desktopId) && desktopId.length >= 3) score = Math.max(score, 70); - if (isSpecificShortIdMatch(shortId, wm)) score = Math.max(score, 66); - if (appName === wm) score = Math.max(score, 85); - if (appName.includes(wm) && wm.length >= 3) score = Math.max(score, 60); - if (wm.includes(appName) && appName.length >= 3) score = Math.max(score, 55); - - if (nWm === abbreviation && abbreviation.length >= 2) { + if (candidate.desktopId === wm) score = Math.max(score, 93); + if (candidate.desktopId.includes(wm) && wm.length >= 3) score = Math.max(score, 80); + if (wm.includes(candidate.desktopId) && candidate.desktopId.length >= 3) { + score = Math.max(score, 70); + } + if (isSpecificShortIdMatch(candidate.shortId, wm)) score = Math.max(score, 66); + if (candidate.appName === wm) score = Math.max(score, 85); + if (candidate.appName.includes(wm) && wm.length >= 3) score = Math.max(score, 60); + if (wm.includes(candidate.appName) && candidate.appName.length >= 3) { + score = Math.max(score, 55); + } + + if (nWm === candidate.abbreviation && candidate.abbreviation.length >= 2) { score = Math.max(score, 88); } - if (nAppName.includes(nWm) && nWm.length >= 3) score = Math.max(score, 62); - if (nDesktopId.includes(nWm) && nWm.length >= 3) score = Math.max(score, 61); + if (candidate.normalizedAppName.includes(nWm) && nWm.length >= 3) { + score = Math.max(score, 62); + } + if (candidate.normalizedDesktopId.includes(nWm) && nWm.length >= 3) { + score = Math.max(score, 61); + } } if (input.appId) { const aId = input.appId.toLowerCase(); const nAId = normalize(input.appId); - if (desktopId.includes(aId) && aId.length >= 3) score = Math.max(score, 75); - if (nAId === abbreviation && abbreviation.length >= 2) score = Math.max(score, 88); + if (candidate.desktopId.includes(aId) && aId.length >= 3) score = Math.max(score, 75); + if (nAId === candidate.abbreviation && candidate.abbreviation.length >= 2) { + score = Math.max(score, 88); + } } const tNorm = normalize(input.title); if (tNorm && tNorm.length >= 3) { - if (tNorm === nDesktopId) score = Math.max(score, 98); - if (tNorm === nAppName) score = Math.max(score, 95); - if (tNorm === nShortId) score = Math.max(score, 94); - if (nAppName.includes(tNorm)) score = Math.max(score, 65); - if (tNorm.includes(nDesktopId)) score = Math.max(score, 68); + if (tNorm === candidate.normalizedDesktopId) score = Math.max(score, 98); + if (tNorm === candidate.normalizedAppName) score = Math.max(score, 95); + if (tNorm === candidate.normalizedShortId) score = Math.max(score, 94); + if (candidate.normalizedAppName.includes(tNorm)) score = Math.max(score, 65); + if (tNorm.includes(candidate.normalizedDesktopId)) score = Math.max(score, 68); } return score; } +export function isIconWeaveSteamGame( + candidate: IconWeaveCandidateMetadata, + wmClass: string, +): boolean { + if (!candidate.steamGameId) return false; + + const normalizedClass = normalize(wmClass); + if (normalizedClass === `steamapp${candidate.steamGameId}`) return true; + + return normalizedClass === candidate.abbreviation && candidate.abbreviation.length >= 2; +} + export function normalize(str: string): string { return (str || '').toLowerCase().replace(/[^a-z0-9]/g, ''); } diff --git a/src/shared/ui/dashVisibility.ts b/src/shared/ui/dashVisibility.ts index c11e8c5..e16abbd 100644 --- a/src/shared/ui/dashVisibility.ts +++ b/src/shared/ui/dashVisibility.ts @@ -206,8 +206,11 @@ export class DashVisibilityController { return; } + // Below Clutter's redraw priority: this used to poll at PRIORITY_DEFAULT for as long as + // a window blocked autohide, and once GC pressure made each tick outlast the 100 ms + // interval it was always ready and starved painting, freezing the whole screen. this._autohideTimeout.replace(() => - GLib.timeout_add(GLib.PRIORITY_DEFAULT, AUTOHIDE_TIMEOUT, () => { + GLib.timeout_add(GLib.PRIORITY_DEFAULT_IDLE, AUTOHIDE_TIMEOUT, () => { if (!this._options.getContentActor()) { this._autohideTimeout.complete(); return GLib.SOURCE_REMOVE; @@ -218,18 +221,22 @@ export class DashVisibilityController { return GLib.SOURCE_REMOVE; } + const menuOpen = this._options.isMenuOpen(); const shouldHide = shouldHideDash({ target: this._target, blocked: this._blockAutoHide, hovered: this._hovered, - menuOpen: this._options.isMenuOpen(), + menuOpen, dragHeld: this._itemDragHold, }); - if (!shouldHide) { + if (shouldHide) { + this.hide(true); + } else if (menuOpen) { + // A closing menu may not change hover, so nothing else would re-arm us. + // Blocking, hover and drag changes call updateAutoHide() themselves. return GLib.SOURCE_CONTINUE; } - this.hide(true); this._autohideTimeout.complete(); return GLib.SOURCE_REMOVE; }), diff --git a/src/styles/_devtool.scss b/src/styles/_devtool.scss index ff89061..ab58fd6 100644 --- a/src/styles/_devtool.scss +++ b/src/styles/_devtool.scss @@ -1,86 +1,608 @@ +@use 'variables' as vars; + +// St parses only a single box-shadow layer, so the multi-layer prototype +// shadows keep their dominant layer and the 0 0 0 1px outline becomes a border. +$devtool-window-bg: #36363a; +$devtool-card-bg: #46464b; +$devtool-fg: #ffffff; +$devtool-heading: #ffffff; +$devtool-dim: rgba(255, 255, 255, 0.62); +$devtool-separator: rgba(255, 255, 255, 0.08); +$devtool-button-bg: rgba(255, 255, 255, 0.09); +$devtool-button-bg-hover: rgba(255, 255, 255, 0.13); +$devtool-button-bg-active: rgba(255, 255, 255, 0.18); +$devtool-row-hover: rgba(255, 255, 255, 0.05); +$devtool-row-active: rgba(255, 255, 255, 0.1); +$devtool-entry-bg: rgba(255, 255, 255, 0.08); +$devtool-switch-off: rgba(255, 255, 255, 0.2); +$devtool-accent-soft: st-mix(-st-accent-color, transparent, 16%); +$devtool-destructive-bg: #c01c28; +$devtool-error: #ff7b63; +$devtool-toast-bg: #e8e8e8; +$devtool-toast-fg: #1a1a1a; +$devtool-shadow-card: 0 1px 2px rgba(0, 0, 0, 0.08); +$devtool-outline-window: rgba(255, 255, 255, 0.08); + +@if vars.$variant == 'light' { + $devtool-window-bg: #fafafb; + $devtool-card-bg: #ffffff; + $devtool-fg: #222226; + $devtool-heading: #222226; + $devtool-dim: rgba(0, 0, 0, 0.55); + $devtool-separator: rgba(0, 0, 0, 0.09); + $devtool-button-bg: rgba(0, 0, 0, 0.07); + $devtool-button-bg-hover: rgba(0, 0, 0, 0.11); + $devtool-button-bg-active: rgba(0, 0, 0, 0.15); + $devtool-row-hover: rgba(0, 0, 0, 0.04); + $devtool-row-active: rgba(0, 0, 0, 0.08); + $devtool-entry-bg: rgba(0, 0, 0, 0.05); + $devtool-switch-off: rgba(0, 0, 0, 0.16); + $devtool-accent-soft: st-mix(-st-accent-color, transparent, 12%); + $devtool-destructive-bg: #e01b24; + $devtool-error: #c01c28; + $devtool-toast-bg: #1a1a1a; + $devtool-toast-fg: #ffffff; + $devtool-outline-window: rgba(0, 0, 0, 0.08); +} + +// Shell popup-menu chrome hosting the card: strip the frame, the card below +// carries its own radius/shadow. -arrow-rise/-boxpointer-gap hide the beak, +// following the .background-menu pattern in gnome-shell-sass. +.aurora-devtool-menu { + -arrow-rise: 0px; + -boxpointer-gap: 10px; + + .popup-menu-content { + padding: 0; + border: none; + border-radius: 14px; + background-color: transparent; + box-shadow: none; + } +} + .aurora-devtool-panel { - width: 360px; - padding: 8px; - spacing: 8px; + width: 448px; + max-height: 690px; + spacing: 0; + padding: 0; + border: 1px solid $devtool-outline-window; + border-radius: 14px; + color: $devtool-fg; + background-color: $devtool-window-bg; + box-shadow: $devtool-shadow-card; + font-size: 15px; } +// Header (Adwaita header bar): BinLayout stack — .aurora-devtool-header-nav +// holds the buttons, .aurora-devtool-header-labels overlays dead-center. .aurora-devtool-header { - spacing: 8px; - padding: 2px 4px 0; + min-height: 47px; + border-bottom: 1px solid $devtool-separator; +} + +.aurora-devtool-header-nav { + spacing: 6px; + padding: 0 8px; +} + +.aurora-devtool-header-labels { + spacing: 0; } .aurora-devtool-title { + font-size: 15px; font-weight: 700; + color: $devtool-heading; + text-align: center; } -.aurora-devtool-body { - spacing: 10px; +.aurora-devtool-subtitle { + font-size: 12px; + color: $devtool-dim; + text-align: center; +} + +.aurora-devtool-header-button { + min-width: 34px; + min-height: 34px; + padding: 0; + border-radius: 8px; + color: $devtool-fg; + background-color: transparent; + box-shadow: none; + transition-duration: 120ms; + + &:hover { + background-color: $devtool-button-bg; + } + + &:active { + background-color: $devtool-button-bg-active; + } +} + +.aurora-devtool-back-button { + padding: 0; +} + +.aurora-devtool-view-switcher { + padding: 12px 16px 2px; } -.aurora-devtool-section-dropdown { +.aurora-devtool-view-track { spacing: 4px; + padding: 3px; + border-radius: 999px; + background-color: $devtool-entry-bg; } -.aurora-devtool-section-trigger, -.aurora-devtool-section-option { - border-radius: 8px; - padding: 7px 9px; - border: 1px solid transparent; +.aurora-devtool-view-button { + padding: 7px 4px; + border-radius: 999px; + color: $devtool-dim; + background-color: transparent; + box-shadow: none; + font-size: 13.5px; + font-weight: 600; + transition-duration: 250ms; - &:active, &.active { - border-color: st-mix(-st-accent-color, rgba(127, 127, 127, 0.35), 35%); - background-color: st-mix(-st-accent-color, rgba(127, 127, 127, 0.12), 24%); + color: $devtool-heading; + background-color: $devtool-button-bg; + box-shadow: none; } } -.aurora-devtool-section-menu { - spacing: 4px; - padding: 4px; - border-radius: 8px; - border: 1px solid rgba(127, 127, 127, 0.18); - background-color: rgba(127, 127, 127, 0.08); +.aurora-devtool-view-button-content { + spacing: 7px; } -.aurora-devtool-section-button-content { - spacing: 7px; +.aurora-devtool-search-box { + padding: 10px 16px 2px; +} + +.aurora-devtool-search { + padding: 7px 14px; + border: 1px solid $devtool-separator; + border-radius: 9px; + background-color: $devtool-entry-bg; + font-size: 14px; + + StLabel.hint-text { + color: $devtool-dim; + } +} + +.aurora-devtool-search-icon { + color: $devtool-dim; +} + +.aurora-devtool-scroll { + // card max-height 690px minus the 47px header; switcher/search shrink it further + max-height: 643px; + padding-top: 8px; } .aurora-devtool-content { - spacing: 8px; + spacing: 0; + padding: 12px 16px 16px; } .aurora-devtool-module-panel { - spacing: 8px; + spacing: 10px; } -.aurora-devtool-summary { - spacing: 8px; - padding: 7px 9px; +.aurora-devtool-page-title { + margin: 6px 6px 2px; + font-size: 18px; + font-weight: 700; + color: $devtool-heading; +} + +.aurora-devtool-page-subtitle { + margin: 0 6px 18px; + font-size: 13.5px; + color: $devtool-dim; +} + +.aurora-devtool-group { + spacing: 0; + margin-bottom: 28px; + + &:last-child { + margin-bottom: 0; + } +} + +.aurora-devtool-group-title { + margin: 0 14px 8px; + font-size: 14px; + font-weight: 700; + color: $devtool-heading; +} + +.aurora-devtool-group-description { + // St parses negative margins as huge unsigned values. + margin: 0 14px 8px; + font-size: 13px; + color: $devtool-dim; +} + +.aurora-devtool-group-list { + spacing: 0; + padding: 0; + border: 1px solid $devtool-separator; + border-radius: 12px; + background-color: $devtool-card-bg; + + > * { + border-top: 1px solid $devtool-separator; + } + + > *:first-child { + border-top: none; + border-radius-topleft: 12px; + border-radius-topright: 12px; + } + + > *:last-child { + border-radius-bottomleft: 12px; + border-radius-bottomright: 12px; + } +} + +.aurora-devtool-row, +.aurora-devtool-module-row { + min-height: 52px; + spacing: 14px; + padding: 12px 14px; +} + +.aurora-devtool-row.activatable { + transition-duration: 100ms; + + &:hover { + background-color: $devtool-row-hover; + } + + &:active { + background-color: $devtool-row-active; + } +} + +.aurora-devtool-row-trailing { + spacing: 10px; +} + +.aurora-devtool-row-icon { + color: $devtool-dim; +} + +.aurora-devtool-row-title { + font-size: 15px; + color: $devtool-heading; +} + +.aurora-devtool-row-subtitle { + // Reset: inherits bold from the `.button` ancestor on module/nav rows. + font-weight: 400; + font-size: 13px; + color: $devtool-dim; +} + +.aurora-devtool-module-open { + padding: 0; + background-color: transparent; + box-shadow: none; + + &:hover, + &:active { + background-color: transparent; + } +} + +.aurora-devtool-navigation-content { + spacing: 14px; +} + +.aurora-devtool-list-button { + background-color: transparent; + box-shadow: none; + border-radius: 0; + transition-duration: 100ms; + + &:hover { + background-color: $devtool-row-hover; + } + + &:active { + background-color: $devtool-row-active; + } +} + +.aurora-devtool-chevron { + color: $devtool-dim; +} + +.aurora-devtool-switch-button { + padding: 4px; + border-radius: 999px; + background-color: transparent; + box-shadow: none; + + .toggle-switch { + background-color: $devtool-switch-off; + transition-duration: 180ms; + + &:checked { + background-color: -st-accent-color; + } + } +} + +.aurora-devtool-hero { + margin: 6px 10px 20px; +} + +.aurora-devtool-hero-title { + font-size: 20px; + font-weight: 700; + color: $devtool-heading; +} + +.aurora-devtool-hero-subtitle { + font-size: 13.5px; + color: $devtool-dim; +} + +.aurora-devtool-banner { + min-height: 0; + spacing: 12px; + padding: 12px 16px; + background-color: $devtool-accent-soft; + border-bottom: 1px solid $devtool-separator; + + .aurora-devtool-button { + padding: 6px 12px; + font-size: 13px; + } +} + +.aurora-devtool-banner-label { + font-size: 14px; + color: $devtool-fg; +} + +.aurora-devtool-button, +.aurora-devtool-action-button { + padding: 7px 12px; border-radius: 8px; - border: 1px solid rgba(127, 127, 127, 0.16); - background-color: rgba(127, 127, 127, 0.08); + color: $devtool-fg; + background-color: $devtool-button-bg; + font-size: 14px; + font-weight: 500; + transition-duration: 120ms; + + &:hover { + background-color: $devtool-button-bg-hover; + } + + &:active { + background-color: $devtool-button-bg-active; + } + + // St has no filter: brightness(); the prototype's 1.07 hover lighten is + // approximated with a white st-mix. + &.suggested { + color: -st-accent-fg-color; + background-color: -st-accent-color; + font-weight: 700; + + &:hover { + background-color: st-mix(-st-accent-color, #ffffff, 93%); + } + } + + &.destructive { + color: #ffffff; + background-color: $devtool-destructive-bg; + font-weight: 700; + + &:hover { + background-color: st-mix($devtool-destructive-bg, #ffffff, 92%); + } + } + + &.flat { + background-color: transparent; + + &:hover { + background-color: $devtool-button-bg; + } + + &:active { + background-color: $devtool-button-bg-hover; + } + } } -.aurora-devtool-summary-label { - font-weight: 600; +.aurora-devtool-action-button.active { + color: -st-accent-fg-color; + background-color: -st-accent-color; +} + +.aurora-devtool-button-content, +.aurora-devtool-action-content { + spacing: 8px; } .aurora-devtool-action-row { + spacing: 10px; +} + +.aurora-devtool-button-row { + spacing: 10px; + padding: 12px 14px; +} + +.aurora-devtool-group-footer { + spacing: 7px; + margin-top: 10px; +} + +.aurora-devtool-inline-controls { + spacing: 7px; +} + +.aurora-devtool-stepper-control { spacing: 8px; } -.aurora-devtool-action-button { +.aurora-devtool-stepper { + spacing: 6px; +} + +.aurora-devtool-stepper-button { + min-width: 28px; + min-height: 28px; + padding: 0; + border-radius: 999px; +} + +.aurora-devtool-stepper-value { + min-width: 22px; + padding: 0; + color: $devtool-fg; + font-size: 14px; + font-feature-settings: 'tnum'; + text-align: center; +} + +.aurora-devtool-stepper-reset { + min-width: 28px; + min-height: 28px; + border-radius: 999px; +} + +.aurora-devtool-time-colon { + color: $devtool-dim; + font-weight: 700; +} + +// Fixed-width anchor for the entry (entryWrap in devTool.ts): St.Entry +// ignores width/min-width set directly on it, so the cap has to come from +// the wrapper's allocation instead. +.aurora-devtool-entry-wrap { + width: 130px; +} + +.aurora-devtool-entry { + padding: 6px 8px; + border: 1px solid $devtool-separator; border-radius: 8px; - padding: 8px 10px; - border: 1px solid transparent; + background-color: $devtool-entry-bg; + color: $devtool-fg; + font-size: 13px; - &.active { - border-color: st-mix(-st-accent-color, rgba(127, 127, 127, 0.35), 42%); - background-color: st-mix(-st-accent-color, rgba(127, 127, 127, 0.12), 30%); + &:focus { + border-color: -st-accent-color; + box-shadow: inset 0 0 0 1px -st-accent-color; + } + + StLabel.hint-text { + color: $devtool-dim; } } -.aurora-devtool-action-content { +.aurora-devtool-kbd { + padding: 3px 8px; + border: 1px solid $devtool-separator; + border-radius: 6px; + background-color: $devtool-button-bg; + font-size: 12.5px; +} + +.aurora-devtool-kbd-chip { + padding: 2px 6px; + border: 1px solid $devtool-separator; + border-radius: 6px; + background-color: $devtool-button-bg; + font-size: 12.5px; +} + +.aurora-devtool-commands-note { + spacing: 6px; + margin: 0 14px; + color: $devtool-dim; + font-size: 12.5px; +} + +.aurora-devtool-segmented { + spacing: 6px; + padding: 0 14px 12px; +} + +.aurora-devtool-segmented-button { + padding: 6px 8px; + font-size: 13px; + + &:checked { + background-color: $devtool-button-bg-active; + } +} + +.aurora-devtool-summary { spacing: 7px; + padding: 8px 9px; + border-radius: 9px; + background-color: $devtool-entry-bg; +} + +.aurora-devtool-summary-icon { + color: $devtool-dim; +} + +.aurora-devtool-summary-label { + font-weight: 600; +} + +.aurora-devtool-error { + padding: 3px 8px; + color: $devtool-error; +} + +.aurora-devtool-status-page { + spacing: 6px; + padding: 60px 40px; +} + +.aurora-devtool-status-icon { + margin-bottom: 10px; + color: $devtool-dim; +} + +.aurora-devtool-status-title { + font-size: 19px; + font-weight: 700; + color: $devtool-heading; + text-align: center; +} + +.aurora-devtool-status-description { + max-width: 380px; + font-size: 14px; + color: $devtool-dim; + text-align: center; +} + +.aurora-devtool-toast { + margin-bottom: 24px; + padding: 10px 18px; + border-radius: 999px; + color: $devtool-toast-fg; + background-color: $devtool-toast-bg; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35); + font-size: 14px; + text-align: center; } diff --git a/tests/performance/runtimePerformance.ts b/tests/performance/runtimePerformance.ts new file mode 100644 index 0000000..956bb76 --- /dev/null +++ b/tests/performance/runtimePerformance.ts @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import os from 'node:os'; +import { performance } from 'node:perf_hooks'; + +import { + createIconWeaveCandidateMetadata, + scoreIconWeaveCandidate, +} from '~/patches/iconWeaveScoring.ts'; +import { parseClipboardLog } from '~/clipboard/clipboardLog.ts'; + +type BenchmarkResult = { + medianMs: number; + p95Ms: number; +}; + +type ClipboardEntry = { + id: string; + kind: 'text' | 'image'; + text: string; + pinned: boolean; + timestamp: number; + contentKey: string; +}; + +let checksum = 0; + +function percentile(samples: number[], fraction: number): number { + const sorted = samples.toSorted((a, b) => a - b); + return sorted[Math.ceil(sorted.length * fraction) - 1] || 0; +} + +function sample(operation: () => number, samples: number[]): void { + const started = performance.now(); + checksum += operation(); + samples.push(performance.now() - started); +} + +function measureComparison( + baselineOperation: () => number, + optimizedOperation: () => number, + sampleCount = 25, +): { baseline: BenchmarkResult; optimized: BenchmarkResult } { + for (let index = 0; index < 5; index++) { + checksum += baselineOperation(); + checksum += optimizedOperation(); + } + + const baselineSamples: number[] = []; + const optimizedSamples: number[] = []; + for (let index = 0; index < sampleCount; index++) { + if (index % 2 === 0) { + sample(baselineOperation, baselineSamples); + sample(optimizedOperation, optimizedSamples); + } else { + sample(optimizedOperation, optimizedSamples); + sample(baselineOperation, baselineSamples); + } + } + + const result = (samples: number[]): BenchmarkResult => ({ + medianMs: percentile(samples, 0.5), + p95Ms: percentile(samples, 0.95), + }); + + return { + baseline: result(baselineSamples), + optimized: result(optimizedSamples), + }; +} + +function parseClipboardLogBaseline(source: string) { + const pinned: ClipboardEntry[] = []; + const history: ClipboardEntry[] = []; + const byId = new Map(); + + for (const line of source.split('\n')) { + if (!line) continue; + + const op = JSON.parse(line) as { + op: 'add' | 'delete' | 'move' | 'pin' | 'unpin'; + id: string; + text?: string; + timestamp?: number; + }; + if (op.op === 'add') { + const entry: ClipboardEntry = { + id: op.id, + kind: 'text', + text: op.text || '', + pinned: false, + timestamp: op.timestamp || 0, + contentKey: op.text || '', + }; + byId.set(entry.id, entry); + history.unshift(entry); + continue; + } + + const entry = byId.get(op.id); + if (!entry) continue; + + if (op.op === 'delete') { + removeEntry(pinned, entry); + removeEntry(history, entry); + byId.delete(op.id); + } else if (op.op === 'move') { + moveToFront(entry.pinned ? pinned : history, entry); + } else if (op.op === 'pin') { + removeEntry(history, entry); + entry.pinned = true; + moveToFront(pinned, entry); + } else { + removeEntry(pinned, entry); + entry.pinned = false; + moveToFront(history, entry); + } + } + + return { pinned, history }; +} + +function removeEntry(entries: ClipboardEntry[], entry: ClipboardEntry): void { + const index = entries.indexOf(entry); + if (index !== -1) entries.splice(index, 1); +} + +function moveToFront(entries: ClipboardEntry[], entry: ClipboardEntry): void { + removeEntry(entries, entry); + entries.unshift(entry); +} + +function fragmentedClipboardLog(entryCount: number): string { + const operations: string[] = []; + for (let index = 1; index <= entryCount; index++) { + operations.push( + JSON.stringify({ op: 'add', id: String(index), text: `entry-${index}`, timestamp: index }), + ); + } + for (let index = 1; index <= entryCount; index++) { + operations.push(JSON.stringify({ op: 'move', id: String(index) })); + } + return operations.join('\n'); +} + +function compactedClipboardLog(entryCount: number): string { + const operations: string[] = []; + for (let index = 1; index <= entryCount; index++) { + operations.push( + JSON.stringify({ op: 'add', id: String(index), text: `entry-${index}`, timestamp: index }), + ); + } + return operations.join('\n'); +} + +function gainPercent(baseline: number, optimized: number): number { + return ((baseline - optimized) / baseline) * 100; +} + +function benchmarkClipboard(size: number, fragmented: boolean): void { + const source = fragmented ? fragmentedClipboardLog(size) : compactedClipboardLog(size); + const baselineState = parseClipboardLogBaseline(source); + const optimizedState = parseClipboardLog(source); + assert.deepEqual( + optimizedState.history.map((entry) => entry.id), + baselineState.history.map((entry) => entry.id), + ); + + const { baseline, optimized } = measureComparison( + () => parseClipboardLogBaseline(source).history.length, + () => parseClipboardLog(source).history.length, + ); + const gain = gainPercent(baseline.medianMs, optimized.medianMs); + + console.log( + JSON.stringify({ + benchmark: 'clipboard-replay', + path: fragmented ? 'fragmented' : 'compacted', + operations: fragmented ? size * 2 : size, + baseline, + optimized, + medianGainPercent: Number(gain.toFixed(1)), + }), + ); + + if (size === 10_000) + assert.ok(gain >= 20, `Clipboard median gain ${gain.toFixed(1)}% is below 20%`); +} + +function benchmarkIconWeave(): void { + const iterations = 100_000; + const scoreInput = { wmClass: 'steam_app_220', appId: '', title: 'Half-Life 2' }; + const metadata = createIconWeaveCandidateMetadata( + 'com.valvesoftware.HalfLife2.desktop', + 'Half-Life 2', + 'steam steam://rungameid/220', + ); + const { baseline, optimized } = measureComparison( + () => { + let score = 0; + for (let index = 0; index < iterations; index++) { + const candidate = createIconWeaveCandidateMetadata( + 'com.valvesoftware.HalfLife2.desktop', + 'Half-Life 2', + 'steam steam://rungameid/220', + ); + score += scoreIconWeaveCandidate({ candidate, ...scoreInput }); + } + return score; + }, + () => { + let score = 0; + for (let index = 0; index < iterations; index++) + score += scoreIconWeaveCandidate({ candidate: metadata, ...scoreInput }); + return score; + }, + ); + const gain = gainPercent(baseline.medianMs, optimized.medianMs); + + console.log( + JSON.stringify({ + benchmark: 'icon-weave-derived-metadata', + path: 'warm-cache', + operations: iterations, + baseline, + optimized, + medianGainPercent: Number(gain.toFixed(1)), + }), + ); + assert.ok(gain >= 20, `IconWeave median gain ${gain.toFixed(1)}% is below 20%`); +} + +console.log( + JSON.stringify({ + benchmark: 'environment', + node: process.version, + platform: process.platform, + arch: process.arch, + cpu: os.cpus()[0]?.model || 'unknown', + samples: 25, + }), +); + +for (const size of [1_000, 10_000]) { + benchmarkClipboard(size, false); + benchmarkClipboard(size, true); +} +benchmarkIconWeave(); + +if (checksum === 0) throw new Error('Benchmark output was not consumed'); diff --git a/tests/shell/clipboard/clipboardHistory.test.js b/tests/shell/clipboard/clipboardHistory.test.js index 387a390..ff5d431 100644 --- a/tests/shell/clipboard/clipboardHistory.test.js +++ b/tests/shell/clipboard/clipboardHistory.test.js @@ -1,5 +1,6 @@ /* eslint camelcase: ["error", { properties: "never", allow: ["^script_"] }] */ +import GdkPixbuf from 'gi://GdkPixbuf'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import St from 'gi://St'; @@ -91,6 +92,18 @@ async function waitForFloatingActions( }); } +async function waitForHistoryWrite(evaluate, description) { + const historyFile = Gio.File.new_for_path( + `${GLib.get_user_runtime_dir()}/aurora-shell/${EXTENSION_UUID}/clipboard-history.log`, + ); + const historyMonitor = historyFile.monitor_file(Gio.FileMonitorFlags.NONE, null); + try { + await waitForCondition({ evaluate, signals: [[historyMonitor, 'changed']], description }); + } finally { + historyMonitor.cancel(); + } +} + export var METRICS = {}; export function init() { @@ -145,53 +158,90 @@ export async function run() { Scripting.scriptEvent('workspacePanelOk'); const beforeTextCount = getClipboardModule().entryCount; - const textHistoryFile = Gio.File.new_for_path( - `${GLib.get_user_runtime_dir()}/aurora-shell/${EXTENSION_UUID}/clipboard-history.log`, + St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, 'aurora-test-clipboard-entry'); + await waitForHistoryWrite( + () => getClipboardModule().entryCount > beforeTextCount, + 'clipboard text to be persisted by the monitor', ); - const textHistoryMonitor = textHistoryFile.monitor_file(Gio.FileMonitorFlags.NONE, null); - try { - St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, 'aurora-test-clipboard-entry'); - await waitForCondition({ - evaluate: () => getClipboardModule().entryCount > beforeTextCount, - signals: [[textHistoryMonitor, 'changed']], - description: 'clipboard text to be persisted by the monitor', - }); - } finally { - textHistoryMonitor.cancel(); - } Scripting.scriptEvent('clipboardWritten'); - const beforeImageCount = getClipboardModule().entryCount; - const historyFile = Gio.File.new_for_path( - `${GLib.get_user_runtime_dir()}/aurora-shell/${EXTENSION_UUID}/clipboard-history.log`, + const clipboard = St.Clipboard.get_default(); + const clipboardModule = getClipboardModule(); + const newestEntry = () => clipboardModule._store.getHistory()[0]; + + const screenshot = GdkPixbuf.Pixbuf.new(GdkPixbuf.Colorspace.RGB, false, 8, 3840, 2160); + screenshot.fill(0x3366ccff); + const [, screenshotPng] = screenshot.save_to_bufferv('png', [], []); + const beforeImageCount = clipboardModule.entryCount; + clipboard.set_content(St.ClipboardType.CLIPBOARD, 'image/png', screenshotPng); + await waitForHistoryWrite( + () => clipboardModule.entryCount > beforeImageCount, + 'screenshot-sized clipboard image to be captured by the monitor', ); - const historyMonitor = historyFile.monitor_file(Gio.FileMonitorFlags.NONE, null); + + const imageEntry = newestEntry(); + if (imageEntry?.kind !== 'image') throw new Error('Newest clipboard entry is not the image'); + const thumbnailPath = imageEntry.filePath.replace(/\.[^./]+$/, '') + '.thumb.png'; + const [, thumbnailWidth, thumbnailHeight] = GdkPixbuf.Pixbuf.get_file_info(thumbnailPath); + if (!thumbnailWidth || Math.max(thumbnailWidth, thumbnailHeight) > 640) { + throw new Error(`Image thumbnail has wrong size: ${thumbnailWidth}x${thumbnailHeight}`); + } + + const store = clipboardModule._store; + const originalWriteThumbnail = store._writeThumbnail; + store._writeThumbnail = async () => { + throw new Error('Injected thumbnail failure'); + }; + let imageWithoutThumbnailAdded; try { - St.Clipboard.get_default().set_content( - St.ClipboardType.CLIPBOARD, - 'image/png', - new Uint8Array([ - 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, - 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 255, 255, 255, - 127, 0, 9, 251, 3, 253, 42, 134, 227, 138, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, - ]), - ); + imageWithoutThumbnailAdded = await clipboardModule.addImage({ + mimeType: 'image/png', + bytes: new GLib.Bytes(screenshotPng), + fingerprint: `thumbnail-failure-${Date.now()}`, + }); + } finally { + store._writeThumbnail = originalWriteThumbnail; + } + const imageWithoutThumbnail = newestEntry(); + if (!imageWithoutThumbnailAdded || imageWithoutThumbnail?.kind !== 'image') + throw new Error('Valid image was dropped when thumbnail generation failed'); + if (!Gio.File.new_for_path(imageWithoutThumbnail.filePath).query_exists(null)) + throw new Error('Original image was removed when thumbnail generation failed'); + if ( + Gio.File.new_for_path( + imageWithoutThumbnail.filePath.replace(/\.[^./]+$/, '') + '.thumb.png', + ).query_exists(null) + ) + throw new Error('Failed thumbnail unexpectedly exists'); + + const marker = 'aurora-test-before-image-restore'; + clipboard.set_text(St.ClipboardType.CLIPBOARD, marker); + await waitForHistoryWrite(() => newestEntry()?.text === marker, 'marker text to be captured'); + + const selection = global.display.get_selection(); + const originalTransfer = selection.transfer_async; + let imageReads = 0; + selection.transfer_async = function (...args) { + imageReads++; + return originalTransfer.apply(this, args); + }; + try { + clipboardModule._onActivate(imageEntry); await waitForCondition({ - evaluate: () => getClipboardModule().entryCount > beforeImageCount, - signals: [[historyMonitor, 'changed']], - description: 'clipboard image to be persisted by the monitor', + evaluate: () => clipboard.get_mimetypes(St.ClipboardType.CLIPBOARD).includes('image/png'), + signals: [[global.display.get_selection(), 'owner-changed']], + description: 'restored image to own the clipboard', }); + await waitForTiming(250, 'negative check: the 50 ms monitor read delay passes with no read'); } finally { - historyMonitor.cancel(); + selection.transfer_async = originalTransfer; } - - if (getClipboardModule().entryCount <= beforeImageCount) { - throw new Error('Clipboard image was not captured by the monitor'); + if (imageReads !== 0) { + throw new Error(`Restoring an image made the monitor read it back (${imageReads} reads)`); } Scripting.scriptEvent('clipboardImageWritten'); - const clipboardModule = getClipboardModule(); const longText = 'This long clipboard entry must wrap onto several visual lines while remaining inside the fixed panel width. '.repeat( 6, @@ -480,6 +530,14 @@ export async function run() { auroraSettings.set_boolean('module-clipboard-history', true); await Scripting.waitLeisure(); + + const reenabledModule = getClipboardModule(); + const beforeReenabledCapture = reenabledModule.entryCount; + clipboard.set_text(St.ClipboardType.CLIPBOARD, 'aurora-test-after-reenable'); + await waitForHistoryWrite( + () => getClipboardModule().entryCount > beforeReenabledCapture, + 'clipboard monitor to resume after re-enable', + ); } let _moduleEnabled = false; diff --git a/tests/shell/desktop/trayIcons/stability.test.js b/tests/shell/desktop/trayIcons/stability.test.js index ce5c928..8a79df1 100644 --- a/tests/shell/desktop/trayIcons/stability.test.js +++ b/tests/shell/desktop/trayIcons/stability.test.js @@ -78,7 +78,7 @@ export async function run() { state.collapsed = !state.collapsed; trayContainer._syncLayout(true); await waitForCondition({ - evaluate: () => !clipArea._viewportTimeout.active, + evaluate: () => clipArea.get_transition('viewport-progress') === null, signals: [[animationCompleted, 'activate']], description: 'TrayClipArea viewport animation to finish before the next collapse toggle', }); diff --git a/tests/shell/dev/devTool.test.js b/tests/shell/dev/devTool.test.js index 8f5f2ee..a7f5801 100644 --- a/tests/shell/dev/devTool.test.js +++ b/tests/shell/dev/devTool.test.js @@ -11,6 +11,7 @@ import { waitForModuleState, } from '../support/testUtils.js'; import { exerciseCaptureTools, exerciseClipboardHistory } from './scenarios/captureAndClipboard.js'; +import { exerciseDevToolUi } from './scenarios/devToolUi.js'; import { exerciseDock } from './scenarios/dock.js'; import { exerciseCalendarReminders, @@ -27,6 +28,7 @@ export function init() { Scripting.defineScriptEvent('extensionEnabled', 'Extension enabled'); Scripting.defineScriptEvent('devToolAbsent', 'DevTool absent without AURORA_DEVTOOLS'); Scripting.defineScriptEvent('devToolFound', 'DevTool found with AURORA_DEVTOOLS'); + Scripting.defineScriptEvent('devToolUiPassed', 'DevTool card navigation and reset passed'); Scripting.defineScriptEvent('captureToolPassed', 'Capture Tool DevTool actions passed'); Scripting.defineScriptEvent('clipboardToolPassed', 'Clipboard History DevTool actions passed'); Scripting.defineScriptEvent('trayIconsToolPassed', 'Tray Icons DevTool actions passed'); @@ -71,10 +73,13 @@ export async function run() { if (!devTool.generalTool) throw new Error('General DevTool section not found'); - await exerciseCaptureTools(settings, devTool); + await exerciseDevToolUi(panelButton, settings); + Scripting.scriptEvent('devToolUiPassed'); + + await exerciseCaptureTools(panelButton, settings, devTool); Scripting.scriptEvent('captureToolPassed'); - await exerciseClipboardHistory(settings, devTool); + await exerciseClipboardHistory(panelButton, settings, devTool); Scripting.scriptEvent('clipboardToolPassed'); await exerciseTrayIcons(devTool, tray); @@ -93,6 +98,7 @@ export async function run() { let _extensionEnabled = false; let _devToolAbsent = false; let _devToolFound = false; +let _devToolUiPassed = false; let _captureToolPassed = false; let _clipboardToolPassed = false; let _trayIconsToolPassed = false; @@ -112,6 +118,10 @@ export function script_devToolFound() { _devToolFound = true; } +export function script_devToolUiPassed() { + _devToolUiPassed = true; +} + export function script_captureToolPassed() { _captureToolPassed = true; } @@ -141,6 +151,7 @@ export function finish() { if (GLib.getenv('AURORA_DEVTOOLS') === '1') { if (!_devToolFound) throw new Error('DevTool was not found with AURORA_DEVTOOLS=1'); + if (!_devToolUiPassed) throw new Error('DevTool card UI checks did not complete'); if (!_captureToolPassed) throw new Error('Capture Tool DevTool actions did not complete'); if (!_clipboardToolPassed) throw new Error('Clipboard History DevTool actions did not complete'); diff --git a/tests/shell/dev/scenarios/captureAndClipboard.js b/tests/shell/dev/scenarios/captureAndClipboard.js index 370faa9..f810a1a 100644 --- a/tests/shell/dev/scenarios/captureAndClipboard.js +++ b/tests/shell/dev/scenarios/captureAndClipboard.js @@ -2,6 +2,53 @@ import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as Scripting from 'resource:///org/gnome/shell/ui/scripting.js'; import { waitForCondition } from '../../support/testUtils.js'; +function findDescendant(actor, predicate) { + if (predicate(actor)) return actor; + if (!actor?.get_children) return null; + for (const child of actor.get_children()) { + const match = findDescendant(child, predicate); + if (match) return match; + } + return null; +} + +async function openDevToolSection(panelButton, key) { + if (panelButton.menu.isOpen) panelButton.menu.close(); + panelButton.menu.open(); + await Scripting.waitLeisure(); + + const devTools = findDescendant( + panelButton.menu.box, + (actor) => actor.name === 'aurora-devtool-focus-view-devtools', + ); + if (!devTools) throw new Error('Dev Tools view button not found'); + devTools.emit('clicked', 1); + await Scripting.waitLeisure(); + + const section = findDescendant( + panelButton.menu.box, + (actor) => actor.name === `aurora-devtool-focus-devtool-${key}`, + ); + if (!section) throw new Error(`${key} DevTool section button not found`); + section.emit('clicked', 1); + await Scripting.waitLeisure(); +} + +async function clickAction(panelButton, accessibleName, expectedFocusName = null) { + const button = findDescendant( + panelButton.menu.box, + (actor) => actor.accessible_name === accessibleName, + ); + if (!button) throw new Error(`${accessibleName} action not found`); + + if (expectedFocusName) button.grab_key_focus(); + button.emit('clicked', 1); + await Scripting.waitLeisure(); + + if (expectedFocusName && global.stage.get_key_focus()?.name !== expectedFocusName) + throw new Error(`${accessibleName} action did not restore focus to ${expectedFocusName}`); +} + function actorSignals(root) { if (!root) return []; const children = root.get_children ? root.get_children() : []; @@ -24,13 +71,14 @@ function waitForInteractionState(tool, interaction, opacity) { }); } -export async function exerciseCaptureTools(settings, devTool) { +export async function exerciseCaptureTools(panelButton, settings, devTool) { settings.set_boolean('module-capture-tools', true); await Scripting.waitLeisure(); const tool = devTool.captureToolsTool; if (!tool) throw new Error('Capture Tool DevTool section not found'); - if (!(await tool.openPreview())) throw new Error('Capture Tool did not open the preview'); + await openDevToolSection(panelButton, 'capture-tools'); + await clickAction(panelButton, 'Open Preview'); await waitForCondition({ evaluate: () => tool.state?.captureVisible && tool.state.toolbarVisible, signals: [[Main.uiGroup, 'child-added'], ...actorSignals(Main.screenshotUI)], @@ -39,46 +87,58 @@ export async function exerciseCaptureTools(settings, devTool) { if (!tool.state?.captureVisible || !tool.state.toolbarVisible) throw new Error('Capture Tool preview is not visible'); - if (!tool.cycleTool()) throw new Error('Capture Tool did not change tool'); - if (!tool.cycleColor()) throw new Error('Capture Tool did not change color'); - if (!tool.cycleWidth()) throw new Error('Capture Tool did not change width'); - if (!tool.toggleInteraction('selection')) - throw new Error('Capture Tool did not simulate selection movement'); + + const initialTool = tool.state.tool; + const initialColor = tool.state.color; + const initialWidth = tool.state.width; + await clickAction(panelButton, `Tool: ${initialTool}`, 'aurora-devtool-focus-capture-tool'); + await clickAction(panelButton, `Color: ${initialColor}`, 'aurora-devtool-focus-capture-color'); + await clickAction(panelButton, `Width: ${initialWidth}`, 'aurora-devtool-focus-capture-width'); + if ( + tool.state?.tool === initialTool || + tool.state?.color === initialColor || + tool.state?.width === initialWidth + ) + throw new Error('Capture Tool appearance controls did not update runtime state'); + + await clickAction(panelButton, 'Move Selection'); await waitForInteractionState(tool, 'selection', 100); - if (!tool.toggleInteraction('drawing')) throw new Error('Capture Tool did not simulate drawing'); + await clickAction(panelButton, 'Draw'); await waitForInteractionState(tool, 'drawing', 100); - if (!tool.setTesseractAvailable(false) || tool.state?.ocrAvailable !== false) + await clickAction(panelButton, 'Tesseract Off'); + if (tool.state?.ocrAvailable !== false) throw new Error('Capture Tool did not simulate unavailable Tesseract'); - if (!tool.setTesseractAvailable(true) || tool.state?.ocrAvailable !== true) + await clickAction(panelButton, 'Tesseract On'); + if (tool.state?.ocrAvailable !== true) throw new Error('Capture Tool did not simulate available Tesseract'); - if (!tool.injectOcr() || !tool.state?.ocrHasResult) - throw new Error('Capture Tool did not inject an OCR result'); + await clickAction(panelButton, 'Inject OCR'); + if (!tool.state?.ocrHasResult) throw new Error('Capture Tool did not inject an OCR result'); if (!tool.state.searchUri?.includes('Aurora%20simulated%20OCR%20result')) throw new Error('Capture Tool did not expose the simulated OCR search URI'); - if (!tool.copyOcr()) throw new Error('Capture Tool did not copy simulated OCR'); - if (!tool.clearAnnotations()) throw new Error('Capture Tool did not clear annotations'); - if (!tool.reset()) throw new Error('Capture Tool did not reset its state'); + await clickAction(panelButton, 'Copy OCR'); + await clickAction(panelButton, 'Clear Annotations'); + await clickAction(panelButton, 'Reset'); if (tool.state?.interaction !== 'idle' || tool.state.ocrAvailabilityOverridden) throw new Error('Capture Tool reset left simulated state active'); Main.screenshotUI.close(true); } -export async function exerciseClipboardHistory(settings, devTool) { +export async function exerciseClipboardHistory(panelButton, settings, devTool) { settings.set_boolean('module-clipboard-history', true); await Scripting.waitLeisure(); const tool = devTool.clipboardHistoryTool; if (!tool) throw new Error('Clipboard History DevTool section not found'); + await openDevToolSection(panelButton, 'clipboard-history'); const previousEntryCount = tool.entryCount; - if (tool.addRandomMessages(2).length !== 2) - throw new Error('Clipboard History did not add random messages'); - if (tool.entryCount < previousEntryCount + 2) + await clickAction(panelButton, 'Add 5 Messages'); + if (tool.entryCount < previousEntryCount + 5) throw new Error('Clipboard History entry count did not increase'); - if (!tool.openPanel()) throw new Error('Clipboard History did not open the panel'); + await clickAction(panelButton, 'Open Panel'); await waitForCondition({ evaluate: () => tool.isPanelOpen, @@ -94,6 +154,7 @@ export async function exerciseClipboardHistory(settings, devTool) { ); if (panel && panel.close) panel.close(); - if (!tool.clearHistory()) throw new Error('Clipboard History did not clear the history'); + await openDevToolSection(panelButton, 'clipboard-history'); + await clickAction(panelButton, 'Clear History'); if (tool.entryCount !== 0) throw new Error('Clipboard History still has entries after clear'); } diff --git a/tests/shell/dev/scenarios/devToolUi.js b/tests/shell/dev/scenarios/devToolUi.js new file mode 100644 index 0000000..3f9e279 --- /dev/null +++ b/tests/shell/dev/scenarios/devToolUi.js @@ -0,0 +1,478 @@ +import Pango from 'gi://Pango'; +import St from 'gi://St'; +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as Scripting from 'resource:///org/gnome/shell/ui/scripting.js'; +import { waitForCondition } from '../../support/testUtils.js'; + +function findDescendant(actor, predicate) { + if (predicate(actor)) return actor; + if (!actor?.get_children) return null; + for (const child of actor.get_children()) { + const match = findDescendant(child, predicate); + if (match) return match; + } + return null; +} + +function findAncestor(actor, predicate) { + let current = actor; + while (current) { + if (predicate(current)) return current; + current = current.get_parent(); + } + return null; +} + +export async function exerciseDevToolUi(panelButton, settings) { + panelButton.menu.open(); + await waitForCondition({ + evaluate: () => panelButton.menu.isOpen, + signals: [[panelButton.menu, 'open-state-changed']], + description: 'DevTool card to open', + }); + + await Scripting.waitLeisure(); + + const cardPanel = findDescendant( + panelButton.menu.box, + (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-panel'), + ); + const headerBox = findDescendant( + panelButton.menu.box, + (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-header'), + ); + const scrollView = findDescendant( + panelButton.menu.box, + (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-scroll'), + ); + if (!cardPanel || !headerBox || !scrollView) + throw new Error('DevTool card structure incomplete (panel/header/scroll)'); + if (cardPanel.get_children()[0] !== headerBox) + throw new Error('Header is not the first child of the DevTool panel'); + const monitor = Main.layoutManager.findMonitorForActor(panelButton); + const monitorIndex = monitor ? monitor.index : Main.layoutManager.primaryIndex; + const workArea = Main.layoutManager.getWorkAreaForMonitor(monitorIndex); + const [cardX, cardY] = cardPanel.get_transformed_position(); + const [cardWidth, cardHeight] = cardPanel.get_transformed_size(); + if ( + cardX < workArea.x || + cardX + cardWidth > workArea.x + workArea.width || + cardY < workArea.y || + cardY + cardHeight > workArea.y + workArea.height + ) + throw new Error( + `DevTool card ${cardX},${cardY} ${cardWidth}x${cardHeight} exceeds ` + + `work area ${workArea.x},${workArea.y} ${workArea.width}x${workArea.height}`, + ); + const [, headerY] = headerBox.get_transformed_position(); + const [, scrollY] = scrollView.get_transformed_position(); + if (!(headerY < scrollY)) + throw new Error(`Header (${headerY}) must be above the scroll view (${scrollY})`); + + const scrollViewport = findDescendant(scrollView, (actor) => actor instanceof St.Viewport); + const pageContent = scrollViewport ? scrollViewport.get_children()[0] : null; + if (!pageContent) throw new Error('DevTool scroll viewport has no page content'); + if (pageContent.width > scrollViewport.width) + throw new Error( + `Page content (${pageContent.width}px) overflows the ` + + `${scrollViewport.width}px scroll viewport`, + ); + + const moduleSwitch = findDescendant( + panelButton.menu.box, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-switch-button'), + ); + if (!moduleSwitch) throw new Error('Module switch not found'); + const initialSwitchState = moduleSwitch.child.state; + moduleSwitch.emit('clicked', 1); + if (moduleSwitch.child.state === initialSwitchState) + throw new Error('Module switch did not toggle'); + moduleSwitch.emit('clicked', 1); + const devToolsButton = findDescendant( + panelButton.menu.box, + (actor) => actor.accessible_name === 'Dev Tools', + ); + if (!devToolsButton) throw new Error('Dev Tools view switcher button not found'); + const switcherIcon = findDescendant(devToolsButton, (actor) => actor instanceof St.Icon); + if (!switcherIcon || switcherIcon.icon_size !== 14) + throw new Error('Dev Tools view switcher button is missing its 14px icon'); + devToolsButton.emit('clicked', 1); + await Scripting.waitLeisure(); + const activeDevToolsButton = findDescendant( + panelButton.menu.box, + (actor) => actor.accessible_name === 'Dev Tools', + ); + if (!activeDevToolsButton?.checked) + throw new Error('Dev Tools view switcher button did not activate'); + const devSectionRow = findDescendant( + panelButton.menu.box, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-list-button'), + ); + if (!devSectionRow) throw new Error('Dev Tools section row not found'); + const [devRowWidth, devRowHeight] = devSectionRow.get_transformed_size(); + if (!(devRowWidth > 0) || !(devRowHeight > 0)) + throw new Error(`Dev Tools section rows are not visible (${devRowWidth}x${devRowHeight})`); + devSectionRow.emit('clicked', 1); + const sectionPanel = findDescendant( + panelButton.menu.box, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-panel'), + ); + if (!sectionPanel) throw new Error('Dev Tool section panel did not render'); + const lookingGlassButton = findDescendant( + sectionPanel, + (actor) => actor.accessible_name === 'Looking Glass', + ); + if (!lookingGlassButton) throw new Error('Looking Glass action not found'); + lookingGlassButton.emit('clicked', 1); + if (panelButton.menu.isOpen) throw new Error('DevTool stayed open behind Looking Glass'); + if (!Main.lookingGlass?.isOpen) throw new Error('Looking Glass did not open'); + Main.lookingGlass.close(); + panelButton.menu.open(); + const modulesTab = findDescendant( + panelButton.menu.box, + (actor) => actor.accessible_name === 'Modules', + ); + if (!modulesTab?.checked) + throw new Error('DevTool did not reset to the Modules root when reopened'); + const resetSearchEntry = findDescendant( + panelButton.menu.box, + (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-search'), + ); + if (!resetSearchEntry || resetSearchEntry.get_text() !== '') + throw new Error('DevTool did not clear the search when reopened'); + + const backAtRoot = findDescendant( + panelButton.menu.box, + (actor) => actor.name === 'aurora-devtool-focus-back', + ); + if (!backAtRoot || backAtRoot.visible) + throw new Error('Back button should exist but stay hidden at the Modules root'); + + const moduleOpen = findDescendant( + panelButton.menu.box, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-open'), + ); + if (!moduleOpen) throw new Error('Module row open button not found'); + moduleOpen.emit('clicked', 1); + const hero = findDescendant( + panelButton.menu.box, + (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-hero'), + ); + if (!hero) throw new Error('Module page did not open (hero block missing)'); + if ( + findDescendant( + hero, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-hero-icon'), + ) + ) + throw new Error('Module detail heading should not show a decorative side icon'); + const backButton = findDescendant( + panelButton.menu.box, + (actor) => actor.name === 'aurora-devtool-focus-back', + ); + if (!backButton?.visible) throw new Error('Back button not visible on a subpage'); + const backLabel = findDescendant(backButton, (actor) => actor.text === 'Back'); + if (backLabel) throw new Error('Back button should use only the symbolic icon'); + const backIcon = findDescendant( + backButton, + (actor) => actor instanceof St.Icon && actor.icon_name === 'go-previous-symbolic', + ); + if (!backIcon) throw new Error('Back button has no symbolic navigation icon'); + backButton.emit('clicked', 1); + const backAfterReturn = findDescendant( + panelButton.menu.box, + (actor) => actor.name === 'aurora-devtool-focus-back', + ); + if (backAfterReturn?.visible) throw new Error('Back button did not return to the Modules root'); + const moduleRowAfterReturn = findDescendant( + panelButton.menu.box, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-row'), + ); + if (!moduleRowAfterReturn) throw new Error('Module list not shown after returning to the root'); + + const dockOpen = findDescendant( + panelButton.menu.box, + (actor) => actor.accessible_name === 'Dock', + ); + if (!dockOpen) throw new Error('Dock module row not found'); + const dockRow = findAncestor( + dockOpen, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-row'), + ); + const dockIcon = dockRow + ? findDescendant( + dockRow, + (actor) => + actor instanceof St.Icon && actor.gicon?.to_string().includes('view-app-grid-symbolic'), + ) + : null; + if (!dockIcon) throw new Error('Dock module does not use the app-grid icon'); + dockOpen.emit('clicked', 1); + + const maxIconLabel = findDescendant( + panelButton.menu.box, + (actor) => actor.text === 'Maximum Icon Size', + ); + const maxIconRow = maxIconLabel + ? findAncestor( + maxIconLabel, + (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-row'), + ) + : null; + const maxIconControl = maxIconRow + ? findDescendant( + maxIconRow, + (actor) => + actor.has_style_class_name && + actor.has_style_class_name('aurora-devtool-stepper-control'), + ) + : null; + if (!maxIconRow || !maxIconControl) throw new Error('Maximum Icon Size stepper not found'); + const [rowX] = maxIconRow.get_transformed_position(); + const [rowWidth] = maxIconRow.get_transformed_size(); + const [controlX] = maxIconControl.get_transformed_position(); + const [controlWidth] = maxIconControl.get_transformed_size(); + if (controlX + controlWidth > rowX + rowWidth) + throw new Error( + `Maximum Icon Size control ends at ${controlX + controlWidth}px, ` + + `outside its row at ${rowX + rowWidth}px`, + ); + const resetIcon = findDescendant( + maxIconControl, + (actor) => actor instanceof St.Icon && actor.icon_name === 'edit-undo-symbolic', + ); + if (!resetIcon) throw new Error('Maximum Icon Size reset action is not symbolic'); + resetIcon.get_parent().emit('clicked', 1); + await Scripting.waitLeisure(); + + const developerSubtitle = findDescendant( + panelButton.menu.box, + (actor) => actor.text === 'Simulate runtime states and trigger module actions', + ); + if (!developerSubtitle) throw new Error('Dock Developer section is missing'); + if (!developerSubtitle.clutter_text.line_wrap) + throw new Error('Dock Developer subtitle does not wrap'); + if (developerSubtitle.clutter_text.ellipsize !== Pango.EllipsizeMode.NONE) + throw new Error('Dock Developer subtitle is clipped with an ellipsis'); + + const subtleChoice = findDescendant( + panelButton.menu.box, + (actor) => actor.accessible_name === 'Effect Intensity: Subtle', + ); + const balancedChoice = findDescendant( + panelButton.menu.box, + (actor) => actor.accessible_name === 'Effect Intensity: Balanced', + ); + const segmentedControl = balancedChoice + ? findAncestor( + balancedChoice, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-segmented'), + ) + : null; + if (!subtleChoice || !balancedChoice || !segmentedControl) + throw new Error('Effect Intensity segmented choices not found'); + const [effectControlX, effectControlY] = segmentedControl.get_transformed_position(); + const [effectControlWidth, effectControlHeight] = segmentedControl.get_transformed_size(); + for (const choice of segmentedControl.get_children()) { + const [choiceX, choiceY] = choice.get_transformed_position(); + const [choiceWidth, choiceHeight] = choice.get_transformed_size(); + if ( + choiceX < effectControlX || + choiceX + choiceWidth > effectControlX + effectControlWidth || + choiceY < effectControlY || + choiceY + choiceHeight > effectControlY + effectControlHeight + ) + throw new Error('Effect Intensity choice overflows segmented control'); + } + balancedChoice.emit('clicked', 1); + if (settings.get_string('dock-motion-profile') !== 'balanced') + throw new Error('Effect Intensity selection did not update its setting'); + if (!balancedChoice.checked || subtleChoice.checked) + throw new Error('Effect Intensity segmented state did not update'); + + const dockBack = findDescendant( + panelButton.menu.box, + (actor) => actor.name === 'aurora-devtool-focus-back', + ); + dockBack.emit('clicked', 1); + + const auroraMenuOpen = findDescendant( + panelButton.menu.box, + (actor) => actor.accessible_name === 'Aurora Menu', + ); + if (!auroraMenuOpen) throw new Error('Aurora Menu module row not found'); + const auroraMenuRow = findAncestor( + auroraMenuOpen, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-row'), + ); + const auroraModuleIcon = auroraMenuRow + ? findDescendant( + auroraMenuRow, + (actor) => + actor instanceof St.Icon && + actor.gicon?.to_string().includes('aurora-shell-menu-symbolic.svg'), + ) + : null; + if (!auroraModuleIcon) throw new Error('Aurora Menu module does not use its custom icon'); + auroraMenuOpen.emit('clicked', 1); + await Scripting.waitLeisure(); + + const softwareCommandLabel = findDescendant( + panelButton.menu.box, + (actor) => actor.text === 'Software Command', + ); + const softwareCommandRow = softwareCommandLabel + ? findAncestor( + softwareCommandLabel, + (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-row'), + ) + : null; + const softwareCommandEntry = softwareCommandRow + ? findDescendant(softwareCommandRow, (actor) => actor instanceof St.Entry) + : null; + if (!softwareCommandEntry) throw new Error('Software Command input not found'); + const entryTheme = softwareCommandEntry.get_theme_node(); + if ( + entryTheme.get_background_color().alpha === 0 || + entryTheme.get_border_width(St.Side.TOP) === 0 + ) + throw new Error('Software Command input has no visible field surface'); + + const auroraMenuScroll = findDescendant( + panelButton.menu.box, + (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-scroll'), + ); + if (!auroraMenuScroll) throw new Error('Aurora Menu settings scroll view not found'); + auroraMenuScroll.vadjustment.value = + auroraMenuScroll.vadjustment.upper - auroraMenuScroll.vadjustment.page_size; + const scrollBeforeRebuild = auroraMenuScroll.vadjustment.value; + if (!(scrollBeforeRebuild > 0)) + throw new Error('Aurora Menu settings page did not become scrollable'); + const auroraMenuModuleSwitch = findDescendant( + panelButton.menu.box, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-switch-button'), + ); + if (!auroraMenuModuleSwitch) throw new Error('Aurora Menu module switch not found'); + auroraMenuModuleSwitch.emit('clicked', 1); + await Scripting.waitLeisure(); + const rebuiltAuroraMenuScroll = findDescendant( + panelButton.menu.box, + (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-scroll'), + ); + if ( + !rebuiltAuroraMenuScroll || + rebuiltAuroraMenuScroll.vadjustment.value < scrollBeforeRebuild - 1 + ) + throw new Error('Toggling a module lost the Module Settings scroll position'); + const rebuiltAuroraMenuSwitch = findDescendant( + panelButton.menu.box, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-switch-button'), + ); + if (!rebuiltAuroraMenuSwitch) throw new Error('Rebuilt Aurora Menu module switch not found'); + rebuiltAuroraMenuSwitch.emit('clicked', 1); + await Scripting.waitLeisure(); + + const iconSelector = findDescendant(panelButton.menu.box, (actor) => + actor.accessible_name?.startsWith('Menu Icon:'), + ); + if (!iconSelector) throw new Error('Aurora Menu icon selector not found'); + iconSelector.emit('clicked', 1); + await Scripting.waitLeisure(); + for (const [label, filename] of [ + ['Menu Icon: Aurora Shell', 'aurora-shell-menu-symbolic.svg'], + ['Menu Icon: Luminus OS', 'luminus-os-symbolic.svg'], + ]) { + const choice = findDescendant(panelButton.menu.box, (actor) => actor.accessible_name === label); + const loadedIcon = choice + ? findDescendant( + choice, + (actor) => actor instanceof St.Icon && actor.gicon?.to_string().includes(filename), + ) + : null; + if (!loadedIcon) throw new Error(`${label} does not load its bundled icon`); + } + + const customCommands = findDescendant( + panelButton.menu.box, + (actor) => actor.accessible_name === 'Custom Menu Commands', + ); + const customCommandsSubtitle = customCommands + ? findDescendant( + customCommands, + (actor) => actor.text === 'Add shortcuts that run commands from Aurora Menu', + ) + : null; + if (!customCommands || !customCommandsSubtitle) + throw new Error('Custom Menu Commands navigation row not found'); + customCommandsSubtitle.text = + 'Add shortcuts that run commands from Aurora Menu and remain inside this settings row ' + + 'even when localization produces a substantially longer description'; + await Scripting.waitLeisure(); + if ( + !customCommandsSubtitle.clutter_text.line_wrap || + customCommandsSubtitle.clutter_text.ellipsize !== Pango.EllipsizeMode.NONE + ) + throw new Error('Custom Menu Commands subtitle is not configured to wrap'); + const [commandsX, commandsY] = customCommands.get_transformed_position(); + const [commandsWidth, commandsHeight] = customCommands.get_transformed_size(); + const [commandsSubtitleX, commandsSubtitleY] = customCommandsSubtitle.get_transformed_position(); + const [commandsSubtitleWidth, commandsSubtitleHeight] = + customCommandsSubtitle.get_transformed_size(); + if ( + commandsSubtitleX < commandsX || + commandsSubtitleX + commandsSubtitleWidth > commandsX + commandsWidth || + commandsSubtitleY < commandsY || + commandsSubtitleY + commandsSubtitleHeight > commandsY + commandsHeight + ) + throw new Error('Custom Menu Commands text extends outside its row'); + + const auroraMenuBack = findDescendant( + panelButton.menu.box, + (actor) => actor.name === 'aurora-devtool-focus-back', + ); + auroraMenuBack.emit('clicked', 1); + + const searchEntry = findDescendant( + panelButton.menu.box, + (actor) => actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-search'), + ); + if (!searchEntry) throw new Error('Search entry not found'); + if (searchEntry.hint_text !== 'Search modules') + throw new Error(`Unexpected search hint: ${searchEntry.hint_text}`); + searchEntry.clutter_text.set_text('zzz-no-such-module'); + const statusPage = findDescendant( + panelButton.menu.box, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-status-page'), + ); + if (!statusPage) throw new Error('Empty-search status page not shown'); + if (!findDescendant(statusPage, (actor) => actor.text === 'No Results Found')) + throw new Error('Empty-search status page title not found'); + searchEntry.clutter_text.set_text(''); + const restoredRow = findDescendant( + panelButton.menu.box, + (actor) => + actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-module-row'), + ); + if (!restoredRow) throw new Error('Module list was not restored after clearing the search'); + + const removedCardMenu = findDescendant( + panelButton.menu.box, + (actor) => + actor.accessible_name === 'Menu' || + (actor.has_style_class_name && actor.has_style_class_name('aurora-devtool-card-menu')), + ); + if (removedCardMenu) throw new Error('Removed DevTool action menu is still present'); + panelButton.menu.close(); +} diff --git a/tests/shell/performance/dockIntellihide.test.js b/tests/shell/performance/dockIntellihide.test.js new file mode 100644 index 0000000..4c724ec --- /dev/null +++ b/tests/shell/performance/dockIntellihide.test.js @@ -0,0 +1,109 @@ +/* eslint camelcase: ["error", { properties: "never", allow: ["^script_"] }] */ + +import * as Scripting from 'resource:///org/gnome/shell/ui/scripting.js'; +import { + EXTENSION_UUID, + getAuroraModule, + getAuroraSettings, + waitForCondition, + waitForExtension, +} from '../support/testUtils.js'; + +export var METRICS = {}; + +export function init() { + Scripting.defineScriptEvent( + 'dockGeometryChecksCoalesced', + 'Dock geometry bursts produce at most one overlap check per frame', + ); + Scripting.defineScriptEvent( + 'dockPendingCheckCancelled', + 'Dock teardown cancels its pending overlap check', + ); +} + +export async function run() { + await waitForExtension(EXTENSION_UUID); + + const settings = getAuroraSettings(); + const originalIntellihide = settings.get_boolean('dock-intellihide'); + settings.set_boolean('dock-intellihide', true); + await Scripting.waitLeisure(); + + try { + const dock = getAuroraModule('dock'); + const intellihide = dock?.bindings?.[0]?.intellihide; + if (!intellihide) throw new Error('Dock intellihide is unavailable'); + + intellihide._queuedRefreshes.clear(); + intellihide._settle.clear(); + + const originalCheckOverlap = intellihide._checkOverlap; + let overlapChecks = 0; + intellihide._checkOverlap = () => { + overlapChecks++; + }; + + const inputSignals = 1_000; + try { + for (let index = 0; index < inputSignals; index++) intellihide._markDirty('benchmark'); + await waitForCondition({ + evaluate: () => overlapChecks >= 1, + signals: [[global.stage, 'after-paint']], + description: 'coalesced Dock overlap check', + }); + } finally { + intellihide._checkOverlap = originalCheckOverlap; + } + if (overlapChecks !== 1) + throw new Error(`${inputSignals} geometry signals produced ${overlapChecks} overlap checks`); + + METRICS = { + inputSignals, + uncoalescedPotentialChecks: inputSignals, + optimizedChecks: overlapChecks, + reductionPercent: ((inputSignals - overlapChecks) / inputSignals) * 100, + }; + Scripting.scriptEvent('dockGeometryChecksCoalesced'); + + let mixedBurstChecks = 0; + intellihide._checkOverlap = function (...args) { + mixedBurstChecks++; + return originalCheckOverlap.apply(this, args); + }; + try { + intellihide._markDirty('geometry-before-state'); + intellihide.refresh('discrete-state'); + await Scripting.waitLeisure(); + } finally { + intellihide._checkOverlap = originalCheckOverlap; + } + if (mixedBurstChecks !== 1) + throw new Error(`Mixed geometry/state burst produced ${mixedBurstChecks} overlap checks`); + + const teardownSubject = new intellihide.constructor(0); + teardownSubject._queuedRefreshes.clear(); + teardownSubject._settle.clear(); + let postDestroyChecks = 0; + teardownSubject._checkOverlap = () => { + postDestroyChecks++; + }; + teardownSubject._markDirty('teardown-benchmark'); + teardownSubject.destroy(); + await Scripting.waitLeisure(); + if (postDestroyChecks !== 0) + throw new Error(`Dock teardown allowed ${postDestroyChecks} pending overlap checks`); + Scripting.scriptEvent('dockPendingCheckCancelled'); + } finally { + settings.set_boolean('dock-intellihide', originalIntellihide); + await Scripting.waitLeisure(); + } +} + +export function script_dockGeometryChecksCoalesced() { + return METRICS; +} + +export function script_dockPendingCheckCancelled() { + return true; +} diff --git a/tests/unit/clipboard/clipboardLog.test.ts b/tests/unit/clipboard/clipboardLog.test.ts index 31ea11c..6d5a859 100644 --- a/tests/unit/clipboard/clipboardLog.test.ts +++ b/tests/unit/clipboard/clipboardLog.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + type ClipboardEntrySnapshot, encodeAddOp, encodeCompactedLog, encodeDeleteOp, @@ -12,6 +13,19 @@ import { removeClipboardEntry, } from '~/clipboard/clipboardLog.ts'; +type ReferenceOp = + | { + op: 'add'; + id: string; + kind?: 'text' | 'image'; + text: string; + timestamp: number; + mimeType?: string; + filePath?: string; + contentKey?: string; + } + | { op: 'delete' | 'move' | 'pin' | 'unpin'; id: string }; + const alpha = { id: '1', kind: 'text' as const, @@ -111,3 +125,141 @@ test('clipboard entries are removed by identity', () => { assert.deepEqual(entries, [beta]); }); + +test('clipboard log replay matches array reference for deterministic mixed operations', () => { + let randomState = 0x5eed1234; + const random = (limit: number): number => { + randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0; + return randomState % limit; + }; + const operations: ReferenceOp[] = []; + + for (let index = 0; index < 2_000; index++) { + const id = String(random(80) + 1); + const operation = random(10); + if (operation < 4) { + operations.push({ + op: 'add', + id, + kind: operation === 0 ? 'image' : 'text', + text: `entry-${index}`, + timestamp: index, + mimeType: operation === 0 ? 'image/png' : undefined, + filePath: operation === 0 ? `/tmp/${index}.png` : undefined, + contentKey: operation % 2 === 0 ? `key-${index}` : undefined, + }); + } else { + const names = ['delete', 'move', 'pin', 'unpin'] as const; + operations.push({ op: names[random(names.length)], id }); + } + } + + const validSource = operations.map((operation) => JSON.stringify(operation) + '\n').join(''); + const malformedTail = '{"op":"add"'; + + assert.deepEqual( + parseClipboardLog(validSource + malformedTail), + parseClipboardLogReference(validSource), + ); +}); + +test('large clipboard log replay avoids array scans while preserving order', () => { + const operations: string[] = []; + const entryCount = 20_000; + for (let index = 1; index <= entryCount; index++) { + operations.push( + JSON.stringify({ op: 'add', id: String(index), text: `entry-${index}`, timestamp: index }), + ); + } + for (let index = 1; index <= entryCount; index++) { + operations.push(JSON.stringify({ op: 'move', id: String(index) })); + } + + const originalIndexOf = Array.prototype.indexOf; + let arrayScans = 0; + Array.prototype.indexOf = function (this: T[], searchElement: T, fromIndex?: number): number { + arrayScans++; + return originalIndexOf.call(this, searchElement, fromIndex); + }; + + try { + const state = parseClipboardLog(operations.join('\n')); + + assert.equal(arrayScans, 0); + assert.equal(state.history.length, entryCount); + assert.equal(state.history[0]?.id, String(entryCount)); + assert.equal(state.history.at(-1)?.id, '1'); + assert.equal(state.wastedOps, entryCount); + } finally { + Array.prototype.indexOf = originalIndexOf; + } +}); + +function parseClipboardLogReference(source: string) { + const pinned: ClipboardEntrySnapshot[] = []; + const history: ClipboardEntrySnapshot[] = []; + const byId = new Map(); + let nextId = 1; + let wastedOps = 0; + + for (const line of source.split('\n')) { + if (!line) continue; + + let op: ReferenceOp; + try { + op = JSON.parse(line) as ReferenceOp; + } catch { + break; + } + + if (op.op === 'add') { + const entry: ClipboardEntrySnapshot = { + id: op.id, + kind: op.kind || 'text', + text: op.text, + pinned: false, + timestamp: op.timestamp, + contentKey: op.contentKey || op.text, + }; + if (op.mimeType) entry.mimeType = op.mimeType; + if (op.filePath) entry.filePath = op.filePath; + byId.set(entry.id, entry); + history.unshift(entry); + nextId = Math.max(nextId, Number.parseInt(entry.id, 10) + 1 || nextId); + continue; + } + + const entry = byId.get(op.id); + if (!entry) continue; + + if (op.op === 'delete') { + removeClipboardEntry(pinned, entry); + removeClipboardEntry(history, entry); + byId.delete(op.id); + wastedOps += 2; + } else if (op.op === 'move') { + const list = entry.pinned ? pinned : history; + moveReferenceEntryToFront(list, entry); + wastedOps += 1; + } else if (op.op === 'pin') { + removeClipboardEntry(history, entry); + entry.pinned = true; + moveReferenceEntryToFront(pinned, entry); + } else if (op.op === 'unpin') { + removeClipboardEntry(pinned, entry); + entry.pinned = false; + moveReferenceEntryToFront(history, entry); + wastedOps += 2; + } + } + + return { pinned, history, nextId, wastedOps }; +} + +function moveReferenceEntryToFront( + list: ClipboardEntrySnapshot[], + entry: ClipboardEntrySnapshot, +): void { + removeClipboardEntry(list, entry); + list.unshift(entry); +} diff --git a/tests/unit/panel/clock/calendarReminders/calendarRemindersLogic.test.ts b/tests/unit/panel/clock/calendarReminders/calendarRemindersLogic.test.ts index 72fdbea..23844d0 100644 --- a/tests/unit/panel/clock/calendarReminders/calendarRemindersLogic.test.ts +++ b/tests/unit/panel/clock/calendarReminders/calendarRemindersLogic.test.ts @@ -6,6 +6,7 @@ import { getStartReminderEvents, getStartReminderId, normalizeCalendarServerEvent, + removeCalendarEventsByPrefix, type CalendarEvent, } from '~/panel/clock/calendarReminders/calendarRemindersLogic.ts'; @@ -44,6 +45,18 @@ test('calendarReminders: normalizes CalendarServer identity and times', () => { assert.strictEqual(normalized.startEpochSeconds, NOW + 60); }); +test('calendarReminders: removes every recurring instance matching a server prefix', () => { + const events = new Map([ + ['calendar-1\nevent-1\nfirst', event({ id: 'calendar-1\nevent-1\nfirst' })], + ['calendar-1\nevent-1\nsecond', event({ id: 'calendar-1\nevent-1\nsecond' })], + ['calendar-1\nevent-2\nfirst', event({ id: 'calendar-1\nevent-2\nfirst' })], + ]); + + assert.equal(removeCalendarEventsByPrefix(events, 'calendar-1\nevent-1\n'), true); + assert.deepEqual([...events.keys()], ['calendar-1\nevent-2\nfirst']); + assert.equal(removeCalendarEventsByPrefix(events, 'missing\n'), false); +}); + test('calendarReminders: start reminders exclude stale, ended and all-day events', () => { const events = [ event({ id: 'future', startEpochSeconds: NOW + 60 }), diff --git a/tests/unit/patches/iconWeaveRegistry.test.ts b/tests/unit/patches/iconWeaveRegistry.test.ts index c760060..5fe14a6 100644 --- a/tests/unit/patches/iconWeaveRegistry.test.ts +++ b/tests/unit/patches/iconWeaveRegistry.test.ts @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { IconWeaveWindowRegistry } from '~/patches/iconWeaveRegistry.ts'; +import { + createIconWeaveResolutionKey, + IconWeaveWindowRegistry, +} from '~/patches/iconWeaveRegistry.ts'; function fakeWindow(wmClass: string, appId: string) { return { @@ -10,43 +13,61 @@ function fakeWindow(wmClass: string, appId: string) { }; } -test('icon weave registry reuses mappings with the same application identity', () => { +test('icon weave registry exposes current window mappings', () => { 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); + assert.equal(registry.mappings.get(first), application); }); -test('icon weave registry retains a processed identity until its last mapping is removed', () => { +test('icon weave registry retains cached resolutions after mappings are removed', () => { const registry = new IconWeaveWindowRegistry(); const application = { id: 'example.desktop' }; const first = fakeWindow('Example', 'com.example.App'); const second = fakeWindow('Example', 'com.example.App'); + const key = createIconWeaveResolutionKey('Example', 'com.example.App', 'Example'); registry.map(first, application); registry.map(second, application); - registry.markProcessed('Example'); + registry.setResolvedApp(key, 'example.desktop'); - registry.remove(first, 'Example', 'com.example.App'); - assert.equal(registry.hasProcessed('Example'), true); + registry.remove(first); + assert.equal(registry.getResolvedApp(key), 'example.desktop'); - registry.remove(second, 'Example', 'com.example.App'); - assert.equal(registry.hasProcessed('Example'), false); + registry.remove(second); + assert.equal(registry.getResolvedApp(key), 'example.desktop'); }); -test('icon weave registry clears mappings and processed identities together', () => { +test('icon weave resolution keys distinguish title-dependent matches', () => { + const first = createIconWeaveResolutionKey('Example', 'com.example.App', 'First'); + const second = createIconWeaveResolutionKey('Example', 'com.example.App', 'Second'); + + assert.notEqual(first, second); +}); + +test('icon weave registry invalidates resolutions without dropping live mappings', () => { const registry = new IconWeaveWindowRegistry(); const window = fakeWindow('Example', 'com.example.App'); + const key = createIconWeaveResolutionKey('Example', 'com.example.App', 'Example'); registry.map(window, { id: 'example.desktop' }); - registry.markProcessed('Example'); - registry.clear(); + registry.setResolvedApp(key, null); + registry.clearResolutions(); + + assert.equal(registry.mappings.size, 1); + assert.equal(registry.hasResolved(key), false); +}); + +test('icon weave registry bounds persistent title resolutions', () => { + const registry = new IconWeaveWindowRegistry(); + + for (let index = 0; index <= 256; index++) { + registry.setResolvedApp(`key-${index}`, `app-${index}.desktop`); + } - assert.equal(registry.mappings.size, 0); - assert.equal(registry.hasProcessed('Example'), false); + assert.equal(registry.hasResolved('key-0'), false); + assert.equal(registry.getResolvedApp('key-256'), 'app-256.desktop'); }); diff --git a/tests/unit/patches/iconWeaveScoring.test.ts b/tests/unit/patches/iconWeaveScoring.test.ts index 1927d41..ff4a002 100644 --- a/tests/unit/patches/iconWeaveScoring.test.ts +++ b/tests/unit/patches/iconWeaveScoring.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + createIconWeaveCandidateMetadata, + isIconWeaveSteamGame, registerIconWeaveWindow, scoreIconWeaveCandidate, unregisterIconWeaveWindow, @@ -9,10 +11,13 @@ import { const MIN_MATCH_SCORE = 50; +function candidate(desktopId: string, appName: string, executable = '') { + return createIconWeaveCandidateMetadata(desktopId, appName, executable); +} + test('IconWeave scoring rejects helper classes that only share a generic short token', () => { const score = scoreIconWeaveCandidate({ - desktopId: 'io.ente.auth', - appName: 'Ente Auth', + candidate: candidate('io.ente.auth', 'Ente Auth'), wmClass: 'nm-openconnect-auth-dialog', appId: '', title: 'Authentication Required', @@ -31,8 +36,7 @@ test('IconWeave registration updates immutably and removes mapped windows', () = test('IconWeave scoring keeps exact identity matches strong', () => { const score = scoreIconWeaveCandidate({ - desktopId: 'io.ente.auth', - appName: 'Ente Auth', + candidate: candidate('io.ente.auth', 'Ente Auth'), wmClass: 'io.ente.auth', appId: '', title: '', @@ -43,8 +47,7 @@ test('IconWeave scoring keeps exact identity matches strong', () => { test('IconWeave scoring keeps compact short-id variants matchable', () => { const score = scoreIconWeaveCandidate({ - desktopId: 'com.discordapp.Discord', - appName: 'Discord', + candidate: candidate('com.discordapp.Discord', 'Discord'), wmClass: 'discordcanary', appId: '', title: '', @@ -52,3 +55,48 @@ test('IconWeave scoring keeps compact short-id variants matchable', () => { assert.ok(score >= MIN_MATCH_SCORE); }); + +test('IconWeave candidate metadata normalizes desktop identity once', () => { + const metadata = candidate( + 'Com.ValveSoftware.Game.desktop', + 'Half-Life 2', + 'steam steam://rungameid/220', + ); + + assert.deepEqual(metadata, { + desktopId: 'com.valvesoftware.game', + appName: 'half-life 2', + shortId: 'game', + normalizedDesktopId: 'comvalvesoftwaregame', + normalizedAppName: 'halflife2', + normalizedShortId: 'game', + abbreviation: 'hl2', + steamGameId: '220', + }); +}); + +test('IconWeave Steam matching uses cached Exec ID and name abbreviation', () => { + const metadata = candidate('half-life-2.desktop', 'Half-Life 2', 'steam://rungameid/220'); + + assert.equal(isIconWeaveSteamGame(metadata, 'steam_app_220'), true); + assert.equal(isIconWeaveSteamGame(metadata, 'hl2'), true); + assert.equal(isIconWeaveSteamGame(metadata, 'steam_app_221'), false); + assert.equal(isIconWeaveSteamGame(candidate('half-life-2.desktop', 'Half-Life 2'), 'hl2'), false); +}); + +test('IconWeave Steam identity takes precedence over ordinary candidate scoring', () => { + const metadata = candidate( + 'com.valvesoftware.steam-app-220.desktop', + 'Half-Life 2', + 'steam://rungameid/220', + ); + const ordinaryScore = scoreIconWeaveCandidate({ + candidate: metadata, + wmClass: 'steam_app_220', + appId: '', + title: '', + }); + + assert.equal(isIconWeaveSteamGame(metadata, 'steam_app_220'), true); + assert.equal(ordinaryScore, 99); +});