diff --git a/docs/superpowers/specs/2026-10-04-app-icon-and-desktop-entry-design.md b/docs/superpowers/specs/2026-10-04-app-icon-and-desktop-entry-design.md new file mode 100644 index 0000000..047527b --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-app-icon-and-desktop-entry-design.md @@ -0,0 +1,227 @@ +# App icon, Adwaita About dialog, and desktop entry + +Date: 2026-10-04 + +## Goal + +Three related pieces of desktop integration udpscope currently lacks: + +1. An application icon drawn to the GNOME HIG, in both full colour and symbolic form. +2. An About dialog that shows it, built on `AdwAboutDialog` rather than `GtkAboutDialog`. +3. A `.desktop` entry, so the app can be launched from the shell rather than only from a terminal. + +## Decisions taken before design + +**The application ID stays `org.udpscope.app`.** GNOME convention is that the application ID, the +icon name and the desktop file's basename are one string, so that string is `org.udpscope.app` +throughout: the icon is named `org.udpscope.app`, the desktop file is +`org.udpscope.app.desktop`, and `gtk_application_new()` is left exactly as it is. The alternative +(moving to `it.ferrarinas.Udpscope`, the reverse-DNS of the project's actual forge domain) was +offered and declined. Nothing else in the tree depends on the ID — the config directory is +`~/.config/udpscope` and the app is `G_APPLICATION_NON_UNIQUE` — so this choice costs nothing +beyond the naming itself. + +**The icon is the instrument face**, not a bare waveform and not a colour version of the existing +trigger icon. A dark panel carrying one bold trace is the clearest available metaphor, it is the +kind of physical object the HIG's top-and-front depth language was written for, and its silhouette +survives being scaled down better than either alternative. + +**The desktop entry is installed by an opt-in CMake target**, not by `install()` rules and not by +documented `cp` commands. The project deliberately has no install step — that is the stated reason +its icons are a GResource — and a target that does nothing until asked preserves that while still +producing a working entry. `install()` rules were rejected because they would create a second copy +of the app that can diverge from the build tree; manual `cp` was rejected because `Exec` would need +the binary on `PATH`, which it is not. + +## The HIG constraints this is built to + +Taken from : + +- Nominal canvas **128×128**, which the artwork should *not* fill. +- A **2px grid** for all detail. +- Depth comes from combining the top and front of an object: a front profile along the bottom, + **no taller than 2 detail units (4 nominal px)**. +- Flat colour on straight surfaces; **gradients reserved for curved surfaces**. +- Shadows **avoided** where possible. +- The standard GNOME palette. +- A **symbolic version is required**, reusing the same metaphor, following the UI-icon (single + colour) rules. +- The icon must still read at **64px** and **32px**. + +## Component 1 — the artwork + +Two new source files, both added to the existing `src/icons/udpscope.gresource.xml` under the +`scalable/apps/` tree (the icons already there are `scalable/actions/`). + +### `src/icons/scalable/apps/org.udpscope.app.svg` + +Full colour, 128×128, every coordinate on the 2px grid: + +| element | geometry | palette colour | +|---|---|---| +| chassis | rounded rect (12, 22)–(116, 106), radius 8 | Dark 3 `#3d3846` | +| front profile | the bottom band of that same silhouette, y 102–106 | Dark 2 `#5e5c64` | +| screen | rounded rect (20, 30)–(108, 94), radius 4 | Dark 4 `#241f31` | +| trace | one sine cycle, centred y = 62, amplitude 18, over x 24–104, 6 px thick | Green 1 `#8ff0a4` | + +The chassis is **one** rounded rect and the front profile is the bottom 4px *of that silhouette*, +drawn as a path that follows its bottom corners — not a separate full-width rectangle underneath it. +A plain rect from (12, 102) to (116, 106) would protrude past the radius-8 corners, which at 128px +reads as a misdrawn bump and at 32px as a dirty edge. + +That band is exactly the HIG's 4px maximum and is the only depth treatment: no gradients (every +surface here is straight, so flat colour is what the HIG asks for) and no shadows. + +The artwork occupies x 12–116 and y 22–106, leaving 12px side margins and 22px top and bottom, so it +does not fill the canvas. Every coordinate above is even. + +Two deliberate omissions. There is **no graticule** — the grid lines a real scope face carries +cannot survive 32px, and the trace already carries the metaphor without them. And the trace is +**one** cycle, a single trough and a single crest, rather than several: at 32px the screen is about +22×16 px, and anything busier reads as a smudge. + +### `src/icons/scalable/apps/org.udpscope.app-symbolic.svg` + +16×16, the same metaphor reduced to a screen frame with a wave inside it. Unlike the colour icon +this is **filled paths only, with an `evenodd` hole for the screen's interior**, because GTK's +symbolic recolour only reaches `fill` on `path`, `rect` and `circle` — a stroke keeps the colour +written in the file and stays dark on a dark theme. Whole-pixel coordinates throughout. + +**This asymmetry is the trap worth documenting:** CLAUDE.md's existing icon-authoring rules say +"everything is a filled `path`" without qualifying it, because every icon in the tree so far has +been symbolic. A 128px colour icon is never recoloured, so it may stroke freely — and stroking is +the sane way to draw a smooth sine. The rule becomes "fills only in a symbolic icon". + +## Component 2 — the About dialog + +`adw_about_dialog_new()` returns an `AdwDialog*`. An `AdwDialog` is **not** a `GtkWindow`, which is +the trap CLAUDE.md already documents for the shortcuts overview, so in `src/help_window.cpp`: + +- `g_about` changes from `GtkWidget*` to `AdwDialog*`. +- It is cleared on `::closed`, not `::close-request` (`AdwDialog` has no such signal). +- It closes with `adw_dialog_close()`, not `gtk_window_close()`. +- It is presented with `adw_dialog_present()`. +- Both `AdwDialog`s can share one `::closed` handler keyed by a pointer-to-pointer, leaving + `on_window_close` to the help window alone. + +`close_help_windows()` therefore becomes one `GtkWindow` (help) plus two `AdwDialog`s (About, +shortcuts), and both its doc comment and the CLAUDE.md invariant that says "two of the three are +`GtkWindow`s" have to be revised to "one of the three". + +Field mapping: + +| `GtkAboutDialog` | `AdwAboutDialog` | +|---|---| +| `program_name` | `application_name` | +| `version` | `version` | +| `comments` | `comments` | +| `license_type` | `license_type` | +| `system_information` | `debug_info` | +| — | `application_icon` = `"org.udpscope.app"` | +| — | `developer_name`, `website` | + +Two gains are taken while mapping. `debug_info` renders as a Troubleshooting page with a copy +button, where `system_information` was inert text. And the string behind it is built as a +`std::string` rather than the `char details[1024]` a previous review flagged for silent truncation +on a long settings path — removing the ceiling rather than widening it, in code already being +rewritten. + +Credits, release notes and an issue URL are deliberately **not** added; nothing asked for them. + +One ordering detail: `gtk_icon_theme_add_resource_path()` currently runs at the top of +`build_toolbar()`. The main window's own icon is now set from a name in the same resource, so the +registration moves up into `run_ui()`, before both `gtk_window_set_icon_name()` and the +`build_toolbar()` call — otherwise whichever of the two runs first resolves its name to nothing. + +## Component 3 — the desktop entry + +### `data/org.udpscope.app.desktop.in` + +``` +[Desktop Entry] +Type=Application +Name=udpscope +GenericName=Oscilloscope +Comment=An oscilloscope for UDPStreamer sources +Exec=@UDPSCOPE_EXEC@ +Icon=org.udpscope.app +Terminal=false +Categories=Development;Science;Electronics; +Keywords=oscilloscope;UDP;signal;waveform;trigger; +StartupNotify=true +``` + +`@UDPSCOPE_EXEC@` is configured to the built binary's absolute path in the build tree. The +consequence to accept knowingly: **moving or deleting the build directory breaks the entry.** That +is the price of having no install step, and it is the right price here because the entry then always +launches exactly the binary that was last built. + +No `StartupWMClass` is needed. GTK takes the WM class from the application ID, and the desktop +file's basename already matches it, which is what associates a running window with this entry. + +### The `desktop-entry` target + +A custom target driving `cmake/install_desktop_entry.cmake`, which writes three files under +`$XDG_DATA_HOME` (defaulting to `~/.local/share`): + +- `applications/org.udpscope.app.desktop` +- `icons/hicolor/scalable/apps/org.udpscope.app.svg` +- `icons/hicolor/symbolic/apps/org.udpscope.app-symbolic.svg` + +The two SVGs are copied from `src/icons/scalable/apps/` in the source tree, not extracted from the +GResource: the same two files serve both the in-binary resource and the installed icon theme, so +there is one copy to edit. + +It then refreshes `update-desktop-database` and `gtk4-update-icon-cache` if either is on `PATH`, runs +`desktop-file-validate` if it is available, and prints every path it wrote. + +`cmake --build build` is unaffected: the target runs only when named. No uninstall target is +provided — the three paths are printed, so undoing it is three `rm`s. + +## Verification + +Two silent failures are possible, and they fail differently. + +A GResource path that was never registered draws `image-missing` while every other line of the dump +passes; the existing `toolbar icons:` line already guards that class with +`gtk_icon_theme_has_icon()`. But a **malformed SVG is worse**: librsvg refuses the whole file, +`has_icon()` still returns true because the *name* resolves, and the icon draws nothing. `has_icon` +cannot see it — and these SVGs have already been bitten once by librsvg, which rejects a file for a +`--` inside an XML comment. + +So the new assertion distinguishes *named* from *renderable*: + +``` +app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16) +``` + +It looks each icon up and queries the resulting paintable's intrinsic size. **Both entries must read +`ok` with a non-zero size**; a parse failure reports `0x0` with the name still resolving, which is +precisely the case the existing check misses. + +Alongside it: + +- The `[help]` probe gains `about icon=org.udpscope.app`, read back through + `adw_about_dialog_get_application_icon()`, so the dialog is asserted to carry the icon rather than + merely to open. +- That probe's existing `closed ok` becomes a stronger crash test, because About is now an + `AdwDialog` closed by a different call than the help window beside it. It must still produce no + `Gtk-CRITICAL` beyond the two already baselined. +- `desktop-file-validate` runs inside the `desktop-entry` target, so a malformed entry fails at the + moment it is asked for rather than being silently ignored by the shell later. + +## Documentation + +- **CLAUDE.md**: the `app icons:` dump line as a documented assertion; the windows invariant revised + to one `GtkWindow` and two `AdwDialog`s; the `desktop-entry` target and the build-path caveat; and + the icon-authoring rules amended so "filled paths only" is scoped to symbolic icons. +- **README.md**: the feature narrative — the icon's HIG constraints and the two omissions + (graticule, multiple cycles) with the 32px reasoning, the move to `AdwAboutDialog` and what it + gained, and how to get the desktop entry. + +## Out of scope + +- An AppStream `metainfo.xml`. Nothing asked for it, and it is only required for store submission. +- Redrawing the four existing action icons. +- A nightly/devel icon variant. +- An uninstall target for the desktop entry.