Implement support for buttons on notifications in Linux

This commit is contained in:
Kovid Goyal
2024-07-31 12:11:21 +05:30
parent ad36c481af
commit aa16918dd4
8 changed files with 60 additions and 25 deletions

2
glfw/glfw3.h vendored
View File

@@ -1316,7 +1316,7 @@ typedef struct GLFWLayerShellConfig {
} GLFWLayerShellConfig; } GLFWLayerShellConfig;
typedef struct GLFWDBUSNotificationData { 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; int32_t timeout; uint8_t urgency; uint32_t replaces;
} GLFWDBUSNotificationData; } GLFWDBUSNotificationData;

8
glfw/linux_notify.c vendored
View File

@@ -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->summary)
APPEND(args, DBUS_TYPE_STRING, n->body) APPEND(args, DBUS_TYPE_STRING, n->body)
check_call(dbus_message_iter_open_container, &args, DBUS_TYPE_ARRAY, "s", &array); check_call(dbus_message_iter_open_container, &args, DBUS_TYPE_ARRAY, "s", &array);
if (n->action_name) { if (n->actions) {
static const char* default_action = "default"; for (size_t i = 0; i < n->num_actions; i++) {
APPEND(array, DBUS_TYPE_STRING, default_action); APPEND(array, DBUS_TYPE_STRING, n->actions[i]);
APPEND(array, DBUS_TYPE_STRING, n->action_name); }
} }
check_call(dbus_message_iter_close_container, &args, &array); check_call(dbus_message_iter_close_container, &args, &array);
check_call(dbus_message_iter_open_container, &args, DBUS_TYPE_ARRAY, "{sv}", &array); check_call(dbus_message_iter_open_container, &args, DBUS_TYPE_ARRAY, "{sv}", &array);

View File

@@ -102,6 +102,9 @@ func (p *parsed_data) generate_chunks(callback func(string)) {
if len(p.image_data) > 0 { if len(p.image_data) > 0 {
add_payload("icon", utils.UnsafeBytesToString(p.image_data)) 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(";") write_chunk(";")
} }
@@ -110,7 +113,7 @@ func (p *parsed_data) run_loop() (err error) {
if err != nil { if err != nil {
return err return err
} }
activated := "" activated := -1
prefix := ESC_CODE_PREFIX + "i=" + p.identifier prefix := ESC_CODE_PREFIX + "i=" + p.identifier
poll_for_close := func() { poll_for_close := func() {
@@ -156,7 +159,9 @@ func (p *parsed_data) run_loop() (err error) {
lp.Quit(0) lp.Quit(0)
} }
case "": 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() lp.KillIfSignalled()
return return
} }
if activated != "" && err == nil { if activated > -1 && err == nil {
fmt.Println(activated) fmt.Println(activated)
} }
return return

View File

@@ -26,6 +26,13 @@ default=kitten-notify
The application name for the notification. 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 --urgency -u
default=normal default=normal
choices=normal,low,critical choices=normal,low,critical
@@ -62,7 +69,7 @@ your own identifier via the --identifier option.
--wait-till-closed -w --wait-till-closed -w
type=bool-set type=bool-set
Wait until the notification is closed. If the user activates the notification, 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. to close the notification manually.

View File

@@ -550,7 +550,7 @@ def dbus_send_notification(
app_icon: str, app_icon: str,
title: str, title: str,
body: str, body: str,
action_text: str = '', actions: dict[str, str],
timeout: int = -1, timeout: int = -1,
urgency: int = 1, urgency: int = 1,
replaces: int = 0, replaces: int = 0,

2
kitty/glfw-wrapper.h generated
View File

@@ -1054,7 +1054,7 @@ typedef struct GLFWLayerShellConfig {
} GLFWLayerShellConfig; } GLFWLayerShellConfig;
typedef struct GLFWDBUSNotificationData { 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; int32_t timeout; uint8_t urgency; uint32_t replaces;
} GLFWDBUSNotificationData; } GLFWDBUSNotificationData;

View File

@@ -2077,10 +2077,11 @@ dbus_notification_created_callback(unsigned long long notification_id, uint32_t
static PyObject* static PyObject*
dbus_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) { dbus_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) {
int timeout = -1, urgency = 1; unsigned int replaces = 0; int timeout = -1, urgency = 1; unsigned int replaces = 0;
GLFWDBUSNotificationData d = {.action_name=""}; GLFWDBUSNotificationData d = {0};
static const char* kwlist[] = {"app_name", "app_icon", "title", "body", "action_text", "timeout", "urgency", "replaces", NULL}; static const char* kwlist[] = {"app_name", "app_icon", "title", "body", "actions", "timeout", "urgency", "replaces", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kw, "ssss|siiI", (char**)kwlist, PyObject *actions = NULL;
&d.app_name, &d.icon, &d.summary, &d.body, &d.action_name, &timeout, &urgency, &replaces)) return 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) { if (!glfwDBusUserNotify) {
PyErr_SetString(PyExc_RuntimeError, "Failed to load glfwDBusUserNotify, did you call glfw_init?"); PyErr_SetString(PyExc_RuntimeError, "Failed to load glfwDBusUserNotify, did you call glfw_init?");
return NULL; return NULL;
@@ -2088,6 +2089,17 @@ dbus_send_notification(PyObject *self UNUSED, PyObject *args, PyObject *kw) {
d.timeout = timeout; d.timeout = timeout;
d.urgency = urgency & 3; d.urgency = urgency & 3;
d.replaces = replaces; 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); unsigned long long notification_id = glfwDBusUserNotify(&d, dbus_notification_created_callback, NULL);
return PyLong_FromUnsignedLongLong(notification_id); return PyLong_FromUnsignedLongLong(notification_id);
} }

