From aa16918dd4d662afa20d6e967a91e6a7bfc8ec0d Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Wed, 31 Jul 2024 12:11:21 +0530 Subject: [PATCH] Implement support for buttons on notifications in Linux --- glfw/glfw3.h | 2 +- glfw/linux_notify.c | 8 ++++---- kittens/notify/main.go | 11 ++++++++--- kittens/notify/main.py | 9 ++++++++- kitty/fast_data_types.pyi | 2 +- kitty/glfw-wrapper.h | 2 +- kitty/glfw.c | 20 ++++++++++++++++---- kitty/notifications.py | 31 +++++++++++++++++++++---------- 8 files changed, 60 insertions(+), 25 deletions(-) diff --git a/glfw/glfw3.h b/glfw/glfw3.h index e8d7a33b6..731622f89 100644 --- a/glfw/glfw3.h +++ b/glfw/glfw3.h @@ -1316,7 +1316,7 @@ typedef struct GLFWLayerShellConfig { } GLFWLayerShellConfig; typedef struct GLFWDBUSNotificationData { - const char *app_name, *icon, *summary, *body, *action_name; + const char *app_name, *icon, *summary, *body, **actions; size_t num_actions; int32_t timeout; uint8_t urgency; uint32_t replaces; } GLFWDBUSNotificationData; diff --git a/glfw/linux_notify.c b/glfw/linux_notify.c index 87a2eb726..88db41ed3 100644 --- a/glfw/linux_notify.c +++ b/glfw/linux_notify.c @@ -124,10 +124,10 @@ glfw_dbus_send_user_notification(const GLFWDBUSNotificationData *n, GLFWDBusnoti APPEND(args, DBUS_TYPE_STRING, n->summary) APPEND(args, DBUS_TYPE_STRING, n->body) check_call(dbus_message_iter_open_container, &args, DBUS_TYPE_ARRAY, "s", &array); - if (n->action_name) { - static const char* default_action = "default"; - APPEND(array, DBUS_TYPE_STRING, default_action); - APPEND(array, DBUS_TYPE_STRING, n->action_name); + if (n->actions) { + for (size_t i = 0; i < n->num_actions; i++) { + APPEND(array, DBUS_TYPE_STRING, n->actions[i]); + } } check_call(dbus_message_iter_close_container, &args, &array); check_call(dbus_message_iter_open_container, &args, DBUS_TYPE_ARRAY, "{sv}", &array); diff --git a/kittens/notify/main.go b/kittens/notify/main.go index c3af15d33..0faec6e21 100644 --- a/kittens/notify/main.go +++ b/kittens/notify/main.go @@ -102,6 +102,9 @@ func (p *parsed_data) generate_chunks(callback func(string)) { if len(p.image_data) > 0 { add_payload("icon", utils.UnsafeBytesToString(p.image_data)) } + if len(p.opts.Button) > 0 { + add_payload("buttons", strings.Join(p.opts.Button, "\u2028")) + } write_chunk(";") } @@ -110,7 +113,7 @@ func (p *parsed_data) run_loop() (err error) { if err != nil { return err } - activated := "" + activated := -1 prefix := ESC_CODE_PREFIX + "i=" + p.identifier poll_for_close := func() { @@ -156,7 +159,9 @@ func (p *parsed_data) run_loop() (err error) { lp.Quit(0) } case "": - activated = utils.IfElse(payload == "", "activated", payload) + if activated, err = strconv.Atoi(utils.IfElse(payload == "", "0", payload)); err != nil { + return fmt.Errorf("Got invalid activation response from terminal: %#v", payload) + } } } } @@ -192,7 +197,7 @@ func (p *parsed_data) run_loop() (err error) { lp.KillIfSignalled() return } - if activated != "" && err == nil { + if activated > -1 && err == nil { fmt.Println(activated) } return diff --git a/kittens/notify/main.py b/kittens/notify/main.py index 5fcdb5cbe..7740705fe 100644 --- a/kittens/notify/main.py +++ b/kittens/notify/main.py @@ -26,6 +26,13 @@ default=kitten-notify The application name for the notification. +--button -b +type=list +Add a button with the specified text to the notification. Can be specified multiple times for multiple buttons. +If --wait-for-completion is used then the kitten will print th ebutton number to STDOUT if the user clicks a button. +1 for the first button, 2 for the second button and so on. + + --urgency -u default=normal choices=normal,low,critical @@ -62,7 +69,7 @@ your own identifier via the --identifier option. --wait-till-closed -w type=bool-set Wait until the notification is closed. If the user activates the notification, -"activated" is printed to STDOUT before quitting. Press the Esc or Ctrl+C keys +"0" is printed to STDOUT before quitting. Press the Esc or Ctrl+C keys to close the notification manually. diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 6fea59ea5..1d69654f8 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -550,7 +550,7 @@ def dbus_send_notification( app_icon: str, title: str, body: str, - action_text: str = '', + actions: dict[str, str], timeout: int = -1, urgency: int = 1, replaces: int = 0, diff --git a/kitty/glfw-wrapper.h b/kitty/glfw-wrapper.h index 916ea3816..eb08b69e5 100644 --- a/kitty/glfw-wrapper.h +++ b/kitty/glfw-wrapper.h @@ -1054,7 +1054,7 @@ typedef struct GLFWLayerShellConfig { } GLFWLayerShellConfig; typedef struct GLFWDBUSNotificationData { - const char *app_name, *icon, *summary, *body, *action_name; + const char *app_name, *icon, *summary, *body, **actions; size_t num_actions; int32_t timeout; uint8_t urgency; uint32_t replaces; } GLFWDBUSNotificationData; diff --git a/kitty/glfw.c b/kitty/glfw.c index 5e1a71301..ca14ddc7c 100644 --- a/kitty/glfw.c +++ b/kitty/glfw.c @@ -2077,10 +2077,11 @@ dbus_notification_created_callback(unsigned long long notification_id, uint32_t static PyObject* dbus_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) { int timeout = -1, urgency = 1; unsigned int replaces = 0; - GLFWDBUSNotificationData d = {.action_name=""}; - static const char* kwlist[] = {"app_name", "app_icon", "title", "body", "action_text", "timeout", "urgency", "replaces", NULL}; - if (!PyArg_ParseTupleAndKeywords(args, kw, "ssss|siiI", (char**)kwlist, - &d.app_name, &d.icon, &d.summary, &d.body, &d.action_name, &timeout, &urgency, &replaces)) return NULL; + GLFWDBUSNotificationData d = {0}; + static const char* kwlist[] = {"app_name", "app_icon", "title", "body", "actions", "timeout", "urgency", "replaces", NULL}; + PyObject *actions = NULL; + if (!PyArg_ParseTupleAndKeywords(args, kw, "ssssO!|iiI", (char**)kwlist, + &d.app_name, &d.icon, &d.summary, &d.body, &PyDict_Type, &actions, &timeout, &urgency, &replaces)) return NULL; if (!glfwDBusUserNotify) { PyErr_SetString(PyExc_RuntimeError, "Failed to load glfwDBusUserNotify, did you call glfw_init?"); return NULL; @@ -2088,6 +2089,17 @@ dbus_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) { d.timeout = timeout; d.urgency = urgency & 3; d.replaces = replaces; + RAII_ALLOC(const char*, aclist, calloc(2*PyDict_Size(actions), sizeof(d.actions[0]))); + if (!aclist) { return PyErr_NoMemory(); } + PyObject *key, *value; Py_ssize_t pos = 0; + d.num_actions = 0; + while (PyDict_Next(actions, &pos, &key, &value)) { + if (!PyUnicode_Check(key) || !PyUnicode_Check(value)) { PyErr_SetString(PyExc_TypeError, "actions must be strings"); return NULL; } + if (PyUnicode_GET_LENGTH(key) == 0 || PyUnicode_GET_LENGTH(value) == 0) { PyErr_SetString(PyExc_TypeError, "actions must be non-empty strings"); return NULL; } + aclist[d.num_actions] = PyUnicode_AsUTF8(key); if (!aclist[d.num_actions++]) return NULL; + aclist[d.num_actions] = PyUnicode_AsUTF8(value); if (!aclist[d.num_actions++]) return NULL; + } + d.actions = aclist; unsigned long long notification_id = glfwDBusUserNotify(&d, dbus_notification_created_callback, NULL); return PyLong_FromUnsignedLongLong(notification_id); } diff --git a/kitty/notifications.py b/kitty/notifications.py index 0f30aff82..7bc748e71 100644 --- a/kitty/notifications.py +++ b/kitty/notifications.py @@ -131,10 +131,11 @@ class PayloadType(Enum): close = 'close' icon = 'icon' alive = 'alive' + buttons = 'buttons' @property def is_text(self) -> bool: - return self in (PayloadType.title, PayloadType.body) + return self in (PayloadType.title, PayloadType.body, PayloadType.buttons) class OnlyWhen(Enum): @@ -198,9 +199,9 @@ class EncodedDataStore: return self.data_store.finalise() -def limit_size(x: str) -> str: - if len(x) > 1024: - x = x[:1024] +def limit_size(x: str, limit: int = 1024) -> str: + if len(x) > limit: + x = x[:limit] return x @@ -217,6 +218,7 @@ class NotificationCommand: application_name: str = '' notification_types: tuple[str, ...] = () timeout: int = -2 + buttons: tuple[str, ...] = () # event callbacks on_activation: Optional[Callable[['NotificationCommand'], None]] = None @@ -343,6 +345,8 @@ class NotificationCommand: self.application_name = prev.application_name if prev.notification_types: self.notification_types = prev.notification_types + self.notification_types + if prev.buttons: + self.buttons += prev.buttons if self.timeout < -1: self.timeout = prev.timeout self.icon_path = prev.icon_path @@ -386,6 +390,9 @@ class NotificationCommand: icd = self.icon_data_cache_ref() if icd: self.icon_path = icd.add_icon(self.icon_data_key, data) + elif self.current_payload_type is PayloadType.buttons: + self.buttons += tuple(limit_size(x, 256) for x in text.split('\u2028') if x) + self.buttons = self.buttons[:8] def finalise(self) -> None: if self.current_payload_buffer: @@ -559,7 +566,7 @@ class MacOSIntegration(DesktopIntegration): from .fast_data_types import cocoa_live_delivered_notifications cocoa_live_delivered_notifications() # so that we purge dead notifications elif event == "activated": - self.notification_manager.notification_activated(desktop_notification_id) + self.notification_manager.notification_activated(desktop_notification_id, 0) elif event == "creation_failed": self.notification_manager.notification_closed(desktop_notification_id) @@ -618,7 +625,8 @@ class FreeDesktopIntegration(DesktopIntegration): if event_type == 'activation_token': self.notification_manager.notification_activation_token_received(desktop_notification_id, str(extra)) elif event_type == 'activated': - self.notification_manager.notification_activated(desktop_notification_id) + button = 0 if extra == 'default' else int(extra) + self.notification_manager.notification_activated(desktop_notification_id, button) elif event_type == 'closed': self.notification_manager.notification_closed(desktop_notification_id) @@ -646,9 +654,12 @@ class FreeDesktopIntegration(DesktopIntegration): replaces_dbus_id = 0 if existing_desktop_notification_id: replaces_dbus_id = self.get_dbus_notification_id(existing_desktop_notification_id, 'notify') or 0 + actions = {'default': ' '} # dbus requires string to not be empty + for i, b in enumerate(nc.buttons): + actions[str(i+1)] = b desktop_notification_id = dbus_send_notification( - app_name=nc.application_name or 'kitty', app_icon=app_icon, title=nc.title, body=body, timeout=nc.timeout, - urgency=nc.urgency.value, replaces=replaces_dbus_id) + app_name=nc.application_name or 'kitty', app_icon=app_icon, title=nc.title, body=body, actions=actions, + timeout=nc.timeout, urgency=nc.urgency.value, replaces=replaces_dbus_id) if debug_desktop_integration: log_error(f'Requested creation of notification with {desktop_notification_id=}') if existing_desktop_notification_id and replaces_dbus_id: @@ -761,7 +772,7 @@ class NotificationManager: if n := self.in_progress_notification_commands.get(desktop_notification_id): n.activation_token = token - def notification_activated(self, desktop_notification_id: int) -> None: + def notification_activated(self, desktop_notification_id: int, which: int) -> None: if n := self.in_progress_notification_commands.get(desktop_notification_id): if not n.close_response_requested: self.purge_notification(n) @@ -769,7 +780,7 @@ class NotificationManager: self.channel.focus(n.channel_id, n.activation_token) if n.report_requested: ident = n.identifier or '0' - self.channel.send(n.channel_id, f'99;i={ident};') + self.channel.send(n.channel_id, f'99;i={ident};{which or ''}') if n.on_activation: try: n.on_activation(n)