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>
253 lines
13 KiB
Markdown
253 lines
13 KiB
Markdown
# 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.
|