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"
+ }
]
}