Merge pull request 'dunst: a tap chooses a notification's action (hq issue 360)' (#167) from fix/dunst-tap-chooses-action into main
This commit was merged in pull request #167.
This commit is contained in:
+17
-6
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 = \"<b>%s</b>\\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 = \"<b>%s</b>\\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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user