Files
udpscope/docs/superpowers/specs/2026-10-04-app-icon-and-desktop-entry-design.md
T
Martino FerrariandClaude Opus 4.6 e17c650ea7 docs: correct the spec's icon verification to a build-time check
The spec asserted that a paintable's intrinsic size reads 0x0 for an SVG
librsvg refuses. It does not: gtk_icon_theme_lookup_icon() never returns NULL
and always reports the requested size, and a valid icon, a malformed one and an
absent name all snapshot to the same texture node. GMarkup accepts the double
hyphen, and gdk-pixbuf goes through glycin here and refuses our own icons.

So the malformed file is caught by preprocess="xml-stripblanks", which runs
xmllint during glib-compile-resources and fails the build with file, line and
column. The runtime line keeps its format but asserts only what it can: the
name resolves, the resource is present, and the root element declares the size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 20:11:42 +02:00

253 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <https://developer.gnome.org/hig/guidelines/app-icons.html>:
- 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.
The two are therefore caught in two different places, because **no runtime GTK call can see the
malformed one.** This was measured rather than assumed, and three candidate checks were tried and
rejected:
- `gtk_icon_theme_lookup_icon()` never returns `NULL` and its paintable's intrinsic size is the
size that was *asked for*, so it reads `128x128` for a valid icon, a malformed one and a name
that does not exist at all. The same three cases snapshot to an identical `GSK_TEXTURE_NODE` of
the requested bounds, since GTK substitutes `image-missing`. There is nothing to compare.
- `GMarkup` accepts `<!-- a -- b -->` without complaint. It is a deliberately lenient subset
parser, so it cannot stand in for the strict one that refuses the file.
- `gdk_pixbuf_new_from_file()` on an SVG goes through glycin on this system, which sandboxes a
subprocess per load, emits fontconfig errors, and refuses this project's existing icons outright
for reasons of its own. Not a usable oracle.
So the malformed file is caught at **build time**, which is better than a probe anyway: every
`<file>` line in `src/icons/udpscope.gresource.xml` gains `preprocess="xml-stripblanks"`, which
makes `glib-compile-resources` pipe each SVG through `xmllint`. A double hyphen inside a comment
then **fails the build** with the file, line and column, instead of drawing nothing at runtime.
The four existing icons already pass it. The cost is that the `xmllint` binary becomes a build
dependency; it ships with libxml2, which GTK already requires.
The runtime line then asserts what a runtime check genuinely can:
```
app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)
```
`ok` is `gtk_icon_theme_has_icon()` **and** the resource bytes being present under the registered
path, and the size is the one the file's root `<svg>` element declares, read out of those bytes.
**Both entries must read `ok` at those sizes.** That covers the unregistered-path case, a renamed
or mis-pathed file, and a canvas size that drifted off the HIG's 128 — and it is computed on the
first tick beside `toolbar icons:`, not in the dump, which runs after the window is destroyed.
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, and the `xml-stripblanks`
build-time check beside the five existing icon-authoring rules — the sixth rule is that the
malformed-SVG failure is a *build* error now and no runtime check can replace it; 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.