diff --git a/modules/dunst/README.md b/modules/dunst/README.md index 6c288e2f..c990dc4d 100644 --- a/modules/dunst/README.md +++ b/modules/dunst/README.md @@ -3,6 +3,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. + 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 @@ -20,10 +22,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 +87,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 + 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. - `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..2712ea02 100644 --- a/modules/dunst/cmd/dunst-tools/manifest_test.go +++ b/modules/dunst/cmd/dunst-tools/manifest_test.go @@ -19,6 +19,9 @@ 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, 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) } @@ -40,22 +43,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 -no-custom -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..40d3eb71 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 (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 -no-custom -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..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 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 (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", @@ -98,5 +98,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" + } ] }