From 2a16d39a3ddfb07c1494ec9f494fc93249706fd9 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 00:07:07 +0200 Subject: [PATCH 1/3] dunst: a tap chooses a notification's action, so a touchpad can answer the desk (hq issue 360) The actions sat on the middle click, which the operator's touchpads cannot give, and the context menu named dmenu, which dunst's PATH does not hold. The left click is now do_action, close_current (one action runs, several open the menu, none closes), the menu is rofi, declared as a package, Super+N opens it from the keyboard through i3's contribution, and send refuses the token default, which dunst would run at a tap without the menu. --- modules/dunst/README.md | 24 +++++--- modules/dunst/cmd/dunst-tools/actions.go | 8 ++- modules/dunst/cmd/dunst-tools/actions_test.go | 1 + .../cmd/dunst-tools/manifest_helpers_test.go | 56 ++++++++++++++----- .../dunst/cmd/dunst-tools/manifest_test.go | 45 +++++++++++++-- modules/dunst/files/dunstrc | 13 +++-- modules/dunst/files/i3/50-dunst.conf | 4 ++ modules/dunst/module.json | 14 ++++- 8 files changed, 134 insertions(+), 31 deletions(-) create mode 100644 modules/dunst/files/i3/50-dunst.conf diff --git a/modules/dunst/README.md b/modules/dunst/README.md index 6c288e2f..b14bc2d1 100644 --- a/modules/dunst/README.md +++ b/modules/dunst/README.md @@ -2,7 +2,8 @@ The notifier as a module (novox/hq ADR 0208, research 026/05). -- Installs `dunst`, and `libnotify` for `notify-send`, the client every program and these tools use. +- Installs `dunst`, `libnotify` for `notify-send`, the client every program and these tools use, and + `rofi`, the context menu its `dunstrc` calls. - Claims the mesh's `node-notifier` seat and serves its verbs `send` and `history`. - Owns `~/.config/dunst/dunstrc` and the directory `~/.config/dunst/dunstrc.d/`. Another module's rule is that module's own file in the directory (ADR 0208 §4). dunst reads the directory after @@ -20,10 +21,15 @@ The notifier as a module (novox/hq ADR 0208, research 026/05). ## Answers from a notification `send` with `actions` runs `notify-send --wait` with one `--action` per answer, answers the id at once and -waits in this bundle for the operator's choice (dunst offers the actions on a middle click, or in its -context menu). The token chosen is said as this module's event `action-chosen`; it becomes the -node-notifier seat's event once seats publish events of their own (novox/hq to-be 46 phase 3). Nothing -is said for a notification closed, expired or replaced without an answer, and a wait ends after seven +waits in this bundle for the operator's choice. A tap (left click) on the notification chooses: with +one action it runs it, with several it opens rofi's menu of them, and without any it closes the +notification (dunst's `do_action, close_current`; a notification with no action but one link opens the +link). An Escape in the menu leaves the notification shown. The middle click does the same, and +**Super+N** (`dunstctl context`, this module's line in i3's configuration) opens the menu of every +notification on screen from the keyboard. The token `default` is refused, because dunst runs an action of +that name at a tap without offering the others. The token chosen is said as this module's event +`action-chosen`; it becomes the node-notifier seat's event once seats publish events of their own +(novox/hq to-be 46 phase 3). Nothing is said for a notification closed, expired or replaced without an answer, and a wait ends after seven days. A notification that offers answers stays until dismissed (`expire_ms` 0). A restart of this bundle ends the waits it held; it says `started` {machine} once it serves, and the operator channel shows the open answers there again (novox/hq ADR 0258). `notify-send` prints the id before it waits, also into a @@ -80,8 +86,12 @@ rounded corners; the other top-right, opaque and square. This module takes the s rest of the desktop is square and opaque, and the top-right corner sits under the bar that shows the count. The file keeps only the settings that differ from dunst's defaults. -- **The context menu works.** It called `/usr/bin/dmenu`, installed on neither machine. It now calls - `dmenu`, the seat command of whichever module holds `node-launcher` (`rofi` on the workstations). +- **The context menu works.** It called `/usr/bin/dmenu`, installed on neither machine, and then + `dmenu`, the node-launcher's command in `~/.local/bin`, which dunst did not find on the laptop + (2026-10-10): it runs in the account's service manager, whose `PATH` does not hold that directory. It now calls `rofi -dmenu`, a + package this module installs. +- **A tap chooses an answer.** The answers were on the middle click only, which the laptop's and the + workstation's touchpads cannot give (novox/hq issue 360). - **The face is the interface one,** Inter (research 026/04), instead of a monospace Nerd font. - `icon_path`, which named two directories of an icon theme that is not installed, is gone. The icon theme is looked up recursively. diff --git a/modules/dunst/cmd/dunst-tools/actions.go b/modules/dunst/cmd/dunst-tools/actions.go index fe0e998f..f2bfcfc3 100644 --- a/modules/dunst/cmd/dunst-tools/actions.go +++ b/modules/dunst/cmd/dunst-tools/actions.go @@ -1,8 +1,8 @@ package main // Answers from a notification (novox/hq ADR 0253, to-be 46 §11 "the desk"): `send` takes actions, each a -// token and a label. dunst offers them on the notification (a middle click, or its context menu, which -// is the node-launcher's menu); the one the operator chooses is emitted as this module's event +// token and a label. dunst offers them on the notification: a tap (left click) runs the only one, or opens +// rofi's menu of them when there are several (the dunstrc's do_action); the one the operator chooses is emitted as this module's event // `action-chosen` {id, token, machine}, and the operator channel's holder acts on it. It is this module's // event until seats publish events of their own (to-be 46 phase 3), when it becomes the node-notifier // seat's. @@ -91,6 +91,10 @@ func choicesOf(args map[string]any) ([]Choice, error) { switch { case !tokenShape.MatchString(tok): return nil, fmt.Errorf("an action's token is letters, digits, - and _, at most 64: %q", tok) + case tok == "default": + // dunst's do_action runs an action named "default" at a tap even when there are others, so a + // tap would choose it without the menu; the name is dunst's, not a token's. + return nil, errors.New(`an action's token may not be "default", which dunst runs at a tap without offering the others`) case seen[tok]: return nil, fmt.Errorf("the token %q is given twice", tok) case label == "" || len(label) > 40 || strings.ContainsAny(label, "\n="): diff --git a/modules/dunst/cmd/dunst-tools/actions_test.go b/modules/dunst/cmd/dunst-tools/actions_test.go index 3ba7f9a4..70a472ce 100644 --- a/modules/dunst/cmd/dunst-tools/actions_test.go +++ b/modules/dunst/cmd/dunst-tools/actions_test.go @@ -83,6 +83,7 @@ func TestActionsAreRefusedForWhatTheyCannotBe(t *testing.T) { for _, bad := range []any{"x", []any{"x"}, []any{map[string]any{"token": "a b", "label": "x"}}, []any{map[string]any{"token": "a", "label": ""}}, []any{map[string]any{"token": "a", "label": "x=y"}}, []any{map[string]any{"token": "a", "label": "x"}, map[string]any{"token": "a", "label": "y"}}, + []any{map[string]any{"token": "default", "label": "x"}, map[string]any{"token": "b", "label": "y"}}, []any{map[string]any{"token": "a", "label": "1"}, map[string]any{"token": "b", "label": "2"}, map[string]any{"token": "c", "label": "3"}, map[string]any{"token": "d", "label": "4"}, map[string]any{"token": "e", "label": "5"}}} { diff --git a/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go b/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go index 94cb1520..55f2f09e 100644 --- a/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go +++ b/modules/dunst/cmd/dunst-tools/manifest_helpers_test.go @@ -12,19 +12,20 @@ import ( // every desktop module that carries it. type manifest struct { - Module string `json:"module"` - Version string `json:"version"` - Capabilities []string `json:"capabilities"` - Requires []string `json:"requires"` - Claims []claim `json:"claims"` - Seats []any `json:"seats"` - Tools []string `json:"tools"` - Settings map[string]setting `json:"settings"` - Emits []string `json:"emits"` - Environment *environment `json:"environment"` - Shell []shellCode `json:"shell"` - Resources []map[string]any `json:"resources"` - Build struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Requires []string `json:"requires"` + Claims []claim `json:"claims"` + Seats []any `json:"seats"` + Tools []string `json:"tools"` + Settings map[string]setting `json:"settings"` + Emits []string `json:"emits"` + Contributions []contribution `json:"contributions"` + Environment *environment `json:"environment"` + Shell []shellCode `json:"shell"` + Resources []map[string]any `json:"resources"` + Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } @@ -182,3 +183,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) { } } } + +type contribution struct { + Seat string `json:"seat"` + Kind string `json:"kind"` + Content string `json:"content"` +} + +// i3Lines is what the module contributes to the window manager. +func (m manifest) i3Lines() string { + var out string + for _, c := range m.Contributions { + if c.Seat == "node-display-session" && c.Kind == "config" { + out += c.Content + } + } + return out +} + +// i3LinesAreSource checks the window-manager contribution is the source file it is written from. +func (m manifest) i3LinesAreSource(t *testing.T, source string) { + t.Helper() + want, err := os.ReadFile(filepath.Join("..", "..", source)) + if err != nil { + t.Fatal(err) + } + if m.i3Lines() != string(want) { + t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source) + } +} diff --git a/modules/dunst/cmd/dunst-tools/manifest_test.go b/modules/dunst/cmd/dunst-tools/manifest_test.go index 60c1c9a2..30d5db95 100644 --- a/modules/dunst/cmd/dunst-tools/manifest_test.go +++ b/modules/dunst/cmd/dunst-tools/manifest_test.go @@ -19,7 +19,8 @@ func TestItClaimsTheNotifierSeatServingSendAndHistory(t *testing.T) { if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-notifier", Scope: "node", Serves: []string{"send", "history"}}}) { t.Fatalf("claims: %+v", m.Claims) } - if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"dunst", "libnotify"}) || absent != nil { + // rofi is the context menu the dunstrc calls by name (see TheFileIsTheDecidedOne). + if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"dunst", "libnotify", "rofi"}) || absent != nil { t.Fatalf("packages: %v, absent %v", present, absent) } } @@ -40,22 +41,58 @@ func TestItOwnsItsFileAndTheDropInDirectory(t *testing.T) { } } -func TestTheFileIsTheDecidedOneAndCallsTheSeatsMenu(t *testing.T) { +func TestTheFileIsTheDecidedOneAndCallsRofi(t *testing.T) { m := readManifest(t) c := m.resource(t, "configuration")["content"].(string) for _, want := range []string{"origin = top-right", "transparency = 0", "corner_radius = 0", "font = Inter ${setting:font-size}", - "width = ${setting:width}", "dmenu = dmenu -p dunst"} { + "width = ${setting:width}", "dmenu = rofi -dmenu -p dunst"} { if !strings.Contains(c, " "+want+"\n") { t.Errorf("lacks %q", want) } } - for _, never := range []string{"/usr/bin/dmenu", "Hack", "icon_path", "/home/"} { + // Not the node-launcher's `dmenu` in ~/.local/bin: dunst's service manager has no such directory on + // its PATH, so that menu never opened. + for _, never := range []string{"/usr/bin/dmenu", "dmenu = dmenu", "Hack", "icon_path", "/home/"} { if strings.Contains(c, never) { t.Errorf("names %q", never) } } } +// A tap chooses (the operator's touchpads have no middle button): the left click is dunst's do_action +// and then close_current, so a notification with one action runs it, one with several opens the menu +// of them (dunst holds it open until the menu answers, and an Escape leaves it shown), and one without +// actions closes. Every other click is as it was. +func TestATapChoosesTheNotificationsAction(t *testing.T) { + m := readManifest(t) + c := m.resource(t, "configuration")["content"].(string) + for _, want := range []string{"mouse_left_click = do_action, close_current", + "mouse_middle_click = do_action, close_current", "mouse_right_click = close_all"} { + if !strings.Contains(c, " "+want+"\n") { + t.Errorf("lacks %q", want) + } + } + if strings.Count(c, "mouse_left_click") != 1 { + t.Errorf("sets mouse_left_click more than once") + } +} + +// The key that chooses from the keyboard is a line i3 places from this contribution (novox/hq ADR +// 0212): a binding only, never a start, so the bus stays the only thing that starts dunst. +func TestTheMenuKeyIsABindingI3Places(t *testing.T) { + m := readManifest(t) + m.i3LinesAreSource(t, "files/i3/50-dunst.conf") + lines := m.i3Lines() + if !strings.Contains(lines, "bindsym $mod+n exec --no-startup-id dunstctl context\n") { + t.Fatalf("no key for the context menu: %s", lines) + } + for _, l := range strings.Split(lines, "\n") { + if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") && !strings.HasPrefix(l, "bindsym ") { + t.Errorf("a line that is not a key: %q", l) + } + } +} + // The font size and the width are preferences with today's values as their defaults (novox/hq ADR // 0262): a machine with a denser screen sets its own, and every other machine's file is unchanged. func TestTheFontSizeAndWidthAreSettingsWithTodaysDefaults(t *testing.T) { diff --git a/modules/dunst/files/dunstrc b/modules/dunst/files/dunstrc index 727a12e1..d8fc1278 100644 --- a/modules/dunst/files/dunstrc +++ b/modules/dunst/files/dunstrc @@ -63,9 +63,11 @@ sticky_history = yes history_length = 20 - # The context menu is the node's dmenu-compatible command, which the holder of node-launcher - # answers (rofi on the workstations). Links open in the desktop's default browser. - dmenu = dmenu -p dunst + # The context menu is rofi, which this module installs. Not `dmenu`, the node-launcher's command in + # ~/.local/bin: dunst runs in the account's service manager, whose PATH does not hold that + # directory (seen on the laptop on 2026-10-10), so the menu never opened. Links open in the + # desktop's default browser. + dmenu = rofi -dmenu -p dunst browser = /usr/bin/xdg-open always_run_script = true @@ -73,7 +75,10 @@ class = Dunst ignore_dbusclose = false - mouse_left_click = close_current + # A tap answers (the touchpads have no middle button). do_action runs the notification's only + # action, or opens the context menu when it has several; without actions it opens its one link, + # if it has one. The notification then closes; a menu dismissed with Escape leaves it shown. + mouse_left_click = do_action, close_current mouse_middle_click = do_action, close_current mouse_right_click = close_all diff --git a/modules/dunst/files/i3/50-dunst.conf b/modules/dunst/files/i3/50-dunst.conf new file mode 100644 index 00000000..fec1f8de --- /dev/null +++ b/modules/dunst/files/i3/50-dunst.conf @@ -0,0 +1,4 @@ +# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push. +# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is +# chosen from the keyboard as a tap chooses it on the notification. +bindsym $mod+n exec --no-startup-id dunstctl context diff --git a/modules/dunst/module.json b/modules/dunst/module.json index 37c0adfa..0b4228f5 100644 --- a/modules/dunst/module.json +++ b/modules/dunst/module.json @@ -45,6 +45,11 @@ "type": "package", "package": "libnotify" }, + { + "id": "menu", + "type": "package", + "package": "rofi" + }, { "id": "configuration-dir", "type": "directory", @@ -66,7 +71,7 @@ "path": "${machine:account-home}/.config/dunst/dunstrc", "owner": "${machine:account}", "mode": "0644", - "content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"%s\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n" + "content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"%s\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is rofi, which this module installs. Not `dmenu`, the node-launcher's command in\n # ~/.local/bin: dunst runs in the account's service manager, whose PATH does not hold that\n # directory (seen on the laptop on 2026-10-10), so the menu never opened. Links open in the\n # desktop's default browser.\n dmenu = rofi -dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n # A tap answers (the touchpads have no middle button). do_action runs the notification's only\n # action, or opens the context menu when it has several; without actions it opens its one link,\n # if it has one. The notification then closes; a menu dismissed with Escape leaves it shown.\n mouse_left_click = do_action, close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n" }, { "id": "notifier", @@ -98,5 +103,12 @@ "action-chosen", "started", "paused" + ], + "contributions": [ + { + "seat": "node-display-session", + "kind": "config", + "content": "# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is\n# chosen from the keyboard as a tap chooses it on the notification.\nbindsym $mod+n exec --no-startup-id dunstctl context\n" + } ] } From f76396de6fef9b3a280c9db5017ca00c497776c8 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 00:11:00 +0200 Subject: [PATCH 2/3] dunst: leave the rofi package to the rofi module, which the merge gate requires Two modules on one machine may not declare the same package; the rofi module is assigned wherever dunst is. --- modules/dunst/README.md | 7 ++++--- modules/dunst/cmd/dunst-tools/manifest_test.go | 6 ++++-- modules/dunst/module.json | 5 ----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/modules/dunst/README.md b/modules/dunst/README.md index b14bc2d1..c990dc4d 100644 --- a/modules/dunst/README.md +++ b/modules/dunst/README.md @@ -2,8 +2,9 @@ The notifier as a module (novox/hq ADR 0208, research 026/05). -- Installs `dunst`, `libnotify` for `notify-send`, the client every program and these tools use, and - `rofi`, the context menu its `dunstrc` calls. +- Installs `dunst`, and `libnotify` for `notify-send`, the client every program and these tools use. + The context menu its `dunstrc` calls is `rofi`, which the `rofi` module installs: two modules on one + machine may not declare the same package, so a machine dunst serves also has `rofi` assigned. - Claims the mesh's `node-notifier` seat and serves its verbs `send` and `history`. - Owns `~/.config/dunst/dunstrc` and the directory `~/.config/dunst/dunstrc.d/`. Another module's rule is that module's own file in the directory (ADR 0208 §4). dunst reads the directory after @@ -89,7 +90,7 @@ count. The file keeps only the settings that differ from dunst's defaults. - **The context menu works.** It called `/usr/bin/dmenu`, installed on neither machine, and then `dmenu`, the node-launcher's command in `~/.local/bin`, which dunst did not find on the laptop (2026-10-10): it runs in the account's service manager, whose `PATH` does not hold that directory. It now calls `rofi -dmenu`, a - package this module installs. + system package the `rofi` module installs. - **A tap chooses an answer.** The answers were on the middle click only, which the laptop's and the workstation's touchpads cannot give (novox/hq issue 360). - **The face is the interface one,** Inter (research 026/04), instead of a monospace Nerd font. diff --git a/modules/dunst/cmd/dunst-tools/manifest_test.go b/modules/dunst/cmd/dunst-tools/manifest_test.go index 30d5db95..fe03a5c4 100644 --- a/modules/dunst/cmd/dunst-tools/manifest_test.go +++ b/modules/dunst/cmd/dunst-tools/manifest_test.go @@ -19,8 +19,10 @@ func TestItClaimsTheNotifierSeatServingSendAndHistory(t *testing.T) { if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-notifier", Scope: "node", Serves: []string{"send", "history"}}}) { t.Fatalf("claims: %+v", m.Claims) } - // rofi is the context menu the dunstrc calls by name (see TheFileIsTheDecidedOne). - if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"dunst", "libnotify", "rofi"}) || absent != nil { + // rofi, the context menu the dunstrc calls by name, is the rofi module's package: two modules on one + // machine may not declare the same package, and the rofi module holds node-launcher on every + // machine dunst serves. + if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"dunst", "libnotify"}) || absent != nil { t.Fatalf("packages: %v, absent %v", present, absent) } } diff --git a/modules/dunst/module.json b/modules/dunst/module.json index 0b4228f5..8f1e9f1e 100644 --- a/modules/dunst/module.json +++ b/modules/dunst/module.json @@ -45,11 +45,6 @@ "type": "package", "package": "libnotify" }, - { - "id": "menu", - "type": "package", - "package": "rofi" - }, { "id": "configuration-dir", "type": "directory", From f055898089e8a371c16416908ba31e6ecf71d6bf Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 10 Oct 2026 00:11:45 +0200 Subject: [PATCH 3/3] dunst: the menu takes no typed text, so a typed line never becomes a choice Review of hq issue 360: rofi without -no-custom hands typed text to dunst as if chosen. --- modules/dunst/cmd/dunst-tools/manifest_test.go | 2 +- modules/dunst/files/dunstrc | 4 ++-- modules/dunst/module.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/dunst/cmd/dunst-tools/manifest_test.go b/modules/dunst/cmd/dunst-tools/manifest_test.go index fe03a5c4..2712ea02 100644 --- a/modules/dunst/cmd/dunst-tools/manifest_test.go +++ b/modules/dunst/cmd/dunst-tools/manifest_test.go @@ -47,7 +47,7 @@ func TestTheFileIsTheDecidedOneAndCallsRofi(t *testing.T) { m := readManifest(t) c := m.resource(t, "configuration")["content"].(string) for _, want := range []string{"origin = top-right", "transparency = 0", "corner_radius = 0", "font = Inter ${setting:font-size}", - "width = ${setting:width}", "dmenu = rofi -dmenu -p dunst"} { + "width = ${setting:width}", "dmenu = rofi -dmenu -no-custom -p dunst"} { if !strings.Contains(c, " "+want+"\n") { t.Errorf("lacks %q", want) } diff --git a/modules/dunst/files/dunstrc b/modules/dunst/files/dunstrc index d8fc1278..40d3eb71 100644 --- a/modules/dunst/files/dunstrc +++ b/modules/dunst/files/dunstrc @@ -63,11 +63,11 @@ sticky_history = yes history_length = 20 - # The context menu is rofi, which this module installs. Not `dmenu`, the node-launcher's command in + # The context menu is rofi (installed by the rofi module); -no-custom keeps typed text from becoming a choice. Not `dmenu`, the node-launcher's command in # ~/.local/bin: dunst runs in the account's service manager, whose PATH does not hold that # directory (seen on the laptop on 2026-10-10), so the menu never opened. Links open in the # desktop's default browser. - dmenu = rofi -dmenu -p dunst + dmenu = rofi -dmenu -no-custom -p dunst browser = /usr/bin/xdg-open always_run_script = true diff --git a/modules/dunst/module.json b/modules/dunst/module.json index 8f1e9f1e..f94d5d96 100644 --- a/modules/dunst/module.json +++ b/modules/dunst/module.json @@ -66,7 +66,7 @@ "path": "${machine:account-home}/.config/dunst/dunstrc", "owner": "${machine:account}", "mode": "0644", - "content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"%s\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is rofi, which this module installs. Not `dmenu`, the node-launcher's command in\n # ~/.local/bin: dunst runs in the account's service manager, whose PATH does not hold that\n # directory (seen on the laptop on 2026-10-10), so the menu never opened. Links open in the\n # desktop's default browser.\n dmenu = rofi -dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n # A tap answers (the touchpads have no middle button). do_action runs the notification's only\n # action, or opens the context menu when it has several; without actions it opens its one link,\n # if it has one. The notification then closes; a menu dismissed with Escape leaves it shown.\n mouse_left_click = do_action, close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n" + "content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n# When the mesh rewrites this file (a font-size or width setting changed), it tells a running dunst\n# to read it again (its unit's reload, dunstctl reload); what is on screen stays.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = ${setting:width}\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter ${setting:font-size}\n line_height = 0\n markup = full\n format = \"%s\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is rofi (installed by the rofi module); -no-custom keeps typed text from becoming a choice. Not `dmenu`, the node-launcher's command in\n # ~/.local/bin: dunst runs in the account's service manager, whose PATH does not hold that\n # directory (seen on the laptop on 2026-10-10), so the menu never opened. Links open in the\n # desktop's default browser.\n dmenu = rofi -dmenu -no-custom -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n # A tap answers (the touchpads have no middle button). do_action runs the notification's only\n # action, or opens the context menu when it has several; without actions it opens its one link,\n # if it has one. The notification then closes; a menu dismissed with Escape leaves it shown.\n mouse_left_click = do_action, close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n\n# What is shown once is never kept (the review of 2026-10-09): a notification marked secret (a link's\n# code, category mesh.secret) and any transient one leave no entry in the history, which\n# node-notifier.history serves to every caller of the mesh's verbs.\n[mesh-secret]\n category = \"mesh.secret\"\n history_ignore = yes\n\n[transient-history-ignore]\n match_transient = yes\n history_ignore = yes\n" }, { "id": "notifier",