dunst: a tap chooses a notification's action, so a touchpad can answer the desk (hq issue 360)
mesh/merge-gate fail: builds dunst → g14, shanks; no bus step; 2 problem(s) the change brings; the first: a machine (f35): nothing could be sent to it with…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request

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.
This commit is contained in:
jochen
2026-10-10 00:07:07 +02:00
parent db84ebf48e
commit 2a16d39a3d
8 changed files with 134 additions and 31 deletions
+6 -2
View File
@@ -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="):
@@ -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"}}} {
@@ -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)
}
}
+41 -4
View File
@@ -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) {