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
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
8 changed files with 60 additions and 25 deletions

2
glfw/glfw3.h vendored
View file

@ -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;

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->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);

View file

@ -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

View file

@ -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.

View file

@ -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,

2
kitty/glfw-wrapper.h generated
View file

@ -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;

View file

@ -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);
}

View file

@ -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)