diff --git a/Makefile b/Makefile deleted file mode 100644 index aeb5df8d..00000000 --- a/Makefile +++ /dev/null @@ -1,16 +0,0 @@ - -version = `python3 -c "import fildem; print(fildem.__version__)"` - -build: - # python3 setup.py bdist_wheel - python3 setup.py install --root=debian --install-layout=deb --no-compile - cd debian && find . -type f ! -regex '.*.hg.*' ! -regex '.*?debian-binary.*' ! -regex '.*?DEBIAN.*' -printf '%P ' | xargs md5sum > DEBIAN/md5sums && cd - - sed -i "s/Version: .*/Version: $(version)/" "debian/DEBIAN/control" - dpkg -b debian/ fildem_$(version)_all.deb - -clean: - rm -rf dist/ - rm -rf build/ - rm -rf *.egg-info/ - rm -rf debian/usr/ - rm debian/DEBIAN/md5sums diff --git a/PKGBUILD b/PKGBUILD deleted file mode 100644 index e28e0aeb..00000000 --- a/PKGBUILD +++ /dev/null @@ -1,35 +0,0 @@ -pkgname=python3-fildem -pkgver=0.6.5 -pkgrel=1 -pkgdesc="This project is a fork of gnomehud with the adition of a global menu bar" -arch=('i686' 'x86_64') -url="https://github.com/gonzaarcr/fildem" -depends=('bamf' - 'appmenu-gtk-module' - 'libkeybinder3' - 'libdbusmenu-gtk2' - 'libdbusmenu-gtk3') -makedepends=('git') -provides=("python3-fildem=$pkgver") -source=('git+https://github.com/gonzaarcr/fildem.git') -md5sums=('SKIP') - -pkgver() { - cd "$srcdir/fildem" - python3 -c "import fildem; print(fildem.__version__)" -} - -build() { - cd "$srcdir/fildem" - python3 setup.py bdist_wheel -} - -check() { - cd "$srcdir/fildem" - python3 setup.py test -} - -package() { - cd "$srcdir/fildem" - python3 setup.py install --skip-build --root=$pkgdir --optimize=1 -} diff --git a/README.md b/README.md index 30d932b1..44988e30 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,55 @@ -# Fildem +# Fildem Rewrite -## Global menu for Gnome +Fildem is now a full rewrite focused on GNOME 48+. -[![Buy Me A Coffee](https://img.shields.io/badge/buy%20me%20a%20coffee-donate-yellow.svg)](https://buymeacoffee.com/gonza) +## Architecture -![Fildem](https://user-images.githubusercontent.com/19943481/95288612-1d272a80-083f-11eb-9400-be88f61e054d.png) +1. `src/core` - C/GObject shared core (`libfildem-core`) +2. `src/daemon` - `fildemd` session daemon (`org.fildem`) +3. `src/extension/fildem-shell@fildem.org` - GNOME Shell extension (ESM) +4. `src/hud` - `fildemhud` GTK4 HUD +5. `interfaces` - versioned D-Bus contracts (`org.fildem.v1.*`) -This project is a fork of gnomehud with the adition of a global menu bar. It’s consist of a Gnome Shell extension and a external program, you must install both for the application to work. +## Build -You can also bring a HUD menu with Alt + Space (on Xorg). - -This is a prototype, as I don’t know if people will like or how much it will last until devs nuke it, so fell free to let me know your opinion. - -## Installation - -### Extension - -To install the extension, download it from the [Gnome extensions website](https://extensions.gnome.org/extension/4114/fildem-global-menu/). - -### Ubuntu - -Download the .deb file from the releases section and run `sudo apt install ./fildem_*.deb` - -### Arch - -Download the .zst file from the releases section and run `sudo pacman -U ./python3-fildem*.zst` - -## Configuration - -In order for the application to work, you must configure the following files (aplies to all operating systems): - -- Create the file `~/.gtkrc-2.0` and append `gtk-modules="appmenu-gtk-module"` -- The file `~/.config/gtk-3.0/settings.ini` should have the line `gtk-modules="appmenu-gtk-module"` under [Settings]. If it doesn’t exist create it and paste the following - -``` -[Settings] -gtk-modules="appmenu-gtk-module" +```bash +meson setup build +meson compile -C build +meson test -C build --print-errorlogs ``` -## Running - -After installation you’ll have two executables, `fildem` and `fildem-hud`. To check if it works use the first one. `fildem-hud` is for using he HUD, if you are on Xorg, you already have it bound to Alt + Space. If you are on Wayland, you can bind some keybinding to that command. - -## Customization - -### Menu always visible - -By default, the menu is visible when you hover the mouse on the panel. If you want the menu to be always visible, unselect “Show menu only when the mouse is over the panel” in the preferences of the extension. - -### AppMenu Button always visible - -The AppMenu button shows the application name or window title (if you have some extension) in the panel. By default, the fildem extension hides that label when the menu is being shown. If you want it to be always visible, you can unselect “Hide App Menu label” in the preferences of the extension. - -### Reduce space between buttons - -If the menu shown on the panel is shifted with relation to the one that appears, like this: - -![Screenshot from 2021-06-17 11-09-00](https://user-images.githubusercontent.com/864630/122452193-da852880-cf5d-11eb-8ca8-27e481ab682c.png) - -you can tweak the "Button padding" in the preferences window of the extension (accessible from the tweak tool). - -### Remove space in between buttons - -In some gnome themes, the buttons have a small spacing between them. This can make the buttons easy to miss and unfocusing our window if it’s not maximized. To fix this, add this somewhere on your `gnome-shell.css` theme: +## Install (local staging) -``` -#panel #panelLeft { - spacing: 0px; } -#panel #panelLeft .panel-button { - spacing: 0px; } +```bash +meson install -C build --destdir /tmp/fildem-install ``` -## Running the program at startup +## Runtime (manual) -If you manage to make the program work and want to have it running automatically at startup you can add an entry to `gnome-session-properties` with the name of the program and the path to execute it. +```bash +./build/src/daemon/fildemd +./build/src/hud/fildemhud +./build/src/ctl/fildemctl status +``` -## Create a shortcut for the HUD on Wayland +Useful commands: -Since it’s not possible to creat a shortcut from the app on Wayland, you have to create it yourself. Go to Settings → Keyboard Shorcuts and create a shortcut that executes `inithud.sh`. +```bash +./build/src/ctl/fildemctl top-level +./build/src/ctl/fildemctl menu-tree +./build/src/ctl/fildemctl actions +./build/src/ctl/fildemctl hud-request +./build/src/ctl/fildemctl hud-query +./build/src/ctl/fildemctl hud-exec +``` -## State of the Apps +## API documentation -To see a list of apps that work check [the wiki](https://github.com/gonzaarcr/Fildem/wiki/Using#state-of-the-apps) +- `docs/DBUS_API_V1.md` +- `docs/REWRITE_PLAN_IT.md` +- `docs/IMPLEMENTATION_PLAN_REAL.md` -## Installation troubleshooting +## Legacy -If you have any question on to get it to work, please don’t create an issue, use [this discussion](https://github.com/gonzaarcr/Fildem/discussions/33). +Legacy fork code was intentionally removed from the active tree. +The legacy baseline is preserved in git tag `legacy/fork-last-2026-03-24`. diff --git a/data/org.fildem.hud.desktop b/data/org.fildem.hud.desktop new file mode 100644 index 00000000..5f3b2f55 --- /dev/null +++ b/data/org.fildem.hud.desktop @@ -0,0 +1,6 @@ +[Desktop Entry] +Type=Application +Name=Fildem HUD +Exec=fildemhud +Terminal=false +Categories=Utility; diff --git a/debian/DEBIAN/control b/debian/DEBIAN/control deleted file mode 100644 index 75c4613a..00000000 --- a/debian/DEBIAN/control +++ /dev/null @@ -1,11 +0,0 @@ -Package: python3-fildem -Source: fildem -Version: 0.6.7 -Architecture: all -Maintainer: Gonzalo -Original-Maintainer: Gonzalo -Depends: libbamf3-dev, bamfdaemon, libkeybinder-3.0-dev, appmenu-gtk2-module, appmenu-gtk3-module, unity-gtk-module-common, python3-gi, python3:any -Section: python -Priority: optional -Homepage: https://github.com/gonzaarcr/fildem -Description: This project is a fork of gnomehud with the adition of a global menu bar. diff --git a/debian/DEBIAN/postinst b/debian/DEBIAN/postinst deleted file mode 100755 index 181dd2bd..00000000 --- a/debian/DEBIAN/postinst +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -set -e - -if which py3compile >/dev/null 2>&1; then - py3compile -p python3-fildem -fi -if which pypy3compile >/dev/null 2>&1; then - pypy3compile -p python3-fildem || true -fi - diff --git a/debian/DEBIAN/prerm b/debian/DEBIAN/prerm deleted file mode 100755 index 609ef38d..00000000 --- a/debian/DEBIAN/prerm +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -set -e - -if which py3clean >/dev/null 2>&1; then - py3clean -p python3-fildem -else - dpkg -L python3-fildem | perl -ne 's,/([^/]*)\.py$,/__pycache__/\1.*, or next; unlink $_ or die $! foreach glob($_)' - find /usr/lib/python3/dist-packages/ -type d -name __pycache__ -empty -print0 | xargs --null --no-run-if-empty rmdir -fi - diff --git a/docs/DBUS_API_V1.md b/docs/DBUS_API_V1.md new file mode 100644 index 00000000..2c35b512 --- /dev/null +++ b/docs/DBUS_API_V1.md @@ -0,0 +1,86 @@ +# Fildem D-Bus API v1 (`org.fildem`) + +Object path root: `/org/fildem/v1` +Bus name: `org.fildem` + +## `org.fildem.v1.Window` + +1. `UpdateActiveWindow(a{sv} context)` +2. `GetActiveWindow() -> a{sv} context` +3. Signal `ActiveWindowChanged(a{sv} context)` + +Required context keys: + +1. `window_uid` (`s`) +2. `app_id` (`s`) + +Optional context keys used by provider adapters: + +1. `title` (`s`) +2. `workspace` (`i`) +3. `monitor` (`i`) +4. `backend` (`s`) values: `wayland` or `x11` +5. `xid` (`s` or integer-compatible variant) +6. `gtk_unique_bus_name` (`s`) +7. `gtk_window_object_path` (`s`) +8. `gtk_application_object_path` (`s`) +9. `gtk_menubar_object_path` (`s`) +10. `gtk_app_menu_object_path` (`s`) + +## `org.fildem.v1.TopLevel` + +1. `SetTopLevel(s window_uid, aa{sv} items)` +2. `GetTopLevel(s window_uid) -> aa{sv} items` +3. Signal `TopLevelChanged(s window_uid, aa{sv} items)` + +Required item keys: + +1. `id` (`s`) +2. `label` (`s`) + +## `org.fildem.v1.MenuTree` + +1. `SetMenuTree(s window_uid, aa{sv} nodes)` +2. `GetMenuTree(s window_uid) -> aa{sv} nodes` +3. `InvalidateMenuTree(s window_uid)` +4. Signal `MenuTreeChanged(s window_uid)` + +Required node keys: + +1. `id` (`s`) +2. `label` (`s`) + +## `org.fildem.v1.Activation` + +1. `ActivateTopLevel(s window_uid, s top_level_id)` +2. `ActivateMenuItem(s window_uid, s item_id)` +3. Signal `ActivationRequested(s window_uid, s target_id)` + +Notes: + +1. `target_id` can be a menu node id or `action:`. +2. Window actions are executed shell-side by the extension when it receives `ActivationRequested`. + +## `org.fildem.v1.WindowActions` + +1. `SetWindowActions(s window_uid, as actions)` +2. `ListWindowActions(s window_uid) -> as actions` +3. `ActivateWindowAction(s window_uid, s action_id)` +4. Signal `WindowActionsChanged(s window_uid, as actions)` + +Notes: + +1. `actions` are stable action labels/ids exposed by the extension (shell-native actions). +2. `ActivateWindowAction` emits `ActivationRequested(window_uid, "action:")` on `org.fildem.v1.Activation`. + +## `org.fildem.v1.Hud` + +1. `RequestHud(s window_uid)` +2. `Query(s window_uid, s term) -> aa{sv} results` +3. `Execute(s window_uid, s entry_id)` +4. Signal `HudRequested(s window_uid)` + +Notes: + +1. `Query` results include both menu entries and window actions. +2. For window actions, result ids are prefixed as `action:`. diff --git a/docs/IMPLEMENTATION_PLAN_REAL.md b/docs/IMPLEMENTATION_PLAN_REAL.md new file mode 100644 index 00000000..72a5fe24 --- /dev/null +++ b/docs/IMPLEMENTATION_PLAN_REAL.md @@ -0,0 +1,143 @@ +# Fildem Rewrite - Piano di Implementazione Reale + +Data riferimento: 24 marzo 2026 + +## Obiettivo di delivery + +Portare la riscrittura a un prodotto completo su stack nuovo (`C/GIO + GNOME Shell ESM + GTK4`) con: + +1. menu top-level affidabile nel panel +2. attivazione voci menu e azioni finestra +3. HUD funzionante su API D-Bus v1 +4. percorso Wayland-first con fallback controllati +5. build, test e packaging riproducibili + +## Stato implementazione per fase + +### Fase A - Specifica legacy -> target + +Stato: completata a livello di analisi (`docs/REWRITE_PLAN_IT.md`). + +Output operativo: + +1. scope legacy esplicitato +2. keep/drop/redesign formalizzato +3. ordine di migrazione definito + +### Fase B - Confini responsabilita' + +Stato: completata. + +Output operativo: + +1. Extension: stato Shell + UI panel + shell actions +2. Daemon: bus owner + provider menu + cache/registry + activation router +3. Core: validazione/serializzazione/cache/query model +4. HUD: client GTK4 disaccoppiato + +### Fase C - API D-Bus v1 + +Stato: completata. + +Artefatti: + +1. `interfaces/org.fildem.v1.Window.xml` +2. `interfaces/org.fildem.v1.TopLevel.xml` +3. `interfaces/org.fildem.v1.MenuTree.xml` +4. `interfaces/org.fildem.v1.Activation.xml` +5. `interfaces/org.fildem.v1.WindowActions.xml` +6. `interfaces/org.fildem.v1.Hud.xml` + +### Fase D - Extension GNOME 48+ ESM + +Stato: completata per MVP/V2 core. + +Implementato: + +1. lifecycle extension + panel controller +2. tracking finestra attiva con context esteso (xid + gtk exporter paths) +3. bridge D-Bus completo (`UpdateActiveWindow`, top-level, actions) +4. window actions shell-native complete (list + execute) +5. sync azioni verso daemon (`SetWindowActions`) +6. handling `ActivationRequested` per execute action da HUD/daemon +7. shortcut HUD configurabile (`hud-shortcut`) + +### Fase E - Core C/GObject/GIO + daemon + +Stato: completata per MVP/V2 core. + +Implementato: + +1. `libfildem-core` (error, serializer, registry, cache, menu query model) +2. `fildemd` con owner `org.fildem` +3. parser/provider GTK exporter (`org.gtk.Menus`) e DBusMenu +4. routing attivazioni (`ActivateTopLevel`, `ActivateMenuItem`, `Execute`) +5. cache/menu publish su `TopLevelChanged` e `MenuTreeChanged` + +### Fase F - HUD GTK4 + +Stato: completata MVP. + +Implementato: + +1. `fildemhud` GTK4 standalone +2. query live su `org.fildem.v1.Hud.Query` +3. execute entry su `org.fildem.v1.Hud.Execute` +4. trigger remoto via `org.fildem.v1.Hud.RequestHud` / `HudRequested` + +### Fase G - Shortcut system moderno + +Stato: parzialmente completata (path operativo pronto). + +Implementato: + +1. keybinding gestito dalla shell extension (`hud-shortcut`) +2. fallback robusto: launch `fildemhud` dal binding + +Backlog immediato per parity avanzata: + +1. backend `org.freedesktop.portal.GlobalShortcuts` dedicato +2. gestione capability/runtime fallback automatici portal -> extension + +### Fase H - Test harness + +Stato: completata base, estensione in corso. + +Implementato: + +1. unit test core (`tests/test-core.c`) +2. contract test introspection XML (`tests/test-contract.c`) +3. `meson test` integrato in build stack + +Backlog immediato: + +1. smoke D-Bus con `dbus-run-session` +2. integration tests su sessione GNOME nested + +## Piano esecutivo prossimo rilascio + +## R1 - Stabilizzazione IPC/UI (pronto) + +1. chiudere bugfix minori extension +2. verifiche manuali su GNOME 48/49 +3. freeze API v1 + +## R2 - GlobalShortcuts portal + +1. modulo dedicated shortcut backend +2. registrazione/attivazione shortcut portal +3. fallback automatico su keybinding extension + +## R3 - Test integrazione e packaging finale + +1. test smoke automatizzati in `dbus-run-session` +2. test nested-shell in CI +3. pacchetti Arch/Fedora/Deb aggiornati su stack nuovo + +## Definizione di done (release complete) + +1. top-level e activate funzionanti su app GTK/DBusMenu principali +2. window actions complete e execute da panel/HUD +3. HUD launch/query/execute via shortcut +4. `meson compile && meson test` verdi in CI +5. pacchetti installabili con service utente `fildemd` diff --git a/docs/REWRITE_PLAN_IT.md b/docs/REWRITE_PLAN_IT.md new file mode 100644 index 00000000..6c289c1e --- /dev/null +++ b/docs/REWRITE_PLAN_IT.md @@ -0,0 +1,305 @@ +# Fildem Rewrite Totale (GNOME 48+) + +## Premessa + +La prossima iterazione di Fildem e' una **riscrittura completa**, non un refactor. +Il codice legacy va usato solo come riferimento comportamentale (feature e aspettative utente), non come base tecnica da evolvere. + +## Scope reale della extension legacy + +### Funzionalita' oggi coperte + +1. Mostrare nel panel i top-level menu dell'app attiva (es. File, Edit, View, Help). +2. Aprire/attivare un menu quando l'utente clicca nel panel. +3. Aggiornarsi al cambio finestra attiva (app, titolo, top-level disponibili, visibilita'). +4. Gestire la visibilita' del menu nel panel (hover-only vs sempre visibile) e il label AppMenu. +5. Esporre window actions (minimize, maximize/unmaximize, move/resize, move workspace/monitor, close). +6. Fare da bridge Wayland verso processo esterno per compensare limiti del backend legacy. + +### Scope implicito (ma critico) + +Nel sistema attuale la extension e' anche: + +1. Source of truth dello stato GNOME Shell. +2. Bridge Shell -> backend. +3. Parte del workaround Wayland. + +## Scope corretto della nuova extension + +### Responsabilita' della nuova extension + +1. Osservare stato GNOME Shell: + - finestra attiva + - app associata + - titolo finestra + - workspace/monitor rilevanti + - disponibilita' menu esportati +2. Gestire UI panel: + - rendering top-level + - behavior hover/click/focus + - indicatori di stato + - app label opzionale +3. Agire da adapter Shell -> daemon: + - invio dati finestra attiva + - notifica cambi top-level/menu + - inoltro richieste utente di open/activate +4. Esporre window actions recuperabili dal backend mantenendo la logica shell-specific nella extension. +5. Integrare activation path moderni (HUD, menubar, fallback controllati). + +### Responsabilita' da escludere dalla extension + +1. Parsing profondo del menu model remoto. +2. Cache complessa del menu tree. +3. Rendering HUD standalone. +4. Process orchestration. +5. Fallback X11-app-centric come percorso primario. + +## Architettura target (riscrittura totale) + +1. `fildem-shell-extension` (GJS/ESM) + - modulo GNOME Shell nativo + - osservazione stato Shell + - rendering panel menu + - API bridge verso daemon +2. `libfildem-core` (C/GObject/GIO) + - libreria core + - discovery/registrazione menu remoti + - conversione a modello interno + - invalidazione cache + - API D-Bus stabile +3. `fildemd` (daemon di sessione) + - processo unico + - bus name interno + - coordinamento extension, HUD e fonti menu +4. `fildem-hud` (GTK4) + - UI HUD standalone + - query/filter/activate + - disaccoppiato dal panel + +## Funzionalita' target + +### MVP + +1. Extension caricabile su GNOME 48+. +2. Detection affidabile della finestra attiva. +3. Top-level menu nel panel. +4. Click -> open/activate menu corretto. +5. Aggiornamento al cambio finestra. +6. Wayland come percorso primario. +7. Daemon unico. +8. HUD minimale funzionante. + +### V2 + +1. Shortcut globali moderni. +2. Preferences Adwaita-native. +3. Supporto monitor/workspace migliorato. +4. Window actions complete. +5. Cache/invalidazione robusta. +6. Logging e diagnostica avanzati. + +### Non-MVP + +1. Compatibilita' X11 estesa. +2. Theming/custom CSS avanzato. +3. Backend alternativi toolkit-specific. +4. Feature parity totale con ogni app legacy. + +## Backlog di riscrittura + +### Fase A - Specifica funzionale dal legacy + +**Obiettivo:** estrarre comportamento osservabile senza riuso della struttura tecnica. + +Passi: + +1. Catalogare feature leggendo: + - `fildemGMenu@gonza.com/extension.js` + - `fildem/utils/menu.py` + - `fildem/utils/window.py` + - `fildem/handlers/global_menu.py` + - `fildem/utils/service.py` +2. Separare: + - comportamento utente + - workaround tecnologici + - bug/limiti storici +3. Produrre matrice `legacy -> target -> keep/drop/redesign`. +4. Marcare esplicitamente workaround X11/Wayland da non portare. + +Deliverable: + +1. Documento di specifica funzionale legacy. +2. Matrice decisionale feature. + +### Fase B - Confini di responsabilita' + +**Obiettivo:** evitare accoppiamento extension/daemon/core/HUD. + +Passi: + +1. Definire artefatti: + - `fildem-shell-extension` + - `libfildem-core` + - `fildemd` + - `fildem-hud` +2. Per ciascuno definire: + - ownership stato + - API pubbliche + - dipendenze consentite + - eventi in ingresso/uscita +3. Vietare logiche duplicate extension/daemon. +4. Formalizzare payload finestra attiva inviato dalla extension. + +Deliverable: + +1. Architecture boundaries document. +2. Contratto dati extension -> daemon. + +### Fase C - Nuova API D-Bus interna + +**Obiettivo:** sostituire metodi/segnali ad hoc legacy con IPC tipizzato. + +Passi: + +1. Definire namespace nuovo (es. `org.fildem`). +2. Progettare interfacce separate per: + - stato finestra attiva + - top-level menus + - menu tree + - attivazione voce + - HUD + - window actions +3. Produrre XML introspection completo. +4. Standardizzare payload/proprieta' (app id, title, window id logico, items, action ids, enabled/toggle). +5. Eliminare dipendenza concettuale da `MyService`/`Echo*`. + +Deliverable: + +1. `org.fildem.*.xml` versionato. +2. Linee guida di compatibilita' API. + +### Fase D - Nuova extension GNOME 48+ (ESM) + +**Obiettivo:** nuova extension moderna, non porting diretto. + +Passi: + +1. Creare struttura extension pulita (stessa directory o nuova dedicata). +2. Implementare `extension.js` ESM con scope minimo: + - lifecycle + - panel UI + - tracking finestra attiva + - bridge col daemon +3. Introdurre moduli interni: + - `panel-controller.js` + - `window-tracker.js` + - `daemon-client.js` + - `window-actions.js` +4. Evitare moduli shell interni quando esiste API pubblica equivalente. +5. Dichiarare compatibilita' GNOME 48+ dopo verifica API/path reali. + +Deliverable: + +1. Extension avviabile e installabile in sessione test. + +### Fase E - Core C/GObject/GIO + +**Obiettivo:** sostituire completamente il core Python. + +Passi: + +1. Creare `src/core/` con moduli piccoli: + - `fildem-menu-registry` + - `fildem-menu-model` + - `fildem-menu-cache` + - `fildem-dbus-client` + - `fildem-dbus-server` +2. Usare `GDBusConnection`, `GDBusProxy`, `GDBusInterfaceSkeleton`. +3. Definire ownership/refcount/ciclo vita oggetti GObject. +4. Aggiungere serializzazione modello menu testabile. +5. Esporre API interne semplici e documentate. + +Deliverable: + +1. Libreria core compilabile. +2. API interna documentata. + +### Fase F - HUD GTK4 + +**Obiettivo:** UI HUD nuova e disaccoppiata dal legacy GTK3. + +Passi: + +1. Creare `src/hud/` separato dal core. +2. Implementare HUD come `GtkApplication`. +3. Usare `GtkListView`/`GtkFilterListModel` per ricerca e navigazione. +4. Usare `GAction`/`GMenuModel` per attivazione. +5. Evitare porting diretto di widget GTK3 rimossi (`Gtk.Menu`, `Gtk.MenuBar`, `Gtk.AccelGroup`, popup grabs). + +Deliverable: + +1. HUD GTK4 funzionante con query/activate. + +### Fase G - Shortcut moderni + +**Obiettivo:** rimuovere dipendenza primaria da key grabbing X11. + +Passi: + +1. Implementare `shortcut-manager`. +2. Integrare `org.freedesktop.portal.GlobalShortcuts` come backend principale (dove disponibile). +3. Prevedere fallback controllati: + - trigger dalla shell extension + - backend X11 opzionale e separato +4. Documentare differenze capability Wayland/X11. + +Deliverable: + +1. Trigger HUD/menu funzionanti su Wayland dove supportati. + +### Fase H - Test harness agentico + +**Obiettivo:** validazione automatica di core, daemon e integrazione shell. + +Passi: + +1. Aggiungere unit test GLib per il core. +2. Aggiungere contract test D-Bus con fixture/mock publisher. +3. Introdurre fixture menu tree + golden output serializzati. +4. Preparare smoke test in sessione isolata con `dbus-run-session`. +5. Preparare test integrazione GNOME nested (separati da unit). +6. Usare attese su segnali/eventi, non sleep fissi. + +Deliverable: + +1. Pipeline test riproducibile locale/CI. + +## Regola di migrazione dal legacy + +Il codice legacy si usa solo per: + +1. nomi/semantica menu +2. comportamento top-level panel +3. window actions supportate +4. flusso update su cambio finestra +5. aspettative utente + +Non va copiato: + +1. struttura Python attuale +2. `os.system`/thread launch legacy +3. workaround `GDK_BACKEND=x11` +4. widget GTK3 legacy +5. servizio D-Bus `Echo*` +6. coupling extension/HUD/backend + +## Ordine di implementazione consigliato + +1. Specifica funzionale legacy (Fase A) +2. Nuova API D-Bus (Fase C) +3. Skeleton daemon C (Fase E, minimo) +4. Nuova extension ESM (Fase D) +5. Menu model + top-level bridge (E + D) +6. HUD GTK4 (Fase F) +7. Shortcut manager (Fase G) +8. Test harness e hardening (Fase H) diff --git a/fildem-hud.desktop b/fildem-hud.desktop deleted file mode 100644 index 486cf9b2..00000000 --- a/fildem-hud.desktop +++ /dev/null @@ -1,11 +0,0 @@ -[Desktop Entry] -Version=1.0 -Type=Application -Name=Fildem Hud -GenericName=Fildem Global Menu Hud -Comment=Fildem Global Menu Hud -Exec=fildem-hud -TryExec=fildem-hud -Icon=search -Terminal=false -Type=Application diff --git a/fildem/__init__.py b/fildem/__init__.py deleted file mode 100644 index cf3e6334..00000000 --- a/fildem/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -name = 'fildem' -__version__ = '0.6.7' diff --git a/fildem/appmenu.py b/fildem/appmenu.py deleted file mode 100644 index 4e70ccdf..00000000 --- a/fildem/appmenu.py +++ /dev/null @@ -1,31 +0,0 @@ -#! /usr/bin/python3 - -import dbus - -from gi.repository import GLib -from dbus.mainloop.glib import DBusGMainLoop - -from fildem.utils.service import BUS_NAME -from fildem.utils.service import AppMenuService, MyService - - -def run_service(): - AppMenuService() - MyService() - - try: - GLib.MainLoop().run() - except KeyboardInterrupt: - GLib.MainLoop().quit() - - -def main(): - DBusGMainLoop(set_as_default=True) - session_bus = dbus.SessionBus() - - if not session_bus.name_has_owner(BUS_NAME): - run_service() - - -if __name__ == "__main__": - main() diff --git a/fildem/command.py b/fildem/command.py deleted file mode 100644 index 6e28b5ed..00000000 --- a/fildem/command.py +++ /dev/null @@ -1,44 +0,0 @@ -#! /usr/bin/python3 - -import os -import threading - -from fildem.handlers.default import HudMenu -from fildem.handlers.global_menu import GlobalMenu -from fildem.handlers.rofi import RofiMenu - -def run_command(module, function): - args = 'python3 -c "from fildem.%s import %s as run; run()"' - args = args % (module, function) - - proc = threading.Thread(target=os.system, args=[args]) - proc.start() - -def run_hud_menu(menu): - run_command('appmenu', 'main') - run_command('keybinder', menu) - -def global_hud_menu(accel, dbus_menu): - menu = GlobalMenu(dbus_menu) - menu.run() - -def default_hud_menu(accel, dbus_menu): - menu = HudMenu(dbus_menu) - menu.run() - -def rofi_hud_menu(*args): - menu = RofiMenu() - menu.run() - -def main(): - run_hud_menu('main') - -def global_menu(): - run_hud_menu('global_menu') - -def rofi(): - run_hud_menu('rofi') - - -if __name__ == "__main__": - main() diff --git a/fildem/handlers/__init__.py b/fildem/handlers/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/fildem/handlers/default.py b/fildem/handlers/default.py deleted file mode 100644 index 2480fd0b..00000000 --- a/fildem/handlers/default.py +++ /dev/null @@ -1,453 +0,0 @@ -import gi -import time - -gi.require_version('Gtk', '3.0') -gi.require_version('Gdk', '3.0') - -from gi.repository import Gtk -from gi.repository import Gdk -from gi.repository import Gio -from gi.repository import GLib -from gi.repository import GObject - -from fildem.utils.fuzzy import FuzzyMatch -from fildem.utils.fuzzy import normalize_string -from fildem.utils.fuzzy import match_replace -from fildem.utils.window import WindowActions -from fildem.utils.wayland import is_wayland - - -def normalize_markup(text): - return text.replace('_', '').replace('&', '&') - - -def run_generator(function): - priority = GLib.PRIORITY_LOW - generator = function() - - GLib.idle_add(lambda: next(generator, False), priority=priority) - - -def inject_custom_style(widget, style_string): - provider = Gtk.CssProvider() - provider.load_from_data(style_string.encode()) - - screen = Gdk.Screen.get_default() - priority = Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - Gtk.StyleContext.add_provider_for_screen(screen, provider, priority) - - -def add_style_class(widget, class_names): - context = widget.get_style_context() - context.add_class(class_names) - - -class CommandListItem(Gtk.ListBoxRow): - - value = GObject.Property(type=str) - index = GObject.Property(type=int) - query = GObject.Property(type=str) - - def __init__(self, *args, **kwargs): - super(Gtk.ListBoxRow, self).__init__(*args, **kwargs) - - self.set_can_focus(False) - - self.query = self.get_property('query') - self.value = self.get_property('value') - self.index = self.get_property('index') - self.fuzzy = FuzzyMatch(text=self.value) - - self.label = Gtk.Label(margin=6, margin_left=10, margin_right=10) - self.label.set_justify(Gtk.Justification.LEFT) - self.label.set_halign(Gtk.Align.START) - - self.connect('notify::query', self.on_query_notify) - - self.add(self.label) - self.set_label(self.value) - - self.show_all() - - def get_label(self): - return self.label.get_label() - - def set_label(self, text): - self.label.set_label(normalize_markup(text)) - - def set_markup(self, markup): - self.label.set_markup(normalize_markup(markup)) - - def position(self): - return self.fuzzy.score if bool(self.query) else -1 - - def visibility(self): - return self.fuzzy.score > -1 if bool(self.query) else True - - def highlight_match(self, match): - return '%s' % match.group(0) - - def highlight_matches(self): - words = self.query.replace(' ', '|') - value = match_replace(words, self.highlight_match, self.value) - - self.set_markup(value) - - def do_label_markup(self): - if bool(self.query): - self.highlight_matches() - - elif '' in self.get_label(): - self.set_label(self.value) - - def on_query_notify(self, *args): - self.fuzzy.set_query(self.query) - - if self.visibility(): - GLib.idle_add(self.do_label_markup, priority=GLib.PRIORITY_HIGH_IDLE) - - -class CommandList(Gtk.ListBox): - - menu_actions = GObject.Property(type=object) - window_actions = GObject.Property(type=object) - - def __init__(self, *args, **kwargs): - super(Gtk.ListBox, self).__init__(*args, **kwargs) - - self.menu_actions = self.get_property('menu-actions') - self.select_value = '' - self.filter_value = '' - self.visible_rows = [] - self.selected_row = 0 - self.selected_obj = None - - self.list_window_actions = False - - self.set_sort_func(self.sort_function) - self.set_filter_func(self.filter_function) - - self.connect('row-selected', self.on_row_selected) - self.connect('notify::menu-actions', self.on_menu_actions_notify) - self.connect('notify::window-actions', self.on_menu_actions_notify) - - def set_filter_value(self, value=None): - self.visible_rows = [] - self.filter_value = normalize_string(value) - - GLib.idle_add(self.invalidate_filter_value, priority=GLib.PRIORITY_LOW) - - def invalidate_filter_value(self): - self.invalidate_filter() - - GLib.idle_add(self.invalidate_sort, priority=GLib.PRIORITY_HIGH) - GLib.idle_add(self.invalidate_selection, priority=GLib.PRIORITY_LOW) - - def invalidate_selection(self): - if bool(self.filter_value): - self.visible_rows = [] - self.foreach(self.append_visible_row) - else: - self.visible_rows = self.get_children() - - self.select_row_by_index(0) - - def reset_selection_state(self, index): - if index == 0: - self.invalidate_selection() - return True - - def append_visible_row(self, row): - if row.visibility(): - self.visible_rows.append(row) - return True - - def select_row_by_index(self, index): - if index in range(0, len(self.visible_rows)): - self.selected_row = index - self.selected_obj = self.visible_rows[index] - - self.selected_obj.activate() - - def get_last_row_index(self): - return len(self.visible_rows) - 1 - - def select_prev_row(self): - lastrow = self.get_last_row_index() - prevrow = self.selected_row - 1 - prevrow = lastrow if prevrow < 0 else prevrow - - self.select_row_by_index(prevrow) - - def select_next_row(self): - lastrow = self.get_last_row_index() - nextrow = self.selected_row + 1 - nextrow = 0 if nextrow > lastrow else nextrow - - self.select_row_by_index(nextrow) - - def sort_function(self, row1, row2): - score_diff = row1.position() - row2.position() - index_diff = row1.index - row2.index - - return score_diff or index_diff - - def filter_function(self, item): - item.set_property('query', self.filter_value) - return item.visibility() - - def do_list_item(self, value, index): - command = CommandListItem(value=value, index=index) - - self.append_visible_row(command) - self.add(command) - - def do_list_items(self): - if self.list_window_actions: - for index, value in enumerate(self.window_actions): - self.do_list_item(value, index) - self.reset_selection_state(index) - yield True - - offset = len(self.window_actions) if self.window_actions is not None else 0 - - for index, value in enumerate(self.menu_actions): - self.do_list_item(value, offset + index) - self.reset_selection_state(offset + index) - yield True - - def on_row_selected(self, listbox, item): - self.select_value = item.value if item else '' - - # args=(, ) - def on_menu_actions_notify(self, *args): - self.visible_rows = [] - self.foreach(lambda item: item.destroy()) - - if args[1].name == 'window-actions': - self.list_window_actions = True - # run_generator(self.do_list_items) - elif args[1].name == 'menu-actions': - run_generator(self.do_list_items) - - -class CommandWindow(Gtk.ApplicationWindow): - - wayland = is_wayland() - - def __init__(self, *args, **kwargs): - if not self.wayland: - kwargs['type'] = Gtk.WindowType.POPUP - super(Gtk.ApplicationWindow, self).__init__(*args, **kwargs) - - self.set_size_request(750, -1) - self.set_keep_above(True) - self.set_resizable(False) - - self.set_type_hint(Gdk.WindowTypeHint.UTILITY) - self.set_position(Gtk.WindowPosition.CENTER) - self.set_custom_position() - - self.set_skip_pager_hint(True) - self.set_skip_taskbar_hint(True) - self.set_destroy_with_parent(True) - - self.empty_label = Gtk.Label(margin=12) - self.empty_label.set_label('No menu actions available!') - - self.empty_box = Gtk.Box(sensitive=False) - self.empty_box.set_size_request(750, -1) - self.empty_box.add(self.empty_label) - - self.command_list = CommandList() - self.command_list.invalidate_selection() - - self.search_entry = Gtk.SearchEntry(hexpand=True, margin=2) - self.search_entry.connect('search-changed', self.on_search_entry_changed) - self.search_entry.set_has_frame(False) - - self.scrolled_window = Gtk.ScrolledWindow(hadjustment=None, vadjustment=None) - self.scrolled_window.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - self.scrolled_window.set_size_request(750, 210) - self.scrolled_window.add(self.command_list) - - self.header_bar = Gtk.HeaderBar(spacing=0) - self.header_bar.set_custom_title(self.search_entry) - - self.main_box = Gtk.Box() - self.main_box.add(self.empty_box) - self.main_box.add(self.scrolled_window) - - self.set_titlebar(self.header_bar) - self.add(self.main_box) - - self.set_dark_variation() - self.set_custom_styles() - - Gdk.event_handler_set(self.on_gdk_event) - - self.connect('show', self.on_window_show) - self.connect('button-press-event', self.on_button_press_event) - - def set_menu_actions(self, actions): - if actions: - self.empty_box.hide() - self.scrolled_window.show() - - self.command_list.set_property('menu-actions', actions) - - def set_window_actions(self, actions): - self.empty_box.hide() - self.scrolled_window.show() - self.command_list.set_property('window-actions', actions) - - def set_custom_position(self): - position = self.get_position() - self.move(position.root_x, 32) - - def set_dark_variation(self, set_dark=True): - settings = Gtk.Settings.get_default() - settings.set_property('gtk-application-prefer-dark-theme', set_dark) - - def set_custom_styles(self): - styles = """entry.search.flat { border: 0; outline: 0; - border-image: none; box-shadow: none; } - - headerbar { box-shadow: none; background: @insensitive_bg_color; - border-radius: 0; border-width: 0 0 1px 0; } - - scrolledwindow overshoot, scrolledwindow undershoot { - background: none; box-shadow: none; } - - scrollbar { opacity: 0; } - - window decoration { box-shadow: none; border-color: @borders; - border-style: solid; border-width: 1px; border-radius: 0; } - """ - - inject_custom_style(self, styles) - - def grab_keyboard(self, window, status, tstamp): - while Gdk.keyboard_grab(window, True, tstamp) != status: - time.sleep(0.1) - - def grab_pointer(self, window, status, tstamp): - mask = Gdk.EventMask.BUTTON_PRESS_MASK - - while Gdk.pointer_grab(window, True, mask, window, None, tstamp) != status: - time.sleep(0.1) - - def emulate_focus_out_event(self): - if not self.wayland: - tstamp = Gdk.CURRENT_TIME - Gdk.keyboard_ungrab(tstamp) - Gdk.pointer_ungrab(tstamp) - - fevent = Gdk.Event(Gdk.EventType.FOCUS_CHANGE) - self.emit('focus-out-event', fevent) - - def clicked_inside(self, event): - size = self.get_size() - x_range = range(0, size.width) - y_range = range(0, size.height) - - return int(event.x) in x_range and int(event.y) in y_range - - def on_gdk_event(self, event): - Gtk.main_do_event(event) - - def on_window_show(self, window): - if not self.wayland: - window = self.get_window() - status = Gdk.GrabStatus.SUCCESS - tstamp = Gdk.CURRENT_TIME - - self.grab_keyboard(window, status, tstamp) - self.grab_pointer(window, status, tstamp) - - self.search_entry.grab_focus() - - def on_button_press_event(self, widget, event): - win_type = event.get_window().get_window_type() - tmp_type = Gdk.WindowType.TEMP - - if win_type == tmp_type and not self.clicked_inside(event): - self.emulate_focus_out_event() - return True - - def on_search_entry_changed(self, *args): - search_value = self.search_entry.get_text() - - self.scrolled_window.unset_placement() - self.command_list.set_filter_value(search_value) - - -class HudMenu(Gtk.Application): - - def __init__(self, dbus_menu, *args, **kwargs): - kwargs['application_id'] = 'org.gonzaarcr.fildemapp' - super(Gtk.Application, self).__init__(*args, **kwargs) - - self.dbus_menu = dbus_menu - - # self.set_accels_for_action('app.start', ['space']) - self.set_accels_for_action('app.quit', ['Escape']) - self.set_accels_for_action('app.prev', ['Up']) - self.set_accels_for_action('app.next', ['Down']) - self.set_accels_for_action('app.execute', ['Return']) - - def add_simple_action(self, name, callback): - action = Gio.SimpleAction.new(name, None) - - action.connect('activate', callback) - self.add_action(action) - - def do_startup(self): - Gtk.Application.do_startup(self) - self.add_simple_action('start', self.on_show_window) - self.add_simple_action('quit', self.on_hide_window) - self.add_simple_action('prev', self.on_prev_command) - self.add_simple_action('next', self.on_next_command) - self.add_simple_action('execute', self.on_execute_command) - - def do_activate(self): - self.window = CommandWindow(application=self, title='Gnome HUD') - self.window.show_all() - - self.window_actions = WindowActions(self.on_window_actions_receive) - self.window_actions.request_window_actions() - - self.window.set_menu_actions(self.dbus_menu.actions) - self.window.connect('focus-out-event', self.on_hide_window) - - self.commands = self.window.command_list - self.commands.connect_after('button-press-event', self.on_commands_click) - - def on_window_actions_receive(self, actions): - self.window.set_window_actions(actions) - - def on_show_window(self, *args): - self.window.show() - - def on_hide_window(self, *args): - self.window.set_dark_variation(False) - self.window.destroy() - self.quit() - - def on_prev_command(self, *args): - self.commands.select_prev_row() - - def on_next_command(self, *args): - self.commands.select_next_row() - - def on_commands_click(self, widget, event): - if event.type == Gdk.EventType._2BUTTON_PRESS: - self.on_execute_command() - - def on_execute_command(self, *args): - selected_value = self.commands.select_value - if selected_value in self.window_actions.actions: - self.window_actions.activate_action(selected_value) - else: - self.dbus_menu.activate(selected_value) - self.on_hide_window() diff --git a/fildem/handlers/global_menu.py b/fildem/handlers/global_menu.py deleted file mode 100644 index 6797923b..00000000 --- a/fildem/handlers/global_menu.py +++ /dev/null @@ -1,366 +0,0 @@ -import gi -import time - -gi.require_version('Gtk', '3.0') -gi.require_version('Gdk', '3.0') - -from gi.repository import Gtk -from gi.repository import Gdk -from gi.repository import Gio - -from fildem.utils.wayland import is_wayland - - -def get_separator(): - return u'\u0020\u0020\u00BB\u0020\u0020' - - -def inject_custom_style(widget, style_string): - provider = Gtk.CssProvider() - provider.load_from_data(style_string.encode()) - - screen = Gdk.Screen.get_default() - priority = Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - Gtk.StyleContext.add_provider_for_screen(screen, provider, priority) - - -def parse_accel(accel: str): - if accel == '': - return None - elif accel.lower() == 'del': - # So hardcoded that it hurts (blame qbittorrent) - return 65535, Gdk.ModifierType(0) - else: - return Gtk.accelerator_parse(accel) - - -class Menu(Gtk.Menu): - - def __init__(self, tree, node, accel_group, activate_callback=None, *args, **kwargs): - """ - tree: Tree - node: Node - accel_group: Gtk.AccelGroup - activate_callback: Callable[[str], None] - If None the action `'app.' + item.action` is used on the MenuItem - """ - super(Gtk.Menu, self).__init__(*args, **kwargs) - self.accel_group = accel_group - self.callback = activate_callback - self.tree = tree - self.node = node - self.add_items(tree, node) - self.show_all() - - def add_items(self, tree, node): - current_section = None - for c in tree.children(node.identifier): - # Separator for Gtk apps - if current_section is not None and c.data.section != current_section: - self.append(Gtk.SeparatorMenuItem()) - current_section = c.data.section - - if c.is_leaf(): - menu_item = self._create_item(c.data) - else: - menu_item = self._create_sub_menu(c) - menu_item.set_label(c.tag) - - self.append(menu_item) - - def _create_sub_menu(self, node): - menu = Menu(self.tree, node, self.accel_group, self.callback) - menu_item = Gtk.MenuItem() - menu_item.set_submenu(menu) - menu_item.set_use_underline(True) - return menu_item - - def _create_item(self, item): - """ - Parameters - ---------- - item: Union[DbusGtkMenuItem, DbusAppMenuItem] - - Returns - ------- - Gtk.MenuItem - """ - if item.separator: - return Gtk.SeparatorMenuItem() - elif item.toggle_type == 'radio': - menu_item = Gtk.RadioMenuItem() - elif item.toggle_type == 'checkmark': - menu_item = Gtk.CheckMenuItem() - elif item.toggle_type == '': - menu_item = Gtk.MenuItem() - - if item.toggle_state: - menu_item.set_active(True) - - menu_item.set_accel_path('/Options') - if self.callback is None: - menu_item.set_property('action_name', 'app.' + str(item.action)) - else: - menu_item.connect('activate', self.callback) - - shortcut = parse_accel(item.accel) - if shortcut is not None: - menu_item.add_accelerator('activate', self.accel_group, shortcut[0], shortcut[1], Gtk.AccelFlags.VISIBLE) - - menu_item.set_label(item.label) - menu_item.set_use_underline(True) - return menu_item - - -class CommandWindow(Gtk.ApplicationWindow): - - wayland = is_wayland() - - def __init__(self, *args, **kwargs): - kwargs['type'] = Gtk.WindowType.POPUP - super(Gtk.ApplicationWindow, self).__init__(*args, **kwargs) - self.app = kwargs['application'] - self.seat = Gdk.Display.get_default().get_default_seat() - - self.set_size_request(-1, -1) - self.set_keep_above(True) - self.set_resizable(False) - - self.set_type_hint(Gdk.WindowTypeHint.UTILITY) - self.set_position(Gtk.WindowPosition.CENTER) - self.set_custom_position() - - self.set_skip_pager_hint(True) - self.set_skip_taskbar_hint(True) - self.set_destroy_with_parent(True) - - self.set_decorated(False) - - self.my_menu_bar = Gtk.MenuBar() - self.accel_group = Gtk.AccelGroup() - - self.main_box = Gtk.VBox() - self.main_box.add(self.my_menu_bar) - - self.add(self.main_box) - # self.set_dark_variation() - self.set_custom_styles() - - Gdk.event_handler_set(self.on_gdk_event) - - self.connect('show', self.on_window_show) - self.connect('button-press-event', self.on_button_press_event) - - def open_menu_shortcut(self, menu): - self.make_opaque() - self.grab_keyboard(self.get_window()) - self.my_menu_bar.select_item(menu) # activate_item(menu) - - def open_menu_by_name(self, name): - name = name.replace('_', '') - for menu in self.my_menu_bar.get_children(): - if menu.get_label().replace('_', '') == name: - self.open_menu_shortcut(menu) - break - - def open_menu_by_character(self, char): - for menu in self.my_menu_bar.get_children(): - label = menu.get_label() - idx = label.find('_') - if idx != -1 and label[idx + 1] == char: - self.open_menu_shortcut(menu) - break - - def set_tree_menu(self, tree): - self.destroy_menus() - children = tree.children(tree[tree.root].identifier) - for c in children: - menu = Menu(tree, c, self.accel_group) - button = Gtk.MenuItem() - button.set_label(c.tag) - button.set_use_underline(True) - button.set_submenu(menu) # set_popup(menu) - button.show_all() - button.set_can_focus(True) - self.my_menu_bar.append(button) - - def destroy_menus(self): - self.main_box.remove(self.my_menu_bar) - self.my_menu_bar = Gtk.MenuBar() - self.main_box.add(self.my_menu_bar) - - def set_custom_position(self, x=-1, y=0): - position = self.get_position() - x = x if x != -1 else position.root_x - self.move(x, y) - - def set_dark_variation(self): - settings = Gtk.Settings.get_default() - settings.set_property('gtk-application-prefer-dark-theme', True) - - def set_custom_styles(self): - styles = """entry.search.flat { border: 0; outline: 0; - border-image: none; box-shadow: none; } - - headerbar { box-shadow: none; background: @insensitive_bg_color; - border-radius: 0; border-width: 0 0 1px 0; } - - scrolledwindow overshoot, scrolledwindow undershoot { - background: none; box-shadow: none; } - - scrollbar { opacity: 0; } - - menubar { background-color: #1d1d1d; } - menubar > menuitem { min-height: 1em; } - - window decoration { box-shadow: none; border-color: @borders; - border-style: solid; border-width: 1px; border-radius: 0; } - """ - - inject_custom_style(self, styles) - - def grab_keyboard(self, window, status=Gdk.GrabStatus.SUCCESS): - if self.wayland: - return - - while self.seat.grab(window, Gdk.SeatCapabilities.KEYBOARD, False, None, None, None) != status: - time.sleep(0.1) - - def ungrab_keyboard(self): - if self.wayland: - return - - self.seat.ungrab() - - def emulate_focus_out_event(self): - tstamp = Gdk.CURRENT_TIME - self.seat.ungrab() - - fevent = Gdk.Event(Gdk.EventType.FOCUS_CHANGE) - self.emit('focus-out-event', fevent) - - def clicked_inside(self, event): - size = self.get_size() - x_range = range(0, size.width) - y_range = range(0, size.height) - - return int(event.x) in x_range and int(event.y) in y_range - - def on_gdk_event(self, event): - Gtk.main_do_event(event) - - def on_window_show(self, window): - window = self.get_window() - status = Gdk.GrabStatus.SUCCESS - tstamp = Gdk.CURRENT_TIME - - self.grab_keyboard(window, status) - - def on_button_press_event(self, widget, event): - win_type = event.get_window().get_window_type() - tmp_type = Gdk.WindowType.TEMP - - if win_type == tmp_type and not self.clicked_inside(event): - self.emulate_focus_out_event() - return True - - def make_opaque(self): - self.set_opacity(1) - - def make_transparent(self): - self.set_opacity(0) - - -class GlobalMenu(Gtk.Application): - - def __init__(self, dbus_menu, initial_menu=None, x=-1, *args, **kwargs): - kwargs['application_id'] = 'org.gonzaarcr.fildemapp' - super(Gtk.Application, self).__init__(*args, **kwargs) - - self.dbus_menu = dbus_menu - self.actions = [] - self.initial_menu = initial_menu - self.initial_x = x - - self.set_accels_for_action('app.quit', ['Escape']) - self.set_accels_for_action('app.prev', ['Up']) - self.set_accels_for_action('app.next', ['Down']) - self.set_accels_for_action('app.execute', ['Return']) - - def add_simple_action(self, name, callback): - action = Gio.SimpleAction.new(name, None) - - action.connect('activate', callback) - self.add_action(action) - - def do_startup(self): - Gtk.Application.do_startup(self) - self.window = CommandWindow(application=self, title='Gnome HUD') - self.add_simple_action('start', self.on_show_window) - self.add_simple_action('quit', self.on_hide_window) - self.add_simple_action('execute', self.on_execute_command) - - def do_activate(self): - self.remove_all_actions() - self.add_menus() - if self.initial_menu is not None: - self.on_menu_activated(self.initial_menu, self.initial_x) - self.window.connect('focus-out-event', self.on_hide_window) - - def add_menus(self): - for item in self.dbus_menu.tree.leaves(): - self.add_menu_action(item.data) - - self.window.set_tree_menu(self.dbus_menu.tree) - self.window.show_all() - - def add_menu_action(self, item): - """ - Adds an action of the foreign app. Do not add actions - of the app here - """ - name = str(item.action) - path = item.path - self.actions.append(name) - action = Gio.SimpleAction.new(name, None) - action.set_enabled(item.enabled) - path = get_separator().join(path) + get_separator() + item.label - callback = lambda a, b: self.dbus_menu.activate(path) - action.connect('activate', callback) - self.add_action(action) - - def remove_all_actions(self): - for name in self.actions: - self.remove_action(name) - self.actions = [] - - def on_show_window(self, *args): - print('GlobalMenu.on_show_window') - self.window.show() - - def on_hide_window(self, *args): - self.window.destroy() - self.quit() - - def on_execute_command(self, *args): - self.dbus_menu.activate(self.commands.select_value) - self.on_hide_window() - - def move_window(self, x: int): - primary_mon = Gdk.Display.get_default().get_primary_monitor() - if primary_mon == None: - # Sometimes Wayland returns None - # It seems to be no way of knowing the primary monitor, - # we just grab the first one - primary_mon = Gdk.Display.get_default().get_monitor(0) - - x = primary_mon.get_geometry().x + x / primary_mon.get_scale_factor() - self.window.set_custom_position(x) - - def on_menu_activated(self, menu: str, x: int): - self.move_window(x) - if len(menu) > 1: - self.window.open_menu_by_name(menu) - else: - self.window.open_menu_by_character(menu) - self.window.make_transparent() diff --git a/fildem/handlers/rofi.py b/fildem/handlers/rofi.py deleted file mode 100644 index cb933290..00000000 --- a/fildem/handlers/rofi.py +++ /dev/null @@ -1,150 +0,0 @@ -import gi - -gi.require_version('Gtk', '3.0') - -from gi.repository import Gtk - -from subprocess import Popen -from subprocess import PIPE - -from fildem.utils.menu import DbusMenu - - -def rgba_to_hex(color): - red = int(color.red * 255) - green = int(color.green * 255) - blue = int(color.blue * 255) - - return "#{0:02x}{1:02x}{2:02x}".format(red, green, blue) - - -class RofiMenu: - - def __init__(self): - self.settings = Gtk.Settings.get_default() - self.context = Gtk.StyleContext() - self.dbus_menu = DbusMenu() - - self.settings.set_property('gtk-application-prefer-dark-theme', True) - - @property - - def selection(self): - selection = self.menu_proc.communicate()[0].decode('utf8').rstrip() - self.menu_proc.stdin.close() - - return selection - - @property - - def items(self): - items = self.dbus_menu.actions - return '\n'.join(items).encode('utf-8') - - @property - - def font_name(self): - return self.settings.get_property('gtk-font-name') - - @property - - def gtk_theme_colors(self): - colors = { - 'header': self.lookup_color('insensitive_bg_color'), - 'base': self.lookup_color('theme_base_color'), - 'text': self.lookup_color('theme_text_color'), - 'bg': self.lookup_color('theme_bg_color'), - 'borders': self.lookup_color('borders'), - 'disabled': self.lookup_color('insensitive_fg_color'), - 'selected_bg': self.lookup_color('theme_selected_bg_color'), - 'selected_fg': self.lookup_color('theme_selected_fg_color'), - 'error_bg': self.lookup_color('error_bg_color'), - 'error_fg': self.lookup_color('error_fg_color') - } - - for name, color in colors.items(): - colors[name] = rgba_to_hex(color) - - return colors - - @property - - def theme_colors(self): - colors = { - 'window': [ - self.gtk_theme_colors['header'], - self.gtk_theme_colors['borders'], - self.gtk_theme_colors['borders'] - ], - 'normal': [ - self.gtk_theme_colors['base'], - self.gtk_theme_colors['text'], - self.gtk_theme_colors['base'], - self.gtk_theme_colors['selected_bg'], - self.gtk_theme_colors['selected_fg'] - ], - 'urgent': [ - self.gtk_theme_colors['base'], - self.gtk_theme_colors['text'], - self.gtk_theme_colors['base'], - self.gtk_theme_colors['error_bg'], - self.gtk_theme_colors['error_fg'] - ] - } - - for name, color in colors.items(): - colors[name] = ', '.join(color) - - return colors - - @property - - def theme_string(self): - style = """ - #window { location: north; anchor: north; border: 1px; - width: 750px; padding: 0; margin: 32px 0 0; } - - #mainbox { spacing: 0; children: [inputbar, %s]; } - - #message { border: 1px 0 0; spacing: 0; padding: 12px; - background-color: %s; } - - #listview { border: 1px 0 0; spacing: 0; scrollbar: false; - padding: 0; lines: 6; background-color: @normal-background; } - - #textbox { text-color: %s; } - #textbox { text-color: %s; } - #inputbar { padding: 14px 12px; } - #element { border: 0; padding: 8px 12px; } - #textbox-prompt-colon { str: ""; } - """ - - layout = 'listview' if self.dbus_menu.actions else 'message' - bcolor = self.gtk_theme_colors['bg'] - tcolor = self.gtk_theme_colors['disabled'] - - return style % (layout, bcolor, tcolor, tcolor) - - def lookup_color(self, key): - return self.context.lookup_color(key)[1] - - def open_menu(self): - options = [ - 'rofi', - '-i', - '-dmenu', - '-theme-str', self.theme_string, - '-p', '⚙', - '-mesg', 'No menu actions available!', - '-font', self.font_name, - '-color-window', self.theme_colors['window'], - '-color-normal', self.theme_colors['normal'], - '-color-urgent', self.theme_colors['urgent'] - ] - - self.menu_proc = Popen(options, stdout=PIPE, stdin=PIPE) - self.menu_proc.stdin.write(self.items) - - def run(self): - self.open_menu() - self.dbus_menu.activate(self.selection) diff --git a/fildem/inithud.py b/fildem/inithud.py deleted file mode 100644 index c3be50d1..00000000 --- a/fildem/inithud.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/python3 - -import dbus - -def main(): - session = dbus.SessionBus() - proxy = session.get_object('com.gonzaarcr.appmenu', '/com/gonzaarcr/appmenu') - proxy.EmitHudActivated() - -if __name__ == "__main__": - main() diff --git a/fildem/keybinder.py b/fildem/keybinder.py deleted file mode 100644 index cee1150c..00000000 --- a/fildem/keybinder.py +++ /dev/null @@ -1,42 +0,0 @@ -#! /usr/bin/python3 - -import gi - -gi.require_version('Keybinder', '3.0') - -from gi.repository import Keybinder, GLib - -from dbus.mainloop.glib import DBusGMainLoop - -from fildem.command import default_hud_menu, rofi_hud_menu, global_hud_menu -from fildem.utils.menu import DbusMenu -from fildem.utils.wayland import is_wayland - - -def run_keybinder(callback): - # for wayland - DBusGMainLoop(set_as_default=True) - dbus_menu = DbusMenu() - - if not is_wayland(): - Keybinder.bind('space', callback, dbus_menu) - # GLib.timeout_add_seconds(1, callback) - try: - GLib.MainLoop().run() - except KeyboardInterrupt: - GLib.MainLoop().quit() - -def main(): - run_keybinder(default_hud_menu) - - -def rofi(): - run_keybinder(rofi_hud_menu) - - -def global_menu(): - run_keybinder(global_hud_menu) - - -if __name__ == "__main__": - main() diff --git a/fildem/menu_model/__init__.py b/fildem/menu_model/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/fildem/menu_model/menu_item.py b/fildem/menu_model/menu_item.py deleted file mode 100644 index 2f3b024e..00000000 --- a/fildem/menu_model/menu_item.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/python3 - -import dbus - - -def format_label(parts): - separator = u'\u0020\u0020\u00BB\u0020\u0020' - return separator.join(parts) - - -class DbusGtkMenuItem(object): - - def __init__(self, item, path=[], enabled=True): - self.path = path - self.separator = False - self.action = str(item.get('action', '')) - self.accel = str(item.get('accel', '')) # p - self.shortcut = str(item.get('shortcut', '')) - self.label = item.get('label', '') - self.text = format_label(self.path + [self.label]) - self.enabled = enabled - self.toggle_type = '' - self.toggle_state = False - # :submenu - # two index that indicate the group - # dbus.String(':submenu'): dbus.Struct((dbus.UInt32(11), dbus.UInt32(0)) - # used for separators and radio button groups - self.section = None - - def set_toggle(self, toggle): - if not len(toggle): - return - toggle = toggle[0] - if isinstance(toggle, dbus.Boolean): - self.toggle_type = 'checkmark' - self.toggle_state = toggle - elif isinstance(toggle, str): - self.toggle_type = 'radio' - self.toggle_state = len(toggle) > 0 - - def set_description(self, desc): - if desc is None: - return - self.enabled = desc[0] - self.toggle_state = desc[1] - -class DbusAppMenuItem(object): - - def __init__(self, item, path=[]): - self.path = path - self.action = int(item[0]) - self.accel = self.get_shorcut(item[1]) - self.separator = item[1].get('type', '') == 'separator' - self.label = item[1].get('label', '') - self.text = format_label(self.path + [self.label]) - self.enabled = item[1].get('enabled', True) - self.visible = item[1].get('visible', True) - self.toggle_state = item[1].get('toggle-state', 0) == 1 - self.toggle_type = item[1].get('toggle-type', '') # 'radio' or 'checkmark' - self.icon_data = item[1].get('icon_data', bytearray()) - # Only used on Gtkapps - self.section = None - self.children = [] - - def get_shorcut(self, item): - shortcut = item.get('shortcut', '') - if len(shortcut) == 0: - return shortcut - - shortcut = shortcut[0] - ret = '' - for i, v in enumerate(shortcut): - # The last one should be on caps? - ret += '<' + v + '>' if (i != len(shortcut) - 1) else v - return ret - - def update_props(self, props): - if 'children-display' in props: - return - self.enabled = props.get('enabled', self.enabled) - self.label = props.get('label', self.label) - self.toggle_state = bool(props.get('toggle-state', self.toggle_state)) - self.toggle_type = props.get('toggle-type', self.toggle_type) - self.visible = props.get('visible', self.visible) diff --git a/fildem/menu_model/menu_model.py b/fildem/menu_model/menu_model.py deleted file mode 100644 index 3586da66..00000000 --- a/fildem/menu_model/menu_model.py +++ /dev/null @@ -1,378 +0,0 @@ -#!/usr/bin/python3 - -import dbus -import time - -from gi.repository import GLib - -from ..treelib import Tree - -from fildem.menu_model.menu_item import DbusGtkMenuItem, DbusAppMenuItem - - -class DbusGtkMenu(object): - - def __init__(self, session, window): - self.results = {} - self.actions = {} - self.accels = {} - self.tree = Tree() - self._update_timer = 0 - self.session = session - self.bus_name = window.get_utf8_prop('_GTK_UNIQUE_BUS_NAME') - # with app. prefix - self.app_path = window.get_utf8_prop('_GTK_APPLICATION_OBJECT_PATH') - # with win. prefix - self.win_path = window.get_utf8_prop('_GTK_WINDOW_OBJECT_PATH') - # with unity. prefix - self.menubar_path = window.get_utf8_prop('_GTK_MENUBAR_OBJECT_PATH') - self.appmenu_path = window.get_utf8_prop('_GTK_APP_MENU_OBJECT_PATH') - - self.top_level_menus = [] - self.signal_matcher = [] - - def activate(self, selection): - action = self.actions.get(selection, '') - - if 'app.' in action: - self.send_action(action, 'app.', self.app_path) - elif 'win.' in action: - self.send_action(action, 'win.', self.win_path) - elif 'unity.' in action: - self.send_action(action, 'unity.', self.menubar_path) - - def send_action(self, name, prefix, path): - obj = self.session.get_object(self.bus_name, path) - interface = dbus.Interface(obj, dbus_interface='org.gtk.Actions') - - interface.Activate(name.replace(prefix, ''), [], dict()) - - def get_results(self): - paths = [self.appmenu_path, self.menubar_path] - - for path in filter(None, paths): - obj = self.session.get_object(self.bus_name, path) - interface = dbus.Interface(obj, dbus_interface='org.gtk.Menus') - try: - results = interface.Start([x for x in range(1024)]) - interface.End([x for x in range(1024)]) - except Exception: - continue - - s = interface.connect_to_signal('Changed', self.on_actions_changed) - self.signal_matcher.append(s) - self.connect_to_actions_iface(obj) - - for menu in results: - self.results[(menu[0], menu[1])] = menu[2] - - self.tree.create_node('Root', 'Root') - self.collect_entries(treelib_parent='Root') - if not len(self.tree.children(self.tree[self.tree.root].identifier)): - self.tree = Tree() - - def collect_entries(self, menu=(0, 0), labels=[], treelib_parent=None): - section = (menu[0], menu[1]) - for menu in self.results.get(section, []): - if 'label' in menu: - if len(labels) == 0: - self.top_level_menus.append(menu.get('label', None)) - - menu_item = DbusGtkMenuItem(menu, labels) - menu_item.section = section - description = self.describe(menu_item.action) - if description is not None: - menu_item.enabled = description[0] - menu_item.set_toggle(description[1]) - - menu_path = labels + [menu_item.label] - - self.tree.create_node(menu_item.label, menu_item.action, treelib_parent, data=menu_item) - - if ':submenu' in menu: - self.collect_entries(menu[':submenu'], menu_path, menu_item.action) - elif 'action' in menu: - self.actions[menu_item.text] = menu_item.action - - elif ':section' in menu: - self.collect_entries(menu[':section'], labels, treelib_parent) - - def describe(self, action): - """ - Describe return this: - dbus.Struct(( - dbus.Boolean(True), # enabled - dbus.Signature(''), - dbus.Array([ # This is empty in a not checked item - dbus.Boolean(True, variant_level=1)], # Checked or not - signature=dbus.Signature('v'))), - signature=None) - """ - if action.startswith('unity'): - path = self.menubar_path - elif action.startswith('win'): - path = self.win_path - elif action.startswith('app'): - path = self.app_path - else: - return None - - dot = action.find('.') - action = action[dot+1:] - obj = self.session.get_object(self.bus_name, path) - interface = dbus.Interface(obj, dbus_interface='org.gtk.Actions') - - try: - description = interface.Describe(action) - except Exception as e: - # import traceback; traceback.print_exc() - return None - enabled = description[0] - checked = description[2] - return enabled, checked - - def connect_to_actions_iface(self, obj): - try: - iface = dbus.Interface(obj, dbus_interface='org.gtk.Actions') - s = iface.connect_to_signal('Changed', self.on_gtk_actions_changed) - self.signal_matcher.append(s) - except Exception as e: - pass - - def on_actions_changed(self, *args): - print(f'on_actions_changed {args=}') - - def on_gtk_actions_changed(self, removed, enabled_changed, state_changed, new_actions): - """ - The name of the actions doesn't have the unity. app. or win. preprended - """ - # print(f'{enabled_changed=} {removed=} {state_changed=} {new_actions=}') - prefixes = ['unity.', 'win.', 'app.'] - for action_name in [*enabled_changed, *state_changed]: - items = map(lambda prefix: self.tree.get_node(prefix + action_name), prefixes) - items = filter(None, items) - item = next(items, None) - if item is None: - print('Item does not exists:', action_name) - continue - - if action_name in enabled_changed: - item.data.enabled = enabled_changed[action_name] - else: - if item.data.toggle_type == 'radio': # 'checkmark': - for s in self.tree.siblings(item.identifier): - if s.data.section == item.data.section: - s.data.toggle_state = False - item.data.toggle_state = state_changed[action_name] - # item.data.enabled = state_changed[action_name] - # item.data.set_description(self.describe(item.data.action)) - - def remove_actions_listener(self): - for s in self.signal_matcher: - s.remove() - self.signal_matcher = [] - - -class DbusAppMenu(object): - - def __init__(self, session, window): - self.actions = {} - self.accels = {} - self.tree = Tree() - self.session = session - self.window = window - self._update_timer = 0 - self.signal_matcher = [] - self.interface = self.get_interface() - self.top_level_menus = [] - self.results = None - - def activate(self, selection): - action = self.actions[selection] - try: - self.interface.Event(action, 'clicked', 0, 0) - except Exception as e: - self.retry_activate(selection) - - def retry_activate(self, selection): - # Electron apps change a lot their menus, we have to update to retry - self.actions = {} - self.accels = {} - self.tree = Tree() - results = self.interface.GetLayout(0, -1, dbus.Array(signature="s")) - self.collect_entries(results[1], []) - action = self.actions[selection] - self.interface.Event(action, 'clicked', 0, 0) - - def get_interface(self): - bus_name = 'com.canonical.AppMenu.Registrar' - bus_path = '/com/canonical/AppMenu/Registrar' - - try: - obj = self.session.get_object(bus_name, bus_path) - interface = dbus.Interface(obj, bus_name) - name, path = interface.GetMenuForWindow(self.window.get_xid()) - obj = self.session.get_object(name, path) - interface = dbus.Interface(obj, 'com.canonical.dbusmenu') - - s = interface.connect_to_signal('ItemsPropertiesUpdated', self.on_actions_changed) - self.signal_matcher.append(s) - s = interface.connect_to_signal('LayoutUpdated', self.layout_updated) - self.signal_matcher.append(s) - - return interface - except dbus.exceptions.DBusException: - # import traceback; traceback.print_exc() - return None - - def get_results(self): - if self.interface: - self.results = self.interface.GetLayout(0, -1, dbus.Array(signature="s")) - self.collect_entries(self.results[1]) - - if not len(self.tree.children(self.tree[self.tree.root].identifier)): - self.tree = Tree() - - def collect_entries(self, item=None, labels=None, treelib_parent=None): - if self.results is None: - return - if item is None: - item = self.results[1] - if labels is None: - labels = [] - menu_item = DbusAppMenuItem(item, labels) - menu_path = labels - - if 'children-display' in item[1]: - item_id = item[0] - try: - self.interface.AboutToShow(item_id) - except Exception: - pass - self.interface.Event(item_id, 'opened', 'not used', dbus.UInt32(time.time())) - item = self.interface.GetLayout(item_id, -1, dbus.Array(signature="s"))[1] - - if bool(menu_item.label) and menu_item.label != 'Root' and menu_item.label != 'DBusMenuRoot': - menu_path = labels + [menu_item.label] - - self.tree.create_node(menu_item.label, menu_item.action, treelib_parent, data=menu_item) - if len(item[2]): - if not self.top_level_menus: - self.top_level_menus = list(map(lambda c: c[1].get('label', ''), item[2])) - - for child in item[2]: - self.collect_entries(child, menu_path, menu_item.action) - - elif bool(menu_item.label) or menu_item.separator: - self.actions[menu_item.text] = menu_item.action - - def on_actions_changed(self, updated, removed): - for upd in updated: - item = self.tree.get_node(int(upd[0])) - if item is not None: - item.data.update_props(upd[1]) - if 'children-display' in upd[1]: - # Just update everything - self.add_timer() - break - - # TODO removed - - def layout_updated(self, revision, parent): - self.add_timer() - - def add_timer(self): - if self._update_timer == 0: - self._update_timer = GLib.timeout_add(200, self._update) - - def _update(self): - self.actions = {} - self.accels = {} - self.tree = Tree() - self.get_results() - self._update_timer = 0 - return False - - def remove_actions_listener(self): - for s in self.signal_matcher: - s.remove() - self.signal_matcher = [] - if self._update_timer != 0: - GLib.source_remove(self._update_timer) - - -class MenuModel: - # The menubars have to be reused so they are cleanup - def __init__(self, session, window): - self.appmenu = None - self.gtkmenu = None - self._session = session - self.window = window - self._init_window(session, window) - - def _init_window(self, session, window): - self.appmenu = DbusAppMenu(session, window) - self.gtkmenu = DbusGtkMenu(session, window) - - def _update_menus(self): - self.gtkmenu.get_results() - if not len(self.gtkmenu.tree): - self.appmenu.get_results() - - @property - def prompt(self): - return self.window.get_app_name() - - @property - def actions(self): - actions = self.gtkmenu.actions - if not len(actions): - actions = self.appmenu.actions - - self.handle_empty(actions) - - return actions.keys() - - @property - def accel(self): - accel = self.gtkmenu.accels - if not len(accel): - accel = self.appmenu.accels - return accel - - @property - def tree(self): - tree = self.appmenu.tree - if tree.root is None: - tree = self.gtkmenu.tree - return tree - - @property - def top_level_menus(self): - if len(self.gtkmenu.top_level_menus): - return self.gtkmenu.top_level_menus - else: - return self.appmenu.top_level_menus - - def activate(self, selection): - if selection in self.gtkmenu.actions: - self.gtkmenu.activate(selection) - - elif selection in self.appmenu.actions: - self.appmenu.activate(selection) - - def handle_empty(self, actions): - if not len(actions): - alert = 'No menu items available!' - promt = '' - try: - promt = self.prompt - except Exception as e: - pass - print('Gnome HUD: WARNING: (%s) %s' % (promt, alert)) - - def __del__(self): - if self.appmenu is not None: - self.appmenu.remove_actions_listener() - if self.gtkmenu is not None: - self.gtkmenu.remove_actions_listener() diff --git a/fildem/run.py b/fildem/run.py deleted file mode 100644 index 910a8f85..00000000 --- a/fildem/run.py +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/python3 - -import os -import sys - -from fildem.command import main as command_main - -def main(): - if sys.path[0] != '': - os.chdir(sys.path[0]) - - if os.environ['XDG_SESSION_TYPE'] != 'x11': - os.environ['GDK_BACKEND'] = 'x11' - - os.environ['UBUNTU_MENUPROXY'] = '0' - command_main() - -if __name__ == '__main__': - main() diff --git a/fildem/treelib/__init__.py b/fildem/treelib/__init__.py deleted file mode 100644 index d7e6ec79..00000000 --- a/fildem/treelib/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright (C) 2011 -# Brett Alistair Kromkamp - brettkromkamp@gmail.com -# Copyright (C) 2012-2017 -# Xiaming Chen - chenxm35@gmail.com -# and other contributors. -# All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -treelib - Python 2/3 Tree Implementation - -`treelib` is a Python module with two primary classes: Node and Tree. -Tree is a self-contained structure with some nodes and connected by branches. -A tree owns merely a root, while a node (except root) has some children and one parent. - -Note: To solve string compatibility between Python 2.x and 3.x, treelib follows -the way of porting Python 3.x to 2/3. That means, all strings are manipulated as -unicode and you do not need u'' prefix anymore. The impacted functions include `str()`, -`show()` and `save2file()` routines. -But if your data contains non-ascii characters and Python 2.x is used, -you have to trigger the compatibility by declaring `unicode_literals` in the code: - -.. code-block:: python - - >>> from __future__ import unicode_literals -""" - -from .tree import Tree -from .node import Node diff --git a/fildem/treelib/exceptions.py b/fildem/treelib/exceptions.py deleted file mode 100644 index de93252f..00000000 --- a/fildem/treelib/exceptions.py +++ /dev/null @@ -1,45 +0,0 @@ -class NodePropertyError(Exception): - """Basic Node attribute error""" - pass - - -class NodeIDAbsentError(NodePropertyError): - """Exception throwed if a node's identifier is unknown""" - pass - - -class NodePropertyAbsentError(NodePropertyError): - """Exception throwed if a node's data property is not specified""" - pass - - -class MultipleRootError(Exception): - """Exception throwed if more than one root exists in a tree.""" - pass - - -class DuplicatedNodeIdError(Exception): - """Exception throwed if an identifier already exists in a tree.""" - pass - - -class LinkPastRootNodeError(Exception): - """ - Exception throwed in Tree.link_past_node() if one attempts - to "link past" the root node of a tree. - """ - pass - - -class InvalidLevelNumber(Exception): - pass - - -class LoopError(Exception): - """ - Exception thrown if trying to move node B to node A's position - while A is B's ancestor. - """ - pass - - diff --git a/fildem/treelib/misc.py b/fildem/treelib/misc.py deleted file mode 100644 index b176e4a8..00000000 --- a/fildem/treelib/misc.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python -# Copyright (C) 2011 -# Brett Alistair Kromkamp - brettkromkamp@gmail.com -# Copyright (C) 2012-2017 -# Xiaming Chen - chenxm35@gmail.com -# and other contributors. -# All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import functools -from warnings import warn, simplefilter - - -def deprecated(alias): - def real_deco(func): - """This is a decorator which can be used to mark functions - as deprecated. It will result in a warning being emmitted - when the function is used. - Derived from answer by Leando: https://stackoverflow.com/a/30253848 - """ - - @functools.wraps(func) - def wrapper(*args, **kwargs): - simplefilter('always', DeprecationWarning) # turn off filter - warn('Call to deprecated function "{}"; use "{}" instead.'.format(func.__name__, alias), - category=DeprecationWarning, stacklevel=2) - simplefilter('default', DeprecationWarning) # reset filter - return func(*args, **kwargs) - - return wrapper - - return real_deco diff --git a/fildem/treelib/node.py b/fildem/treelib/node.py deleted file mode 100644 index 6accae12..00000000 --- a/fildem/treelib/node.py +++ /dev/null @@ -1,279 +0,0 @@ -#!/usr/bin/env python -# Copyright (C) 2011 -# Brett Alistair Kromkamp - brettkromkamp@gmail.com -# Copyright (C) 2012-2017 -# Xiaming Chen - chenxm35@gmail.com -# and other contributors. -# All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -Node structure in treelib. - -A :class:`Node` object contains basic properties such as node identifier, -node tag, parent node, children nodes etc., and some operations for a node. -""" -from __future__ import unicode_literals - -import copy -import uuid -from collections import defaultdict -from warnings import warn - -from .exceptions import NodePropertyError -from .misc import deprecated - - -class Node(object): - """ - Nodes are elementary objects that are stored in the `_nodes` dictionary of a Tree. - Use `data` attribute to store node-specific data. - """ - - #: Mode constants for routine `update_fpointer()`. - (ADD, DELETE, INSERT, REPLACE) = list(range(4)) - - def __init__(self, tag=None, identifier=None, expanded=True, data=None): - """Create a new Node object to be placed inside a Tree object""" - - #: if given as a parameter, must be unique - self._identifier = None - self._set_identifier(identifier) - - #: None or something else - #: if None, self._identifier will be set to the identifier's value. - if tag is None: - self._tag = self._identifier - else: - self._tag = tag - - #: boolean - self.expanded = expanded - - #: identifier of the parent's node : - self._predecessor = {} - #: identifier(s) of the soons' node(s) : - self._successors = defaultdict(list) - - #: User payload associated with this node. - self.data = data - - # for retro-compatibility on bpointer/fpointer - self._initial_tree_id = None - - def __lt__(self, other): - return self.tag < other.tag - - def set_initial_tree_id(self, tree_id): - if self._initial_tree_id is None: - self._initial_tree_id = tree_id - - def _set_identifier(self, nid): - """Initialize self._set_identifier""" - if nid is None: - self._identifier = str(uuid.uuid1()) - else: - self._identifier = nid - - @property - @deprecated(alias='node.predecessor') - def bpointer(self): - """Use predecessor method, this property is deprecated and only kept for retro-compatilibity. Parents of - a node are dependant on a given tree. This implementation keeps the previous behavior by keeping returning - bpointer of first declared tree. - """ - if self._initial_tree_id not in self._predecessor.keys(): - return None - return self._predecessor[self._initial_tree_id] - - @bpointer.setter - @deprecated(alias='node.set_predecessor') - def bpointer(self, value): - self.set_predecessor(value, self._initial_tree_id) - - @deprecated(alias='node.set_predecessor') - def update_bpointer(self, nid): - self.set_predecessor(nid, self._initial_tree_id) - - @property - @deprecated(alias='node.successors') - def fpointer(self): - """Use successors method, this property is deprecated and only kept for retro-compatilibity. Children of - a node are dependant on a given tree. This implementation keeps the previous behavior by keeping returning - fpointer of first declared tree. - """ - if self._initial_tree_id not in self._successors: - return [] - return self._successors[self._initial_tree_id] - - @fpointer.setter - @deprecated(alias='node.update_successors') - def fpointer(self, value): - self.set_successors(value, tree_id=self._initial_tree_id) - - @deprecated(alias='node.update_successors') - def update_fpointer(self, nid, mode=ADD, replace=None): - """Deprecated""" - self.update_successors(nid, mode, replace, self._initial_tree_id) - - def predecessor(self, tree_id): - """ - The parent ID of a node in a given tree. - """ - return self._predecessor[tree_id] - - def set_predecessor(self, nid, tree_id): - """Set the value of `_predecessor`.""" - self._predecessor[tree_id] = nid - - def successors(self, tree_id): - """ - With a getting operator, a list of IDs of node's children is obtained. With - a setting operator, the value can be list, set, or dict. For list or set, - it is converted to a list type by the package; for dict, the keys are - treated as the node IDs. - """ - return self._successors[tree_id] - - def set_successors(self, value, tree_id=None): - """Set the value of `_successors`.""" - setter_lookup = { - 'NoneType': lambda x: list(), - 'list': lambda x: x, - 'dict': lambda x: list(x.keys()), - 'set': lambda x: list(x) - } - - t = value.__class__.__name__ - if t in setter_lookup: - f_setter = setter_lookup[t] - self._successors[tree_id] = f_setter(value) - else: - raise NotImplementedError('Unsupported value type %s' % t) - - def update_successors(self, nid, mode=ADD, replace=None, tree_id=None): - """ - Update the children list with different modes: addition (Node.ADD or - Node.INSERT) and deletion (Node.DELETE). - """ - if nid is None: - return - - def _manipulator_append(): - self.successors(tree_id).append(nid) - - def _manipulator_delete(): - if nid in self.successors(tree_id): - self.successors(tree_id).remove(nid) - else: - warn('Nid %s wasn\'t present in fpointer' % nid) - - def _manipulator_insert(): - warn("WARNING: INSERT is deprecated to ADD mode") - self.update_successors(nid, tree_id=tree_id) - - def _manipulator_replace(): - if replace is None: - raise NodePropertyError( - 'Argument "repalce" should be provided when mode is {}'.format(mode) - ) - ind = self.successors(tree_id).index(nid) - self.successors(tree_id)[ind] = replace - - manipulator_lookup = { - self.ADD: '_manipulator_append', - self.DELETE: '_manipulator_delete', - self.INSERT: '_manipulator_insert', - self.REPLACE: '_manipulator_replace' - } - - if mode not in manipulator_lookup: - raise NotImplementedError('Unsupported node updating mode %s' % str(mode)) - - f_name = manipulator_lookup.get(mode) - f = locals()[f_name] - return f() - - @property - def identifier(self): - """ - The unique ID of a node within the scope of a tree. This attribute can be - accessed and modified with ``.`` and ``=`` operator respectively. - """ - return self._identifier - - def clone_pointers(self, former_tree_id, new_tree_id): - former_bpointer = self.predecessor(former_tree_id) - self.set_predecessor(former_bpointer, new_tree_id) - former_fpointer = self.successors(former_tree_id) - # fpointer is a list and would be copied by reference without deepcopy - self.set_successors(copy.deepcopy(former_fpointer), tree_id=new_tree_id) - - def reset_pointers(self, tree_id): - self.set_predecessor(None, tree_id) - self.set_successors([], tree_id=tree_id) - - @identifier.setter - def identifier(self, value): - """Set the value of `_identifier`.""" - if value is None: - print("WARNING: node ID can not be None") - else: - self._set_identifier(value) - - def is_leaf(self, tree_id=None): - """Return true if current node has no children.""" - if tree_id is None: - # for retro-compatilibity - if self._initial_tree_id not in self._successors.keys(): - return True - else: - tree_id = self._initial_tree_id - - if len(self.successors(tree_id)) == 0: - return True - else: - return False - - def is_root(self, tree_id=None): - """Return true if self has no parent, i.e. as root.""" - if tree_id is None: - # for retro-compatilibity - if self._initial_tree_id not in self._predecessor.keys(): - return True - else: - tree_id = self._initial_tree_id - - return self.predecessor(tree_id) is None - - @property - def tag(self): - """ - The readable node name for human. This attribute can be accessed and - modified with ``.`` and ``=`` operator respectively. - """ - return self._tag - - @tag.setter - def tag(self, value): - """Set the value of `_tag`.""" - self._tag = value if value is not None else None - - def __repr__(self): - name = self.__class__.__name__ - kwargs = [ - "tag={0}".format(self.tag), - "identifier={0}".format(self.identifier), - "data={0}".format(self.data), - ] - return "%s(%s)" % (name, ", ".join(kwargs)) diff --git a/fildem/treelib/plugins.py b/fildem/treelib/plugins.py deleted file mode 100644 index ff54b11e..00000000 --- a/fildem/treelib/plugins.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# Copyright (C) 2011 -# Brett Alistair Kromkamp - brettkromkamp@gmail.com -# Copyright (C) 2012-2017 -# Xiaming Chen - chenxm35@gmail.com -# and other contributors. -# All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -This is a public location to maintain contributed -utilities to extend the basic Tree class. - -Deprecated! We prefer a unified processing of Tree object. -""" -from __future__ import unicode_literals - -from .misc import deprecated - - -@deprecated(alias='tree.to_graphviz()') -def export_to_dot(tree, filename=None, shape='circle', graph='digraph'): - """Exports the tree in the dot format of the graphviz software""" - tree.to_graphviz(filename=filename, shape=shape, graph=graph) diff --git a/fildem/treelib/tree.py b/fildem/treelib/tree.py deleted file mode 100644 index 6ee18c95..00000000 --- a/fildem/treelib/tree.py +++ /dev/null @@ -1,996 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -# Copyright (C) 2011 -# Brett Alistair Kromkamp - brettkromkamp@gmail.com -# Copyright (C) 2012-2017 -# Xiaming Chen - chenxm35@gmail.com -# and other contributors. -# All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -""" -Tree structure in `treelib`. - -The :class:`Tree` object defines the tree-like structure based on :class:`Node` objects. -A new tree can be created from scratch without any parameter or a shallow/deep copy of another tree. -When deep=True, a deepcopy operation is performed on feeding tree parameter and more memory -is required to create the tree. -""" -from __future__ import print_function -from __future__ import unicode_literals - -try: - from builtins import str as text -except ImportError: - from __builtin__ import str as text - -import codecs -import json -import uuid -from copy import deepcopy -from future.utils import python_2_unicode_compatible, iteritems - -try: - from StringIO import StringIO -except ImportError: - from io import StringIO - -from .exceptions import * -from .node import Node - -__author__ = 'chenxm' - - -@python_2_unicode_compatible -class Tree(object): - """Tree objects are made of Node(s) stored in _nodes dictionary.""" - - #: ROOT, DEPTH, WIDTH, ZIGZAG constants : - (ROOT, DEPTH, WIDTH, ZIGZAG) = list(range(4)) - node_class = Node - - def __contains__(self, identifier): - return identifier in self.nodes.keys() - - def __init__(self, tree=None, deep=False, node_class=None, identifier=None): - """Initiate a new tree or copy another tree with a shallow or - deep copy. - """ - self._identifier = None - self._set_identifier(identifier) - - if node_class: - assert issubclass(node_class, Node) - self.node_class = node_class - - #: dictionary, identifier: Node object - self._nodes = {} - - #: Get or set the identifier of the root. This attribute can be accessed and modified - #: with ``.`` and ``=`` operator respectively. - self.root = None - - if tree is not None: - self.root = tree.root - for nid, node in iteritems(tree.nodes): - new_node = deepcopy(node) if deep else node - self._nodes[nid] = new_node - if tree.identifier != self._identifier: - new_node.clone_pointers(tree.identifier, self._identifier) - - def _clone(self, identifier=None, with_tree=False, deep=False): - """Clone current instance, with or without tree. - - Method intended to be overloaded, to avoid rewriting whole "subtree" and "remove_subtree" methods when - inheriting from Tree. - >>> class TreeWithComposition(Tree): - >>> def __init__(self, tree_description, tree=None, deep=False, identifier=None): - >>> self.tree_description = tree_description - >>> super(TreeWithComposition, self).__init__(tree=tree, deep=deep, identifier=identifier) - >>> - >>> def _clone(self, identifier=None, with_tree=False, deep=False): - >>> return TreeWithComposition( - >>> identifier=identifier, - >>> deep=deep, - >>> tree=self if with_tree else None, - >>> tree_description=self.tree_description - >>> ) - >>> my_custom_tree = TreeWithComposition(tree_description="smart tree") - >>> subtree = my_custom_tree.subtree() - >>> subtree.tree_description - "smart tree" - """ - return self.__class__(identifier=identifier, tree=self if with_tree else None, deep=deep) - - @property - def identifier(self): - return self._identifier - - def _set_identifier(self, nid): - """Initialize self._set_identifier""" - if nid is None: - self._identifier = str(uuid.uuid1()) - else: - self._identifier = nid - - def __getitem__(self, key): - """Return _nodes[key]""" - try: - return self._nodes[key] - except KeyError: - raise NodeIDAbsentError("Node '%s' is not in the tree" % key) - - def __len__(self): - """Return len(_nodes)""" - return len(self._nodes) - - def __str__(self): - self._reader = "" - - def write(line): - self._reader += line.decode('utf-8') + "\n" - - self.__print_backend(func=write) - return self._reader - - def __print_backend(self, nid=None, level=ROOT, idhidden=True, filter=None, - key=None, reverse=False, line_type='ascii-ex', - data_property=None, func=print): - """ - Another implementation of printing tree using Stack - Print tree structure in hierarchy style. - - For example: - - .. code-block:: bash - - Root - |___ C01 - | |___ C11 - | |___ C111 - | |___ C112 - |___ C02 - |___ C03 - | |___ C31 - - A more elegant way to achieve this function using Stack - structure, for constructing the Nodes Stack push and pop nodes - with additional level info. - - UPDATE: the @key @reverse is present to sort node at each - level. - """ - # Factory for proper get_label() function - if data_property: - if idhidden: - def get_label(node): - return getattr(node.data, data_property) - else: - def get_label(node): - return "%s[%s]" % (getattr(node.data, data_property), node.identifier) - else: - if idhidden: - def get_label(node): - return node.tag - else: - def get_label(node): - return "%s[%s]" % (node.tag, node.identifier) - - # legacy ordering - if key is None: - def key(node): - return node - - # iter with func - for pre, node in self.__get(nid, level, filter, key, reverse, - line_type): - label = get_label(node) - func('{0}{1}'.format(pre, label).encode('utf-8')) - - def __get(self, nid, level, filter_, key, reverse, line_type): - # default filter - if filter_ is None: - def filter_(node): - return True - - # render characters - dt = { - 'ascii': ('|', '|-- ', '+-- '), - 'ascii-ex': ('\u2502', '\u251c\u2500\u2500 ', '\u2514\u2500\u2500 '), - 'ascii-exr': ('\u2502', '\u251c\u2500\u2500 ', '\u2570\u2500\u2500 '), - 'ascii-em': ('\u2551', '\u2560\u2550\u2550 ', '\u255a\u2550\u2550 '), - 'ascii-emv': ('\u2551', '\u255f\u2500\u2500 ', '\u2559\u2500\u2500 '), - 'ascii-emh': ('\u2502', '\u255e\u2550\u2550 ', '\u2558\u2550\u2550 '), - }[line_type] - - return self.__get_iter(nid, level, filter_, key, reverse, dt, []) - - def __get_iter(self, nid, level, filter_, key, reverse, dt, is_last): - dt_vertical_line, dt_line_box, dt_line_corner = dt - - nid = self.root if nid is None else nid - node = self[nid] - - if level == self.ROOT: - yield "", node - else: - leading = ''.join(map(lambda x: dt_vertical_line + ' ' * 3 - if not x else ' ' * 4, is_last[0:-1])) - lasting = dt_line_corner if is_last[-1] else dt_line_box - yield leading + lasting, node - - if filter_(node) and node.expanded: - children = [self[i] for i in node.successors(self._identifier) if filter_(self[i])] - idxlast = len(children) - 1 - if key: - children.sort(key=key, reverse=reverse) - elif reverse: - children = reversed(children) - level += 1 - for idx, child in enumerate(children): - is_last.append(idx == idxlast) - for item in self.__get_iter(child.identifier, level, filter_, - key, reverse, dt, is_last): - yield item - is_last.pop() - - def __update_bpointer(self, nid, parent_id): - """set self[nid].bpointer""" - self[nid].set_predecessor(parent_id, self._identifier) - - def __update_fpointer(self, nid, child_id, mode): - if nid is None: - return - else: - self[nid].update_successors(child_id, mode, tree_id=self._identifier) - - def add_node(self, node, parent=None): - """ - Add a new node object to the tree and make the parent as the root by default. - - The 'node' parameter refers to an instance of Class::Node. - """ - if not isinstance(node, self.node_class): - raise OSError( - "First parameter must be object of {}".format(self.node_class)) - - if node.identifier in self._nodes: - raise DuplicatedNodeIdError("Can't create node " - "with ID '%s'" % node.identifier) - - pid = parent.identifier if isinstance( - parent, self.node_class) else parent - - if pid is None: - if self.root is not None: - raise MultipleRootError("A tree takes one root merely.") - else: - self.root = node.identifier - elif not self.contains(pid): - raise NodeIDAbsentError("Parent node '%s' " - "is not in the tree" % pid) - - self._nodes.update({node.identifier: node}) - self.__update_fpointer(pid, node.identifier, self.node_class.ADD) - self.__update_bpointer(node.identifier, pid) - node.set_initial_tree_id(self._identifier) - - def all_nodes(self): - """Return all nodes in a list""" - return list(self._nodes.values()) - - def all_nodes_itr(self): - """ - Returns all nodes in an iterator. - Added by William Rusnack - """ - return self._nodes.values() - - def ancestor(self, nid, level=None): - """ - For a given id, get ancestor node object at a given level. - If no level is provided, the parent node is returned. - """ - if not self.contains(nid): - raise NodeIDAbsentError("Node '%s' is not in the tree" % nid) - - descendant = self[nid] - ascendant = self[nid].bpointer - ascendant_level = self.level(ascendant) - - if level is None: - return ascendant - elif nid == self.root: - return self[nid] - elif level >= self.level(descendant.identifier): - raise InvalidLevelNumber("Descendant level (level %s) must be greater \ - than its ancestor's level (level %s)" % (str(self.level(descendant.identifier)), level)) - - while ascendant is not None: - if ascendant_level == level: - return self[ascendant] - else: - descendant = ascendant - ascendant = self[descendant].bpointer - ascendant_level = self.level(ascendant) - return None - - def children(self, nid): - """ - Return the children (Node) list of nid. - Empty list is returned if nid does not exist - """ - return [self[i] for i in self.is_branch(nid)] - - def contains(self, nid): - """Check if the tree contains node of given id""" - return True if nid in self._nodes else False - - def create_node(self, tag=None, identifier=None, parent=None, data=None): - """ - Create a child node for given @parent node. If ``identifier`` is absent, - a UUID will be generated automatically. - """ - node = self.node_class(tag=tag, identifier=identifier, data=data) - self.add_node(node, parent) - return node - - def depth(self, node=None): - """ - Get the maximum level of this tree or the level of the given node. - - @param node Node instance or identifier - @return int - @throw NodeIDAbsentError - """ - ret = 0 - if node is None: - # Get maximum level of this tree - leaves = self.leaves() - for leave in leaves: - level = self.level(leave.identifier) - ret = level if level >= ret else ret - else: - # Get level of the given node - if not isinstance(node, self.node_class): - nid = node - else: - nid = node.identifier - if not self.contains(nid): - raise NodeIDAbsentError("Node '%s' is not in the tree" % nid) - ret = self.level(nid) - return ret - - def expand_tree(self, nid=None, mode=DEPTH, filter=None, key=None, - reverse=False, sorting=True): - """ - Python generator to traverse the tree (or a subtree) with optional - node filtering and sorting. - - Loosely based on an algorithm from 'Essential LISP' by John R. Anderson, - Albert T. Corbett, and Brian J. Reiser, page 239-241. - - :param nid: Node identifier from which tree traversal will start. - If None tree root will be used - :param mode: Traversal mode, may be either DEPTH, WIDTH or ZIGZAG - :param filter: the @filter function is performed on Node object during - traversing. In this manner, the traversing will NOT visit the node - whose condition does not pass the filter and its children. - :param key: the @key and @reverse are present to sort nodes at each - level. If @key is None sorting is performed on node tag. - :param reverse: if True reverse sorting - :param sorting: if True perform node sorting, if False return - nodes in original insertion order. In latter case @key and - @reverse parameters are ignored. - :return: Node IDs that satisfy the conditions - :rtype: generator object - """ - nid = self.root if nid is None else nid - if not self.contains(nid): - raise NodeIDAbsentError("Node '%s' is not in the tree" % nid) - - filter = (lambda x: True) if (filter is None) else filter - if filter(self[nid]): - yield nid - queue = [self[i] for i in self[nid].successors(self._identifier) if filter(self[i])] - if mode in [self.DEPTH, self.WIDTH]: - if sorting: - queue.sort(key=key, reverse=reverse) - while queue: - yield queue[0].identifier - expansion = [self[i] for i in queue[0].successors(self._identifier) - if filter(self[i])] - if sorting: - expansion.sort(key=key, reverse=reverse) - if mode is self.DEPTH: - queue = expansion + queue[1:] # depth-first - elif mode is self.WIDTH: - queue = queue[1:] + expansion # width-first - - elif mode is self.ZIGZAG: - # Suggested by Ilya Kuprik (ilya-spy@ynadex.ru). - stack_fw = [] - queue.reverse() - stack = stack_bw = queue - direction = False - while stack: - expansion = [self[i] for i in stack[0].successors(self._identifier) - if filter(self[i])] - yield stack.pop(0).identifier - if direction: - expansion.reverse() - stack_bw = expansion + stack_bw - else: - stack_fw = expansion + stack_fw - if not stack: - direction = not direction - stack = stack_fw if direction else stack_bw - - else: - raise ValueError( - "Traversal mode '{}' is not supported".format(mode)) - - def filter_nodes(self, func): - """ - Filters all nodes by function. - - :param func: is passed one node as an argument and that node is included if function returns true, - :return: a filter iterator of the node in python 3 or a list of the nodes in python 2. - - Added by William Rusnack. - """ - return filter(func, self.all_nodes_itr()) - - def get_node(self, nid): - """ - Get the object of the node with ID of ``nid``. - - An alternative way is using '[]' operation on the tree. But small difference exists between them: - ``get_node()`` will return None if ``nid`` is absent, whereas '[]' will raise ``KeyError``. - """ - if nid is None or not self.contains(nid): - return None - return self._nodes[nid] - - def is_branch(self, nid): - """ - Return the children (ID) list of nid. - Empty list is returned if nid does not exist - """ - if nid is None: - raise OSError("First parameter can't be None") - if not self.contains(nid): - raise NodeIDAbsentError("Node '%s' is not in the tree" % nid) - - try: - fpointer = self[nid].successors(self._identifier) - except KeyError: - fpointer = [] - return fpointer - - def leaves(self, nid=None): - """Get leaves of the whole tree or a subtree.""" - leaves = [] - if nid is None: - for node in self._nodes.values(): - if node.is_leaf(self._identifier): - leaves.append(node) - else: - for node in self.expand_tree(nid): - if self[node].is_leaf(self._identifier): - leaves.append(self[node]) - return leaves - - def level(self, nid, filter=None): - """ - Get the node level in this tree. - The level is an integer starting with '0' at the root. - In other words, the root lives at level '0'; - - Update: @filter params is added to calculate level passing - exclusive nodes. - """ - return len([n for n in self.rsearch(nid, filter)]) - 1 - - def link_past_node(self, nid): - """ - Delete a node by linking past it. - - For example, if we have `a -> b -> c` and delete node b, we are left - with `a -> c`. - """ - if not self.contains(nid): - raise NodeIDAbsentError("Node '%s' is not in the tree" % nid) - if self.root == nid: - raise LinkPastRootNodeError("Cannot link past the root node, " - "delete it with remove_node()") - # Get the parent of the node we are linking past - parent = self[self[nid].predecessor(self._identifier)] - # Set the children of the node to the parent - for child in self[nid].successors(self._identifier): - self[child].set_predecessor(parent.identifier, self._identifier) - # Link the children to the parent - for id_ in self[nid].successors(self._identifier) or []: - parent.update_successors(id_, tree_id=self._identifier) - # Delete the node - parent.update_successors(nid, mode=parent.DELETE, tree_id=self._identifier) - del self._nodes[nid] - - def move_node(self, source, destination): - """ - Move node @source from its parent to another parent @destination. - """ - if not self.contains(source) or not self.contains(destination): - raise NodeIDAbsentError - elif self.is_ancestor(source, destination): - raise LoopError - - parent = self[source].predecessor(self._identifier) - self.__update_fpointer(parent, source, self.node_class.DELETE) - self.__update_fpointer(destination, source, self.node_class.ADD) - self.__update_bpointer(source, destination) - - def is_ancestor(self, ancestor, grandchild): - """ - Check if the @ancestor the preceding nodes of @grandchild. - - :param ancestor: the node identifier - :param grandchild: the node identifier - :return: True or False - """ - parent = self[grandchild].predecessor(self._identifier) - child = grandchild - while parent is not None: - if parent == ancestor: - return True - else: - child = self[child].predecessor(self._identifier) - parent = self[child].predecessor(self._identifier) - return False - - @property - def nodes(self): - """Return a dict form of nodes in a tree: {id: node_instance}.""" - return self._nodes - - def parent(self, nid): - """Get parent :class:`Node` object of given id.""" - if not self.contains(nid): - raise NodeIDAbsentError("Node '%s' is not in the tree" % nid) - - pid = self[nid].predecessor(self._identifier) - if pid is None or not self.contains(pid): - return None - - return self[pid] - - def merge(self, nid, new_tree, deep=False): - """Patch @new_tree on current tree by pasting new_tree root children on current tree @nid node. - - Consider the following tree: - >>> current.show() - root - ├── A - └── B - >>> new_tree.show() - root2 - ├── C - └── D - └── D1 - Merging new_tree on B node: - >>>current.merge('B', new_tree) - >>>current.show() - root - ├── A - └── B - ├── C - └── D - └── D1 - - Note: if current tree is empty and nid is None, the new_tree root will be used as root on current tree. In all - other cases new_tree root is not pasted. - """ - if new_tree.root is None: - return - - if nid is None: - if self.root is None: - new_tree_root = new_tree[new_tree.root] - self.add_node(new_tree_root) - nid = new_tree.root - else: - raise ValueError('Must define "nid" under which new tree is merged.') - for child in new_tree.children(new_tree.root): - self.paste(nid=nid, new_tree=new_tree.subtree(child.identifier), deep=deep) - - def paste(self, nid, new_tree, deep=False): - """ - Paste a @new_tree to the original one by linking the root - of new tree to given node (nid). - - Update: add @deep copy of pasted tree. - """ - assert isinstance(new_tree, Tree) - - if new_tree.root is None: - return - - if nid is None: - raise ValueError('Must define "nid" under which new tree is pasted.') - - if not self.contains(nid): - raise NodeIDAbsentError("Node '%s' is not in the tree" % nid) - - set_joint = set(new_tree._nodes) & set(self._nodes) # joint keys - if set_joint: - raise ValueError('Duplicated nodes %s exists.' % list(map(text, set_joint))) - - for cid, node in iteritems(new_tree.nodes): - if deep: - node = deepcopy(new_tree[node]) - self._nodes.update({cid: node}) - node.clone_pointers(new_tree.identifier, self._identifier) - - self.__update_bpointer(new_tree.root, nid) - self.__update_fpointer(nid, new_tree.root, self.node_class.ADD) - - def paths_to_leaves(self): - """ - Use this function to get the identifiers allowing to go from the root - nodes to each leaf. - - :return: a list of list of identifiers, root being not omitted. - - For example: - - .. code-block:: python - - Harry - |___ Bill - |___ Jane - | |___ Diane - | |___ George - | |___ Jill - | |___ Mary - | |___ Mark - - Expected result: - - .. code-block:: python - - [['harry', 'jane', 'diane', 'mary'], - ['harry', 'jane', 'mark'], - ['harry', 'jane', 'diane', 'george', 'jill'], - ['harry', 'bill']] - - """ - res = [] - - for leaf in self.leaves(): - res.append([nid for nid in self.rsearch(leaf.identifier)][::-1]) - - return res - - def remove_node(self, identifier): - """Remove a node indicated by 'identifier' with all its successors. - Return the number of removed nodes. - """ - if not self.contains(identifier): - raise NodeIDAbsentError("Node '%s' " - "is not in the tree" % identifier) - - parent = self[identifier].predecessor(self._identifier) - - # Remove node and its children - removed = list(self.expand_tree(identifier)) - - for id_ in removed: - if id_ == self.root: - self.root = None - self.__update_bpointer(id_, None) - for cid in self[id_].successors(self._identifier) or []: - self.__update_fpointer(id_, cid, self.node_class.DELETE) - - # Update parent info - self.__update_fpointer(parent, identifier, self.node_class.DELETE) - self.__update_bpointer(identifier, None) - - for id_ in removed: - self.nodes.pop(id_) - return len(removed) - - def remove_subtree(self, nid, identifier=None): - """ - Get a subtree with ``nid`` being the root. If nid is None, an - empty tree is returned. - - For the original tree, this method is similar to - `remove_node(self,nid)`, because given node and its children - are removed from the original tree in both methods. - For the returned value and performance, these two methods are - different: - - * `remove_node` returns the number of deleted nodes; - * `remove_subtree` returns a subtree of deleted nodes; - - You are always suggested to use `remove_node` if your only to - delete nodes from a tree, as the other one need memory - allocation to store the new tree. - - :return: a :class:`Tree` object. - """ - st = self._clone(identifier) - if nid is None: - return st - - if not self.contains(nid): - raise NodeIDAbsentError("Node '%s' is not in the tree" % nid) - st.root = nid - - # in original tree, the removed nid will be unreferenced from its parents children - parent = self[nid].predecessor(self._identifier) - - removed = list(self.expand_tree(nid)) - for id_ in removed: - if id_ == self.root: - self.root = None - st._nodes.update({id_: self._nodes.pop(id_)}) - st[id_].clone_pointers(self._identifier, st.identifier) - st[id_].reset_pointers(self._identifier) - if id_ == nid: - st[id_].set_predecessor(None, st.identifier) - self.__update_fpointer(parent, nid, self.node_class.DELETE) - return st - - def rsearch(self, nid, filter=None): - """ - Traverse the tree branch along the branch from nid to its - ancestors (until root). - - :param filter: the function of one variable to act on the :class:`Node` object. - """ - if nid is None: - return - - if not self.contains(nid): - raise NodeIDAbsentError("Node '%s' is not in the tree" % nid) - - filter = (lambda x: True) if (filter is None) else filter - - current = nid - while current is not None: - if filter(self[current]): - yield current - # subtree() hasn't update the bpointer - current = self[current].predecessor(self._identifier) if self.root != current else None - - def save2file(self, filename, nid=None, level=ROOT, idhidden=True, - filter=None, key=None, reverse=False, line_type='ascii-ex', data_property=None): - """ - Save the tree into file for offline analysis. - """ - - def _write_line(line, f): - f.write(line + b'\n') - - handler = lambda x: _write_line(x, open(filename, 'ab')) - - self.__print_backend(nid, level, idhidden, filter, - key, reverse, line_type, data_property, func=handler) - - def show(self, nid=None, level=ROOT, idhidden=True, filter=None, - key=None, reverse=False, line_type='ascii-ex', data_property=None, stdout=True): - """ - Print the tree structure in hierarchy style. - - You have three ways to output your tree data, i.e., stdout with ``show()``, - plain text file with ``save2file()``, and json string with ``to_json()``. The - former two use the same backend to generate a string of tree structure in a - text graph. - - * Version >= 1.2.7a*: you can also specify the ``line_type`` parameter, such as 'ascii' (default), 'ascii-ex', 'ascii-exr', 'ascii-em', 'ascii-emv', 'ascii-emh') to the change graphical form. - - :param nid: the reference node to start expanding. - :param level: the node level in the tree (root as level 0). - :param idhidden: whether hiding the node ID when printing. - :param filter: the function of one variable to act on the :class:`Node` object. - When this parameter is specified, the traversing will not continue to following - children of node whose condition does not pass the filter. - :param key: the ``key`` param for sorting :class:`Node` objects in the same level. - :param reverse: the ``reverse`` param for sorting :class:`Node` objects in the same level. - :param line_type: - :param data_property: the property on the node data object to be printed. - :return: None - """ - self._reader = "" - - def write(line): - self._reader += line.decode('utf-8') + "\n" - - try: - self.__print_backend(nid, level, idhidden, filter, - key, reverse, line_type, data_property, func=write) - except NodeIDAbsentError: - print('Tree is empty') - - if stdout: - print(self._reader) - else: - return self._reader - - def siblings(self, nid): - """ - Return the siblings of given @nid. - - If @nid is root or there are no siblings, an empty list is returned. - """ - siblings = [] - - if nid != self.root: - pid = self[nid].predecessor(self._identifier) - siblings = [self[i] for i in self[pid].successors(self._identifier) if i != nid] - - return siblings - - def size(self, level=None): - """ - Get the number of nodes of the whole tree if @level is not - given. Otherwise, the total number of nodes at specific level - is returned. - - @param level The level number in the tree. It must be between - [0, tree.depth]. - - Otherwise, InvalidLevelNumber exception will be raised. - """ - if level is None: - return len(self._nodes) - else: - try: - level = int(level) - return len([node for node in self.all_nodes_itr() if self.level(node.identifier) == level]) - except: - raise TypeError( - "level should be an integer instead of '%s'" % type(level)) - - def subtree(self, nid, identifier=None): - """ - Return a shallow COPY of subtree with nid being the new root. - If nid is None, return an empty tree. - If you are looking for a deepcopy, please create a new tree - with this shallow copy, e.g., - - .. code-block:: python - - new_tree = Tree(t.subtree(t.root), deep=True) - - This line creates a deep copy of the entire tree. - """ - st = self._clone(identifier) - if nid is None: - return st - - if not self.contains(nid): - raise NodeIDAbsentError("Node '%s' is not in the tree" % nid) - - st.root = nid - for node_n in self.expand_tree(nid): - st._nodes.update({self[node_n].identifier: self[node_n]}) - # define nodes parent/children in this tree - # all pointers are the same as copied tree, except the root - st[node_n].clone_pointers(self._identifier, st.identifier) - if node_n == nid: - # reset root parent for the new tree - st[node_n].set_predecessor(None, st.identifier) - return st - - def update_node(self, nid, **attrs): - """ - Update node's attributes. - - :param nid: the identifier of modified node - :param attrs: attribute pairs recognized by Node object - :return: None - """ - cn = self[nid] - for attr, val in iteritems(attrs): - if attr == 'identifier': - # Updating node id meets following contraints: - # * Update node identifier property - # * Update parent's followers - # * Update children's parents - # * Update tree registration of var _nodes - # * Update tree root if necessary - cn = self._nodes.pop(nid) - setattr(cn, 'identifier', val) - self._nodes[val] = cn - - if cn.predecessor(self._identifier) is not None: - self[cn.predecessor(self._identifier)].update_successors( - nid, mode=self.node_class.REPLACE, replace=val, - tree_id=self._identifier - ) - - for fp in cn.successors(self._identifier): - self[fp].set_predecessor(val, self._identifier) - - if self.root == nid: - self.root = val - else: - setattr(cn, attr, val) - - def to_dict(self, nid=None, key=None, sort=True, reverse=False, with_data=False): - """Transform the whole tree into a dict.""" - - nid = self.root if (nid is None) else nid - ntag = self[nid].tag - tree_dict = {ntag: {"children": []}} - if with_data: - tree_dict[ntag]["data"] = self[nid].data - - if self[nid].expanded: - queue = [self[i] for i in self[nid].successors(self._identifier)] - key = (lambda x: x) if (key is None) else key - if sort: - queue.sort(key=key, reverse=reverse) - - for elem in queue: - tree_dict[ntag]["children"].append( - self.to_dict(elem.identifier, with_data=with_data, sort=sort, reverse=reverse)) - if len(tree_dict[ntag]["children"]) == 0: - tree_dict = self[nid].tag if not with_data else \ - {ntag: {"data": self[nid].data}} - return tree_dict - - def to_json(self, with_data=False, sort=True, reverse=False): - """To format the tree in JSON format.""" - return json.dumps(self.to_dict(with_data=with_data, sort=sort, reverse=reverse)) - - def to_graphviz(self, filename=None, shape='circle', graph='digraph'): - """Exports the tree in the dot format of the graphviz software""" - nodes, connections = [], [] - if self.nodes: - - for n in self.expand_tree(mode=self.WIDTH): - nid = self[n].identifier - state = '"{0}" [label="{1}", shape={2}]'.format( - nid, self[n].tag, shape) - nodes.append(state) - - for c in self.children(nid): - cid = c.identifier - connections.append('"{0}" -> "{1}"'.format(nid, cid)) - - # write nodes and connections to dot format - is_plain_file = filename is not None - if is_plain_file: - f = codecs.open(filename, 'w', 'utf-8') - else: - f = StringIO() - - f.write(graph + ' tree {\n') - for n in nodes: - f.write('\t' + n + '\n') - - if len(connections) > 0: - f.write('\n') - - for c in connections: - f.write('\t' + c + '\n') - - f.write('}') - - if not is_plain_file: - print(f.getvalue()) - - f.close() diff --git a/fildem/utils/__init__.py b/fildem/utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/fildem/utils/fuzzy.py b/fildem/utils/fuzzy.py deleted file mode 100644 index d9d4b5e2..00000000 --- a/fildem/utils/fuzzy.py +++ /dev/null @@ -1,53 +0,0 @@ -import re - -# from fuzzysearch import find_near_matches - - -def match_replace(pattern, replacement, text): - regex = re.compile(pattern, re.IGNORECASE) - return regex.sub(replacement, text) - - -def normalize_string(string): - string = match_replace('[^\w]', ' ', string) - string = match_replace('\s+', ' ', string) - - return string.lower().strip() - - -def contains_words(text, words, require_all=True): - for word in words: - if word in text: - if not require_all: - return True - elif require_all: - return False - - return require_all - - -class FuzzyMatch(object): - - def __init__(self, text): - self.score = -1 - self.query = '' - self.value = normalize_string(text) - - def set_query(self, query): - if bool(query) and query != self.query: - self.query = query - self.score = self.get_score(query) - - def get_score(self, query): - query = normalize_string(query) - words = query.split(' ') - - if not contains_words(self.value, words): - return -1 - else: - return 1 - - fuzzy = find_near_matches(query, self.value, max_l_dist=1) - score = sum(map(lambda m: m.dist, fuzzy)) - - return score diff --git a/fildem/utils/global_keybinder.py b/fildem/utils/global_keybinder.py deleted file mode 100644 index a73d25c0..00000000 --- a/fildem/utils/global_keybinder.py +++ /dev/null @@ -1,47 +0,0 @@ -import gi -import os - -gi.require_version('Keybinder', '3.0') - -from gi.repository import Keybinder - -from fildem.utils.wayland import is_wayland - - -class GlobalKeybinder: - """ - Global keybinder for mnemonic, like Alt+F for files, etc. - """ - def __init__(self, callback=None): - Keybinder.init() - self.keybinding_strings = [] - self.keybinder_callback = callback - - @classmethod - def create(cls, callback=None): - if not is_wayland(): - return cls(callback) - else: - return DummyKeybinder() - - def add_keybinding(self, character): - acc = '' + character - Keybinder.bind(acc, lambda accelerator: self.on_keybind_activated(character)) - self.keybinding_strings.append(acc) - - def on_keybind_activated(self, char): - self.keybinder_callback(char) - - def remove_all_keybindings(self): - for k in self.keybinding_strings: - Keybinder.unbind(k) - self.keybinding_strings = [] - - -class DummyKeybinder: - - def add_keybinding(self, character): - pass - - def remove_all_keybindings(self): - pass diff --git a/fildem/utils/menu.py b/fildem/utils/menu.py deleted file mode 100644 index 5e89bbdc..00000000 --- a/fildem/utils/menu.py +++ /dev/null @@ -1,148 +0,0 @@ -import dbus - -from gi.repository import GLib - -from fildem.utils.global_keybinder import GlobalKeybinder -from fildem.utils.window import WindowManager -from fildem.utils.service import MyService - -from fildem.handlers.default import HudMenu -from fildem.handlers.global_menu import GlobalMenu - -from fildem.menu_model.menu_model import MenuModel - - -class DbusMenu: - - def __init__(self): - self.keyb = GlobalKeybinder.create(self.on_keybind_activated) - self.app = None - self.session = dbus.SessionBus() - self.window = WindowManager.new_window() - self.tries = 0 - self.retry_timer_id = 0 - self.collect_timer = 0 - self._menu_model = None - - self._init_window() - self._listen_menu_activated() - self._listen_hud_activated() - WindowManager.add_listener(self.on_window_switched) - - def _init_window(self): - self._menu_model = MenuModel(self.session, self.window) - self._update() - - def on_window_switched(self, window): - self.reset_timeout() - self.window = window - self._init_window() - - def reset_timeout(self): - if self.retry_timer_id: - GLib.source_remove(self.retry_timer_id) - self.tries = 0 - self.retry_timer_id = 0 - - if self.collect_timer: - GLib.source_remove(self.collect_timer) - - def _listen_menu_activated(self): - proxy = self.session.get_object(MyService.BUS_NAME, MyService.BUS_PATH) - signal = proxy.connect_to_signal("MenuActivated", self.on_menu_activated) - - def _listen_hud_activated(self): - proxy = self.session.get_object(MyService.BUS_NAME, MyService.BUS_PATH) - signal = proxy.connect_to_signal("HudActivated", self.on_hud_activated) - - def on_menu_activated(self, menu: str, x: int): - if menu == '__fildem_move': - self._move_menu(x) - return - - if x != -1: - self._start_app(menu, x) - else: - self._start_app(menu) - - def on_hud_activated(self): - menu = HudMenu(self) - menu.run() - - def on_keybind_activated(self, character: str): - self.on_app_started() - self._start_app(character) - - def _move_menu(self, x: int): - if self.app is None: - return - - self.app.move_window(x) - - def _start_app(self, menu_activated: str, offset=300): - if self.app is None: - self.app = GlobalMenu(self, menu_activated, offset) - self.app.connect('shutdown', self.on_app_shutdown) - self.app.run() - - def on_app_started(self): - self._echo_onoff(True) - - def on_app_shutdown(self, app): - self._echo_onoff(False) - self.app = None - - def _echo_onoff(self, on: bool): - self.proxy = dbus.SessionBus().get_object(MyService.BUS_NAME, MyService.BUS_PATH) - self.proxy.EchoMenuOnOff(on) - - def _handle_shortcuts(self, top_level_menus): - self.keyb.remove_all_keybindings() - for label in top_level_menus: - idx = label.find('_') - if idx == -1: - continue - c = label[idx + 1] - self.keyb.add_keybinding(c) - - def _retry_init(self): - self.retry_timer_id = 0 - self._init_window() - return False - - def _update_menus(self): - self._menu_model._update_menus() - - max_tries = 2 - if self.tries < max_tries and self.tree.root is None: - self.tries += 1 - self.retry_timer_id = GLib.timeout_add_seconds(2, self._retry_init) - - def _update(self): - self._update_menus() - self._handle_shortcuts(self._menu_model.top_level_menus) - self._send_msg(self._menu_model.top_level_menus) - - def _send_msg(self, top_level_menus): - if len(top_level_menus) == 0: - top_level_menus = dbus.Array(signature="s") - proxy = self.session.get_object(MyService.BUS_NAME, MyService.BUS_PATH) - proxy.EchoSendTopLevelMenus(top_level_menus) - - @property - def prompt(self): - return self.window.get_app_name() - - @property - def actions(self): - return self._menu_model.actions - - def accel(self): - return self._menu_model.accel - - @property - def tree(self): - return self._menu_model.tree - - def activate(self, selection): - self._menu_model.activate(selection) diff --git a/fildem/utils/service.py b/fildem/utils/service.py deleted file mode 100644 index 66279b61..00000000 --- a/fildem/utils/service.py +++ /dev/null @@ -1,124 +0,0 @@ -import dbus -import dbus.service - -from gi.repository import GLib - -BUS_NAME = 'com.canonical.AppMenu.Registrar' -BUS_PATH = '/com/canonical/AppMenu/Registrar' - - -class AppMenuService(dbus.service.Object): - """ - Types: - - u: int - - a: array - - y: byte - - s: string - - o: DBus object path - - g: DBus type signature - - https://people.gnome.org/~ryanl/glib-docs/gvariant-format-strings.html - """ - def __init__(self): - self.window_dict = dict() - - bus_name = dbus.service.BusName(BUS_NAME, bus=dbus.SessionBus()) - dbus.service.Object.__init__(self, bus_name, BUS_PATH) - - @dbus.service.method(BUS_NAME, in_signature='uo', sender_keyword='sender') - def RegisterWindow(self, windowId, menuObjectPath, sender): - self.window_dict[windowId] = [dbus.String(sender), dbus.ObjectPath(menuObjectPath)] - - @dbus.service.method(BUS_NAME, in_signature='u') - def UnregisterWindow(self, windowId): - if windowId in self.window_dict: - del self.window_dict[windowId] - - @dbus.service.method(BUS_NAME, in_signature='u', out_signature='so') - def GetMenuForWindow(self, windowId): - if windowId in self.window_dict: - return self.window_dict[windowId] - - @dbus.service.method(BUS_NAME, out_signature='a{u(so)}') - def GetMenus(self): - return self.window_dict - - @dbus.service.method(BUS_NAME) - def Q(self): - GLib.MainLoop().quit() - - -class MyService(dbus.service.Object): - - BUS_PATH = '/com/gonzaarcr/appmenu' - BUS_NAME = 'com.gonzaarcr.appmenu' - - def __init__(self): - self.bus_name = dbus.service.BusName(self.BUS_NAME, bus=dbus.SessionBus()) - dbus.service.Object.__init__(self, self.bus_name, self.BUS_PATH) - - @dbus.service.signal(BUS_NAME, signature='su') - def MenuActivated(self, menu, x): - pass - - @dbus.service.method(BUS_NAME, in_signature='su') - def EchoSignal(self, menu, x): - self.MenuActivated(menu, x) - - @dbus.service.method(BUS_NAME, in_signature='a{ss}') - def WindowSwitched(self, window_data): - self.WindowSwitchedSignal(window_data) - - @dbus.service.signal(BUS_NAME, signature='a{ss}') - def WindowSwitchedSignal(self, window_data): - pass - - @dbus.service.method(BUS_NAME, in_signature='as') - def EchoSendTopLevelMenus(self, top_level_menus): - self.SendTopLevelMenus(top_level_menus) - - @dbus.service.signal(BUS_NAME, signature='as') - def SendTopLevelMenus(self, top_level_menus): - pass - - @dbus.service.method(BUS_NAME, in_signature='b') - def EchoMenuOnOff(self, on): - self.MenuOnOff(on) - - @dbus.service.signal(BUS_NAME, signature='b') - def MenuOnOff(self, on): - pass - - # Window action stuff (the menu on alt-space) - @dbus.service.method(BUS_NAME) - def RequestWindowActions(self): - self.RequestWindowActionsSignal() - - @dbus.service.signal(BUS_NAME) - def RequestWindowActionsSignal(self): - pass - - @dbus.service.method(BUS_NAME, in_signature='as') - def ListWindowActions(self, action_list): - self.ListWindowActionsSignal(action_list) - - @dbus.service.signal(BUS_NAME, signature='as') - def ListWindowActionsSignal(self, top_level_menus): - pass - - @dbus.service.method(BUS_NAME, in_signature='s') - def ActivateWindowAction(self, action): - self.ActivateWindowActionSignal(action) - - @dbus.service.signal(BUS_NAME, signature='s') - def ActivateWindowActionSignal(self, action): - pass - - # Needed to activate the hud on wayland - @dbus.service.method(BUS_NAME) - def EmitHudActivated(self): - self.HudActivated() - - @dbus.service.signal(BUS_NAME) - def HudActivated(self): - pass diff --git a/fildem/utils/wayland.py b/fildem/utils/wayland.py deleted file mode 100644 index df15661e..00000000 --- a/fildem/utils/wayland.py +++ /dev/null @@ -1,8 +0,0 @@ -import os - - -def is_wayland(): - disp = os.environ.get('WAYLAND_DISPLAY') - type = os.environ.get('XDG_SESSION_TYPE') - - return 'wayland' in (disp or type) diff --git a/fildem/utils/window.py b/fildem/utils/window.py deleted file mode 100644 index 62e6da4f..00000000 --- a/fildem/utils/window.py +++ /dev/null @@ -1,174 +0,0 @@ -import dbus -import gi -import os - -from gi.repository import Gio - -from fildem.utils.service import MyService -from fildem.utils.wayland import is_wayland - -''' -Everything in this file is due to Wayland compatibility due to Bamf. -This is the only file that should have import bamf. -Its replacement is the gnome extension -''' -# if there’s a problem, maybe try this -# loginctl show-session $(loginctl | grep $(whoami) | awk '{print $1}') -p Type -# 'Wayland' means nothing, what matters is if it is x11 or not -wayland = is_wayland() - -if not wayland: - gi.require_version('Bamf', '3') - from gi.repository import Bamf - - -class Window(object): - def __init__(self, bamf_window=None): - super(Window, self).__init__() - self.bamf_window = bamf_window - self.xid = 0 - self.props = {} - - def get_xid(self): - if self.bamf_window != None: - return self.bamf_window.get_xid() - - return self.xid - - def set_xid(self, xid): - self.xid = xid - - def get_utf8_prop(self, id): - if self.bamf_window != None: - return self.bamf_window.get_utf8_prop(id) - - return self.props[id] if id in self.props else None - - def set_utf8_prop(self, key, value): - self.props[key] = value - - def get_app_name(self): - if not wayland: - return WindowManager.get_app_name() - elif 'appName' in self.props: - return self.props['appName'] - - return '' - - -class WindowActions(object): - """Window actions from the shell from the alt-space menu""" - def __init__(self, callback=None): - super(WindowActions, self).__init__() - - self.listeners = [] - if callback is not None: - self.listeners.append(callback) - - session = dbus.SessionBus() - self.proxy = session.get_object(MyService.BUS_NAME, MyService.BUS_PATH) - self.actions = [] - signal = self.proxy.connect_to_signal("ListWindowActionsSignal", self.on_actions_receive) - - def request_window_actions(self): - self.proxy.RequestWindowActions() - - def on_actions_receive(self, actions): - self.actions = actions - for callback in self.listeners: - callback(actions) - - def activate_action(self, action): - self.proxy.ActivateWindowAction(action) - - -class WindowManager(object): - """ - This is meant to be used as a class, not an object. - Singletons are always implemented as objects, but - I don’t see why not this way - - Attributes - ---------- - listers: List[Callable[Window]] - list of callback functions to be called. There will be only - one anyways. - matcher: Bamf.Matcher - Bamf matcher. - https://lazka.github.io/pgi-docs/Bamf-3/classes/Matcher.html - - Methods - ------- - new_window(win_data={}) - Returns a window object. To be used when initializing and - on window switching. `win_data` is the info from the extension. - """ - listeners = [] - matcher = None - - @classmethod - def _start_listener(cls): - if not wayland: - cls._get_matcher().connect('active-window-changed', cls._window_switched_bamf) - else: - session = dbus.SessionBus() - proxy = session.get_object('com.gonzaarcr.appmenu', '/com/gonzaarcr/appmenu') - signal = proxy.connect_to_signal("WindowSwitchedSignal", cls._window_switched) - - @classmethod - def _get_matcher(cls): - if cls.matcher == None: - cls.matcher = Bamf.Matcher.get_default() - return cls.matcher - - @classmethod - def new_window(cls, win_data={}): - if not wayland: - return Window(cls._get_matcher().get_active_window()) - - # Wayland - win = Window() - for p in win_data: - if p == 'xid': - win.set_xid(int(win_data[p]) if win_data[p] != '' else 0) - else: - win.set_utf8_prop('_' + p.upper(), win_data[p]) - - return win - - @classmethod - def _window_switched_bamf(cls, matcher, object, p0): - # I think the argumets are last and current window respectevely - # https://git.launchpad.net/bamf/tree/src/bamf-matcher.c - if p0 == None: - return - bamf_win = p0 - if bamf_win == None: - win = cls.new_window() - else: - win = Window(bamf_win) - cls._call_all_listeners(win) - - @classmethod - def _window_switched(cls, win_data): - win = cls.new_window(win_data) - cls._call_all_listeners(win) - - @classmethod - def _call_all_listeners(cls, window): - for callback in cls.listeners: - callback(window) - - @classmethod - def add_listener(cls, callback): - if len(cls.listeners) == 0: - cls._start_listener() - cls.listeners.append(callback) - - @classmethod - def get_app_name(cls): - app = cls._get_matcher().get_active_application() - file = app.get_desktop_file() - info = Gio.DesktopAppInfo.new_from_filename(file) - - return info.get_string('Name') diff --git a/fildemGMenu@gonza.com/extension.js b/fildemGMenu@gonza.com/extension.js deleted file mode 100644 index e5ca00a6..00000000 --- a/fildemGMenu@gonza.com/extension.js +++ /dev/null @@ -1,694 +0,0 @@ -'use strict'; - -const { loadInterfaceXML } = imports.misc.fileUtils; - -const { Clutter, Gio, GLib, GObject, Meta, St } = imports.gi; - -const AppSystem = imports.gi.Shell.AppSystem.get_default(); -const WinTracker = imports.gi.Shell.WindowTracker.get_default(); - -const ExtensionUtils = imports.misc.extensionUtils; -const Me = ExtensionUtils.getCurrentExtension(); - -const Settings = Me.imports.settings.FildemGlobalMenuSettings; -const Main = imports.ui.main; -const PanelMenu = imports.ui.panelMenu; -const WindowMenu = imports.ui.windowMenu; - - -function log(msg) { - const debug = true; - if (debug) - global.log('[FILDEM_MENU] ' + msg); -} - - -const WindowActions = class WindowActions { - constructor() { - this._win = global.display.get_focus_window(); - this.actions = []; - } - - // gitlab.gnome.org/GNOME/gnome-shell/-/blob/gnome-3-36/js/ui/windowMenu.js - getActions() { - let type = this._win.get_window_type(); - let win = this._win; - - if (win.can_minimize()) - this.actions.push('Minimize'); - - if (win.can_maximize()) - this.actions.push(win.get_maximized() ? 'Unmaximize' : 'Maximize'); - - if (win.allows_move()) - this.actions.push('Move'); - - if (win.allows_resize()) - this.actions.push('Resize'); - - if (win.titlebar_is_onscreen() && type != Meta.WindowType.DOCK && type != Meta.WindowType.DESKTOP) - this.actions.push('Move Titlebar Onscreen') - - if (win.get_maximized() == Meta.MaximizeFlags.BOTH - || type == Meta.WindowType.DOCK - || type == Meta.WindowType.DESKTOP - || type == Meta.WindowType.SPLASHSCREEN) { - - this.actions.push('Always on Top' + (win.is_above() ? ' ✓' : '')); - } - - if (Main.sessionMode.hasWorkspaces - && (!Meta.prefs_get_workspaces_only_on_primary() || win.is_on_primary_monitor())) { - - let isSticky = win.is_on_all_workspaces(); - - if (win.is_always_on_all_workspaces()) { - this.actions.push('Always on Visible Workspace' + (isSticky ? ' ✓' : '')); - } - - if (!isSticky) { - let workspace = win.get_workspace(); - if (workspace != workspace.get_neighbor(Meta.MotionDirection.LEFT)) - this.actions.push('Move to Workspace Left'); - - if (workspace != workspace.get_neighbor(Meta.MotionDirection.RIGHT)) - this.actions.push('Move to Workspace Right'); - - if (workspace != workspace.get_neighbor(Meta.MotionDirection.UP)) - this.actions.push('Move to Workspace Up'); - - if (workspace != workspace.get_neighbor(Meta.MotionDirection.DOWN)) - this.actions.push('Move to Workspace Down'); - } - } - - let display = global.display; - let nMonitors = display.get_n_monitors(); - let monitorIndex = win.get_monitor(); - if (nMonitors > 1 && monitorIndex >= 0) { - let dir = Meta.DisplayDirection.UP; - let upMonitorIndex = display.get_monitor_neighbor_index(monitorIndex, dir); - if (upMonitorIndex != -1) - this.actions.push('Move to Monitor Up'); - - dir = Meta.DisplayDirection.DOWN; - let downMonitorIndex = display.get_monitor_neighbor_index(monitorIndex, dir); - if (downMonitorIndex != -1) - this.actions.push('Move to Monitor Down'); - - dir = Meta.DisplayDirection.LEFT; - let leftMonitorIndex = display.get_monitor_neighbor_index(monitorIndex, dir); - if (leftMonitorIndex != -1) - this.actions.push('Move to Monitor Left'); - - dir = Meta.DisplayDirection.RIGHT; - let rightMonitorIndex = display.get_monitor_neighbor_index(monitorIndex, dir); - if (rightMonitorIndex != -1) - this.actions.push('Move to Monitor Right'); - } - - if (win.can_close()) - this.actions.push('Close'); - - return this.actions; - } - - _doAction(action) { - if (action.endsWith(' ✓')) { - action = action.substr(0, action.length - 2); - } - let win = this._win; - switch (action) { - case 'Minimize': - win.minimize(); - break; - case 'Unmaximize': - win.unmaximize(Meta.MaximizeFlags.BOTH); - break; - case 'Maximize': - win.maximize(Meta.MaximizeFlags.BOTH); - break; - case 'Move': - GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { - WindowMenu.WindowMenu.prototype._grabAction(win, Meta.GrabOp.KEYBOARD_MOVING, global.display.get_current_time_roundtrip()); - }); - break; - case 'Resize': - GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { - WindowMenu.WindowMenu.prototype._grabAction(win, Meta.GrabOp.KEYBOARD_RESIZING_UNKNOWN, global.display.get_current_time_roundtrip()); - }); - break; - case 'Move Titlebar Onscreen': - win.shove_titlebar_onscreen(); - break; - case 'Always on Top': - if (win.is_above()) - win.unmake_above(); - else - win.make_above(); - break; - case 'Always on Visible Workspace': - if (win.is_on_all_workspaces()) - win.unstick(); - else - win.stick(); - break; - case 'Move to Workspace Left': - this._moveToWorkspace(Meta.MotionDirection.LEFT); - break; - case 'Move to Workspace Right': - this._moveToWorkspace(Meta.MotionDirection.RIGHT); - break; - case 'Move to Workspace Up': - this._moveToWorkspace(Meta.MotionDirection.UP); - break; - case 'Move to Workspace Down': - this._moveToWorkspace(Meta.MotionDirection.DOWN); - break; - case 'Move to Monitor Up': - this._moveToMonitor(Meta.DisplayDirection.UP); - break; - case 'Move to Monitor Down': - this._moveToMonitor(Meta.DisplayDirection.DOWN); - break; - case 'Move to Monitor Left': - this._moveToMonitor(Meta.DisplayDirection.LEFT); - break; - case 'Move to Monitor Right': - this._moveToMonitor(Meta.DisplayDirection.RIGHT); - break; - case 'Close': - win.delete(global.get_current_time()); - break; - } - } - - _moveToWorkspace(dir) { - let workspace = this._win.get_workspace(); - this._win.change_workspace(workspace.get_neighbor(dir)); - } - - _moveToMonitor(dir) { - let monitorIndex = window.get_monitor(); - let newMonitorIndex = global.display.get_monitor_neighbor_index(monitorIndex, dir); - if (newMonitorIndex != -1) { - this._win.move_to_monitor(newMonitorIndex); - } - } -} - -/** - * A single Button like File, Edit, etc. - */ -var MenuButton = GObject.registerClass( -class MenuButton extends PanelMenu.Button { - - _init(label, menuBar) { - label = label.replace('_', ''); - super._init(0.0, label); - this._label = label; - this._menuBar = menuBar; - - this.box = new St.BoxLayout({style_class: 'panel-status-menu-box menubar-button'}); - this.labelWidget = new St.Label({ - text: this._label, - y_align: Clutter.ActorAlign.CENTER, - reactive: true - }); - this.box.add_child(this.labelWidget); - this.add_child(this.box); - this.connect('button-release-event', this.onButtonEvent.bind(this)); - } - - _onStyleChanged(actor) { - super._onStyleChanged(actor); - let padding = this._menuBar.extension.settings.get_int('min-padding'); - this._minHPadding = padding; - this._natHPadding = padding; - } - - onButtonEvent(actor, event) { - if (event.get_button() !== 1) - return Clutter.EVENT_PROPAGATE; - - this._menuBar.onButtonClicked(this._label); - return Clutter.EVENT_STOP; - } -}); - -const Cache = class Cache { - constructor() { - this.N = 10; - this.lru = []; - this.entries = {}; - this.lastQueriedKey = ''; - } - - get(key) { - this.lastQueriedKey = key; - return this.entries[key]; - } - - _set(key, value) { - if (this.entries[key]) { - const oldItem = this.lru.splice(this.lru.indexOf(key), 1); - } - this.lru.push(key); - this.entries[key] = value; - - if (this.lru.length > this.N) { - const toRemove = this.lru.pop(); - this.entries[toRemove] = undefined; - } - } - - withCache(f) { - const self = this; - const g = (param) => { - self._set(self.lastQueriedKey, param); - f(param); - } - return g; - } -} - -/** - * This is a manager not a container - */ -const MenuBar = class MenuBar { - constructor(proxy, extension) { - this._menuButtons = []; - this._proxy = proxy; - this.extension = extension; - // pixels from x_0 to the start of the menu - this._width_offset = 300; - this.MARGIN_FIRST_ELEMENT = 4; - this._isShowingMenu = false; - - this._cache = new Cache(); - - this._notifyFocusWinId = global.display.connect('notify::focus-window', this._onWindowSwitched.bind(this)); - this._proxy.listeners['SendTopLevelMenus'].push(this._cache.withCache(this.setMenus.bind(this))); - this._proxy.listeners['MenuOnOff'].push(this._onMenuOnOff.bind(this)); - Main.panel.reactive = true; - Main.panel.track_hover = true; - - this._panelEvHandlers = []; - this._forceShowMenu = false; - this._showAppMenuButton = false; - this.setForceShowMenu(); - this.setHideAppMenuButton(); - - Main.overview.connect('showing', this._onOverviewOpened.bind(this)); - Main.overview.connect('hiding', this._onOverviewClosed.bind(this)); - } - - setForceShowMenu() { - this._forceShowMenu = !this.extension.settings.get_boolean('show-only-when-hover'); - - if (!this._forceShowMenu) { - this._panelEvHandlers.push(Main.panel.connect('enter-event', this._onPanelEnter.bind(this))); - this._panelEvHandlers.push(Main.panel.connect('leave-event', this._onPanelLeave.bind(this))); - } else { - for (let h of this._panelEvHandlers) { - Main.panel.disconnect(h); - } - this._panelEvHandlers = []; - } - } - - setHideAppMenuButton() { - this._showAppMenuButton = !this.extension.settings.get_boolean('hide-app-menu'); - - let appBtn = Main.panel._leftBox.get_children().filter(item => { - item.get_first_child().constructor.name == 'AppMenuButton' - }); - if (appBtn.length > 0) { - this._appMenuButton = appBtn[0]; - } - this._restoreLabel(); - } - - addMenuButton(label, setmargin) { - let menuButton = new MenuButton(label, this); - this._menuButtons.push(menuButton); - const nItems = Main.panel._leftBox.get_children().length; - menuButton.hide(); - if (setmargin) - menuButton.set_style('margin-left: '+ this.MARGIN_FIRST_ELEMENT + 'px') - Main.panel.addToStatusArea(label, menuButton, nItems, 'left'); - } - - setMenus(menus) { - // The expansion/shrink can be annoying, so we only do it - // when there’s no menus - if (menus.length === 0) { - this._hideMenu(); - } - this.removeAll(); - let first = true; - for (let menu of menus) { - this.addMenuButton(menu, first); - first = false; - } - if (this._forceShowMenu && !Main.overview.visibleTarget) { - this._onPanelEnter(); - } - } - - _onPanelEnter() { - if (this._menuButtons.length === 0 || Main.overview.visibleTarget) - return; - - this._hideAppMenuButton(); - this._showMenu(); - } - - // Hides the label and calculates the width - _hideAppMenuButton() { - let width = 0; - for (let el of Main.panel._leftBox.get_children()) { - let firstChild = el.get_first_child(); - if (firstChild === this._menuButtons[0]) { - this._width_offset = width; - break; - } - if (firstChild.constructor.name == 'AppMenuButton') { - // [Deprecated] - this._appMenuButton = firstChild; - let label = firstChild._label; - - if (!this._showAppMenuButton) { - label.hide(); - } - this._width_offset = width + el.width; - // break; - } - if (el.is_visible()) { - width += el.get_width(); - } - } - } - - _showMenu() { - this._menuButtons.forEach(btn => btn.show()); - this._menuButtons.forEach(btn => btn.ease({ - opacity: 255, - mode: Clutter.AnimationMode.EASE_OUT_QUART, - duration: 250 - })); - } - - _onPanelLeave() { - if (this._isShowingMenu || this._forceShowMenu) - return; - - this._hideMenu(); - } - - _hideMenu() { - this._menuButtons.forEach(btn => btn.ease({ - opacity: 0, - mode: Clutter.AnimationMode.EASE_OUT_QUART, - duration: 100, - onComplete: () => { this._menuButtons.forEach(btn => btn.hide()); this._restoreLabel() } - })); - } - - _restoreLabel() { - if (this._appMenuButton) { - this._appMenuButton._label.show(); - } - } - - _onMenuOnOff(on) { - if (on) { - this._onPanelEnter(); - this.onButtonClicked('__fildem_move', this._width_offset); - } else { - this._isShowingMenu = false; - this._onPanelLeave(); - } - } - - onButtonClicked(label) { - this._isShowingMenu = true; - this._proxy.EchoSignal(label, this._width_offset); - } - - removeAll() { - for (let e of this._menuButtons) { - e.destroy(); - } - this._menuButtons = []; - } - - _onWindowSwitched() { - this.removeAll(); - this._restoreLabel(); - this._hideMenu(); - const overview = Main.overview.visibleTarget; - const focusApp = WinTracker.focus_app || Main.panel.statusArea.appMenu._targetApp; - if (focusApp) { - let windowData = {}; - // TODO does the window matter? - let win = focusApp.get_windows()[0]; - let appId = focusApp.get_id(); // *.desktop - - // Check cache - let cachedValue = this._cache.get(appId); - if (cachedValue) { - this.setMenus(cachedValue); - } - - // global.log(`app id: ${focusApp.get_id()} win id: ${win.get_id()}`); - // TODO check pixel-saver extension for others way of obtaining xid - let xid = ''; - try { - xid = parseInt(win.get_description().match(/0x[0-9a-f]+/)[0]); - } catch (e) {} - windowData['xid'] = String(xid); - for (let p in win) { - if (p.startsWith('gtk_') && win[p] != null) { - windowData[p] = win[p]; - } - } - this._proxy.WindowSwitched(windowData); - } - } - - _onOverviewOpened() { - this._hideMenu(); - } - - _onOverviewClosed() { - if (this._forceShowMenu && this._menuButtons.length) { - this._hideAppMenuButton(); - this._showMenu(); - } - } - - _disconnectAll() { - // AppSystem.disconnect(this._appStateChangedId); - // WinTracker.disconnect(this._notifyFocusAppId); - for (let h of this._panelEvHandlers) { - Main.panel.disconnect(h); - } - global.display.disconnect(this._notifyFocusWinId); - } - - destroy() { - this._disconnectAll(); - this.removeAll(); - this._restoreLabel(); - } -}; - -const ifaceXml = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -const TestProxy = Gio.DBusProxy.makeProxyWrapper(ifaceXml); - -const BUS_NAME = 'com.gonzaarcr.appmenu'; -const BUS_PATH = '/com/gonzaarcr/appmenu'; - -class MyProxy { - constructor() { - this._createProxy(); - this._handlerIds = []; - } - - async _createProxy() { - this._proxy = new TestProxy( - Gio.DBus.session, - BUS_NAME, - BUS_PATH, - this._onProxyReady.bind(this) - ); - this.listeners = { - 'MenuActivated': [], - 'SendTopLevelMenus': [], - 'MenuOnOff': [] - } - } - - async _onProxyReady(result, error) { - let id = undefined; - id = this._proxy.connectSignal('SendTopLevelMenus', this._onSendTopLevelMenus.bind(this)); - this._handlerIds.push(id); - id = this._proxy.connectSignal('RequestWindowActionsSignal', this._onRequestWindowActionsSignal.bind(this)); - this._handlerIds.push(id); - id = this._proxy.connectSignal('ActivateWindowActionSignal', this._onActivateWindowActionSignal.bind(this)); - this._handlerIds.push(id); - id = this._proxy.connectSignal('MenuOnOff', this._onMenuOnOff.bind(this)); - this._handlerIds.push(id); - } - - async _onMenuActivated(proxy, nameOwner, args) { - global.log(`TestSignal: ${args[0]}`); - } - - async _onSendTopLevelMenus(proxy, nameOwner, args) { - let topLevelMenus = args[0]; - for (let callback of this.listeners['SendTopLevelMenus']) { - callback(topLevelMenus); - } - } - - async _onRequestWindowActionsSignal(proxy, nameOwner, args) { - this._currentWindow = new WindowActions(); - let actions = this._currentWindow.getActions(); - this._proxy.ListWindowActionsRemote(actions); - } - - async _onActivateWindowActionSignal(proxy, nameOwner, args) { - this._currentWindow._doAction(args[0]); - } - - async _onMenuOnOff(proxy, nameOwner, args) { - for (let callback of this.listeners['MenuOnOff']) { - callback(args[0]); - } - } - - _onNameOwnerChanged(proxy, sender, [name, oldOwner, newOwner]) { - global.log(`${name} ${oldOwner} ${newOwner}`) - } - - async WindowSwitched(windowData) { - this._proxy.WindowSwitchedRemote(windowData); - } - - async EchoSignal(menu, x) { - this._proxy.EchoSignalRemote(menu, x); - } - - destroy() { - for (let id of this._handlerIds) { - this._proxy.disconnectSignal(id); - } - this._handlerIds = []; - } -}; - - -class Extension { - constructor(settings) { - this.settings = settings; - this._handlerIds = []; - this.myProxy = new MyProxy(); - this.menubar = new MenuBar(this.myProxy, this); - - this._connectSettings(); - } - - _connectSettings() { - this._handlerIds.push(this.settings.connect( - 'changed::show-only-when-hover', - () => { this.menubar.setForceShowMenu(); } - )); - this._handlerIds.push(this.settings.connect( - 'changed::hide-app-menu', - () => { this.menubar.setHideAppMenuButton(); } - )); - } - - destroy() { - this._disconnectSettings(); - - this.menubar.destroy(); - this.myProxy.destroy(); - } - - _disconnectSettings() { - for (let h of this._handlerIds) { - this.settings.disconnect(h); - } - } -} - - -let extension; - -function init(metadata) { -} - -function enable() { - let settings = new Settings(Me.metadata['settings-schema']); - extension = new Extension(settings); -} - -function disable() { - extension.destroy(); - extension = null; -} diff --git a/fildemGMenu@gonza.com/metadata.json b/fildemGMenu@gonza.com/metadata.json deleted file mode 100644 index 99203480..00000000 --- a/fildemGMenu@gonza.com/metadata.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "Fildem global menu", - "description": "Global menu for Gnome", - "settings-schema": "org.gnome.shell.extensions.fildem-global-menu", - "shell-version": [ - "3.36", - "3.38", - "40" - ], - "url": "https://github.com/gonzaarcr/Fildem", - "uuid": "fildemGMenu@gonza.com" -} diff --git a/fildemGMenu@gonza.com/prefs.js b/fildemGMenu@gonza.com/prefs.js deleted file mode 100644 index 18642eb6..00000000 --- a/fildemGMenu@gonza.com/prefs.js +++ /dev/null @@ -1,96 +0,0 @@ -const GObject = imports.gi.GObject; -const Gio = imports.gi.Gio; -const Gtk = imports.gi.Gtk; -const Config = imports.misc.config; - -const Me = imports.misc.extensionUtils.getCurrentExtension(); -const Settings = Me.imports.settings.FildemGlobalMenuSettings; - -const SHELL_VERSION = Config.PACKAGE_VERSION; - - -const PrefsWidget = GObject.registerClass( -class PrefsWidget extends Gtk.Box { - - _init(settings, params) { - super._init(params); - - this._buildable = new Gtk.Builder(); - this._buildable.add_from_file(Me.path + '/settings.ui'); - - let prefsWidget = this._getWidget('prefs_widget'); - if (SHELL_VERSION < '40') { - this.add(prefsWidget); - } else { - this.append(prefsWidget); - } - - this._settings = settings; - this._bindBooleans(); - this._bindIntSpins(); - } - - show_all() { - if (SHELL_VERSION < '40') - super.show_all(); - } - - _getWidget(name) { - let wname = name.replace(/-/g, '_'); - return this._buildable.get_object(wname); - } - - /******************** - * Int Spins - ********************/ - - _getIntSpins() { - return [ - 'min-padding' - ]; - } - - _bindIntSpin(setting) { - let widget = this._getWidget(setting); - widget.set_value(this._settings.get_int(setting)); - widget.connect('value-changed', (spin) => { - this._settings.set_int(setting, spin.get_value()); - }); - } - - _bindIntSpins() { - this._getIntSpins().forEach(this._bindIntSpin, this); - } - - /******************** - * Booleans - ********************/ - - _getBooleans() { - return [ - 'show-only-when-hover', - 'hide-app-menu' - ]; - } - - _bindBoolean(setting) { - let widget = this._getWidget(setting); - this._settings.bind(setting, widget, 'active', Gio.SettingsBindFlags.DEFAULT); - } - - _bindBooleans() { - this._getBooleans().forEach(this._bindBoolean, this); - } -}); - -function init() { - -} - -function buildPrefsWidget() { - let settings = new Settings(Me.metadata['settings-schema']); - let widget = new PrefsWidget(settings); - widget.show_all(); - - return widget; -} diff --git a/fildemGMenu@gonza.com/schemas/gschemas.compiled b/fildemGMenu@gonza.com/schemas/gschemas.compiled deleted file mode 100644 index cc2decfd..00000000 Binary files a/fildemGMenu@gonza.com/schemas/gschemas.compiled and /dev/null differ diff --git a/fildemGMenu@gonza.com/schemas/org.gnome.shell.extensions.fildem-global-menu.gschema.xml b/fildemGMenu@gonza.com/schemas/org.gnome.shell.extensions.fildem-global-menu.gschema.xml deleted file mode 100644 index c6dcacfe..00000000 --- a/fildemGMenu@gonza.com/schemas/org.gnome.shell.extensions.fildem-global-menu.gschema.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - 6 - Button paddings (tweak this if the menu and items desynchronize) - - - true - Show menu only when the mouse is over the panel - - - true - Hide the app label when showing the menu - - - diff --git a/fildemGMenu@gonza.com/settings.js b/fildemGMenu@gonza.com/settings.js deleted file mode 100644 index 206c1f2f..00000000 --- a/fildemGMenu@gonza.com/settings.js +++ /dev/null @@ -1,28 +0,0 @@ -const GObject = imports.gi.GObject; -const Gio = imports.gi.Gio; -const GioSSS = Gio.SettingsSchemaSource; - -const Me = imports.misc.extensionUtils.getCurrentExtension(); - -var FildemGlobalMenuSettings = GObject.registerClass( -class FildemGlobalMenuSettings extends Gio.Settings { - _init(schema) { - let schemaDir = Me.dir.get_child('schemas'); - let schemaSource = null; - - if (schemaDir.query_exists(null)) { - schemaSource = GioSSS.new_from_directory(schemaDir.get_path(), GioSSS.get_default(), false); - } else { - schemaSource = GioSSS.get_default(); - } - - let schemaObj = schemaSource.lookup(schema, true); - - if (!schemaObj) { - let message = `Schema ${schema} could not be found for extension ${Me.metadata.uuid}`; - throw new Error(message + '. Please check your installation.'); - } - - super._init({ settings_schema: schemaObj }); - } -}); diff --git a/fildemGMenu@gonza.com/settings.ui b/fildemGMenu@gonza.com/settings.ui deleted file mode 100644 index da37c4ee..00000000 --- a/fildemGMenu@gonza.com/settings.ui +++ /dev/null @@ -1,109 +0,0 @@ - - - - - - 500 - True - False - 20 - 20 - 20 - 20 - True - vertical - 15 - - - - - - - True - False - 50 - - - True - False - center - 1 - 0 - Button paddings (tweak this if the menu and items desynchronize) - - - - - 0 - 50 - 3 - 1 - - - True - True - center - adjustment_padding - - - - - - - - - - - True - False - 50 - - - True - False - center - 1 - 0 - Show menu only when the mouse is over the panel - - - - - True - True - center - - - - - - - - - - - True - False - 50 - - - True - False - center - 1 - 0 - Hide the app label when showing the menu - - - - - True - True - center - - - - - - - diff --git a/interfaces/org.fildem.v1.Activation.xml b/interfaces/org.fildem.v1.Activation.xml new file mode 100644 index 00000000..1425b67d --- /dev/null +++ b/interfaces/org.fildem.v1.Activation.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + diff --git a/interfaces/org.fildem.v1.Hud.xml b/interfaces/org.fildem.v1.Hud.xml new file mode 100644 index 00000000..f7f854d7 --- /dev/null +++ b/interfaces/org.fildem.v1.Hud.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/interfaces/org.fildem.v1.MenuTree.xml b/interfaces/org.fildem.v1.MenuTree.xml new file mode 100644 index 00000000..82ea153a --- /dev/null +++ b/interfaces/org.fildem.v1.MenuTree.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + diff --git a/interfaces/org.fildem.v1.TopLevel.xml b/interfaces/org.fildem.v1.TopLevel.xml new file mode 100644 index 00000000..47d182d1 --- /dev/null +++ b/interfaces/org.fildem.v1.TopLevel.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + diff --git a/interfaces/org.fildem.v1.Window.xml b/interfaces/org.fildem.v1.Window.xml new file mode 100644 index 00000000..dc957e30 --- /dev/null +++ b/interfaces/org.fildem.v1.Window.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + diff --git a/interfaces/org.fildem.v1.WindowActions.xml b/interfaces/org.fildem.v1.WindowActions.xml new file mode 100644 index 00000000..f2f08784 --- /dev/null +++ b/interfaces/org.fildem.v1.WindowActions.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/meson.build b/meson.build new file mode 100644 index 00000000..a1965348 --- /dev/null +++ b/meson.build @@ -0,0 +1,68 @@ +project( + 'fildem-rewrite', + 'c', + version: '1.0.0', + default_options: [ + 'warning_level=2', + 'c_std=c11', + ], +) + +glib_dep = dependency('glib-2.0') +gio_dep = dependency('gio-2.0') +gobject_dep = dependency('gobject-2.0') +gtk4_dep = dependency('gtk4') +glib_compile_schemas = find_program('glib-compile-schemas') + +source_interfaces_dir = join_paths(meson.project_source_root(), 'interfaces') +installed_interfaces_dir = join_paths(get_option('prefix'), + get_option('datadir'), + 'fildem', + 'interfaces') + +add_project_arguments( + '-DFILDEM_SOURCE_INTERFACES_DIR="' + source_interfaces_dir + '"', + '-DFILDEM_INSTALLED_INTERFACES_DIR="' + installed_interfaces_dir + '"', + language: 'c', +) + +subdir('src') +subdir('tests') + +install_subdir( + 'interfaces', + install_dir: join_paths(get_option('datadir'), 'fildem'), +) + +install_subdir( + 'src/extension/fildem-shell@fildem.org', + install_dir: join_paths(get_option('datadir'), 'gnome-shell', 'extensions'), +) + +extension_schema_compiled = custom_target( + 'fildem-shell-compiled-schema', + input: 'src/extension/fildem-shell@fildem.org/schemas/org.fildem.shell.gschema.xml', + output: 'gschemas.compiled', + command: [ + glib_compile_schemas, + '--targetdir', '@OUTDIR@', + meson.current_source_dir() + '/src/extension/fildem-shell@fildem.org/schemas', + ], + build_by_default: true, + install: true, + install_dir: join_paths(get_option('datadir'), + 'gnome-shell', + 'extensions', + 'fildem-shell@fildem.org', + 'schemas'), +) + +install_data( + 'data/org.fildem.hud.desktop', + install_dir: join_paths(get_option('datadir'), 'applications'), +) + +install_data( + 'packaging/systemd/fildemd.service', + install_dir: join_paths(get_option('prefix'), 'lib', 'systemd', 'user'), +) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 00000000..5067a876 --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,25 @@ +pkgname=fildem-rewrite +pkgver=1.0.0 +pkgrel=1 +pkgdesc="Fildem rewrite: daemon, HUD and GNOME Shell extension for GNOME 48+" +arch=('x86_64') +url="https://github.com/gonzaarcr/Fildem" +license=('GPL3') +depends=('glib2' 'gtk4' 'gnome-shell') +makedepends=('meson' 'ninja' 'gcc' 'pkgconf') +source=() +sha256sums=() + +build() { + meson setup build \ + --prefix /usr + meson compile -C build +} + +check() { + meson test -C build +} + +package() { + meson install -C build --destdir "$pkgdir" +} diff --git a/packaging/arch/README.md b/packaging/arch/README.md new file mode 100644 index 00000000..f7409e13 --- /dev/null +++ b/packaging/arch/README.md @@ -0,0 +1,9 @@ +# Arch Packaging (Rewrite) + +Recommended split packages: + +1. `fildemd` +2. `fildemhud` +3. `gnome-shell-extension-fildem-shell` + +Build system: Meson + Ninja. diff --git a/packaging/deb/README.md b/packaging/deb/README.md new file mode 100644 index 00000000..f76e5a59 --- /dev/null +++ b/packaging/deb/README.md @@ -0,0 +1,16 @@ +# Debian Packaging (Rewrite) + +Target binary packages: + +1. `fildemd` (daemon + interfaces) +2. `fildemhud` (GTK4 HUD) +3. `gnome-shell-extension-fildem-shell` (UUID `fildem-shell@fildem.org`) + +Expected install assets: + +1. `/usr/bin/fildemd` +2. `/usr/bin/fildemhud` +3. `/usr/share/fildem/interfaces/org.fildem.v1.*.xml` +4. `/usr/share/gnome-shell/extensions/fildem-shell@fildem.org/` +5. `/usr/share/applications/org.fildem.hud.desktop` +6. `/usr/lib/systemd/user/fildemd.service` diff --git a/packaging/deb/control b/packaging/deb/control new file mode 100644 index 00000000..eb56b9dd --- /dev/null +++ b/packaging/deb/control @@ -0,0 +1,13 @@ +Source: fildem-rewrite +Section: gnome +Priority: optional +Maintainer: Fildem Maintainers +Build-Depends: debhelper-compat (= 13), meson, ninja-build, pkg-config, libglib2.0-dev, libgtk-4-dev +Standards-Version: 4.6.2 +Rules-Requires-Root: no + +Package: fildem-rewrite +Architecture: any +Depends: ${shlibs:Depends}, ${misc:Depends}, gnome-shell +Description: Fildem rewrite for GNOME 48+ + Daemon, HUD and GNOME Shell extension rewrite based on org.fildem.v1 API. diff --git a/packaging/deb/rules b/packaging/deb/rules new file mode 100755 index 00000000..b5c7202d --- /dev/null +++ b/packaging/deb/rules @@ -0,0 +1,7 @@ +#!/usr/bin/make -f + +%: + dh $@ --buildsystem=meson + +override_dh_auto_configure: + dh_auto_configure -- --prefix=/usr diff --git a/packaging/fedora/README.md b/packaging/fedora/README.md new file mode 100644 index 00000000..a08147d5 --- /dev/null +++ b/packaging/fedora/README.md @@ -0,0 +1,9 @@ +# Fedora Packaging (Rewrite) + +Spec should package: + +1. daemon binary `fildemd` +2. HUD binary `fildemhud` +3. interface XML files under `%{_datadir}/fildem/interfaces` +4. GNOME shell extension under `%{_datadir}/gnome-shell/extensions/fildem-shell@fildem.org` +5. desktop entry and user systemd unit diff --git a/packaging/fedora/fildem.spec b/packaging/fedora/fildem.spec new file mode 100644 index 00000000..8efeca71 --- /dev/null +++ b/packaging/fedora/fildem.spec @@ -0,0 +1,51 @@ +Name: fildem-rewrite +Version: 1.0.0 +Release: 1%{?dist} +Summary: Fildem rewrite for GNOME 48+ +License: GPL-3.0-only +URL: https://github.com/gonzaarcr/Fildem +Source0: %{name}-%{version}.tar.gz + +BuildRequires: gcc +BuildRequires: meson +BuildRequires: ninja-build +BuildRequires: pkgconfig(glib-2.0) +BuildRequires: pkgconfig(gio-2.0) +BuildRequires: pkgconfig(gobject-2.0) +BuildRequires: pkgconfig(gtk4) +Requires: gnome-shell + +%description +Fildem rewrite delivers: +- fildemd session daemon +- fildemhud GTK4 HUD +- GNOME Shell extension fildem-shell@fildem.org + +%prep +%autosetup -n %{name}-%{version} + +%build +%meson +%meson_build + +%install +%meson_install + +%check +%meson_test + +%files +%license LICENSE.txt +%doc README.md +%{_bindir}/fildemd +%{_bindir}/fildemhud +%{_bindir}/fildemctl +%{_libdir}/libfildem-core.so +%{_datadir}/applications/org.fildem.hud.desktop +%{_datadir}/fildem/interfaces/* +%{_datadir}/gnome-shell/extensions/fildem-shell@fildem.org/* +%{_prefix}/lib/systemd/user/fildemd.service + +%changelog +* Mon Mar 23 2026 Fildem Maintainers - 1.0.0-1 +- Initial rewrite package skeleton diff --git a/packaging/systemd/fildemd.service b/packaging/systemd/fildemd.service new file mode 100644 index 00000000..8c124e5c --- /dev/null +++ b/packaging/systemd/fildemd.service @@ -0,0 +1,13 @@ +[Unit] +Description=Fildem session daemon +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=/usr/bin/fildemd +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=default.target diff --git a/setup.py b/setup.py deleted file mode 100644 index 6a51b7db..00000000 --- a/setup.py +++ /dev/null @@ -1,39 +0,0 @@ -import setuptools - -import fildem - -with open('README.md', 'r') as fh: - long_description = fh.read() - -setuptools.setup( - name='fildem', - version=fildem.__version__, - author='Gonzalo', - author_email='gonzaarcr@gmail.com', - description='Fildem Global Menu for Gnome Desktop', - long_description=long_description, - long_description_content_type='text/markdown', - url='https://github.com/gonzaarcr/Fildem', - packages=setuptools.find_packages(), - data_files=[ - ('share/applications', ['fildem-hud.desktop']) - ], - install_requires=[ - 'PyGObject>=3.30.0' - ], - classifiers=[ - 'Programming Language :: Python :: 3', - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - 'Operating System :: POSIX :: Linux' - ], - project_urls={ - 'Bug Reports': 'https://github.com/gonzaarcr/Fildem/issues', - 'Source': 'https://github.com/gonzaarcr/Fildem', - }, - entry_points={ - 'console_scripts': [ - 'fildem = fildem.run:main', - 'fildem-hud = fildem.inithud:main' - ] - } -) diff --git a/src/core/fildem-core.h b/src/core/fildem-core.h new file mode 100644 index 00000000..e40b8137 --- /dev/null +++ b/src/core/fildem-core.h @@ -0,0 +1,7 @@ +#pragma once + +#include "fildem-error.h" +#include "fildem-menu-cache.h" +#include "fildem-menu-model.h" +#include "fildem-menu-registry.h" +#include "fildem-serializer.h" diff --git a/src/core/fildem-error.c b/src/core/fildem-error.c new file mode 100644 index 00000000..2bc90e24 --- /dev/null +++ b/src/core/fildem-error.c @@ -0,0 +1,7 @@ +#include "fildem-error.h" + +GQuark +fildem_error_quark(void) +{ + return g_quark_from_static_string("fildem-error-quark"); +} diff --git a/src/core/fildem-error.h b/src/core/fildem-error.h new file mode 100644 index 00000000..81f57315 --- /dev/null +++ b/src/core/fildem-error.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +typedef enum { + FILDEM_ERROR_INVALID_ARGUMENT, + FILDEM_ERROR_NOT_FOUND, +} FildemError; + +#define FILDEM_ERROR fildem_error_quark() + +GQuark fildem_error_quark(void); diff --git a/src/core/fildem-menu-cache.c b/src/core/fildem-menu-cache.c new file mode 100644 index 00000000..75f81fb6 --- /dev/null +++ b/src/core/fildem-menu-cache.c @@ -0,0 +1,130 @@ +#include "fildem-menu-cache.h" + +#include "fildem-serializer.h" + +typedef struct { + GHashTable *tree_map; + GHashTable *generation_map; +} FildemMenuCachePrivate; + +struct _FildemMenuCache { + GObject parent_instance; +}; + +G_DEFINE_TYPE_WITH_PRIVATE(FildemMenuCache, fildem_menu_cache, G_TYPE_OBJECT) + +static void +fildem_menu_cache_dispose(GObject *object) +{ + FildemMenuCache *self = FILDEM_MENU_CACHE(object); + FildemMenuCachePrivate *priv = fildem_menu_cache_get_instance_private(self); + + g_clear_pointer(&priv->tree_map, g_hash_table_unref); + g_clear_pointer(&priv->generation_map, g_hash_table_unref); + + G_OBJECT_CLASS(fildem_menu_cache_parent_class)->dispose(object); +} + +static void +fildem_menu_cache_class_init(FildemMenuCacheClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS(klass); + object_class->dispose = fildem_menu_cache_dispose; +} + +static void +fildem_menu_cache_init(FildemMenuCache *self) +{ + FildemMenuCachePrivate *priv = fildem_menu_cache_get_instance_private(self); + + priv->tree_map = g_hash_table_new_full(g_str_hash, + g_str_equal, + g_free, + (GDestroyNotify)g_variant_unref); + priv->generation_map = g_hash_table_new_full(g_str_hash, + g_str_equal, + g_free, + NULL); +} + +FildemMenuCache * +fildem_menu_cache_new(void) +{ + return g_object_new(FILDEM_TYPE_MENU_CACHE, NULL); +} + +void +fildem_menu_cache_set_tree(FildemMenuCache *self, + const gchar *window_uid, + GVariant *nodes) +{ + FildemMenuCachePrivate *priv; + + g_return_if_fail(FILDEM_IS_MENU_CACHE(self)); + g_return_if_fail(window_uid != NULL); + g_return_if_fail(nodes != NULL); + + priv = fildem_menu_cache_get_instance_private(self); + g_hash_table_replace(priv->tree_map, + g_strdup(window_uid), + g_variant_ref_sink(nodes)); +} + +GVariant * +fildem_menu_cache_get_tree(FildemMenuCache *self, + const gchar *window_uid) +{ + FildemMenuCachePrivate *priv; + GVariant *value; + + g_return_val_if_fail(FILDEM_IS_MENU_CACHE(self), NULL); + g_return_val_if_fail(window_uid != NULL, NULL); + + priv = fildem_menu_cache_get_instance_private(self); + value = g_hash_table_lookup(priv->tree_map, window_uid); + + if (value == NULL) { + return g_variant_ref_sink(fildem_empty_menu_tree()); + } + + return g_variant_ref(value); +} + +void +fildem_menu_cache_invalidate(FildemMenuCache *self, + const gchar *window_uid) +{ + FildemMenuCachePrivate *priv; + gpointer raw_value; + guint64 generation = 0; + + g_return_if_fail(FILDEM_IS_MENU_CACHE(self)); + g_return_if_fail(window_uid != NULL); + + priv = fildem_menu_cache_get_instance_private(self); + raw_value = g_hash_table_lookup(priv->generation_map, window_uid); + + if (raw_value != NULL) { + generation = GPOINTER_TO_UINT(raw_value); + } + + generation++; + g_hash_table_replace(priv->generation_map, + g_strdup(window_uid), + GUINT_TO_POINTER((guint)generation)); +} + +guint64 +fildem_menu_cache_generation(FildemMenuCache *self, + const gchar *window_uid) +{ + FildemMenuCachePrivate *priv; + gpointer raw_value; + + g_return_val_if_fail(FILDEM_IS_MENU_CACHE(self), 0); + g_return_val_if_fail(window_uid != NULL, 0); + + priv = fildem_menu_cache_get_instance_private(self); + raw_value = g_hash_table_lookup(priv->generation_map, window_uid); + return raw_value == NULL ? 0 : (guint64)GPOINTER_TO_UINT(raw_value); +} diff --git a/src/core/fildem-menu-cache.h b/src/core/fildem-menu-cache.h new file mode 100644 index 00000000..db7091a2 --- /dev/null +++ b/src/core/fildem-menu-cache.h @@ -0,0 +1,24 @@ +#pragma once + +#include + +G_BEGIN_DECLS + +#define FILDEM_TYPE_MENU_CACHE (fildem_menu_cache_get_type()) + +G_DECLARE_FINAL_TYPE(FildemMenuCache, fildem_menu_cache, FILDEM, MENU_CACHE, GObject) + +FildemMenuCache *fildem_menu_cache_new(void); + +void fildem_menu_cache_set_tree(FildemMenuCache *self, + const gchar *window_uid, + GVariant *nodes); +GVariant *fildem_menu_cache_get_tree(FildemMenuCache *self, + const gchar *window_uid); + +void fildem_menu_cache_invalidate(FildemMenuCache *self, + const gchar *window_uid); +guint64 fildem_menu_cache_generation(FildemMenuCache *self, + const gchar *window_uid); + +G_END_DECLS diff --git a/src/core/fildem-menu-model.c b/src/core/fildem-menu-model.c new file mode 100644 index 00000000..4bb04873 --- /dev/null +++ b/src/core/fildem-menu-model.c @@ -0,0 +1,92 @@ +#include "fildem-menu-model.h" + +static gboolean +match_term(const gchar *candidate, const gchar *term) +{ + g_autofree gchar *candidate_fold = NULL; + g_autofree gchar *term_fold = NULL; + + if (term == NULL || *term == 0) { + return TRUE; + } + + if (candidate == NULL) { + return FALSE; + } + + candidate_fold = g_utf8_casefold(candidate, -1); + term_fold = g_utf8_casefold(term, -1); + + return g_strstr_len(candidate_fold, -1, term_fold) != NULL; +} + +static void +append_result(GVariantBuilder *array_builder, + const gchar *id, + const gchar *label, + const gchar *kind) +{ + GVariantBuilder item_builder; + + g_variant_builder_init(&item_builder, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&item_builder, "{sv}", "id", g_variant_new_string(id)); + g_variant_builder_add(&item_builder, "{sv}", "label", g_variant_new_string(label)); + g_variant_builder_add(&item_builder, "{sv}", "kind", g_variant_new_string(kind)); + + g_variant_builder_add_value(array_builder, g_variant_builder_end(&item_builder)); +} + +GVariant * +fildem_menu_model_query(GVariant *menu_nodes, + GVariant *window_actions, + const gchar *term) +{ + GVariantBuilder results; + GVariantIter node_iter; + GVariantIter actions_iter; + GVariant *item = NULL; + const gchar *action = NULL; + + g_variant_builder_init(&results, G_VARIANT_TYPE("aa{sv}")); + + if (menu_nodes != NULL && g_variant_is_of_type(menu_nodes, G_VARIANT_TYPE("aa{sv}"))) { + g_variant_iter_init(&node_iter, menu_nodes); + while ((item = g_variant_iter_next_value(&node_iter)) != NULL) { + const gchar *id = NULL; + const gchar *label = NULL; + gboolean enabled = TRUE; + gboolean visible = TRUE; + gboolean is_separator = FALSE; + + g_variant_lookup(item, "id", "&s", &id); + g_variant_lookup(item, "label", "&s", &label); + g_variant_lookup(item, "enabled", "b", &enabled); + g_variant_lookup(item, "visible", "b", &visible); + g_variant_lookup(item, "is_separator", "b", &is_separator); + + if (id != NULL && + label != NULL && + enabled && + visible && + !is_separator && + match_term(label, term)) { + append_result(&results, id, label, "menu"); + } + + g_variant_unref(item); + } + } + + if (window_actions != NULL && g_variant_is_of_type(window_actions, G_VARIANT_TYPE("as"))) { + g_variant_iter_init(&actions_iter, window_actions); + while (g_variant_iter_next(&actions_iter, "&s", &action)) { + g_autofree gchar *action_id = NULL; + if (match_term(action, term)) { + action_id = g_strdup_printf("action:%s", action); + append_result(&results, action_id, action, "window-action"); + } + } + } + + return g_variant_builder_end(&results); +} diff --git a/src/core/fildem-menu-model.h b/src/core/fildem-menu-model.h new file mode 100644 index 00000000..ed57e10c --- /dev/null +++ b/src/core/fildem-menu-model.h @@ -0,0 +1,11 @@ +#pragma once + +#include + +G_BEGIN_DECLS + +GVariant *fildem_menu_model_query(GVariant *menu_nodes, + GVariant *window_actions, + const gchar *term); + +G_END_DECLS diff --git a/src/core/fildem-menu-registry.c b/src/core/fildem-menu-registry.c new file mode 100644 index 00000000..c161e014 --- /dev/null +++ b/src/core/fildem-menu-registry.c @@ -0,0 +1,128 @@ +#include "fildem-menu-registry.h" + +#include "fildem-serializer.h" + +typedef struct { + GHashTable *top_level_map; + GHashTable *actions_map; +} FildemMenuRegistryPrivate; + +struct _FildemMenuRegistry { + GObject parent_instance; +}; + +G_DEFINE_TYPE_WITH_PRIVATE(FildemMenuRegistry, fildem_menu_registry, G_TYPE_OBJECT) + +static void +fildem_menu_registry_dispose(GObject *object) +{ + FildemMenuRegistry *self = FILDEM_MENU_REGISTRY(object); + FildemMenuRegistryPrivate *priv = fildem_menu_registry_get_instance_private(self); + + g_clear_pointer(&priv->top_level_map, g_hash_table_unref); + g_clear_pointer(&priv->actions_map, g_hash_table_unref); + + G_OBJECT_CLASS(fildem_menu_registry_parent_class)->dispose(object); +} + +static void +fildem_menu_registry_class_init(FildemMenuRegistryClass *klass) +{ + GObjectClass *object_class = G_OBJECT_CLASS(klass); + object_class->dispose = fildem_menu_registry_dispose; +} + +static void +fildem_menu_registry_init(FildemMenuRegistry *self) +{ + FildemMenuRegistryPrivate *priv = fildem_menu_registry_get_instance_private(self); + + priv->top_level_map = g_hash_table_new_full(g_str_hash, + g_str_equal, + g_free, + (GDestroyNotify)g_variant_unref); + priv->actions_map = g_hash_table_new_full(g_str_hash, + g_str_equal, + g_free, + (GDestroyNotify)g_variant_unref); +} + +FildemMenuRegistry * +fildem_menu_registry_new(void) +{ + return g_object_new(FILDEM_TYPE_MENU_REGISTRY, NULL); +} + +void +fildem_menu_registry_set_top_level(FildemMenuRegistry *self, + const gchar *window_uid, + GVariant *items) +{ + FildemMenuRegistryPrivate *priv; + + g_return_if_fail(FILDEM_IS_MENU_REGISTRY(self)); + g_return_if_fail(window_uid != NULL); + g_return_if_fail(items != NULL); + + priv = fildem_menu_registry_get_instance_private(self); + g_hash_table_replace(priv->top_level_map, + g_strdup(window_uid), + g_variant_ref_sink(items)); +} + +GVariant * +fildem_menu_registry_get_top_level(FildemMenuRegistry *self, + const gchar *window_uid) +{ + FildemMenuRegistryPrivate *priv; + GVariant *value; + + g_return_val_if_fail(FILDEM_IS_MENU_REGISTRY(self), NULL); + g_return_val_if_fail(window_uid != NULL, NULL); + + priv = fildem_menu_registry_get_instance_private(self); + value = g_hash_table_lookup(priv->top_level_map, window_uid); + + if (value == NULL) { + return g_variant_ref_sink(fildem_empty_top_level_items()); + } + + return g_variant_ref(value); +} + +void +fildem_menu_registry_set_actions(FildemMenuRegistry *self, + const gchar *window_uid, + GVariant *actions) +{ + FildemMenuRegistryPrivate *priv; + + g_return_if_fail(FILDEM_IS_MENU_REGISTRY(self)); + g_return_if_fail(window_uid != NULL); + g_return_if_fail(actions != NULL); + + priv = fildem_menu_registry_get_instance_private(self); + g_hash_table_replace(priv->actions_map, + g_strdup(window_uid), + g_variant_ref_sink(actions)); +} + +GVariant * +fildem_menu_registry_get_actions(FildemMenuRegistry *self, + const gchar *window_uid) +{ + FildemMenuRegistryPrivate *priv; + GVariant *value; + + g_return_val_if_fail(FILDEM_IS_MENU_REGISTRY(self), NULL); + g_return_val_if_fail(window_uid != NULL, NULL); + + priv = fildem_menu_registry_get_instance_private(self); + value = g_hash_table_lookup(priv->actions_map, window_uid); + + if (value == NULL) { + return g_variant_ref_sink(fildem_empty_actions()); + } + + return g_variant_ref(value); +} diff --git a/src/core/fildem-menu-registry.h b/src/core/fildem-menu-registry.h new file mode 100644 index 00000000..9073334d --- /dev/null +++ b/src/core/fildem-menu-registry.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +G_BEGIN_DECLS + +#define FILDEM_TYPE_MENU_REGISTRY (fildem_menu_registry_get_type()) + +G_DECLARE_FINAL_TYPE(FildemMenuRegistry, fildem_menu_registry, FILDEM, MENU_REGISTRY, GObject) + +FildemMenuRegistry *fildem_menu_registry_new(void); + +void fildem_menu_registry_set_top_level(FildemMenuRegistry *self, + const gchar *window_uid, + GVariant *items); +GVariant *fildem_menu_registry_get_top_level(FildemMenuRegistry *self, + const gchar *window_uid); + +void fildem_menu_registry_set_actions(FildemMenuRegistry *self, + const gchar *window_uid, + GVariant *actions); +GVariant *fildem_menu_registry_get_actions(FildemMenuRegistry *self, + const gchar *window_uid); + +G_END_DECLS diff --git a/src/core/fildem-serializer.c b/src/core/fildem-serializer.c new file mode 100644 index 00000000..71eedae5 --- /dev/null +++ b/src/core/fildem-serializer.c @@ -0,0 +1,155 @@ +#include "fildem-serializer.h" + +#include + +static const gchar *FILDEM_BUS_NAME = "org.fildem"; +static const gchar *FILDEM_OBJECT_PATH = "/org/fildem/v1"; + +const gchar * +fildem_bus_name(void) +{ + return FILDEM_BUS_NAME; +} + +const gchar * +fildem_object_path(void) +{ + return FILDEM_OBJECT_PATH; +} + +static gboolean +validate_dict_array_with_keys(GVariant *items, + const gchar *signature, + const gchar *required_key_1, + const gchar *required_key_2, + GError **error) +{ + GVariantIter iter; + GVariant *item = NULL; + + if (!g_variant_is_of_type(items, G_VARIANT_TYPE(signature))) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Expected variant type %s", + signature); + return FALSE; + } + + g_variant_iter_init(&iter, items); + while ((item = g_variant_iter_next_value(&iter)) != NULL) { + const gchar *v1 = NULL; + const gchar *v2 = NULL; + gboolean ok; + + ok = fildem_variant_lookup_string_required(item, required_key_1, &v1, error) && + fildem_variant_lookup_string_required(item, required_key_2, &v2, error); + + g_variant_unref(item); + + if (!ok) { + return FALSE; + } + } + + return TRUE; +} + +gboolean +fildem_variant_lookup_string_required(GVariant *dict, + const gchar *key, + const gchar **value_out, + GError **error) +{ + if (!g_variant_is_of_type(dict, G_VARIANT_TYPE("a{sv}"))) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Expected dictionary type a{sv}"); + return FALSE; + } + + if (!g_variant_lookup(dict, key, "&s", value_out)) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Missing required key %s", + key); + return FALSE; + } + + if (*value_out == NULL || strlen(*value_out) == 0) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Required key %s cannot be empty", + key); + return FALSE; + } + + return TRUE; +} + +gboolean +fildem_validate_window_context(GVariant *context, GError **error) +{ + const gchar *window_uid = NULL; + const gchar *app_id = NULL; + + if (!g_variant_is_of_type(context, G_VARIANT_TYPE(FILDEM_ACTIVE_WINDOW_SIGNATURE))) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "WindowContext must have signature %s", + FILDEM_ACTIVE_WINDOW_SIGNATURE); + return FALSE; + } + + if (!fildem_variant_lookup_string_required(context, "window_uid", &window_uid, error)) { + return FALSE; + } + + if (!fildem_variant_lookup_string_required(context, "app_id", &app_id, error)) { + return FALSE; + } + + return TRUE; +} + +gboolean +fildem_validate_top_level_items(GVariant *items, GError **error) +{ + return validate_dict_array_with_keys(items, + FILDEM_TOP_LEVEL_SIGNATURE, + "id", + "label", + error); +} + +gboolean +fildem_validate_menu_tree(GVariant *nodes, GError **error) +{ + return validate_dict_array_with_keys(nodes, + FILDEM_MENU_TREE_SIGNATURE, + "id", + "label", + error); +} + +GVariant * +fildem_empty_top_level_items(void) +{ + return g_variant_new_array(G_VARIANT_TYPE("a{sv}"), NULL, 0); +} + +GVariant * +fildem_empty_menu_tree(void) +{ + return g_variant_new_array(G_VARIANT_TYPE("a{sv}"), NULL, 0); +} + +GVariant * +fildem_empty_actions(void) +{ + return g_variant_new_strv(NULL, 0); +} diff --git a/src/core/fildem-serializer.h b/src/core/fildem-serializer.h new file mode 100644 index 00000000..cb297b9e --- /dev/null +++ b/src/core/fildem-serializer.h @@ -0,0 +1,29 @@ +#pragma once + +#include + +#include "fildem-error.h" + +#define FILDEM_ACTIVE_WINDOW_SIGNATURE "a{sv}" +#define FILDEM_TOP_LEVEL_SIGNATURE "aa{sv}" +#define FILDEM_MENU_TREE_SIGNATURE "aa{sv}" + +G_BEGIN_DECLS + +const gchar *fildem_bus_name(void); +const gchar *fildem_object_path(void); + +gboolean fildem_validate_window_context(GVariant *context, GError **error); +gboolean fildem_validate_top_level_items(GVariant *items, GError **error); +gboolean fildem_validate_menu_tree(GVariant *nodes, GError **error); + +gboolean fildem_variant_lookup_string_required(GVariant *dict, + const gchar *key, + const gchar **value_out, + GError **error); + +GVariant *fildem_empty_top_level_items(void); +GVariant *fildem_empty_menu_tree(void); +GVariant *fildem_empty_actions(void); + +G_END_DECLS diff --git a/src/core/meson.build b/src/core/meson.build new file mode 100644 index 00000000..c21c6740 --- /dev/null +++ b/src/core/meson.build @@ -0,0 +1,31 @@ +fildem_core_sources = files( + 'fildem-error.c', + 'fildem-serializer.c', + 'fildem-menu-registry.c', + 'fildem-menu-cache.c', + 'fildem-menu-model.c', +) + +fildem_core_headers = files( + 'fildem-core.h', + 'fildem-error.h', + 'fildem-serializer.h', + 'fildem-menu-registry.h', + 'fildem-menu-cache.h', + 'fildem-menu-model.h', +) + +fildem_core_dep = declare_dependency( + include_directories: include_directories('.'), + dependencies: [glib_dep, gio_dep, gobject_dep], +) + +fildem_core = library( + 'fildem-core', + fildem_core_sources, + include_directories: include_directories('.'), + dependencies: [glib_dep, gio_dep, gobject_dep], + install: true, +) + +install_headers(fildem_core_headers, subdir: 'fildem') diff --git a/src/ctl/fildemctl.c b/src/ctl/fildemctl.c new file mode 100644 index 00000000..6d9f4160 --- /dev/null +++ b/src/ctl/fildemctl.c @@ -0,0 +1,360 @@ +#include +#include + +#include "fildem-core.h" + +static GDBusProxy * +new_proxy(const gchar *interface_name) +{ + g_autoptr(GError) error = NULL; + GDBusProxy *proxy; + + proxy = g_dbus_proxy_new_for_bus_sync(G_BUS_TYPE_SESSION, + G_DBUS_PROXY_FLAGS_NONE, + NULL, + fildem_bus_name(), + fildem_object_path(), + interface_name, + NULL, + &error); + if (proxy == NULL) { + g_error("cannot connect to %s: %s", interface_name, error->message); + } + + return proxy; +} + +static gint +call_void_method(const gchar *interface_name, + const gchar *method_name, + GVariant *parameters) +{ + g_autoptr(GDBusProxy) proxy = NULL; + g_autoptr(GError) error = NULL; + g_autoptr(GVariant) ignored = NULL; + + proxy = new_proxy(interface_name); + ignored = g_dbus_proxy_call_sync(proxy, + method_name, + parameters, + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + &error); + if (ignored == NULL) { + g_printerr("error: %s\n", error->message); + return 1; + } + + return 0; +} + +static gint +print_active_window(void) +{ + g_autoptr(GDBusProxy) proxy = NULL; + g_autoptr(GError) error = NULL; + g_autoptr(GVariant) reply = NULL; + GVariant *context = NULL; + const gchar *window_uid = ""; + const gchar *app_id = ""; + const gchar *title = ""; + + proxy = new_proxy("org.fildem.v1.Window"); + reply = g_dbus_proxy_call_sync(proxy, + "GetActiveWindow", + NULL, + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + &error); + if (reply == NULL) { + g_printerr("error: %s\n", error->message); + return 1; + } + + g_variant_get(reply, "(@a{sv})", &context); + g_variant_lookup(context, "window_uid", "&s", &window_uid); + g_variant_lookup(context, "app_id", "&s", &app_id); + g_variant_lookup(context, "title", "&s", &title); + + g_print("window_uid=%s\napp_id=%s\ntitle=%s\n", window_uid, app_id, title); + g_variant_unref(context); + return 0; +} + +static gint +print_top_level(const gchar *window_uid) +{ + g_autoptr(GDBusProxy) proxy = NULL; + g_autoptr(GError) error = NULL; + g_autoptr(GVariant) reply = NULL; + GVariant *items = NULL; + GVariantIter iter; + GVariant *item = NULL; + const gchar *id; + const gchar *label; + + proxy = new_proxy("org.fildem.v1.TopLevel"); + reply = g_dbus_proxy_call_sync(proxy, + "GetTopLevel", + g_variant_new("(s)", window_uid), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + &error); + if (reply == NULL) { + g_printerr("error: %s\n", error->message); + return 1; + } + + g_variant_get(reply, "(@aa{sv})", &items); + g_variant_iter_init(&iter, items); + while ((item = g_variant_iter_next_value(&iter)) != NULL) { + id = ""; + label = ""; + g_variant_lookup(item, "id", "&s", &id); + g_variant_lookup(item, "label", "&s", &label); + g_print("%s\t%s\n", id, label); + g_variant_unref(item); + } + + g_variant_unref(items); + return 0; +} + +static gint +print_menu_tree(const gchar *window_uid) +{ + g_autoptr(GDBusProxy) proxy = NULL; + g_autoptr(GError) error = NULL; + g_autoptr(GVariant) reply = NULL; + GVariant *nodes = NULL; + GVariantIter iter; + GVariant *item = NULL; + + proxy = new_proxy("org.fildem.v1.MenuTree"); + reply = g_dbus_proxy_call_sync(proxy, + "GetMenuTree", + g_variant_new("(s)", window_uid), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + &error); + if (reply == NULL) { + g_printerr("error: %s\n", error->message); + return 1; + } + + g_variant_get(reply, "(@aa{sv})", &nodes); + g_variant_iter_init(&iter, nodes); + while ((item = g_variant_iter_next_value(&iter)) != NULL) { + const gchar *id = ""; + const gchar *parent_id = ""; + const gchar *label = ""; + gboolean enabled = TRUE; + gboolean visible = TRUE; + + g_variant_lookup(item, "id", "&s", &id); + g_variant_lookup(item, "parent_id", "&s", &parent_id); + g_variant_lookup(item, "label", "&s", &label); + g_variant_lookup(item, "enabled", "b", &enabled); + g_variant_lookup(item, "visible", "b", &visible); + + g_print("%s\tparent=%s\tenabled=%d\tvisible=%d\t%s\n", + id, + parent_id, + enabled ? 1 : 0, + visible ? 1 : 0, + label); + + g_variant_unref(item); + } + + g_variant_unref(nodes); + return 0; +} + +static gint +print_window_actions(const gchar *window_uid) +{ + g_autoptr(GDBusProxy) proxy = NULL; + g_autoptr(GError) error = NULL; + g_autoptr(GVariant) reply = NULL; + GVariant *actions = NULL; + GVariantIter iter; + const gchar *action = NULL; + + proxy = new_proxy("org.fildem.v1.WindowActions"); + reply = g_dbus_proxy_call_sync(proxy, + "ListWindowActions", + g_variant_new("(s)", window_uid), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + &error); + if (reply == NULL) { + g_printerr("error: %s\n", error->message); + return 1; + } + + g_variant_get(reply, "(@as)", &actions); + g_variant_iter_init(&iter, actions); + while (g_variant_iter_next(&iter, "&s", &action)) { + g_print("%s\n", action); + } + + g_variant_unref(actions); + return 0; +} + +static gint +query_hud(const gchar *window_uid, const gchar *term) +{ + g_autoptr(GDBusProxy) proxy = NULL; + g_autoptr(GError) error = NULL; + g_autoptr(GVariant) reply = NULL; + GVariant *results = NULL; + GVariantIter iter; + GVariant *item = NULL; + + proxy = new_proxy("org.fildem.v1.Hud"); + reply = g_dbus_proxy_call_sync(proxy, + "Query", + g_variant_new("(ss)", window_uid, term), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + &error); + if (reply == NULL) { + g_printerr("error: %s\n", error->message); + return 1; + } + + g_variant_get(reply, "(@aa{sv})", &results); + g_variant_iter_init(&iter, results); + while ((item = g_variant_iter_next_value(&iter)) != NULL) { + const gchar *id = ""; + const gchar *label = ""; + const gchar *kind = ""; + + g_variant_lookup(item, "id", "&s", &id); + g_variant_lookup(item, "label", "&s", &label); + g_variant_lookup(item, "kind", "&s", &kind); + + g_print("%s\t%s\t%s\n", id, kind, label); + g_variant_unref(item); + } + + g_variant_unref(results); + return 0; +} + +static void +print_usage(void) +{ + g_printerr("usage:\n"); + g_printerr(" fildemctl status\n"); + g_printerr(" fildemctl top-level \n"); + g_printerr(" fildemctl menu-tree \n"); + g_printerr(" fildemctl actions \n"); + g_printerr(" fildemctl activate-top-level \n"); + g_printerr(" fildemctl activate-item \n"); + g_printerr(" fildemctl activate-action \n"); + g_printerr(" fildemctl hud-request \n"); + g_printerr(" fildemctl hud-query \n"); + g_printerr(" fildemctl hud-exec \n"); +} + +int +main(int argc, char **argv) +{ + if (argc == 1 || g_strcmp0(argv[1], "status") == 0 || g_strcmp0(argv[1], "window") == 0) { + return print_active_window(); + } + + if (g_strcmp0(argv[1], "top-level") == 0) { + if (argc < 3) { + g_printerr("usage: fildemctl top-level \n"); + return 2; + } + return print_top_level(argv[2]); + } + + if (g_strcmp0(argv[1], "menu-tree") == 0) { + if (argc < 3) { + print_usage(); + return 2; + } + return print_menu_tree(argv[2]); + } + + if (g_strcmp0(argv[1], "actions") == 0) { + if (argc < 3) { + print_usage(); + return 2; + } + return print_window_actions(argv[2]); + } + + if (g_strcmp0(argv[1], "activate-top-level") == 0) { + if (argc < 4) { + print_usage(); + return 2; + } + return call_void_method("org.fildem.v1.Activation", + "ActivateTopLevel", + g_variant_new("(ss)", argv[2], argv[3])); + } + + if (g_strcmp0(argv[1], "activate-item") == 0) { + if (argc < 4) { + print_usage(); + return 2; + } + return call_void_method("org.fildem.v1.Activation", + "ActivateMenuItem", + g_variant_new("(ss)", argv[2], argv[3])); + } + + if (g_strcmp0(argv[1], "activate-action") == 0) { + if (argc < 4) { + print_usage(); + return 2; + } + return call_void_method("org.fildem.v1.WindowActions", + "ActivateWindowAction", + g_variant_new("(ss)", argv[2], argv[3])); + } + + if (g_strcmp0(argv[1], "hud-query") == 0) { + if (argc < 4) { + print_usage(); + return 2; + } + return query_hud(argv[2], argv[3]); + } + + if (g_strcmp0(argv[1], "hud-request") == 0) { + if (argc < 3) { + print_usage(); + return 2; + } + return call_void_method("org.fildem.v1.Hud", + "RequestHud", + g_variant_new("(s)", argv[2])); + } + + if (g_strcmp0(argv[1], "hud-exec") == 0) { + if (argc < 4) { + print_usage(); + return 2; + } + return call_void_method("org.fildem.v1.Hud", + "Execute", + g_variant_new("(ss)", argv[2], argv[3])); + } + + print_usage(); + return 2; +} diff --git a/src/ctl/meson.build b/src/ctl/meson.build new file mode 100644 index 00000000..7d63d738 --- /dev/null +++ b/src/ctl/meson.build @@ -0,0 +1,8 @@ +fildemctl = executable( + 'fildemctl', + 'fildemctl.c', + include_directories: include_directories('../core'), + dependencies: [glib_dep, gio_dep, gobject_dep, fildem_core_dep], + link_with: [fildem_core], + install: true, +) diff --git a/src/daemon/fildemd.c b/src/daemon/fildemd.c new file mode 100644 index 00000000..56e63733 --- /dev/null +++ b/src/daemon/fildemd.c @@ -0,0 +1,1716 @@ +#include +#include + +#include +#include + +#include "fildem-core.h" + +typedef enum { + PROVIDER_NONE, + PROVIDER_GTK, + PROVIDER_DBUSMENU, +} ProviderKind; + +typedef struct { + const gchar *name; + const gchar *xml_file; + GDBusNodeInfo *node_info; + guint registration_id; +} InterfaceRegistration; + +typedef struct { + GMainLoop *loop; + GDBusConnection *connection; + guint owner_id; + GVariant *active_window; + FildemMenuRegistry *registry; + FildemMenuCache *cache; + InterfaceRegistration interfaces[6]; +} FildemDaemon; + +static const gchar *INTERFACE_FILES[6][2] = { + {"org.fildem.v1.Window", "org.fildem.v1.Window.xml"}, + {"org.fildem.v1.TopLevel", "org.fildem.v1.TopLevel.xml"}, + {"org.fildem.v1.MenuTree", "org.fildem.v1.MenuTree.xml"}, + {"org.fildem.v1.Activation", "org.fildem.v1.Activation.xml"}, + {"org.fildem.v1.WindowActions", "org.fildem.v1.WindowActions.xml"}, + {"org.fildem.v1.Hud", "org.fildem.v1.Hud.xml"}, +}; + +static const gchar *DBUSMENU_REGISTRAR_BUS = "com.canonical.AppMenu.Registrar"; +static const gchar *DBUSMENU_REGISTRAR_PATH = "/com/canonical/AppMenu/Registrar"; +static const gchar *DBUSMENU_IFACE = "com.canonical.dbusmenu"; + +static GVariant * +empty_window_context(void) +{ + GVariantBuilder builder; + + g_variant_builder_init(&builder, G_VARIANT_TYPE("a{sv}")); + return g_variant_builder_end(&builder); +} + +static GVariant * +empty_dict_variant(void) +{ + GVariantBuilder builder; + + g_variant_builder_init(&builder, G_VARIANT_TYPE("a{sv}")); + return g_variant_builder_end(&builder); +} + +static gchar * +lookup_interface_file_path(const gchar *name) +{ + g_autofree gchar *installed = NULL; + + installed = g_build_filename(FILDEM_INSTALLED_INTERFACES_DIR, name, NULL); + if (g_file_test(installed, G_FILE_TEST_EXISTS)) { + return g_steal_pointer(&installed); + } + + return g_build_filename(FILDEM_SOURCE_INTERFACES_DIR, name, NULL); +} + +static gboolean +load_interface_info(InterfaceRegistration *entry, GError **error) +{ + g_autofree gchar *path = NULL; + g_autofree gchar *contents = NULL; + + path = lookup_interface_file_path(entry->xml_file); + + if (!g_file_get_contents(path, &contents, NULL, error)) { + return FALSE; + } + + entry->node_info = g_dbus_node_info_new_for_xml(contents, error); + if (entry->node_info == NULL || entry->node_info->interfaces == NULL || + entry->node_info->interfaces[0] == NULL) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Invalid introspection xml for %s", + entry->name); + return FALSE; + } + + return TRUE; +} + +static void +return_dbus_error(GDBusMethodInvocation *invocation, + GError *error, + const gchar *fallback) +{ + if (error != NULL) { + g_dbus_method_invocation_return_dbus_error(invocation, + "org.fildem.Error.InvalidArgument", + error->message); + g_error_free(error); + return; + } + + g_dbus_method_invocation_return_dbus_error(invocation, + "org.fildem.Error.InvalidArgument", + fallback); +} + +static void +emit_signal(FildemDaemon *daemon, + const gchar *interface_name, + const gchar *signal_name, + GVariant *parameters) +{ + g_dbus_connection_emit_signal(daemon->connection, + NULL, + fildem_object_path(), + interface_name, + signal_name, + parameters, + NULL); +} + +static void +emit_activation_requested(FildemDaemon *daemon, + const gchar *window_uid, + const gchar *target_id) +{ + emit_signal(daemon, + "org.fildem.v1.Activation", + "ActivationRequested", + g_variant_new("(&s&s)", window_uid, target_id)); +} + +static gboolean +variant_as_int64(GVariant *value, gint64 *out) +{ + if (g_variant_is_of_type(value, G_VARIANT_TYPE_INT16)) { + *out = g_variant_get_int16(value); + return TRUE; + } + + if (g_variant_is_of_type(value, G_VARIANT_TYPE_UINT16)) { + *out = g_variant_get_uint16(value); + return TRUE; + } + + if (g_variant_is_of_type(value, G_VARIANT_TYPE_INT32)) { + *out = g_variant_get_int32(value); + return TRUE; + } + + if (g_variant_is_of_type(value, G_VARIANT_TYPE_UINT32)) { + *out = g_variant_get_uint32(value); + return TRUE; + } + + if (g_variant_is_of_type(value, G_VARIANT_TYPE_INT64)) { + *out = g_variant_get_int64(value); + return TRUE; + } + + if (g_variant_is_of_type(value, G_VARIANT_TYPE_UINT64)) { + *out = g_variant_get_uint64(value); + return TRUE; + } + + if (g_variant_is_of_type(value, G_VARIANT_TYPE_STRING)) { + const gchar *raw = g_variant_get_string(value, NULL); + gchar *end = NULL; + gint64 parsed; + + errno = 0; + parsed = g_ascii_strtoll(raw, &end, 10); + if (errno == 0 && end != NULL && *end == '\0') { + *out = parsed; + return TRUE; + } + } + + return FALSE; +} + +static gboolean +variant_as_boolean(GVariant *value, gboolean *out) +{ + gint64 int_value; + + if (g_variant_is_of_type(value, G_VARIANT_TYPE_BOOLEAN)) { + *out = g_variant_get_boolean(value); + return TRUE; + } + + if (variant_as_int64(value, &int_value)) { + *out = int_value != 0; + return TRUE; + } + + if (g_variant_is_of_type(value, G_VARIANT_TYPE_STRING)) { + const gchar *raw = g_variant_get_string(value, NULL); + + if (g_ascii_strcasecmp(raw, "true") == 0) { + *out = TRUE; + return TRUE; + } + + if (g_ascii_strcasecmp(raw, "false") == 0) { + *out = FALSE; + return TRUE; + } + } + + return FALSE; +} + +static gboolean +context_lookup_string(GVariant *context, + const gchar *key, + const gchar **out) +{ + return g_variant_lookup(context, key, "&s", out) && *out != NULL && (*out)[0] != '\0'; +} + +static gboolean +context_lookup_int64(GVariant *context, + const gchar *key, + gint64 *out) +{ + g_autoptr(GVariant) value = NULL; + + value = g_variant_lookup_value(context, key, NULL); + if (value == NULL) { + return FALSE; + } + + return variant_as_int64(value, out); +} + +static gboolean +dict_lookup_string(GVariant *dict, + const gchar *key, + const gchar **out) +{ + return g_variant_lookup(dict, key, "&s", out) && *out != NULL; +} + +static gboolean +dict_lookup_boolean(GVariant *dict, + const gchar *key, + gboolean default_value) +{ + g_autoptr(GVariant) value = NULL; + gboolean out; + + value = g_variant_lookup_value(dict, key, NULL); + if (value == NULL) { + return default_value; + } + + if (!variant_as_boolean(value, &out)) { + return default_value; + } + + return out; +} + +static gint64 +dict_lookup_int64(GVariant *dict, + const gchar *key, + gint64 default_value) +{ + g_autoptr(GVariant) value = NULL; + gint64 out; + + value = g_variant_lookup_value(dict, key, NULL); + if (value == NULL) { + return default_value; + } + + if (!variant_as_int64(value, &out)) { + return default_value; + } + + return out; +} + +static void +normalize_label_and_mnemonic(const gchar *raw, + gchar **label_out, + gchar **mnemonic_out) +{ + GString *clean; + gchar *mnemonic = NULL; + gsize i; + + if (raw == NULL) { + *label_out = g_strdup(""); + *mnemonic_out = g_strdup(""); + return; + } + + clean = g_string_new(NULL); + for (i = 0; raw[i] != '\0'; i++) { + if (raw[i] == '_' && raw[i + 1] != '\0') { + if (mnemonic == NULL) { + mnemonic = g_strndup(&raw[i + 1], 1); + } + continue; + } + + g_string_append_c(clean, raw[i]); + } + + if (mnemonic == NULL) { + mnemonic = g_strdup(""); + } + + *label_out = g_string_free(clean, FALSE); + *mnemonic_out = mnemonic; +} + +static GVariant * +build_top_level_item(const gchar *id, + const gchar *label, + gboolean enabled, + gboolean visible, + const gchar *mnemonic) +{ + GVariantBuilder dict; + + g_variant_builder_init(&dict, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&dict, "{sv}", "id", g_variant_new_string(id)); + g_variant_builder_add(&dict, "{sv}", "label", g_variant_new_string(label)); + g_variant_builder_add(&dict, "{sv}", "enabled", g_variant_new_boolean(enabled)); + g_variant_builder_add(&dict, "{sv}", "visible", g_variant_new_boolean(visible)); + g_variant_builder_add(&dict, "{sv}", "mnemonic", g_variant_new_string(mnemonic)); + + return g_variant_builder_end(&dict); +} + +static GVariant * +build_menu_node(const gchar *id, + const gchar *parent_id, + const gchar *label, + gboolean enabled, + gboolean visible, + const gchar *toggle_type, + gboolean toggle_state, + const gchar *accel, + gboolean is_separator, + guint position) +{ + GVariantBuilder dict; + + g_variant_builder_init(&dict, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&dict, "{sv}", "id", g_variant_new_string(id)); + g_variant_builder_add(&dict, "{sv}", "parent_id", g_variant_new_string(parent_id)); + g_variant_builder_add(&dict, "{sv}", "label", g_variant_new_string(label)); + g_variant_builder_add(&dict, "{sv}", "enabled", g_variant_new_boolean(enabled)); + g_variant_builder_add(&dict, "{sv}", "visible", g_variant_new_boolean(visible)); + g_variant_builder_add(&dict, "{sv}", "toggle_type", g_variant_new_string(toggle_type)); + g_variant_builder_add(&dict, "{sv}", "toggle_state", g_variant_new_boolean(toggle_state)); + g_variant_builder_add(&dict, "{sv}", "accel", g_variant_new_string(accel)); + g_variant_builder_add(&dict, "{sv}", "is_separator", g_variant_new_boolean(is_separator)); + g_variant_builder_add(&dict, "{sv}", "position", g_variant_new_int32((gint32)position)); + + return g_variant_builder_end(&dict); +} + +static void +publish_menu_state(FildemDaemon *daemon, + const gchar *window_uid, + GVariant *top_level, + GVariant *menu_tree) +{ + fildem_menu_registry_set_top_level(daemon->registry, window_uid, g_variant_ref(top_level)); + fildem_menu_cache_set_tree(daemon->cache, window_uid, g_variant_ref(menu_tree)); + + emit_signal(daemon, + "org.fildem.v1.TopLevel", + "TopLevelChanged", + g_variant_new("(&s@aa{sv})", window_uid, g_variant_ref(top_level))); + emit_signal(daemon, + "org.fildem.v1.MenuTree", + "MenuTreeChanged", + g_variant_new("(&s)", window_uid)); +} + +static GDBusProxy * +new_proxy(const gchar *bus_name, + const gchar *object_path, + const gchar *interface_name, + GError **error) +{ + return g_dbus_proxy_new_for_bus_sync(G_BUS_TYPE_SESSION, + G_DBUS_PROXY_FLAGS_NONE, + NULL, + bus_name, + object_path, + interface_name, + NULL, + error); +} + +static gboolean +dbusmenu_get_menu_proxy(GVariant *context, GDBusProxy **proxy_out, GError **error) +{ + g_autoptr(GDBusProxy) registrar = NULL; + g_autoptr(GVariant) reply = NULL; + const gchar *menu_bus = NULL; + const gchar *menu_path = NULL; + gint64 xid = 0; + + if (!context_lookup_int64(context, "xid", &xid) || xid <= 0) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Missing valid xid in active window context"); + return FALSE; + } + + registrar = new_proxy(DBUSMENU_REGISTRAR_BUS, + DBUSMENU_REGISTRAR_PATH, + DBUSMENU_REGISTRAR_BUS, + error); + if (registrar == NULL) { + return FALSE; + } + + reply = g_dbus_proxy_call_sync(registrar, + "GetMenuForWindow", + g_variant_new("(u)", (guint32)xid), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + error); + if (reply == NULL) { + return FALSE; + } + + g_variant_get(reply, "(&s&o)", &menu_bus, &menu_path); + if (menu_bus == NULL || menu_path == NULL) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_NOT_FOUND, + "Menu bus/path not found for xid=%" G_GINT64_FORMAT, + xid); + return FALSE; + } + + *proxy_out = new_proxy(menu_bus, menu_path, DBUSMENU_IFACE, error); + return *proxy_out != NULL; +} + +static gchar * +dbusmenu_shortcut_to_string(GVariant *value) +{ + GString *out; + GVariant *first = NULL; + GVariantIter token_iter; + const gchar *token = NULL; + GPtrArray *tokens; + guint i; + + if (value == NULL) { + return g_strdup(""); + } + + if (!g_variant_is_of_type(value, G_VARIANT_TYPE("aas"))) { + return g_strdup(""); + } + + if (g_variant_n_children(value) == 0) { + return g_strdup(""); + } + + first = g_variant_get_child_value(value, 0); + if (first == NULL || !g_variant_is_of_type(first, G_VARIANT_TYPE("as"))) { + g_clear_pointer(&first, g_variant_unref); + return g_strdup(""); + } + + tokens = g_ptr_array_new_with_free_func(g_free); + g_variant_iter_init(&token_iter, first); + while (g_variant_iter_next(&token_iter, "&s", &token)) { + g_ptr_array_add(tokens, g_strdup(token)); + } + + out = g_string_new(NULL); + for (i = 0; i < tokens->len; i++) { + const gchar *entry = g_ptr_array_index(tokens, i); + + if (i + 1 < tokens->len) { + g_string_append_printf(out, "<%s>", entry); + } else { + g_string_append(out, entry); + } + } + + g_ptr_array_free(tokens, TRUE); + g_variant_unref(first); + + return g_string_free(out, FALSE); +} + +static gboolean +variant_child_as_int64(GVariant *container, + guint index, + gint64 *out) +{ + g_autoptr(GVariant) child = NULL; + + if (g_variant_n_children(container) <= index) { + return FALSE; + } + + child = g_variant_get_child_value(container, index); + return variant_as_int64(child, out); +} + +static void +dbusmenu_collect_children(GVariant *children, + const gchar *parent_id, + gboolean top_level, + guint *position, + GVariantBuilder *top_builder, + GVariantBuilder *tree_builder); + +static void +dbusmenu_collect_node(GVariant *node, + const gchar *parent_id, + gboolean top_level, + guint *position, + GVariantBuilder *top_builder, + GVariantBuilder *tree_builder) +{ + g_autoptr(GVariant) id_value = NULL; + g_autoptr(GVariant) props = NULL; + g_autoptr(GVariant) children = NULL; + g_autoptr(GVariant) shortcut = NULL; + gint64 node_numeric_id = 0; + const gchar *raw_label = NULL; + const gchar *raw_type = NULL; + const gchar *raw_toggle_type = ""; + gboolean enabled; + gboolean visible; + gboolean separator; + gboolean toggle_state; + gchar *label = NULL; + gchar *mnemonic = NULL; + g_autofree gchar *accel = NULL; + g_autofree gchar *node_id = NULL; + const gchar *next_parent = parent_id == NULL ? "" : parent_id; + + if (g_variant_n_children(node) < 3) { + return; + } + + id_value = g_variant_get_child_value(node, 0); + if (!variant_as_int64(id_value, &node_numeric_id)) { + return; + } + + props = g_variant_get_child_value(node, 1); + children = g_variant_get_child_value(node, 2); + if (!g_variant_is_of_type(props, G_VARIANT_TYPE("a{sv}"))) { + return; + } + + dict_lookup_string(props, "label", &raw_label); + dict_lookup_string(props, "type", &raw_type); + dict_lookup_string(props, "toggle-type", &raw_toggle_type); + + enabled = dict_lookup_boolean(props, "enabled", TRUE); + visible = dict_lookup_boolean(props, "visible", TRUE); + toggle_state = dict_lookup_int64(props, "toggle-state", 0) != 0; + separator = g_strcmp0(raw_type, "separator") == 0; + + shortcut = g_variant_lookup_value(props, "shortcut", NULL); + accel = dbusmenu_shortcut_to_string(shortcut); + + node_id = g_strdup_printf("%" G_GINT64_FORMAT, node_numeric_id); + + if (raw_label != NULL) { + normalize_label_and_mnemonic(raw_label, &label, &mnemonic); + } else { + label = g_strdup(""); + mnemonic = g_strdup(""); + } + + if (separator || *label != '\0') { + g_variant_builder_add_value(tree_builder, + build_menu_node(node_id, + next_parent, + label, + enabled, + visible, + raw_toggle_type, + toggle_state, + accel, + separator, + (*position)++)); + + if (top_level && !separator && *label != '\0') { + g_variant_builder_add_value(top_builder, + build_top_level_item(node_id, + label, + enabled, + visible, + mnemonic)); + } + + next_parent = node_id; + } + + if (children != NULL && g_variant_is_of_type(children, G_VARIANT_TYPE("av"))) { + dbusmenu_collect_children(children, + next_parent, + FALSE, + position, + top_builder, + tree_builder); + } + + g_free(label); + g_free(mnemonic); +} + +static void +dbusmenu_collect_children(GVariant *children, + const gchar *parent_id, + gboolean top_level, + guint *position, + GVariantBuilder *top_builder, + GVariantBuilder *tree_builder) +{ + GVariantIter iter; + GVariant *child = NULL; + + g_variant_iter_init(&iter, children); + while ((child = g_variant_iter_next_value(&iter)) != NULL) { + GVariant *tuple = NULL; + + if (g_variant_is_of_type(child, G_VARIANT_TYPE_VARIANT)) { + tuple = g_variant_get_variant(child); + } else { + tuple = g_variant_ref(child); + } + + dbusmenu_collect_node(tuple, + parent_id, + top_level, + position, + top_builder, + tree_builder); + + g_variant_unref(tuple); + g_variant_unref(child); + } +} + +static gboolean +fetch_from_dbusmenu(GVariant *context, + GVariant **top_level_out, + GVariant **menu_tree_out, + GError **error) +{ + g_autoptr(GDBusProxy) menu_proxy = NULL; + g_autoptr(GVariant) reply = NULL; + g_autoptr(GVariant) root = NULL; + g_autoptr(GVariant) children = NULL; + GVariantBuilder top_builder; + GVariantBuilder tree_builder; + guint position = 0; + + if (!dbusmenu_get_menu_proxy(context, &menu_proxy, error)) { + return FALSE; + } + + reply = g_dbus_proxy_call_sync(menu_proxy, + "GetLayout", + g_variant_new("(ii@as)", + 0, + -1, + g_variant_new_strv(NULL, 0)), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + error); + if (reply == NULL) { + return FALSE; + } + + if (g_variant_n_children(reply) < 2) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Invalid GetLayout reply from dbusmenu provider"); + return FALSE; + } + + root = g_variant_get_child_value(reply, 1); + if (g_variant_n_children(root) < 3) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Invalid dbusmenu root node"); + return FALSE; + } + + children = g_variant_get_child_value(root, 2); + + g_variant_builder_init(&top_builder, G_VARIANT_TYPE("aa{sv}")); + g_variant_builder_init(&tree_builder, G_VARIANT_TYPE("aa{sv}")); + + if (children != NULL && g_variant_is_of_type(children, G_VARIANT_TYPE("av"))) { + dbusmenu_collect_children(children, + "", + TRUE, + &position, + &top_builder, + &tree_builder); + } + + *top_level_out = g_variant_ref_sink(g_variant_builder_end(&top_builder)); + *menu_tree_out = g_variant_ref_sink(g_variant_builder_end(&tree_builder)); + + return TRUE; +} + +static gchar * +gtk_section_key_from_variant(GVariant *section) +{ + gint64 first = 0; + gint64 second = 0; + + if (!variant_child_as_int64(section, 0, &first) || + !variant_child_as_int64(section, 1, &second)) { + return NULL; + } + + return g_strdup_printf("%" G_GINT64_FORMAT ":%" G_GINT64_FORMAT, first, second); +} + +static void +gtk_collect_section(GHashTable *section_map, + const gchar *section_key, + const gchar *parent_id, + guint depth, + guint *position, + GVariantBuilder *top_builder, + GVariantBuilder *tree_builder) +{ + GVariant *entries = NULL; + GVariantIter iter; + GVariant *entry = NULL; + guint index = 0; + + if (depth > 64) { + return; + } + + entries = g_hash_table_lookup(section_map, section_key); + if (entries == NULL || !g_variant_is_of_type(entries, G_VARIANT_TYPE_ARRAY)) { + return; + } + + g_variant_iter_init(&iter, entries); + while ((entry = g_variant_iter_next_value(&iter)) != NULL) { + GVariant *dict = NULL; + const gchar *raw_label = NULL; + const gchar *action = NULL; + gchar *label = NULL; + gchar *mnemonic = NULL; + gchar *next_parent = NULL; + gboolean enabled = TRUE; + gboolean visible = TRUE; + + if (g_variant_is_of_type(entry, G_VARIANT_TYPE_VARIANT)) { + dict = g_variant_get_variant(entry); + } else { + dict = g_variant_ref(entry); + } + + if (!g_variant_is_of_type(dict, G_VARIANT_TYPE("a{sv}"))) { + g_variant_unref(dict); + g_variant_unref(entry); + index++; + continue; + } + + dict_lookup_string(dict, "label", &raw_label); + dict_lookup_string(dict, "action", &action); + enabled = dict_lookup_boolean(dict, "enabled", TRUE); + visible = dict_lookup_boolean(dict, "visible", TRUE); + + if (raw_label != NULL && raw_label[0] != '\0') { + g_autofree gchar *node_id = NULL; + + normalize_label_and_mnemonic(raw_label, &label, &mnemonic); + + if (action != NULL && action[0] != '\0') { + node_id = g_strdup(action); + } else { + node_id = g_strdup_printf("gtk:%s:%u", section_key, index); + } + + g_variant_builder_add_value(tree_builder, + build_menu_node(node_id, + parent_id == NULL ? "" : parent_id, + label, + enabled, + visible, + "", + FALSE, + "", + FALSE, + (*position)++)); + + if (depth == 0) { + g_variant_builder_add_value(top_builder, + build_top_level_item(node_id, + label, + enabled, + visible, + mnemonic)); + } + + next_parent = g_strdup(node_id); + g_free(label); + g_free(mnemonic); + } else { + next_parent = g_strdup(parent_id == NULL ? "" : parent_id); + } + + { + g_autoptr(GVariant) submenu = g_variant_lookup_value(dict, ":submenu", NULL); + if (submenu != NULL) { + g_autofree gchar *sub_key = gtk_section_key_from_variant(submenu); + if (sub_key != NULL) { + gtk_collect_section(section_map, + sub_key, + next_parent, + depth + 1, + position, + top_builder, + tree_builder); + } + } + } + + { + g_autoptr(GVariant) section = g_variant_lookup_value(dict, ":section", NULL); + if (section != NULL) { + g_autofree gchar *sub_key = gtk_section_key_from_variant(section); + if (sub_key != NULL) { + gtk_collect_section(section_map, + sub_key, + next_parent, + depth + 1, + position, + top_builder, + tree_builder); + } + } + } + + g_free(next_parent); + g_variant_unref(dict); + g_variant_unref(entry); + index++; + } +} + +static gboolean +fetch_from_gtk_exporter(GVariant *context, + GVariant **top_level_out, + GVariant **menu_tree_out, + GError **error) +{ + const gchar *bus_name = NULL; + const gchar *menu_path = NULL; + g_autoptr(GDBusProxy) menus_proxy = NULL; + g_autoptr(GVariant) reply = NULL; + g_autoptr(GVariant) sections = NULL; + GHashTable *section_map; + GVariantIter iter; + GVariant *section = NULL; + GVariantBuilder ids_builder; + GVariantBuilder top_builder; + GVariantBuilder tree_builder; + guint i; + guint position = 0; + + if (!context_lookup_string(context, "gtk_unique_bus_name", &bus_name)) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Missing gtk_unique_bus_name in active window context"); + return FALSE; + } + + if (!context_lookup_string(context, "gtk_menubar_object_path", &menu_path) && + !context_lookup_string(context, "gtk_app_menu_object_path", &menu_path)) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Missing gtk_menubar_object_path in active window context"); + return FALSE; + } + + menus_proxy = new_proxy(bus_name, menu_path, "org.gtk.Menus", error); + if (menus_proxy == NULL) { + return FALSE; + } + + g_variant_builder_init(&ids_builder, G_VARIANT_TYPE("ai")); + for (i = 0; i < 256; i++) { + g_variant_builder_add(&ids_builder, "i", (gint32)i); + } + + reply = g_dbus_proxy_call_sync(menus_proxy, + "Start", + g_variant_new("(@ai)", g_variant_builder_end(&ids_builder)), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + error); + if (reply == NULL) { + return FALSE; + } + + /* Best effort; ignored when unsupported by provider */ + g_dbus_proxy_call_sync(menus_proxy, + "End", + g_variant_new("(@ai)", g_variant_new_array(G_VARIANT_TYPE_INT32, NULL, 0)), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + NULL); + + if (g_variant_n_children(reply) == 0) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Invalid Start reply from gtk menu exporter"); + return FALSE; + } + + sections = g_variant_get_child_value(reply, 0); + if (!g_variant_is_of_type(sections, G_VARIANT_TYPE_ARRAY)) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "GTK Start reply has invalid sections type"); + return FALSE; + } + + section_map = g_hash_table_new_full(g_str_hash, + g_str_equal, + g_free, + (GDestroyNotify)g_variant_unref); + + g_variant_iter_init(&iter, sections); + while ((section = g_variant_iter_next_value(&iter)) != NULL) { + g_autofree gchar *key = NULL; + g_autoptr(GVariant) entries = NULL; + + key = gtk_section_key_from_variant(section); + if (key == NULL || g_variant_n_children(section) < 3) { + g_variant_unref(section); + continue; + } + + entries = g_variant_get_child_value(section, 2); + if (entries != NULL && g_variant_is_of_type(entries, G_VARIANT_TYPE_ARRAY)) { + g_hash_table_replace(section_map, g_steal_pointer(&key), g_variant_ref(entries)); + } + + g_variant_unref(section); + } + + g_variant_builder_init(&top_builder, G_VARIANT_TYPE("aa{sv}")); + g_variant_builder_init(&tree_builder, G_VARIANT_TYPE("aa{sv}")); + + gtk_collect_section(section_map, + "0:0", + "", + 0, + &position, + &top_builder, + &tree_builder); + + g_hash_table_unref(section_map); + + *top_level_out = g_variant_ref_sink(g_variant_builder_end(&top_builder)); + *menu_tree_out = g_variant_ref_sink(g_variant_builder_end(&tree_builder)); + + return TRUE; +} + +static ProviderKind +choose_provider(GVariant *context) +{ + const gchar *dummy = NULL; + gint64 xid = 0; + + if (context_lookup_string(context, "gtk_unique_bus_name", &dummy) && + (context_lookup_string(context, "gtk_menubar_object_path", &dummy) || + context_lookup_string(context, "gtk_app_menu_object_path", &dummy))) { + return PROVIDER_GTK; + } + + if (context_lookup_int64(context, "xid", &xid) && xid > 0) { + return PROVIDER_DBUSMENU; + } + + return PROVIDER_NONE; +} + +static gboolean +activate_gtk_action(GVariant *context, + const gchar *full_action, + GError **error) +{ + const gchar *bus_name = NULL; + const gchar *path_key = NULL; + const gchar *object_path = NULL; + const gchar *action_name = NULL; + g_autoptr(GDBusProxy) actions_proxy = NULL; + g_autoptr(GVariant) reply = NULL; + + if (!context_lookup_string(context, "gtk_unique_bus_name", &bus_name)) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Missing gtk_unique_bus_name in active window context"); + return FALSE; + } + + if (g_str_has_prefix(full_action, "app.")) { + path_key = "gtk_application_object_path"; + action_name = full_action + 4; + } else if (g_str_has_prefix(full_action, "win.")) { + path_key = "gtk_window_object_path"; + action_name = full_action + 4; + } else if (g_str_has_prefix(full_action, "unity.")) { + path_key = "gtk_menubar_object_path"; + action_name = full_action + 6; + } else { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_NOT_FOUND, + "Action '%s' is not a GTK action id", + full_action); + return FALSE; + } + + if (!context_lookup_string(context, path_key, &object_path)) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_INVALID_ARGUMENT, + "Missing %s in active window context", + path_key); + return FALSE; + } + + actions_proxy = new_proxy(bus_name, object_path, "org.gtk.Actions", error); + if (actions_proxy == NULL) { + return FALSE; + } + + reply = g_dbus_proxy_call_sync(actions_proxy, + "Activate", + g_variant_new("(s@av@a{sv})", + action_name, + g_variant_new_array(G_VARIANT_TYPE("v"), NULL, 0), + empty_dict_variant()), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + error); + + return reply != NULL; +} + +static gboolean +activate_dbusmenu_item(GVariant *context, + const gchar *target_id, + gboolean is_top_level, + GError **error) +{ + g_autoptr(GDBusProxy) menu_proxy = NULL; + g_autoptr(GVariant) reply = NULL; + gchar *end = NULL; + gint64 parsed_id; + guint32 timestamp; + + parsed_id = g_ascii_strtoll(target_id, &end, 10); + if (end == NULL || *end != '\0') { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_NOT_FOUND, + "Target '%s' is not a dbusmenu item id", + target_id); + return FALSE; + } + + if (!dbusmenu_get_menu_proxy(context, &menu_proxy, error)) { + return FALSE; + } + + timestamp = (guint32)(g_get_real_time() / 1000); + reply = g_dbus_proxy_call_sync(menu_proxy, + "Event", + g_variant_new("(isvu)", + (gint32)parsed_id, + is_top_level ? "opened" : "clicked", + g_variant_new_int32(0), + timestamp), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + error); + return reply != NULL; +} + +static gboolean +active_window_uid_matches(FildemDaemon *daemon, + const gchar *window_uid) +{ + const gchar *active_uid = NULL; + + if (daemon->active_window == NULL || + !context_lookup_string(daemon->active_window, "window_uid", &active_uid)) { + return FALSE; + } + + return g_strcmp0(active_uid, window_uid) == 0; +} + +static gboolean +activate_target(FildemDaemon *daemon, + const gchar *window_uid, + const gchar *target_id, + gboolean is_top_level, + GError **error) +{ + ProviderKind provider; + + if (!active_window_uid_matches(daemon, window_uid)) { + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_NOT_FOUND, + "Window %s is not active", + window_uid); + return FALSE; + } + + if (g_str_has_prefix(target_id, "action:")) { + emit_activation_requested(daemon, window_uid, target_id); + return TRUE; + } + + provider = choose_provider(daemon->active_window); + + if (provider == PROVIDER_GTK) { + if (activate_gtk_action(daemon->active_window, target_id, error)) { + return TRUE; + } + + g_clear_error(error); + return activate_dbusmenu_item(daemon->active_window, target_id, is_top_level, error); + } + + if (provider == PROVIDER_DBUSMENU) { + return activate_dbusmenu_item(daemon->active_window, target_id, is_top_level, error); + } + + g_set_error(error, + FILDEM_ERROR, + FILDEM_ERROR_NOT_FOUND, + "No menu provider available for active window"); + return FALSE; +} + +static void +refresh_window_menu_state(FildemDaemon *daemon) +{ + const gchar *window_uid = NULL; + g_autoptr(GVariant) top_level = NULL; + g_autoptr(GVariant) menu_tree = NULL; + g_autoptr(GError) error = NULL; + ProviderKind provider; + + if (daemon->active_window == NULL || + !context_lookup_string(daemon->active_window, "window_uid", &window_uid)) { + return; + } + + provider = choose_provider(daemon->active_window); + + if (provider == PROVIDER_GTK) { + if (!fetch_from_gtk_exporter(daemon->active_window, &top_level, &menu_tree, &error)) { + g_debug("gtk menu provider unavailable: %s", error->message); + g_clear_error(&error); + fetch_from_dbusmenu(daemon->active_window, &top_level, &menu_tree, &error); + } + } else if (provider == PROVIDER_DBUSMENU) { + fetch_from_dbusmenu(daemon->active_window, &top_level, &menu_tree, &error); + } + + if (top_level == NULL || menu_tree == NULL) { + if (error != NULL) { + g_debug("menu provider fetch failed for window %s: %s", window_uid, error->message); + g_clear_error(&error); + } + + top_level = g_variant_ref_sink(fildem_empty_top_level_items()); + menu_tree = g_variant_ref_sink(fildem_empty_menu_tree()); + } + + publish_menu_state(daemon, window_uid, top_level, menu_tree); +} + +static void +handle_window_methods(FildemDaemon *daemon, + const gchar *method_name, + GVariant *parameters, + GDBusMethodInvocation *invocation) +{ + if (g_strcmp0(method_name, "UpdateActiveWindow") == 0) { + GVariant *context = NULL; + g_autoptr(GError) error = NULL; + + g_variant_get(parameters, "(@a{sv})", &context); + if (!fildem_validate_window_context(context, &error)) { + g_variant_unref(context); + return_dbus_error(invocation, g_steal_pointer(&error), "invalid window context"); + return; + } + + g_clear_pointer(&daemon->active_window, g_variant_unref); + daemon->active_window = g_variant_ref_sink(context); + + emit_signal(daemon, + "org.fildem.v1.Window", + "ActiveWindowChanged", + g_variant_new("(@a{sv})", g_variant_ref(daemon->active_window))); + + refresh_window_menu_state(daemon); + + g_dbus_method_invocation_return_value(invocation, NULL); + return; + } + + if (g_strcmp0(method_name, "GetActiveWindow") == 0) { + GVariant *context = daemon->active_window == NULL ? + empty_window_context() : + g_variant_ref(daemon->active_window); + + g_dbus_method_invocation_return_value(invocation, + g_variant_new("(@a{sv})", context)); + return; + } + + g_dbus_method_invocation_return_dbus_error(invocation, + "org.fildem.Error.UnknownMethod", + "Unknown method on org.fildem.v1.Window"); +} + +static void +handle_top_level_methods(FildemDaemon *daemon, + const gchar *method_name, + GVariant *parameters, + GDBusMethodInvocation *invocation) +{ + if (g_strcmp0(method_name, "SetTopLevel") == 0) { + const gchar *window_uid = NULL; + GVariant *items = NULL; + g_autoptr(GError) error = NULL; + + g_variant_get(parameters, "(&s@aa{sv})", &window_uid, &items); + if (!fildem_validate_top_level_items(items, &error)) { + g_variant_unref(items); + return_dbus_error(invocation, g_steal_pointer(&error), "invalid top-level payload"); + return; + } + + fildem_menu_registry_set_top_level(daemon->registry, window_uid, items); + + emit_signal(daemon, + "org.fildem.v1.TopLevel", + "TopLevelChanged", + g_variant_new("(&s@aa{sv})", window_uid, g_variant_ref(items))); + + g_variant_unref(items); + g_dbus_method_invocation_return_value(invocation, NULL); + return; + } + + if (g_strcmp0(method_name, "GetTopLevel") == 0) { + const gchar *window_uid = NULL; + GVariant *items; + + g_variant_get(parameters, "(&s)", &window_uid); + items = fildem_menu_registry_get_top_level(daemon->registry, window_uid); + + g_dbus_method_invocation_return_value(invocation, + g_variant_new("(@aa{sv})", items)); + return; + } + + g_dbus_method_invocation_return_dbus_error(invocation, + "org.fildem.Error.UnknownMethod", + "Unknown method on org.fildem.v1.TopLevel"); +} + +static void +handle_menu_tree_methods(FildemDaemon *daemon, + const gchar *method_name, + GVariant *parameters, + GDBusMethodInvocation *invocation) +{ + if (g_strcmp0(method_name, "SetMenuTree") == 0) { + const gchar *window_uid = NULL; + GVariant *nodes = NULL; + g_autoptr(GError) error = NULL; + + g_variant_get(parameters, "(&s@aa{sv})", &window_uid, &nodes); + if (!fildem_validate_menu_tree(nodes, &error)) { + g_variant_unref(nodes); + return_dbus_error(invocation, g_steal_pointer(&error), "invalid menu tree payload"); + return; + } + + fildem_menu_cache_set_tree(daemon->cache, window_uid, nodes); + + emit_signal(daemon, + "org.fildem.v1.MenuTree", + "MenuTreeChanged", + g_variant_new("(&s)", window_uid)); + + g_variant_unref(nodes); + g_dbus_method_invocation_return_value(invocation, NULL); + return; + } + + if (g_strcmp0(method_name, "GetMenuTree") == 0) { + const gchar *window_uid = NULL; + GVariant *nodes; + + g_variant_get(parameters, "(&s)", &window_uid); + nodes = fildem_menu_cache_get_tree(daemon->cache, window_uid); + g_dbus_method_invocation_return_value(invocation, + g_variant_new("(@aa{sv})", nodes)); + return; + } + + if (g_strcmp0(method_name, "InvalidateMenuTree") == 0) { + const gchar *window_uid = NULL; + + g_variant_get(parameters, "(&s)", &window_uid); + fildem_menu_cache_invalidate(daemon->cache, window_uid); + + emit_signal(daemon, + "org.fildem.v1.MenuTree", + "MenuTreeChanged", + g_variant_new("(&s)", window_uid)); + + g_dbus_method_invocation_return_value(invocation, NULL); + return; + } + + g_dbus_method_invocation_return_dbus_error(invocation, + "org.fildem.Error.UnknownMethod", + "Unknown method on org.fildem.v1.MenuTree"); +} + +static void +handle_activation_methods(FildemDaemon *daemon, + const gchar *method_name, + GVariant *parameters, + GDBusMethodInvocation *invocation) +{ + const gchar *window_uid = NULL; + const gchar *target_id = NULL; + gboolean is_top_level; + g_autoptr(GError) error = NULL; + + if (g_strcmp0(method_name, "ActivateTopLevel") != 0 && + g_strcmp0(method_name, "ActivateMenuItem") != 0) { + g_dbus_method_invocation_return_dbus_error(invocation, + "org.fildem.Error.UnknownMethod", + "Unknown method on org.fildem.v1.Activation"); + return; + } + + g_variant_get(parameters, "(&s&s)", &window_uid, &target_id); + is_top_level = g_strcmp0(method_name, "ActivateTopLevel") == 0; + + if (!activate_target(daemon, window_uid, target_id, is_top_level, &error)) { + return_dbus_error(invocation, g_steal_pointer(&error), "activation failed"); + return; + } + + g_dbus_method_invocation_return_value(invocation, NULL); +} + +static void +handle_window_actions_methods(FildemDaemon *daemon, + const gchar *method_name, + GVariant *parameters, + GDBusMethodInvocation *invocation) +{ + if (g_strcmp0(method_name, "SetWindowActions") == 0) { + const gchar *window_uid = NULL; + GVariant *actions = NULL; + + g_variant_get(parameters, "(&s@as)", &window_uid, &actions); + fildem_menu_registry_set_actions(daemon->registry, window_uid, actions); + + emit_signal(daemon, + "org.fildem.v1.WindowActions", + "WindowActionsChanged", + g_variant_new("(&s@as)", window_uid, g_variant_ref(actions))); + + g_variant_unref(actions); + g_dbus_method_invocation_return_value(invocation, NULL); + return; + } + + if (g_strcmp0(method_name, "ListWindowActions") == 0) { + const gchar *window_uid = NULL; + GVariant *actions; + + g_variant_get(parameters, "(&s)", &window_uid); + actions = fildem_menu_registry_get_actions(daemon->registry, window_uid); + + g_dbus_method_invocation_return_value(invocation, + g_variant_new("(@as)", actions)); + return; + } + + if (g_strcmp0(method_name, "ActivateWindowAction") == 0) { + const gchar *window_uid = NULL; + const gchar *action_id = NULL; + g_autofree gchar *entry_id = NULL; + + g_variant_get(parameters, "(&s&s)", &window_uid, &action_id); + entry_id = g_strdup_printf("action:%s", action_id); + emit_activation_requested(daemon, window_uid, entry_id); + + g_dbus_method_invocation_return_value(invocation, NULL); + return; + } + + g_dbus_method_invocation_return_dbus_error(invocation, + "org.fildem.Error.UnknownMethod", + "Unknown method on org.fildem.v1.WindowActions"); +} + +static void +handle_hud_methods(FildemDaemon *daemon, + const gchar *method_name, + GVariant *parameters, + GDBusMethodInvocation *invocation) +{ + if (g_strcmp0(method_name, "RequestHud") == 0) { + const gchar *window_uid = ""; + + g_variant_get(parameters, "(&s)", &window_uid); + emit_signal(daemon, + "org.fildem.v1.Hud", + "HudRequested", + g_variant_new("(&s)", window_uid)); + + g_dbus_method_invocation_return_value(invocation, NULL); + return; + } + + if (g_strcmp0(method_name, "Query") == 0) { + const gchar *window_uid = NULL; + const gchar *term = NULL; + GVariant *nodes; + GVariant *actions; + GVariant *results; + + g_variant_get(parameters, "(&s&s)", &window_uid, &term); + + nodes = fildem_menu_cache_get_tree(daemon->cache, window_uid); + actions = fildem_menu_registry_get_actions(daemon->registry, window_uid); + results = fildem_menu_model_query(nodes, actions, term); + + g_variant_unref(nodes); + g_variant_unref(actions); + + g_dbus_method_invocation_return_value(invocation, + g_variant_new("(@aa{sv})", results)); + return; + } + + if (g_strcmp0(method_name, "Execute") == 0) { + const gchar *window_uid = NULL; + const gchar *entry_id = NULL; + g_autoptr(GError) error = NULL; + + g_variant_get(parameters, "(&s&s)", &window_uid, &entry_id); + + if (!activate_target(daemon, window_uid, entry_id, FALSE, &error)) { + return_dbus_error(invocation, g_steal_pointer(&error), "execute failed"); + return; + } + + g_dbus_method_invocation_return_value(invocation, NULL); + return; + } + + g_dbus_method_invocation_return_dbus_error(invocation, + "org.fildem.Error.UnknownMethod", + "Unknown method on org.fildem.v1.Hud"); +} + +static void +handle_method_call(GDBusConnection *connection, + const gchar *sender, + const gchar *object_path, + const gchar *interface_name, + const gchar *method_name, + GVariant *parameters, + GDBusMethodInvocation *invocation, + gpointer user_data) +{ + FildemDaemon *daemon = user_data; + + if (g_strcmp0(interface_name, "org.fildem.v1.Window") == 0) { + handle_window_methods(daemon, method_name, parameters, invocation); + return; + } + + if (g_strcmp0(interface_name, "org.fildem.v1.TopLevel") == 0) { + handle_top_level_methods(daemon, method_name, parameters, invocation); + return; + } + + if (g_strcmp0(interface_name, "org.fildem.v1.MenuTree") == 0) { + handle_menu_tree_methods(daemon, method_name, parameters, invocation); + return; + } + + if (g_strcmp0(interface_name, "org.fildem.v1.Activation") == 0) { + handle_activation_methods(daemon, method_name, parameters, invocation); + return; + } + + if (g_strcmp0(interface_name, "org.fildem.v1.WindowActions") == 0) { + handle_window_actions_methods(daemon, method_name, parameters, invocation); + return; + } + + if (g_strcmp0(interface_name, "org.fildem.v1.Hud") == 0) { + handle_hud_methods(daemon, method_name, parameters, invocation); + return; + } + + g_dbus_method_invocation_return_dbus_error(invocation, + "org.fildem.Error.UnknownInterface", + "Unknown interface"); + + (void)connection; + (void)sender; + (void)object_path; +} + +static const GDBusInterfaceVTable interface_vtable = { + .method_call = handle_method_call, + .get_property = NULL, + .set_property = NULL, +}; + +static gboolean +register_interfaces(FildemDaemon *daemon, GError **error) +{ + guint i; + + for (i = 0; i < G_N_ELEMENTS(INTERFACE_FILES); i++) { + GDBusInterfaceInfo *interface_info; + + daemon->interfaces[i].name = INTERFACE_FILES[i][0]; + daemon->interfaces[i].xml_file = INTERFACE_FILES[i][1]; + + if (!load_interface_info(&daemon->interfaces[i], error)) { + return FALSE; + } + + interface_info = daemon->interfaces[i].node_info->interfaces[0]; + daemon->interfaces[i].registration_id = g_dbus_connection_register_object( + daemon->connection, + fildem_object_path(), + interface_info, + &interface_vtable, + daemon, + NULL, + error); + + if (daemon->interfaces[i].registration_id == 0) { + return FALSE; + } + } + + return TRUE; +} + +static void +unregister_interfaces(FildemDaemon *daemon) +{ + guint i; + + for (i = 0; i < G_N_ELEMENTS(daemon->interfaces); i++) { + if (daemon->connection != NULL && daemon->interfaces[i].registration_id != 0) { + g_dbus_connection_unregister_object(daemon->connection, + daemon->interfaces[i].registration_id); + daemon->interfaces[i].registration_id = 0; + } + + if (daemon->interfaces[i].node_info != NULL) { + g_dbus_node_info_unref(daemon->interfaces[i].node_info); + daemon->interfaces[i].node_info = NULL; + } + } +} + +static void +on_bus_acquired(GDBusConnection *connection, + const gchar *name, + gpointer user_data) +{ + FildemDaemon *daemon = user_data; + g_autoptr(GError) error = NULL; + + daemon->connection = g_object_ref(connection); + + if (!register_interfaces(daemon, &error)) { + g_critical("failed to register dbus interfaces: %s", error->message); + g_main_loop_quit(daemon->loop); + return; + } + + g_message("fildemd bus acquired: %s", name); +} + +static void +on_name_acquired(GDBusConnection *connection, + const gchar *name, + gpointer user_data) +{ + g_message("fildemd name acquired: %s", name); + (void)connection; + (void)user_data; +} + +static void +on_name_lost(GDBusConnection *connection, + const gchar *name, + gpointer user_data) +{ + FildemDaemon *daemon = user_data; + g_warning("fildemd lost bus name: %s", name); + g_main_loop_quit(daemon->loop); + (void)connection; +} + +int +main(void) +{ + FildemDaemon daemon = {0}; + + daemon.registry = fildem_menu_registry_new(); + daemon.cache = fildem_menu_cache_new(); + daemon.loop = g_main_loop_new(NULL, FALSE); + + daemon.owner_id = g_bus_own_name(G_BUS_TYPE_SESSION, + fildem_bus_name(), + G_BUS_NAME_OWNER_FLAGS_NONE, + on_bus_acquired, + on_name_acquired, + on_name_lost, + &daemon, + NULL); + + g_main_loop_run(daemon.loop); + + if (daemon.owner_id != 0) { + g_bus_unown_name(daemon.owner_id); + } + + unregister_interfaces(&daemon); + g_clear_object(&daemon.connection); + g_clear_pointer(&daemon.active_window, g_variant_unref); + g_clear_object(&daemon.registry); + g_clear_object(&daemon.cache); + g_clear_pointer(&daemon.loop, g_main_loop_unref); + + return 0; +} diff --git a/src/daemon/meson.build b/src/daemon/meson.build new file mode 100644 index 00000000..7a6f12a1 --- /dev/null +++ b/src/daemon/meson.build @@ -0,0 +1,8 @@ +fildemd = executable( + 'fildemd', + 'fildemd.c', + include_directories: include_directories('../core'), + dependencies: [glib_dep, gio_dep, gobject_dep, fildem_core_dep], + link_with: [fildem_core], + install: true, +) diff --git a/src/extension/fildem-shell@fildem.org/daemon-client.js b/src/extension/fildem-shell@fildem.org/daemon-client.js new file mode 100644 index 00000000..0d868693 --- /dev/null +++ b/src/extension/fildem-shell@fildem.org/daemon-client.js @@ -0,0 +1,245 @@ +import Gio from 'gi://Gio'; +import GLib from 'gi://GLib'; + +const BUS_NAME = 'org.fildem'; +const OBJECT_PATH = '/org/fildem/v1'; + +const IFACE_WINDOW = 'org.fildem.v1.Window'; +const IFACE_TOP_LEVEL = 'org.fildem.v1.TopLevel'; +const IFACE_ACTIVATION = 'org.fildem.v1.Activation'; +const IFACE_WINDOW_ACTIONS = 'org.fildem.v1.WindowActions'; +const IFACE_HUD = 'org.fildem.v1.Hud'; + +function _variantTypeForValue(value) { + if (typeof value === 'string') + return new GLib.Variant('s', value); + if (typeof value === 'boolean') + return new GLib.Variant('b', value); + if (Number.isInteger(value)) + return new GLib.Variant('i', value); + if (typeof value === 'number') + return new GLib.Variant('d', value); + return new GLib.Variant('s', String(value ?? '')); +} + +function _dictToVariant(dict) { + const packed = {}; + for (const [key, value] of Object.entries(dict)) + packed[key] = _variantTypeForValue(value); + + return new GLib.Variant('a{sv}', packed); +} + +function _tuple(...values) { + return GLib.Variant.new_tuple(values); +} + +function _parseTopLevel(rawItems) { + return rawItems.map(item => ({ + id: item.id ?? '', + label: item.label ?? '', + enabled: item.enabled ?? true, + visible: item.visible ?? true, + mnemonic: item.mnemonic ?? '', + })).filter(item => item.id.length > 0 && item.label.length > 0); +} + +export class DaemonClient { + constructor(log) { + this._log = log; + this._topLevelChangedHandlers = []; + this._windowActionsChangedHandlers = []; + this._activationRequestedHandlers = []; + + this._windowProxy = this._newProxy(IFACE_WINDOW); + this._topLevelProxy = this._newProxy(IFACE_TOP_LEVEL); + this._activationProxy = this._newProxy(IFACE_ACTIVATION); + this._windowActionsProxy = this._newProxy(IFACE_WINDOW_ACTIONS); + this._hudProxy = this._newProxy(IFACE_HUD); + + this._topLevelSignalId = this._topLevelProxy.connectSignal( + 'TopLevelChanged', + (_proxy, _sender, [windowUid, items]) => { + const parsed = _parseTopLevel(items); + for (const handler of this._topLevelChangedHandlers) + handler(windowUid, parsed); + } + ); + + this._windowActionsSignalId = this._windowActionsProxy.connectSignal( + 'WindowActionsChanged', + (_proxy, _sender, [windowUid, actions]) => { + for (const handler of this._windowActionsChangedHandlers) + handler(windowUid, actions); + } + ); + + this._activationSignalId = this._activationProxy.connectSignal( + 'ActivationRequested', + (_proxy, _sender, [windowUid, targetId]) => { + for (const handler of this._activationRequestedHandlers) + handler(windowUid, targetId); + } + ); + } + + destroy() { + if (this._topLevelProxy && this._topLevelSignalId) + this._topLevelProxy.disconnectSignal(this._topLevelSignalId); + if (this._windowActionsProxy && this._windowActionsSignalId) + this._windowActionsProxy.disconnectSignal(this._windowActionsSignalId); + if (this._activationProxy && this._activationSignalId) + this._activationProxy.disconnectSignal(this._activationSignalId); + + this._topLevelSignalId = 0; + this._windowActionsSignalId = 0; + this._activationSignalId = 0; + + this._windowProxy = null; + this._topLevelProxy = null; + this._activationProxy = null; + this._windowActionsProxy = null; + this._hudProxy = null; + } + + onTopLevelChanged(handler) { + this._topLevelChangedHandlers.push(handler); + } + + onWindowActionsChanged(handler) { + this._windowActionsChangedHandlers.push(handler); + } + + onActivationRequested(handler) { + this._activationRequestedHandlers.push(handler); + } + + updateActiveWindow(context) { + try { + const contextVariant = _dictToVariant(context); + this._windowProxy.call_sync( + 'UpdateActiveWindow', + _tuple(contextVariant), + Gio.DBusCallFlags.NONE, + -1, + null + ); + } catch (error) { + this._log(`UpdateActiveWindow failed: ${error}`); + } + } + + getTopLevel(windowUid) { + try { + const reply = this._topLevelProxy.call_sync( + 'GetTopLevel', + _tuple(new GLib.Variant('s', windowUid)), + Gio.DBusCallFlags.NONE, + -1, + null + ); + const [items] = reply.deep_unpack(); + return _parseTopLevel(items); + } catch (error) { + this._log(`GetTopLevel failed: ${error}`); + return []; + } + } + + activateTopLevel(windowUid, topLevelId) { + try { + this._activationProxy.call_sync( + 'ActivateTopLevel', + _tuple( + new GLib.Variant('s', windowUid), + new GLib.Variant('s', topLevelId) + ), + Gio.DBusCallFlags.NONE, + -1, + null + ); + } catch (error) { + this._log(`ActivateTopLevel failed: ${error}`); + } + } + + setWindowActions(windowUid, actions) { + const normalized = Array.isArray(actions) ? actions.map(a => String(a)) : []; + try { + this._windowActionsProxy.call_sync( + 'SetWindowActions', + _tuple( + new GLib.Variant('s', windowUid), + new GLib.Variant('as', normalized) + ), + Gio.DBusCallFlags.NONE, + -1, + null + ); + } catch (error) { + this._log(`SetWindowActions failed: ${error}`); + } + } + + requestHud(windowUid) { + try { + this._hudProxy.call_sync( + 'RequestHud', + _tuple(new GLib.Variant('s', windowUid ?? '')), + Gio.DBusCallFlags.NONE, + -1, + null + ); + return true; + } catch (error) { + this._log(`RequestHud failed: ${error}`); + return false; + } + } + + listWindowActions(windowUid) { + try { + const reply = this._windowActionsProxy.call_sync( + 'ListWindowActions', + _tuple(new GLib.Variant('s', windowUid)), + Gio.DBusCallFlags.NONE, + -1, + null + ); + const [actions] = reply.deep_unpack(); + return actions; + } catch (error) { + this._log(`ListWindowActions failed: ${error}`); + return []; + } + } + + activateWindowAction(windowUid, actionId) { + try { + this._windowActionsProxy.call_sync( + 'ActivateWindowAction', + _tuple( + new GLib.Variant('s', windowUid), + new GLib.Variant('s', actionId) + ), + Gio.DBusCallFlags.NONE, + -1, + null + ); + } catch (error) { + this._log(`ActivateWindowAction failed: ${error}`); + } + } + + _newProxy(interfaceName) { + return Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, + Gio.DBusProxyFlags.NONE, + null, + BUS_NAME, + OBJECT_PATH, + interfaceName, + null + ); + } +} diff --git a/src/extension/fildem-shell@fildem.org/extension.js b/src/extension/fildem-shell@fildem.org/extension.js new file mode 100644 index 00000000..82895558 --- /dev/null +++ b/src/extension/fildem-shell@fildem.org/extension.js @@ -0,0 +1,128 @@ +import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js'; + +import {DaemonClient} from './daemon-client.js'; +import {PanelController} from './panel-controller.js'; +import {WindowTrackerController} from './window-tracker.js'; +import {WindowActionsController} from './window-actions.js'; +import {ShortcutManager} from './shortcut-manager.js'; + +import GLib from 'gi://GLib'; + +function _log(message) { + global.log(`[fildem-shell] ${message}`); +} + +export default class FildemShellExtension extends Extension { + enable() { + this._settings = this.getSettings('org.fildem.shell'); + this._daemonClient = new DaemonClient(_log); + this._windowTracker = new WindowTrackerController(); + this._windowActions = new WindowActionsController(this._daemonClient, _log); + this._shortcutManager = new ShortcutManager(this._settings, () => this._launchHud(), _log); + this._currentContext = null; + this._currentWindow = null; + + this._panel = new PanelController(this._settings, { + onTopLevelActivate: id => this._activateTopLevel(id), + onWindowActionActivate: actionId => this._activateWindowAction(actionId), + }); + + this._daemonClient.onTopLevelChanged((windowUid, items) => { + if (this._currentContext?.window_uid === windowUid) + this._panel.setTopLevelItems(items); + }); + + this._daemonClient.onWindowActionsChanged((windowUid, actions) => { + if (this._currentContext?.window_uid === windowUid) + this._panel.setWindowActions(actions); + }); + + this._daemonClient.onActivationRequested((windowUid, targetId) => { + this._onActivationRequested(windowUid, targetId); + }); + + this._windowTracker.start((context, window) => this._onWindowContextChanged(context, window)); + this._shortcutManager.enable(); + } + + disable() { + this._currentContext = null; + + if (this._windowTracker) { + this._windowTracker.destroy(); + this._windowTracker = null; + } + + if (this._panel) { + this._panel.destroy(); + this._panel = null; + } + + if (this._daemonClient) { + this._daemonClient.destroy(); + this._daemonClient = null; + } + + if (this._shortcutManager) { + this._shortcutManager.destroy(); + this._shortcutManager = null; + } + + this._currentWindow = null; + this._windowActions = null; + this._settings = null; + } + + _onWindowContextChanged(context, window) { + this._currentContext = context; + this._currentWindow = window; + this._windowActions.setWindow(window); + this._panel.setContext(context); + + if (!context.has_window || !context.window_uid) { + this._panel.setTopLevelItems([]); + this._panel.setWindowActions([]); + return; + } + + this._daemonClient.updateActiveWindow(context); + const actions = this._windowActions.sync(context.window_uid); + this._panel.setTopLevelItems(this._daemonClient.getTopLevel(context.window_uid)); + this._panel.setWindowActions(actions); + } + + _activateTopLevel(topLevelId) { + const windowUid = this._currentContext?.window_uid; + if (!windowUid || !topLevelId) + return; + this._daemonClient.activateTopLevel(windowUid, topLevelId); + } + + _activateWindowAction(actionId) { + const windowUid = this._currentContext?.window_uid; + if (!windowUid || !actionId) + return; + this._windowActions.requestActivation(windowUid, actionId); + } + + _onActivationRequested(windowUid, targetId) { + if (windowUid !== this._currentContext?.window_uid || !targetId) + return; + + if (targetId.startsWith('action:')) { + this._windowActions.execute(targetId.slice('action:'.length)); + const actions = this._windowActions.sync(windowUid); + this._panel.setWindowActions(actions); + } + } + + _launchHud() { + const windowUid = this._currentContext?.window_uid ?? ''; + this._daemonClient.requestHud(windowUid); + try { + GLib.spawn_command_line_async('fildemhud'); + } catch (error) { + _log(`failed to launch HUD: ${error}`); + } + } +} diff --git a/src/extension/fildem-shell@fildem.org/metadata.json b/src/extension/fildem-shell@fildem.org/metadata.json new file mode 100644 index 00000000..d434db26 --- /dev/null +++ b/src/extension/fildem-shell@fildem.org/metadata.json @@ -0,0 +1,12 @@ +{ + "name": "Fildem Shell (Rewrite)", + "description": "Global menu and HUD bridge for GNOME 48+", + "uuid": "fildem-shell@fildem.org", + "url": "https://github.com/gonzaarcr/Fildem", + "shell-version": [ + "48", + "49" + ], + "settings-schema": "org.fildem.shell", + "gettext-domain": "fildem" +} diff --git a/src/extension/fildem-shell@fildem.org/panel-controller.js b/src/extension/fildem-shell@fildem.org/panel-controller.js new file mode 100644 index 00000000..b58e5d12 --- /dev/null +++ b/src/extension/fildem-shell@fildem.org/panel-controller.js @@ -0,0 +1,190 @@ +import GObject from 'gi://GObject'; +import Clutter from 'gi://Clutter'; +import St from 'gi://St'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; +import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; + +const VISIBILITY_AUTO = 0; +const VISIBILITY_ALWAYS = 1; +const VISIBILITY_HOVER = 2; + +const FildemIndicator = GObject.registerClass( +class FildemIndicator extends PanelMenu.Button { + _init() { + super._init(0.0, 'FildemShellIndicator'); + + this._menusVisible = false; + this._menuButtons = []; + + this._rootBox = new St.BoxLayout({ + style_class: 'panel-status-menu-box', + reactive: true, + }); + this._appLabel = new St.Label({ + text: '', + y_align: Clutter.ActorAlign.CENTER, + style_class: 'fildem-app-label', + }); + this._menusBox = new St.BoxLayout({ + style_class: 'fildem-top-level-box', + reactive: true, + }); + + this._rootBox.add_child(this._appLabel); + this._rootBox.add_child(this._menusBox); + this.add_child(this._rootBox); + } + + setButtonPadding(padding) { + for (const button of this._menuButtons) + button.set_style(`padding-left: ${padding}px; padding-right: ${padding}px;`); + } + + setAppLabel(label) { + this._appLabel.set_text(label ?? ''); + } + + setAppLabelVisible(visible) { + this._appLabel.visible = visible; + } + + setTopLevelItems(items, onActivate) { + this._clearTopLevelItems(); + + for (const item of items) { + const button = new St.Button({ + can_focus: true, + reactive: true, + track_hover: true, + style_class: 'panel-button fildem-top-level-item', + label: item.label, + }); + + button.connect('clicked', () => { + onActivate(item.id, item.label); + }); + this._menusBox.add_child(button); + this._menuButtons.push(button); + } + + this.setMenusVisible(this._menusVisible); + } + + setWindowActions(actions, onActivate) { + this.menu.removeAll(); + + if (actions.length === 0) + return; + + const section = new PopupMenu.PopupMenuSection(); + for (const actionId of actions) { + const item = new PopupMenu.PopupMenuItem(actionId); + item.connect('activate', () => onActivate(actionId)); + section.addMenuItem(item); + } + this.menu.addMenuItem(section); + } + + setMenusVisible(visible) { + this._menusVisible = visible; + this._menusBox.visible = visible; + } + + _clearTopLevelItems() { + for (const button of this._menuButtons) + button.destroy(); + this._menuButtons = []; + } +}); + +export class PanelController { + constructor(settings, callbacks) { + this._settings = settings; + this._callbacks = callbacks; + + this._context = null; + this._isHover = false; + this._panelSignalIds = []; + this._settingsSignalIds = []; + + this._indicator = new FildemIndicator(); + Main.panel.addToStatusArea('fildem-shell-indicator', this._indicator, 0, 'left'); + + this._panelSignalIds.push(Main.panel.connect('enter-event', () => { + this._isHover = true; + this._refreshVisibility(); + })); + this._panelSignalIds.push(Main.panel.connect('leave-event', () => { + this._isHover = false; + this._refreshVisibility(); + })); + + this._settingsSignalIds.push(this._settings.connect('changed::visibility-mode', () => this._refreshVisibility())); + this._settingsSignalIds.push(this._settings.connect('changed::show-app-label-when-menu-visible', () => this._refreshVisibility())); + this._settingsSignalIds.push(this._settings.connect('changed::button-padding', () => this._refreshPadding())); + + this._refreshPadding(); + this._refreshVisibility(); + } + + destroy() { + for (const id of this._panelSignalIds) + Main.panel.disconnect(id); + this._panelSignalIds = []; + + for (const id of this._settingsSignalIds) + this._settings.disconnect(id); + this._settingsSignalIds = []; + + this._indicator.destroy(); + } + + setContext(context) { + this._context = context; + this._indicator.setAppLabel(context.app_id || context.title || ''); + this._refreshVisibility(); + } + + setTopLevelItems(items) { + this._indicator.setTopLevelItems(items, (id, label) => { + this._callbacks.onTopLevelActivate(id, label); + }); + this._refreshPadding(); + this._refreshVisibility(); + } + + setWindowActions(actions) { + this._indicator.setWindowActions(actions, actionId => { + this._callbacks.onWindowActionActivate(actionId); + }); + } + + _refreshPadding() { + this._indicator.setButtonPadding(this._settings.get_int('button-padding')); + } + + _refreshVisibility() { + const mode = this._settings.get_int('visibility-mode'); + const context = this._context ?? {}; + const hasWindow = Boolean(context.has_window); + const isMaximized = Boolean(context.is_maximized); + + let showMenus = false; + if (!hasWindow) { + showMenus = false; + } else if (mode === VISIBILITY_ALWAYS) { + showMenus = true; + } else if (mode === VISIBILITY_HOVER) { + showMenus = this._isHover; + } else if (mode === VISIBILITY_AUTO) { + showMenus = isMaximized || this._isHover; + } + + const showAppLabelWhenMenuVisible = this._settings.get_boolean('show-app-label-when-menu-visible'); + + this._indicator.setMenusVisible(showMenus); + this._indicator.setAppLabelVisible(!showMenus || showAppLabelWhenMenuVisible); + } +} diff --git a/src/extension/fildem-shell@fildem.org/prefs.js b/src/extension/fildem-shell@fildem.org/prefs.js new file mode 100644 index 00000000..ef10df02 --- /dev/null +++ b/src/extension/fildem-shell@fildem.org/prefs.js @@ -0,0 +1,70 @@ +import Adw from 'gi://Adw'; +import Gio from 'gi://Gio'; +import Gtk from 'gi://Gtk'; + +import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; + +const VISIBILITY_LABELS = [ + 'Automatic', + 'Always visible', + 'Only on hover', +]; + +export default class FildemShellPreferences extends ExtensionPreferences { + fillPreferencesWindow(window) { + const settings = this.getSettings('org.fildem.shell'); + + const page = new Adw.PreferencesPage(); + const group = new Adw.PreferencesGroup({ + title: 'Panel', + description: 'Top-level menu behavior in the GNOME panel.', + }); + + const visibilityModel = Gtk.StringList.new(VISIBILITY_LABELS); + const visibilityRow = new Adw.ComboRow({ + title: 'Visibility mode', + model: visibilityModel, + selected: settings.get_int('visibility-mode'), + }); + visibilityRow.connect('notify::selected', row => { + settings.set_int('visibility-mode', row.selected); + }); + + const showLabelRow = new Adw.SwitchRow({ + title: 'Show app label when menu is visible', + active: settings.get_boolean('show-app-label-when-menu-visible'), + }); + settings.bind('show-app-label-when-menu-visible', + showLabelRow, 'active', Gio.SettingsBindFlags.DEFAULT); + + const paddingAdjustment = new Gtk.Adjustment({ + lower: 0, + upper: 24, + step_increment: 1, + page_increment: 2, + value: settings.get_int('button-padding'), + }); + const paddingRow = new Adw.SpinRow({ + title: 'Button padding', + adjustment: paddingAdjustment, + }); + settings.bind('button-padding', paddingRow, 'value', Gio.SettingsBindFlags.DEFAULT); + + const initialShortcut = settings.get_strv('hud-shortcut')[0] ?? ''; + const shortcutRow = new Adw.EntryRow({ + title: 'HUD shortcut', + text: initialShortcut, + }); + shortcutRow.connect('notify::text', row => { + const text = (row.text ?? '').trim(); + settings.set_strv('hud-shortcut', text.length > 0 ? [text] : []); + }); + + group.add(visibilityRow); + group.add(showLabelRow); + group.add(paddingRow); + group.add(shortcutRow); + page.add(group); + window.add(page); + } +} diff --git a/src/extension/fildem-shell@fildem.org/schemas/org.fildem.shell.gschema.xml b/src/extension/fildem-shell@fildem.org/schemas/org.fildem.shell.gschema.xml new file mode 100644 index 00000000..143e91b2 --- /dev/null +++ b/src/extension/fildem-shell@fildem.org/schemas/org.fildem.shell.gschema.xml @@ -0,0 +1,25 @@ + + + + + + 0 + Panel menu visibility mode + 0 auto, 1 always visible, 2 only on hover. + + + false + Show app label while top-level menus are visible + + + + 6 + Horizontal padding for top-level menu buttons + + + ["<Alt>space"] + Shortcut used to open the Fildem HUD + Single accelerator in GNOME format, for example <Alt>space. + + + diff --git a/src/extension/fildem-shell@fildem.org/shortcut-manager.js b/src/extension/fildem-shell@fildem.org/shortcut-manager.js new file mode 100644 index 00000000..5e846312 --- /dev/null +++ b/src/extension/fildem-shell@fildem.org/shortcut-manager.js @@ -0,0 +1,54 @@ +import Meta from 'gi://Meta'; +import Shell from 'gi://Shell'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; + +const KEY_HUD_SHORTCUT = 'hud-shortcut'; + +export class ShortcutManager { + constructor(settings, onHudRequested, log) { + this._settings = settings; + this._onHudRequested = onHudRequested; + this._log = log; + this._settingsSignalId = 0; + } + + enable() { + this._bindKey(); + this._settingsSignalId = this._settings.connect( + `changed::${KEY_HUD_SHORTCUT}`, + () => this._bindKey() + ); + } + + destroy() { + if (this._settingsSignalId) { + this._settings.disconnect(this._settingsSignalId); + this._settingsSignalId = 0; + } + this._unbindKey(); + } + + _bindKey() { + this._unbindKey(); + try { + Main.wm.addKeybinding( + KEY_HUD_SHORTCUT, + this._settings, + Meta.KeyBindingFlags.NONE, + Shell.ActionMode.ALL, + () => this._onHudRequested() + ); + } catch (error) { + this._log(`cannot bind HUD shortcut: ${error}`); + } + } + + _unbindKey() { + try { + Main.wm.removeKeybinding(KEY_HUD_SHORTCUT); + } catch (_error) { + /* no-op if not yet bound */ + } + } +} diff --git a/src/extension/fildem-shell@fildem.org/window-actions.js b/src/extension/fildem-shell@fildem.org/window-actions.js new file mode 100644 index 00000000..69923ef0 --- /dev/null +++ b/src/extension/fildem-shell@fildem.org/window-actions.js @@ -0,0 +1,215 @@ +import GLib from 'gi://GLib'; +import Meta from 'gi://Meta'; + +import * as Main from 'resource:///org/gnome/shell/ui/main.js'; +import * as WindowMenu from 'resource:///org/gnome/shell/ui/windowMenu.js'; + +const TOGGLED_SUFFIX = ' (On)'; + +function _stripToggleSuffix(actionId) { + if (actionId.endsWith(TOGGLED_SUFFIX)) + return actionId.slice(0, -TOGGLED_SUFFIX.length); + return actionId; +} + +function _withToggleSuffixIfEnabled(label, enabled) { + return enabled ? `${label}${TOGGLED_SUFFIX}` : label; +} + +export class WindowActionsController { + constructor(daemonClient, log) { + this._daemonClient = daemonClient; + this._log = log; + this._window = null; + } + + setWindow(window) { + this._window = window ?? null; + } + + list() { + const win = this._window; + if (!win) + return []; + + const actions = []; + const type = win.get_window_type(); + + if (win.can_minimize()) + actions.push('Minimize'); + + if (win.can_maximize()) + actions.push(win.get_maximized() ? 'Unmaximize' : 'Maximize'); + + if (win.allows_move()) + actions.push('Move'); + + if (win.allows_resize()) + actions.push('Resize'); + + if (win.titlebar_is_onscreen() && + type !== Meta.WindowType.DOCK && + type !== Meta.WindowType.DESKTOP) { + actions.push('Move Titlebar Onscreen'); + } + + if (win.get_maximized() === Meta.MaximizeFlags.BOTH || + type === Meta.WindowType.DOCK || + type === Meta.WindowType.DESKTOP || + type === Meta.WindowType.SPLASHSCREEN) { + actions.push(_withToggleSuffixIfEnabled('Always on Top', win.is_above())); + } + + if (Main.sessionMode.hasWorkspaces && + (!Meta.prefs_get_workspaces_only_on_primary() || win.is_on_primary_monitor())) { + const isSticky = win.is_on_all_workspaces(); + if (win.is_always_on_all_workspaces()) { + actions.push(_withToggleSuffixIfEnabled('Always on Visible Workspace', isSticky)); + } + + if (!isSticky) { + const workspace = win.get_workspace(); + if (workspace && workspace !== workspace.get_neighbor(Meta.MotionDirection.LEFT)) + actions.push('Move to Workspace Left'); + if (workspace && workspace !== workspace.get_neighbor(Meta.MotionDirection.RIGHT)) + actions.push('Move to Workspace Right'); + if (workspace && workspace !== workspace.get_neighbor(Meta.MotionDirection.UP)) + actions.push('Move to Workspace Up'); + if (workspace && workspace !== workspace.get_neighbor(Meta.MotionDirection.DOWN)) + actions.push('Move to Workspace Down'); + } + } + + const display = global.display; + const monitorIndex = win.get_monitor(); + if (display.get_n_monitors() > 1 && monitorIndex >= 0) { + if (display.get_monitor_neighbor_index(monitorIndex, Meta.DisplayDirection.UP) !== -1) + actions.push('Move to Monitor Up'); + if (display.get_monitor_neighbor_index(monitorIndex, Meta.DisplayDirection.DOWN) !== -1) + actions.push('Move to Monitor Down'); + if (display.get_monitor_neighbor_index(monitorIndex, Meta.DisplayDirection.LEFT) !== -1) + actions.push('Move to Monitor Left'); + if (display.get_monitor_neighbor_index(monitorIndex, Meta.DisplayDirection.RIGHT) !== -1) + actions.push('Move to Monitor Right'); + } + + if (win.can_close()) + actions.push('Close'); + + return actions; + } + + sync(windowUid) { + if (!windowUid) + return []; + + const actions = this.list(); + this._daemonClient.setWindowActions(windowUid, actions); + return actions; + } + + requestActivation(windowUid, actionId) { + if (!windowUid || !actionId) + return; + this._daemonClient.activateWindowAction(windowUid, actionId); + } + + execute(actionId) { + const win = this._window; + const action = _stripToggleSuffix(actionId ?? ''); + if (!win || !action) + return; + + switch (action) { + case 'Minimize': + win.minimize(); + break; + case 'Unmaximize': + win.unmaximize(Meta.MaximizeFlags.BOTH); + break; + case 'Maximize': + win.maximize(Meta.MaximizeFlags.BOTH); + break; + case 'Move': + this._grabOperation(win, Meta.GrabOp.KEYBOARD_MOVING); + break; + case 'Resize': + this._grabOperation(win, Meta.GrabOp.KEYBOARD_RESIZING_UNKNOWN); + break; + case 'Move Titlebar Onscreen': + win.shove_titlebar_onscreen(); + break; + case 'Always on Top': + if (win.is_above()) + win.unmake_above(); + else + win.make_above(); + break; + case 'Always on Visible Workspace': + if (win.is_on_all_workspaces()) + win.unstick(); + else + win.stick(); + break; + case 'Move to Workspace Left': + this._moveToWorkspace(win, Meta.MotionDirection.LEFT); + break; + case 'Move to Workspace Right': + this._moveToWorkspace(win, Meta.MotionDirection.RIGHT); + break; + case 'Move to Workspace Up': + this._moveToWorkspace(win, Meta.MotionDirection.UP); + break; + case 'Move to Workspace Down': + this._moveToWorkspace(win, Meta.MotionDirection.DOWN); + break; + case 'Move to Monitor Up': + this._moveToMonitor(win, Meta.DisplayDirection.UP); + break; + case 'Move to Monitor Down': + this._moveToMonitor(win, Meta.DisplayDirection.DOWN); + break; + case 'Move to Monitor Left': + this._moveToMonitor(win, Meta.DisplayDirection.LEFT); + break; + case 'Move to Monitor Right': + this._moveToMonitor(win, Meta.DisplayDirection.RIGHT); + break; + case 'Close': + win.delete(global.get_current_time()); + break; + default: + this._log(`Unsupported window action: ${action}`); + break; + } + } + + _grabOperation(win, grabOp) { + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => { + try { + WindowMenu.WindowMenu.prototype._grabAction( + win, + grabOp, + global.display.get_current_time_roundtrip() + ); + } catch (error) { + this._log(`window grab action failed: ${error}`); + } + return GLib.SOURCE_REMOVE; + }); + } + + _moveToWorkspace(win, direction) { + const workspace = win.get_workspace(); + if (!workspace) + return; + win.change_workspace(workspace.get_neighbor(direction)); + } + + _moveToMonitor(win, direction) { + const monitorIndex = win.get_monitor(); + const newIndex = global.display.get_monitor_neighbor_index(monitorIndex, direction); + if (newIndex !== -1) + win.move_to_monitor(newIndex); + } +} diff --git a/src/extension/fildem-shell@fildem.org/window-tracker.js b/src/extension/fildem-shell@fildem.org/window-tracker.js new file mode 100644 index 00000000..2184f7f4 --- /dev/null +++ b/src/extension/fildem-shell@fildem.org/window-tracker.js @@ -0,0 +1,102 @@ +import Meta from 'gi://Meta'; +import Shell from 'gi://Shell'; + +export class WindowTrackerController { + constructor() { + this._shellTracker = Shell.WindowTracker.get_default(); + this._signals = []; + this._callback = null; + } + + start(callback) { + this._callback = callback; + this._signals.push(global.display.connect('notify::focus-window', () => this._emit())); + this._emit(); + } + + destroy() { + for (const signalId of this._signals) + global.display.disconnect(signalId); + this._signals = []; + this._callback = null; + } + + _emit() { + if (!this._callback) + return; + const win = global.display.get_focus_window(); + this._callback(this._buildContext(win), win); + } + + _buildContext(win) { + if (!win) { + return { + has_window: false, + window_uid: '', + app_id: '', + title: '', + workspace: -1, + monitor: -1, + is_maximized: false, + is_focused: false, + backend: Meta.is_wayland_compositor() ? 'wayland' : 'x11', + xid: '', + gtk_unique_bus_name: '', + gtk_application_object_path: '', + gtk_window_object_path: '', + gtk_menubar_object_path: '', + gtk_app_menu_object_path: '', + }; + } + + const app = this._shellTracker.get_window_app(win); + const workspace = win.get_workspace(); + const fallbackAppId = win.get_wm_class_instance?.() ?? + win.get_wm_class?.() ?? + `window-${win.get_id()}`; + + const context = { + has_window: true, + window_uid: `${win.get_id()}`, + app_id: app?.get_id() ?? fallbackAppId, + title: win.get_title() ?? '', + workspace: workspace ? workspace.index() : -1, + monitor: win.get_monitor(), + is_maximized: win.get_maximized() === Meta.MaximizeFlags.BOTH, + is_focused: true, + backend: Meta.is_wayland_compositor() ? 'wayland' : 'x11', + xid: this._extractXid(win), + gtk_unique_bus_name: '', + gtk_application_object_path: '', + gtk_window_object_path: '', + gtk_menubar_object_path: '', + gtk_app_menu_object_path: '', + }; + + for (const key of [ + 'gtk_unique_bus_name', + 'gtk_application_object_path', + 'gtk_window_object_path', + 'gtk_menubar_object_path', + 'gtk_app_menu_object_path', + ]) { + const value = win[key]; + if (value !== null && value !== undefined) + context[key] = String(value); + } + + return context; + } + + _extractXid(win) { + try { + const description = win.get_description?.() ?? ''; + const match = description.match(/0x[0-9a-fA-F]+/); + if (match) + return `${Number.parseInt(match[0], 16)}`; + } catch (_error) { + /* keep empty xid */ + } + return ''; + } +} diff --git a/src/hud/fildemhud.c b/src/hud/fildemhud.c new file mode 100644 index 00000000..b7e7af74 --- /dev/null +++ b/src/hud/fildemhud.c @@ -0,0 +1,329 @@ +#include +#include + +#include "fildem-core.h" + +typedef struct { + GtkApplication *app; + GtkWidget *window; + GtkSearchEntry *search_entry; + GtkStringList *string_list; + GPtrArray *entry_ids; + GDBusProxy *window_proxy; + GDBusProxy *hud_proxy; + gchar *window_uid; +} HudState; + +static void +refresh_results(HudState *state); + +static void +clear_results(HudState *state) +{ + guint n_items = g_list_model_get_n_items(G_LIST_MODEL(state->string_list)); + + while (n_items > 0) { + gtk_string_list_remove(state->string_list, n_items - 1); + n_items--; + } + g_ptr_array_set_size(state->entry_ids, 0); +} + +static void +append_result(HudState *state, const gchar *id, const gchar *label) +{ + gtk_string_list_append(state->string_list, label); + g_ptr_array_add(state->entry_ids, g_strdup(id)); +} + +static const gchar * +resolve_window_uid(HudState *state) +{ + g_autoptr(GError) error = NULL; + g_autoptr(GVariant) reply = NULL; + GVariant *context = NULL; + const gchar *window_uid = NULL; + + reply = g_dbus_proxy_call_sync(state->window_proxy, + "GetActiveWindow", + NULL, + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + &error); + if (reply == NULL) { + g_warning("fildemhud GetActiveWindow failed: %s", error->message); + return ""; + } + + g_variant_get(reply, "(@a{sv})", &context); + g_variant_lookup(context, "window_uid", "&s", &window_uid); + + g_free(state->window_uid); + state->window_uid = g_strdup(window_uid == NULL ? "" : window_uid); + g_variant_unref(context); + + return state->window_uid; +} + +static void +refresh_results(HudState *state) +{ + g_autoptr(GError) error = NULL; + g_autoptr(GVariant) reply = NULL; + g_autoptr(GVariant) results = NULL; + GVariantIter iter; + GVariant *item = NULL; + const gchar *term; + const gchar *window_uid; + + term = gtk_editable_get_text(GTK_EDITABLE(state->search_entry)); + window_uid = resolve_window_uid(state); + + reply = g_dbus_proxy_call_sync(state->hud_proxy, + "Query", + g_variant_new("(ss)", window_uid, term), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + &error); + if (reply == NULL) { + g_warning("fildemhud Query failed: %s", error->message); + return; + } + + g_variant_get(reply, "(@aa{sv})", &results); + + clear_results(state); + + g_variant_iter_init(&iter, results); + while ((item = g_variant_iter_next_value(&iter)) != NULL) { + const gchar *id = NULL; + const gchar *label = NULL; + + g_variant_lookup(item, "id", "&s", &id); + g_variant_lookup(item, "label", "&s", &label); + + if (id != NULL && label != NULL) { + append_result(state, id, label); + } + + g_variant_unref(item); + } +} + +static void +on_window_destroy(GtkWidget *widget, gpointer user_data) +{ + HudState *state = user_data; + state->window = NULL; + state->search_entry = NULL; + (void)widget; +} + +static void +on_search_changed(GtkEditable *editable, gpointer user_data) +{ + HudState *state = user_data; + refresh_results(state); + (void)editable; +} + +static void +on_list_activated(GtkListView *list_view, guint position, gpointer user_data) +{ + HudState *state = user_data; + const gchar *entry_id; + const gchar *window_uid; + g_autoptr(GError) error = NULL; + g_autoptr(GVariant) ignored = NULL; + + if (position >= state->entry_ids->len) { + return; + } + + entry_id = g_ptr_array_index(state->entry_ids, position); + window_uid = resolve_window_uid(state); + + ignored = g_dbus_proxy_call_sync(state->hud_proxy, + "Execute", + g_variant_new("(ss)", window_uid, entry_id), + G_DBUS_CALL_FLAGS_NONE, + -1, + NULL, + &error); + if (ignored == NULL) { + g_warning("fildemhud Execute failed: %s", error->message); + return; + } + + gtk_window_close(GTK_WINDOW(state->window)); + (void)list_view; +} + +static void +on_list_item_setup(GtkSignalListItemFactory *factory, + GtkListItem *item, + gpointer user_data) +{ + GtkWidget *label = gtk_label_new(NULL); + gtk_label_set_xalign(GTK_LABEL(label), 0.0f); + gtk_list_item_set_child(item, label); + + (void)factory; + (void)user_data; +} + +static void +on_list_item_bind(GtkSignalListItemFactory *factory, + GtkListItem *item, + gpointer user_data) +{ + GtkWidget *label = gtk_list_item_get_child(item); + GObject *obj = gtk_list_item_get_item(item); + + gtk_label_set_label(GTK_LABEL(label), gtk_string_object_get_string(GTK_STRING_OBJECT(obj))); + + (void)factory; + (void)user_data; +} + +static GtkWidget * +create_list_view(HudState *state) +{ + GtkSelectionModel *selection; + GtkListItemFactory *factory; + + selection = GTK_SELECTION_MODEL(gtk_single_selection_new(G_LIST_MODEL(state->string_list))); + factory = gtk_signal_list_item_factory_new(); + + g_signal_connect(factory, + "setup", + G_CALLBACK(on_list_item_setup), + NULL); + + g_signal_connect(factory, + "bind", + G_CALLBACK(on_list_item_bind), + NULL); + + return gtk_list_view_new(selection, factory); +} + +static void +on_activate(GtkApplication *app, gpointer user_data) +{ + HudState *state = user_data; + GtkWidget *box; + GtkWidget *list_view; + + if (state->window != NULL) { + refresh_results(state); + gtk_window_present(GTK_WINDOW(state->window)); + return; + } + + state->window = gtk_application_window_new(app); + gtk_window_set_title(GTK_WINDOW(state->window), "Fildem HUD"); + gtk_window_set_default_size(GTK_WINDOW(state->window), 640, 420); + + box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); + gtk_widget_set_margin_top(box, 12); + gtk_widget_set_margin_bottom(box, 12); + gtk_widget_set_margin_start(box, 12); + gtk_widget_set_margin_end(box, 12); + + state->search_entry = GTK_SEARCH_ENTRY(gtk_search_entry_new()); + gtk_editable_set_text(GTK_EDITABLE(state->search_entry), ""); + g_signal_connect(state->search_entry, "changed", G_CALLBACK(on_search_changed), state); + + list_view = create_list_view(state); + g_signal_connect(list_view, "activate", G_CALLBACK(on_list_activated), state); + + gtk_box_append(GTK_BOX(box), GTK_WIDGET(state->search_entry)); + gtk_box_append(GTK_BOX(box), list_view); + + gtk_window_set_child(GTK_WINDOW(state->window), box); + g_signal_connect(state->window, "destroy", G_CALLBACK(on_window_destroy), state); + gtk_window_present(GTK_WINDOW(state->window)); + + refresh_results(state); +} + +static void +on_hud_proxy_signal(GDBusProxy *proxy, + const gchar *sender_name, + const gchar *signal_name, + GVariant *parameters, + gpointer user_data) +{ + HudState *state = user_data; + const gchar *window_uid = NULL; + + if (g_strcmp0(signal_name, "HudRequested") != 0) { + return; + } + + g_variant_get(parameters, "(&s)", &window_uid); + (void)window_uid; + + if (state->window != NULL) { + refresh_results(state); + gtk_window_present(GTK_WINDOW(state->window)); + return; + } + + g_application_activate(G_APPLICATION(state->app)); + + (void)proxy; + (void)sender_name; +} + +static GDBusProxy * +new_proxy(const gchar *interface_name) +{ + g_autoptr(GError) error = NULL; + GDBusProxy *proxy; + + proxy = g_dbus_proxy_new_for_bus_sync(G_BUS_TYPE_SESSION, + G_DBUS_PROXY_FLAGS_NONE, + NULL, + fildem_bus_name(), + fildem_object_path(), + interface_name, + NULL, + &error); + if (proxy == NULL) { + g_error("failed to create proxy for %s: %s", interface_name, error->message); + } + + return proxy; +} + +int +main(int argc, char **argv) +{ + HudState state = {0}; + int status; + + state.app = gtk_application_new("org.fildem.hud", G_APPLICATION_DEFAULT_FLAGS); + state.string_list = gtk_string_list_new(NULL); + state.entry_ids = g_ptr_array_new_with_free_func(g_free); + + state.window_proxy = new_proxy("org.fildem.v1.Window"); + state.hud_proxy = new_proxy("org.fildem.v1.Hud"); + g_signal_connect(state.hud_proxy, "g-signal", G_CALLBACK(on_hud_proxy_signal), &state); + + g_signal_connect(state.app, "activate", G_CALLBACK(on_activate), &state); + + status = g_application_run(G_APPLICATION(state.app), argc, argv); + + g_clear_object(&state.window_proxy); + g_clear_object(&state.hud_proxy); + g_clear_object(&state.string_list); + g_clear_object(&state.app); + g_free(state.window_uid); + g_ptr_array_free(state.entry_ids, TRUE); + + return status; +} diff --git a/src/hud/meson.build b/src/hud/meson.build new file mode 100644 index 00000000..83c89545 --- /dev/null +++ b/src/hud/meson.build @@ -0,0 +1,8 @@ +fildemhud = executable( + 'fildemhud', + 'fildemhud.c', + include_directories: include_directories('../core'), + dependencies: [glib_dep, gio_dep, gobject_dep, gtk4_dep, fildem_core_dep], + link_with: [fildem_core], + install: true, +) diff --git a/src/meson.build b/src/meson.build new file mode 100644 index 00000000..98a5b4de --- /dev/null +++ b/src/meson.build @@ -0,0 +1,4 @@ +subdir('core') +subdir('daemon') +subdir('hud') +subdir('ctl') diff --git a/tests/meson.build b/tests/meson.build new file mode 100644 index 00000000..75ff61a8 --- /dev/null +++ b/tests/meson.build @@ -0,0 +1,17 @@ +test_core = executable( + 'test-core', + 'test-core.c', + include_directories: include_directories('../src/core'), + dependencies: [glib_dep, gio_dep, gobject_dep, fildem_core_dep], + link_with: [fildem_core], +) + +test('core', test_core) + +test_contract = executable( + 'test-contract', + 'test-contract.c', + dependencies: [glib_dep, gio_dep, gobject_dep], +) + +test('dbus-contract', test_contract) diff --git a/tests/test-contract.c b/tests/test-contract.c new file mode 100644 index 00000000..016b799f --- /dev/null +++ b/tests/test-contract.c @@ -0,0 +1,78 @@ +#include +#include + +typedef struct { + const gchar *file_name; + const gchar *interface_name; +} InterfaceExpectation; + +static const InterfaceExpectation EXPECTED_INTERFACES[] = { + {"org.fildem.v1.Window.xml", "org.fildem.v1.Window"}, + {"org.fildem.v1.TopLevel.xml", "org.fildem.v1.TopLevel"}, + {"org.fildem.v1.MenuTree.xml", "org.fildem.v1.MenuTree"}, + {"org.fildem.v1.Activation.xml", "org.fildem.v1.Activation"}, + {"org.fildem.v1.WindowActions.xml", "org.fildem.v1.WindowActions"}, + {"org.fildem.v1.Hud.xml", "org.fildem.v1.Hud"}, +}; + +static gboolean +interface_has_method(GDBusInterfaceInfo *interface_info, const gchar *method_name) +{ + guint i; + + if (interface_info == NULL || interface_info->methods == NULL) { + return FALSE; + } + + for (i = 0; interface_info->methods[i] != NULL; i++) { + if (g_strcmp0(interface_info->methods[i]->name, method_name) == 0) { + return TRUE; + } + } + + return FALSE; +} + +static void +introspection_xml_is_valid(void) +{ + guint i; + + for (i = 0; i < G_N_ELEMENTS(EXPECTED_INTERFACES); i++) { + g_autoptr(GError) error = NULL; + g_autofree gchar *path = NULL; + g_autofree gchar *contents = NULL; + g_autoptr(GDBusNodeInfo) node = NULL; + + path = g_build_filename(FILDEM_SOURCE_INTERFACES_DIR, + EXPECTED_INTERFACES[i].file_name, + NULL); + g_assert_true(g_file_test(path, G_FILE_TEST_EXISTS)); + + g_assert_true(g_file_get_contents(path, &contents, NULL, &error)); + g_assert_no_error(error); + + node = g_dbus_node_info_new_for_xml(contents, &error); + g_assert_no_error(error); + g_assert_nonnull(node); + g_assert_nonnull(node->interfaces); + g_assert_nonnull(node->interfaces[0]); + g_assert_cmpstr(node->interfaces[0]->name, + ==, + EXPECTED_INTERFACES[i].interface_name); + + if (g_strcmp0(node->interfaces[0]->name, "org.fildem.v1.Hud") == 0) { + g_assert_true(interface_has_method(node->interfaces[0], "RequestHud")); + g_assert_true(interface_has_method(node->interfaces[0], "Query")); + g_assert_true(interface_has_method(node->interfaces[0], "Execute")); + } + } +} + +int +main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + g_test_add_func("/fildem/contract/introspection-xml-is-valid", introspection_xml_is_valid); + return g_test_run(); +} diff --git a/tests/test-core.c b/tests/test-core.c new file mode 100644 index 00000000..a9756cfb --- /dev/null +++ b/tests/test-core.c @@ -0,0 +1,154 @@ +#include + +#include "fildem-core.h" + +static GVariant * +new_window_context(const gchar *window_uid, const gchar *app_id) +{ + GVariantBuilder builder; + + g_variant_builder_init(&builder, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&builder, "{sv}", "window_uid", g_variant_new_string(window_uid)); + g_variant_builder_add(&builder, "{sv}", "app_id", g_variant_new_string(app_id)); + + return g_variant_builder_end(&builder); +} + +static GVariant * +new_menu_nodes(void) +{ + GVariantBuilder items; + GVariantBuilder item; + + g_variant_builder_init(&items, G_VARIANT_TYPE("aa{sv}")); + + g_variant_builder_init(&item, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&item, "{sv}", "id", g_variant_new_string("menu.open")); + g_variant_builder_add(&item, "{sv}", "label", g_variant_new_string("Open")); + g_variant_builder_add(&item, "{sv}", "enabled", g_variant_new_boolean(TRUE)); + g_variant_builder_add(&item, "{sv}", "visible", g_variant_new_boolean(TRUE)); + g_variant_builder_add(&item, "{sv}", "is_separator", g_variant_new_boolean(FALSE)); + g_variant_builder_add_value(&items, g_variant_builder_end(&item)); + + g_variant_builder_init(&item, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&item, "{sv}", "id", g_variant_new_string("menu.separator")); + g_variant_builder_add(&item, "{sv}", "label", g_variant_new_string("")); + g_variant_builder_add(&item, "{sv}", "enabled", g_variant_new_boolean(TRUE)); + g_variant_builder_add(&item, "{sv}", "visible", g_variant_new_boolean(TRUE)); + g_variant_builder_add(&item, "{sv}", "is_separator", g_variant_new_boolean(TRUE)); + g_variant_builder_add_value(&items, g_variant_builder_end(&item)); + + g_variant_builder_init(&item, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&item, "{sv}", "id", g_variant_new_string("menu.hidden")); + g_variant_builder_add(&item, "{sv}", "label", g_variant_new_string("Hidden")); + g_variant_builder_add(&item, "{sv}", "enabled", g_variant_new_boolean(TRUE)); + g_variant_builder_add(&item, "{sv}", "visible", g_variant_new_boolean(FALSE)); + g_variant_builder_add(&item, "{sv}", "is_separator", g_variant_new_boolean(FALSE)); + g_variant_builder_add_value(&items, g_variant_builder_end(&item)); + + g_variant_builder_init(&item, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&item, "{sv}", "id", g_variant_new_string("menu.disabled")); + g_variant_builder_add(&item, "{sv}", "label", g_variant_new_string("Disabled")); + g_variant_builder_add(&item, "{sv}", "enabled", g_variant_new_boolean(FALSE)); + g_variant_builder_add(&item, "{sv}", "visible", g_variant_new_boolean(TRUE)); + g_variant_builder_add(&item, "{sv}", "is_separator", g_variant_new_boolean(FALSE)); + g_variant_builder_add_value(&items, g_variant_builder_end(&item)); + + return g_variant_builder_end(&items); +} + +static void +validate_window_context_ok(void) +{ + g_autoptr(GError) error = NULL; + GVariant *ctx = new_window_context("w-1", "org.gnome.Nautilus.desktop"); + + g_assert_true(fildem_validate_window_context(ctx, &error)); + g_assert_no_error(error); +} + +static void +validate_window_context_missing_key(void) +{ + g_autoptr(GError) error = NULL; + GVariantBuilder builder; + GVariant *ctx; + + g_variant_builder_init(&builder, G_VARIANT_TYPE("a{sv}")); + g_variant_builder_add(&builder, "{sv}", "window_uid", g_variant_new_string("w-1")); + ctx = g_variant_builder_end(&builder); + + g_assert_false(fildem_validate_window_context(ctx, &error)); + g_assert_error(error, FILDEM_ERROR, FILDEM_ERROR_INVALID_ARGUMENT); +} + +static void +menu_model_query_filters_results(void) +{ + g_autoptr(GVariant) items = new_menu_nodes(); + g_autoptr(GVariant) actions = g_variant_new_strv((const gchar *[]) {"Minimize", "Close", NULL}, -1); + g_autoptr(GVariant) results = fildem_menu_model_query(items, actions, "clo"); + GVariant *entry = NULL; + const gchar *id = NULL; + const gchar *kind = NULL; + + g_assert_cmpuint(g_variant_n_children(results), ==, 1); + entry = g_variant_get_child_value(results, 0); + g_variant_lookup(entry, "id", "&s", &id); + g_variant_lookup(entry, "kind", "&s", &kind); + g_assert_cmpstr(id, ==, "action:Close"); + g_assert_cmpstr(kind, ==, "window-action"); + g_variant_unref(entry); +} + +static gboolean +results_have_id(GVariant *results, const gchar *expected_id) +{ + GVariantIter iter; + GVariant *item = NULL; + + g_variant_iter_init(&iter, results); + while ((item = g_variant_iter_next_value(&iter)) != NULL) { + const gchar *id = NULL; + gboolean match = FALSE; + + g_variant_lookup(item, "id", "&s", &id); + match = g_strcmp0(id, expected_id) == 0; + g_variant_unref(item); + + if (match) { + return TRUE; + } + } + + return FALSE; +} + +static void +menu_model_query_skips_invisible_and_separators(void) +{ + g_autoptr(GVariant) items = new_menu_nodes(); + g_autoptr(GVariant) actions = g_variant_new_strv((const gchar *[]) {"Close", NULL}, -1); + g_autoptr(GVariant) results = fildem_menu_model_query(items, actions, ""); + + g_assert_cmpuint(g_variant_n_children(results), ==, 2); + g_assert_true(results_have_id(results, "menu.open")); + g_assert_true(results_have_id(results, "action:Close")); + g_assert_false(results_have_id(results, "menu.hidden")); + g_assert_false(results_have_id(results, "menu.disabled")); + g_assert_false(results_have_id(results, "menu.separator")); +} + +int +main(int argc, char **argv) +{ + g_test_init(&argc, &argv, NULL); + + g_test_add_func("/fildem/serializer/window-context-ok", validate_window_context_ok); + g_test_add_func("/fildem/serializer/window-context-missing-key", validate_window_context_missing_key); + g_test_add_func("/fildem/model/query-filters", menu_model_query_filters_results); + g_test_add_func("/fildem/model/query-skips-invisible-and-separators", + menu_model_query_skips_invisible_and_separators); + + return g_test_run(); +}