Files
udpscope/docs/superpowers/specs/2026-10-04-app-icon-and-desktop-entry-design.md
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

13 KiB
Raw Permalink Blame History

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 AdwDialogs 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 AdwDialogs (About, shortcuts), and both its doc comment and the CLAUDE.md invariant that says "two of the three are GtkWindows" 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 rms.

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 AdwDialogs; 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.