View File

@@ -131,10 +131,11 @@ class PayloadType(Enum):
close = 'close' close = 'close'
icon = 'icon' icon = 'icon'
alive = 'alive' alive = 'alive'
buttons = 'buttons'
@property @property
def is_text(self) -> bool: def is_text(self) -> bool:
return self in (PayloadType.title, PayloadType.body) return self in (PayloadType.title, PayloadType.body, PayloadType.buttons)
class OnlyWhen(Enum): class OnlyWhen(Enum):
@@ -198,9 +199,9 @@ class EncodedDataStore:
return self.data_store.finalise() return self.data_store.finalise()
def limit_size(x: str) -> str: def limit_size(x: str, limit: int = 1024) -> str:
if len(x) > 1024: if len(x) > limit:
x = x[:1024] x = x[:limit]
return x return x
@@ -217,6 +218,7 @@ class NotificationCommand:
application_name: str = '' application_name: str = ''
notification_types: tuple[str, ...] = () notification_types: tuple[str, ...] = ()
timeout: int = -2 timeout: int = -2
buttons: tuple[str, ...] = ()
# event callbacks # event callbacks
on_activation: Optional[Callable[['NotificationCommand'], None]] = None on_activation: Optional[Callable[['NotificationCommand'], None]] = None
@@ -343,6 +345,8 @@ class NotificationCommand:
self.application_name = prev.application_name self.application_name = prev.application_name
if prev.notification_types: if prev.notification_types:
self.notification_types = prev.notification_types + self.notification_types self.notification_types = prev.notification_types + self.notification_types
if prev.buttons:
self.buttons += prev.buttons
if self.timeout < -1: if self.timeout < -1:
self.timeout = prev.timeout self.timeout = prev.timeout
self.icon_path = prev.icon_path self.icon_path = prev.icon_path
@@ -386,6 +390,9 @@ class NotificationCommand:
icd = self.icon_data_cache_ref() icd = self.icon_data_cache_ref()
if icd: if icd:
self.icon_path = icd.add_icon(self.icon_data_key, data) 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: def finalise(self) -> None:
if self.current_payload_buffer: if self.current_payload_buffer:
@@ -559,7 +566,7 @@ class MacOSIntegration(DesktopIntegration):
from .fast_data_types import cocoa_live_delivered_notifications from .fast_data_types import cocoa_live_delivered_notifications
cocoa_live_delivered_notifications() # so that we purge dead notifications cocoa_live_delivered_notifications() # so that we purge dead notifications
elif event == "activated": elif event == "activated":
self.notification_manager.notification_activated(desktop_notification_id) self.notification_manager.notification_activated(desktop_notification_id, 0)
elif event == "creation_failed": elif event == "creation_failed":
self.notification_manager.notification_closed(desktop_notification_id) self.notification_manager.notification_closed(desktop_notification_id)
@@ -618,7 +625,8 @@ class FreeDesktopIntegration(DesktopIntegration):
if event_type == 'activation_token': if event_type == 'activation_token':
self.notification_manager.notification_activation_token_received(desktop_notification_id, str(extra)) self.notification_manager.notification_activation_token_received(desktop_notification_id, str(extra))
elif event_type == 'activated': 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': elif event_type == 'closed':
self.notification_manager.notification_closed(desktop_notification_id) self.notification_manager.notification_closed(desktop_notification_id)
@@ -646,9 +654,12 @@ class FreeDesktopIntegration(DesktopIntegration):
replaces_dbus_id = 0 replaces_dbus_id = 0
if existing_desktop_notification_id: if existing_desktop_notification_id:
replaces_dbus_id = self.get_dbus_notification_id(existing_desktop_notification_id, 'notify') or 0 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( 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, app_name=nc.application_name or 'kitty', app_icon=app_icon, title=nc.title, body=body, actions=actions,
urgency=nc.urgency.value, replaces=replaces_dbus_id) timeout=nc.timeout, urgency=nc.urgency.value, replaces=replaces_dbus_id)
if debug_desktop_integration: if debug_desktop_integration:
log_error(f'Requested creation of notification with {desktop_notification_id=}') log_error(f'Requested creation of notification with {desktop_notification_id=}')
if existing_desktop_notification_id and replaces_dbus_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): if n := self.in_progress_notification_commands.get(desktop_notification_id):
n.activation_token = token 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 n := self.in_progress_notification_commands.get(desktop_notification_id):
if not n.close_response_requested: if not n.close_response_requested:
self.purge_notification(n) self.purge_notification(n)
@@ -769,7 +780,7 @@ class NotificationManager:
self.channel.focus(n.channel_id, n.activation_token) self.channel.focus(n.channel_id, n.activation_token)
if n.report_requested: if n.report_requested:
ident = n.identifier or '0' 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: if n.on_activation:
try: try:
n.on_activation(n) n.on_activation(n)