Add a new escape code to allow terminal programs to trigger desktop notifications
Fixes #1474
This commit is contained in:
parent
cd76d109f5
commit
eca53bfab0
@ -16,6 +16,9 @@ To update |kitty|, :doc:`follow the instructions <binary>`.
|
|||||||
so people using kitty from homebrew/source are out of luck. Complain to
|
so people using kitty from homebrew/source are out of luck. Complain to
|
||||||
Apple.
|
Apple.
|
||||||
|
|
||||||
|
- Add a new extensible escape code to allow terminal programs to trigger
|
||||||
|
desktop notifications. See :ref:`desktop_notifications` (:iss:`1474`)
|
||||||
|
|
||||||
|
|
||||||
0.18.3 [2020-08-11]
|
0.18.3 [2020-08-11]
|
||||||
-------------------
|
-------------------
|
||||||
|
|||||||
@ -228,3 +228,126 @@ no ill-effects in other terminal emulators.
|
|||||||
In case you're using software that can't be easily adapted to this
|
In case you're using software that can't be easily adapted to this
|
||||||
protocol extension, it can be disabled by specifying ``no-append`` to the
|
protocol extension, it can be disabled by specifying ``no-append`` to the
|
||||||
:opt:`clipboard_control` setting.
|
:opt:`clipboard_control` setting.
|
||||||
|
|
||||||
|
|
||||||
|
.. _desktop_notifications:
|
||||||
|
|
||||||
|
|
||||||
|
Desktop notifications
|
||||||
|
---------------------------------
|
||||||
|
|
||||||
|
|kitty| implements an extensible escape code (OSC 99) to show desktop
|
||||||
|
notifications. It is easy to use from shell scripts and fully extensible to
|
||||||
|
show title and body. Clicking on the notification can optionally focus the
|
||||||
|
window it came from, and/or send an escape code back to the application running
|
||||||
|
in that window.
|
||||||
|
|
||||||
|
The design of the escape code is partially based on the discussion in
|
||||||
|
the defunct
|
||||||
|
`terminal-wg <https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/13>`_
|
||||||
|
|
||||||
|
The escape code has the form::
|
||||||
|
|
||||||
|
<OSC> 99 ; metadata ; payload <terminator>
|
||||||
|
|
||||||
|
Here ``<OSC>`` is :code:`<ESC>]` and ``<terminator>`` is :code:`<ESC><backslash>`. The
|
||||||
|
metadata is a section of colon separated :code:`key=value` pairs. Every key
|
||||||
|
must be a single character from the set :code:`[a-zA-Z]` and every value must
|
||||||
|
be a character from the set :code:`a-zA-Z0-9-_/\+.,(){}[]*&^%$#@!`~`. The
|
||||||
|
payload must be interpreted based on the metadata section. The two semi-colons
|
||||||
|
*must* always be present even when no metadata is present.
|
||||||
|
|
||||||
|
Before going into details, lets see how one can display a simple, single line
|
||||||
|
notification from a shell script::
|
||||||
|
|
||||||
|
printf '\x1b]99;;Hello world\x1b\\'
|
||||||
|
|
||||||
|
To show a message with a title and a body::
|
||||||
|
|
||||||
|
printf '\x1b]99;i=1:d=0;Hello world\x1b\\'
|
||||||
|
printf '\x1b]99;i=1:d=1:p=body;This is cool\x1b\\'
|
||||||
|
|
||||||
|
The most important key in the metadata is the ``p`` key, it controls how the
|
||||||
|
payload is interpreted. A value of ``title`` means the payload is setting the
|
||||||
|
title for the notification. A value of ``body`` means it is setting the body,
|
||||||
|
and so on, see the table below for full details.
|
||||||
|
|
||||||
|
The design of the escape code is fundamentally chunked, this is because
|
||||||
|
different terminal emulators have different limits on how large a single escape
|
||||||
|
code can be. Chunking is accomplished by the ``i`` and ``d`` keys. The ``i``
|
||||||
|
key is the *notification id* which can be any string containing the characters
|
||||||
|
``[a-zA-Z0-9_-+.]``. The ``d`` key stands for *done* and
|
||||||
|
can only take the values ``0`` and ``1``. A value of ``0`` means the
|
||||||
|
notification is not yet done and the terminal emulator should hold off
|
||||||
|
displaying it. A value of ``1`` means the notification is done, and should be
|
||||||
|
displayed. You can specify the title or body multiple times and the terminal
|
||||||
|
emulator will concatenate them, thereby allowing arbitrarily long text
|
||||||
|
(terminal emulators are free to impose a sensible limit to avoid
|
||||||
|
Denial-of-Service attacks).
|
||||||
|
|
||||||
|
Both the ``title`` and ``body`` payloads must be either UTF-8 encoded plain
|
||||||
|
text with no embedded escape codes, or UTF-8 text that is base64 encoded, in
|
||||||
|
which case there must be an ``e=1`` key in the metadata to indicate the payload
|
||||||
|
is base64 encoded.
|
||||||
|
|
||||||
|
When the user clicks the notification, a couple of things can happen, the
|
||||||
|
terminal emulator can focus the window from which the notification came, and/or
|
||||||
|
it can send back an escape code to the application indicating the notification
|
||||||
|
was activated. This is controlled by the ``a`` key which takes a comma
|
||||||
|
separated set of values, ``report`` and ``focus``. The value ``focus`` means
|
||||||
|
focus the window from which the notification was issued and is the default.
|
||||||
|
``report`` means send an escape code back to the application. The format of the
|
||||||
|
escape code is::
|
||||||
|
|
||||||
|
<OSC> 99 ; i=identifier ; <terminator>
|
||||||
|
|
||||||
|
The value of ``identifier`` comes from the ``i`` key in the escape code sent by
|
||||||
|
the application. If the application sends no identifier, then the terminal
|
||||||
|
*must* use ``i=0``. Actions can be preceded by a negative sign to turn them
|
||||||
|
off, so for example if you do not want any action, turn off the default
|
||||||
|
``focus`` action with::
|
||||||
|
|
||||||
|
a=-focus
|
||||||
|
|
||||||
|
Complete specification of all the metadata keys is in the table below. If a
|
||||||
|
terminal emulator encounters a key in the metadata it does not understand,
|
||||||
|
the key *must* be ignored, to allow for future extensibility of this escape
|
||||||
|
code. Similarly if values for known keys are unknown, the terminal emulator
|
||||||
|
*should* either ignore the entire escape code or perform a best guess effort
|
||||||
|
to display it based on what it does understand.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
It is possible to extend this escape code to allow specifying an icon for
|
||||||
|
the notification, however, given that some platforms, such as macOS, dont
|
||||||
|
allow displaying custom icons on a notification, at all, it was decided to
|
||||||
|
leave it out of the spec for the time being.
|
||||||
|
|
||||||
|
Similarly, features such a scheduled notifications could be added in future
|
||||||
|
revisions.
|
||||||
|
|
||||||
|
|
||||||
|
======= ==================== ========= =================
|
||||||
|
Key Value Default Description
|
||||||
|
======= ==================== ========= =================
|
||||||
|
``a`` Comma separated list ``focus`` What action to perform when the
|
||||||
|
of ``report``, notification is clicked
|
||||||
|
``focus``, with
|
||||||
|
optional leading
|
||||||
|
``-``
|
||||||
|
|
||||||
|
``d`` ``0`` or ``1`` ``1`` Indicates if the notification is
|
||||||
|
complete or not.
|
||||||
|
|
||||||
|
``e`` ``0`` or ``1`` ``0`` If set to ``1`` means the payload is base64 encoded UTF-8,
|
||||||
|
otherwise it is plain UTF-8 text with no C0 control codes in it
|
||||||
|
|
||||||
|
``i`` ``[a-zA-Z0-9-_+.]`` ``0`` Identifier for the notification
|
||||||
|
|
||||||
|
``p`` One of ``title`` or ``title`` Whether the payload is the notification title or body. If a
|
||||||
|
``body``. notification has no title, the body will be used as title.
|
||||||
|
======= ==================== ========= =================
|
||||||
|
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|kitty| also supports the legacy OSC 9 protocol developed by iTerm2 for
|
||||||
|
desktop notifications.
|
||||||
|
|||||||
@ -39,6 +39,7 @@ from .fast_data_types import (
|
|||||||
)
|
)
|
||||||
from .keys import get_shortcut, shortcut_matches
|
from .keys import get_shortcut, shortcut_matches
|
||||||
from .layout.base import set_layout_options
|
from .layout.base import set_layout_options
|
||||||
|
from .notify import notification_activated
|
||||||
from .options_stub import Options
|
from .options_stub import Options
|
||||||
from .os_window_size import initial_window_size_func
|
from .os_window_size import initial_window_size_func
|
||||||
from .rgb import Color, color_from_int
|
from .rgb import Color, color_from_int
|
||||||
@ -63,14 +64,6 @@ class OSWindowDict(TypedDict):
|
|||||||
tabs: List[TabDict]
|
tabs: List[TabDict]
|
||||||
|
|
||||||
|
|
||||||
def notification_activated(identifier: str) -> None:
|
|
||||||
if identifier == 'new-version':
|
|
||||||
from .update_check import notification_activated as do
|
|
||||||
do()
|
|
||||||
elif identifier.startswith('test-notify-'):
|
|
||||||
log_error(f'Test notification {identifier} activated')
|
|
||||||
|
|
||||||
|
|
||||||
def listen_on(spec: str) -> int:
|
def listen_on(spec: str) -> int:
|
||||||
import socket
|
import socket
|
||||||
family, address, socket_path = parse_address_spec(spec)
|
family, address, socket_path = parse_address_spec(spec)
|
||||||
@ -1545,3 +1538,12 @@ class Boss:
|
|||||||
now = monotonic()
|
now = monotonic()
|
||||||
ident = f'test-notify-{now}'
|
ident = f'test-notify-{now}'
|
||||||
notify(f'Test {now}', f'At: {now}', identifier=ident)
|
notify(f'Test {now}', f'At: {now}', identifier=ident)
|
||||||
|
|
||||||
|
def notification_activated(self, identifier: str, window_id: int, focus: bool, report: bool) -> None:
|
||||||
|
w = self.window_id_map.get(window_id)
|
||||||
|
if w is None:
|
||||||
|
return
|
||||||
|
if focus:
|
||||||
|
self.set_active_window(w, switch_os_window_if_needed=True)
|
||||||
|
if report:
|
||||||
|
w.report_notification_activated(identifier)
|
||||||
|
|||||||
152
kitty/notify.py
152
kitty/notify.py
@ -2,9 +2,14 @@
|
|||||||
# vim:fileencoding=utf-8
|
# vim:fileencoding=utf-8
|
||||||
# License: GPLv3 Copyright: 2019, Kovid Goyal <kovid at kovidgoyal.net>
|
# License: GPLv3 Copyright: 2019, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
from typing import Dict, Optional
|
from base64 import standard_b64decode
|
||||||
|
from collections import OrderedDict
|
||||||
|
from itertools import count
|
||||||
|
from typing import Dict, Optional, Callable
|
||||||
|
|
||||||
from .constants import is_macos, logo_png_file
|
from .constants import is_macos, logo_png_file
|
||||||
|
from .fast_data_types import get_boss
|
||||||
|
from .utils import log_error
|
||||||
|
|
||||||
if is_macos:
|
if is_macos:
|
||||||
from .fast_data_types import cocoa_send_notification
|
from .fast_data_types import cocoa_send_notification
|
||||||
@ -35,7 +40,6 @@ else:
|
|||||||
rmap = {v: k for k, v in identifier_map.items()}
|
rmap = {v: k for k, v in identifier_map.items()}
|
||||||
identifier = rmap.get(notification_id)
|
identifier = rmap.get(notification_id)
|
||||||
if identifier is not None:
|
if identifier is not None:
|
||||||
from .boss import notification_activated
|
|
||||||
notification_activated(identifier)
|
notification_activated(identifier)
|
||||||
|
|
||||||
def notify(
|
def notify(
|
||||||
@ -52,3 +56,147 @@ else:
|
|||||||
alloc_id = dbus_send_notification(application, icf, title, body, 'Click to see changes', timeout)
|
alloc_id = dbus_send_notification(application, icf, title, body, 'Click to see changes', timeout)
|
||||||
if alloc_id and identifier is not None:
|
if alloc_id and identifier is not None:
|
||||||
alloc_map[alloc_id] = identifier
|
alloc_map[alloc_id] = identifier
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationCommand:
|
||||||
|
|
||||||
|
done: bool = True
|
||||||
|
identifier: str = '0'
|
||||||
|
title: str = ''
|
||||||
|
body: str = ''
|
||||||
|
actions: str = ''
|
||||||
|
|
||||||
|
|
||||||
|
def parse_osc_9(raw: str) -> NotificationCommand:
|
||||||
|
ans = NotificationCommand()
|
||||||
|
ans.title = raw
|
||||||
|
return ans
|
||||||
|
|
||||||
|
|
||||||
|
def parse_osc_99(raw: str) -> NotificationCommand:
|
||||||
|
cmd = NotificationCommand()
|
||||||
|
metadata, payload = raw.partition(';')[::2]
|
||||||
|
payload_is_encoded = False
|
||||||
|
payload_type = 'title'
|
||||||
|
if metadata:
|
||||||
|
for part in metadata.split(':'):
|
||||||
|
try:
|
||||||
|
k, v = part.split('=', 1)
|
||||||
|
except Exception:
|
||||||
|
log_error('Malformed OSC 99: metadata is not key=value pairs')
|
||||||
|
return cmd
|
||||||
|
if k == 'p':
|
||||||
|
payload_type = v
|
||||||
|
elif k == 'i':
|
||||||
|
cmd.identifier = v
|
||||||
|
elif k == 'e':
|
||||||
|
payload_is_encoded = v == '1'
|
||||||
|
elif k == 'd':
|
||||||
|
cmd.done = v != '0'
|
||||||
|
elif k == 'a':
|
||||||
|
cmd.actions += ',' + v
|
||||||
|
if payload_type not in ('body', 'title'):
|
||||||
|
log_error(f'Malformed OSC 99: unknown payload type: {payload_type}')
|
||||||
|
return NotificationCommand()
|
||||||
|
if payload_is_encoded:
|
||||||
|
try:
|
||||||
|
payload = standard_b64decode(payload).decode('utf-8')
|
||||||
|
except Exception:
|
||||||
|
log_error('Malformed OSC 99: payload is not base64 encoded UTF-8 text')
|
||||||
|
return NotificationCommand()
|
||||||
|
if payload_type == 'title':
|
||||||
|
cmd.title = payload
|
||||||
|
else:
|
||||||
|
cmd.body = payload
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def limit_size(x: str) -> str:
|
||||||
|
if len(x) > 1024:
|
||||||
|
x = x[:1024]
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
|
def merge_osc_99(prev: NotificationCommand, cmd: NotificationCommand) -> NotificationCommand:
|
||||||
|
if prev.done or prev.identifier != cmd.identifier:
|
||||||
|
return cmd
|
||||||
|
cmd.actions = limit_size(prev.actions + ',' + cmd.actions)
|
||||||
|
cmd.title = limit_size(prev.title + cmd.title)
|
||||||
|
cmd.body = limit_size(prev.body + cmd.body)
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
identifier_registry: "OrderedDict[str, RegisteredNotification]" = OrderedDict()
|
||||||
|
id_counter = count()
|
||||||
|
|
||||||
|
|
||||||
|
class RegisteredNotification:
|
||||||
|
identifier: str
|
||||||
|
window_id: int
|
||||||
|
focus: bool = True
|
||||||
|
report: bool = False
|
||||||
|
|
||||||
|
def __init__(self, cmd: NotificationCommand, window_id: int):
|
||||||
|
self.window_id = window_id
|
||||||
|
for x in cmd.actions.strip(',').split(','):
|
||||||
|
val = not x.startswith('-')
|
||||||
|
x = x.lstrip('+-')
|
||||||
|
if x == 'focus':
|
||||||
|
self.focus = val
|
||||||
|
elif x == 'report':
|
||||||
|
self.report = val
|
||||||
|
self.identifier = cmd.identifier
|
||||||
|
|
||||||
|
|
||||||
|
def register_identifier(identifier: str, cmd: NotificationCommand, window_id: int) -> None:
|
||||||
|
identifier_registry[identifier] = RegisteredNotification(cmd, window_id)
|
||||||
|
if len(identifier_registry) > 100:
|
||||||
|
identifier_registry.popitem(False)
|
||||||
|
|
||||||
|
|
||||||
|
def notification_activated(identifier: str, activated_implementation: Optional[Callable] = None) -> None:
|
||||||
|
if identifier == 'new-version':
|
||||||
|
from .update_check import notification_activated as do
|
||||||
|
do()
|
||||||
|
elif identifier.startswith('test-notify-'):
|
||||||
|
log_error(f'Test notification {identifier} activated')
|
||||||
|
else:
|
||||||
|
r = identifier_registry.pop(identifier, None)
|
||||||
|
if r is not None and (r.focus or r.report):
|
||||||
|
if activated_implementation is None:
|
||||||
|
get_boss().notification_activated(r.identifier, r.window_id, r.focus, r.report)
|
||||||
|
else:
|
||||||
|
activated_implementation(r.identifier, r.window_id, r.focus, r.report)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_registry() -> None:
|
||||||
|
global id_counter
|
||||||
|
identifier_registry.clear()
|
||||||
|
id_counter = count()
|
||||||
|
|
||||||
|
|
||||||
|
def notify_with_command(cmd: NotificationCommand, window_id: int, notify: Callable = notify) -> None:
|
||||||
|
title = cmd.title or cmd.body
|
||||||
|
body = cmd.body if cmd.title else ''
|
||||||
|
if title:
|
||||||
|
identifier = 'i' + str(next(id_counter))
|
||||||
|
notify(title, body, identifier=identifier)
|
||||||
|
register_identifier(identifier, cmd, window_id)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_notification_cmd(
|
||||||
|
osc_code: int,
|
||||||
|
raw_data: str,
|
||||||
|
window_id: int,
|
||||||
|
prev_cmd: NotificationCommand,
|
||||||
|
notify_implementation: Callable = notify
|
||||||
|
) -> Optional[NotificationCommand]:
|
||||||
|
if osc_code == 99:
|
||||||
|
cmd = merge_osc_99(prev_cmd, parse_osc_99(raw_data))
|
||||||
|
if cmd.done:
|
||||||
|
notify_with_command(cmd, window_id, notify_implementation)
|
||||||
|
cmd = NotificationCommand()
|
||||||
|
return cmd
|
||||||
|
if osc_code == 9:
|
||||||
|
cmd = parse_osc_9(raw_data)
|
||||||
|
notify_with_command(cmd, window_id, notify_implementation)
|
||||||
|
|||||||
@ -301,10 +301,11 @@ handle_esc_mode_char(Screen *screen, uint32_t ch, PyObject DUMP_UNUSED *dump_cal
|
|||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
// OSC mode {{{
|
// OSC mode {{{
|
||||||
|
|
||||||
static inline void
|
static inline void
|
||||||
dispatch_osc(Screen *screen, PyObject DUMP_UNUSED *dump_callback) {
|
dispatch_osc(Screen *screen, PyObject DUMP_UNUSED *dump_callback) {
|
||||||
|
#define DISPATCH_OSC_WITH_CODE(name) REPORT_OSC2(name, code, string); name(screen, code, string);
|
||||||
#define DISPATCH_OSC(name) REPORT_OSC(name, string); name(screen, string);
|
#define DISPATCH_OSC(name) REPORT_OSC(name, string); name(screen, string);
|
||||||
#define SET_COLOR(name) REPORT_OSC2(name, code, string); name(screen, code, string);
|
|
||||||
const unsigned int limit = screen->parser_buf_pos;
|
const unsigned int limit = screen->parser_buf_pos;
|
||||||
unsigned int code=0, i;
|
unsigned int code=0, i;
|
||||||
for (i = 0; i < MIN(limit, 5u); i++) {
|
for (i = 0; i < MIN(limit, 5u); i++) {
|
||||||
@ -329,7 +330,11 @@ dispatch_osc(Screen *screen, PyObject DUMP_UNUSED *dump_callback) {
|
|||||||
break;
|
break;
|
||||||
case 4:
|
case 4:
|
||||||
case 104:
|
case 104:
|
||||||
SET_COLOR(set_color_table_color);
|
DISPATCH_OSC_WITH_CODE(set_color_table_color);
|
||||||
|
break;
|
||||||
|
case 9:
|
||||||
|
case 99:
|
||||||
|
DISPATCH_OSC_WITH_CODE(desktop_notify)
|
||||||
break;
|
break;
|
||||||
case 10:
|
case 10:
|
||||||
case 11:
|
case 11:
|
||||||
@ -341,7 +346,7 @@ dispatch_osc(Screen *screen, PyObject DUMP_UNUSED *dump_callback) {
|
|||||||
case 112:
|
case 112:
|
||||||
case 117:
|
case 117:
|
||||||
case 119:
|
case 119:
|
||||||
SET_COLOR(set_dynamic_color);
|
DISPATCH_OSC_WITH_CODE(set_dynamic_color);
|
||||||
break;
|
break;
|
||||||
case 52:
|
case 52:
|
||||||
DISPATCH_OSC(clipboard_control);
|
DISPATCH_OSC(clipboard_control);
|
||||||
@ -361,7 +366,7 @@ dispatch_osc(Screen *screen, PyObject DUMP_UNUSED *dump_callback) {
|
|||||||
Py_CLEAR(string);
|
Py_CLEAR(string);
|
||||||
}
|
}
|
||||||
#undef DISPATCH_OSC
|
#undef DISPATCH_OSC
|
||||||
#undef SET_COLOR
|
#undef DISPATCH_OSC_WITH_CODE
|
||||||
}
|
}
|
||||||
// }}}
|
// }}}
|
||||||
|
|
||||||
|
|||||||
@ -1495,6 +1495,11 @@ set_title(Screen *self, PyObject *title) {
|
|||||||
CALLBACK("title_changed", "O", title);
|
CALLBACK("title_changed", "O", title);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void
|
||||||
|
desktop_notify(Screen *self, unsigned int osc_code, PyObject *data) {
|
||||||
|
CALLBACK("desktop_notify", "IO", osc_code, data);
|
||||||
|
}
|
||||||
|
|
||||||
void
|
void
|
||||||
set_icon(Screen *self, PyObject *icon) {
|
set_icon(Screen *self, PyObject *icon) {
|
||||||
CALLBACK("icon_changed", "O", icon);
|
CALLBACK("icon_changed", "O", icon);
|
||||||
|
|||||||
@ -178,6 +178,7 @@ void screen_handle_print(Screen *, PyObject *cmd);
|
|||||||
void screen_designate_charset(Screen *, uint32_t which, uint32_t as);
|
void screen_designate_charset(Screen *, uint32_t which, uint32_t as);
|
||||||
void screen_use_latin1(Screen *, bool);
|
void screen_use_latin1(Screen *, bool);
|
||||||
void set_title(Screen *self, PyObject*);
|
void set_title(Screen *self, PyObject*);
|
||||||
|
void desktop_notify(Screen *self, unsigned int, PyObject*);
|
||||||
void set_icon(Screen *self, PyObject*);
|
void set_icon(Screen *self, PyObject*);
|
||||||
void set_dynamic_color(Screen *self, unsigned int code, PyObject*);
|
void set_dynamic_color(Screen *self, unsigned int code, PyObject*);
|
||||||
void clipboard_control(Screen *self, PyObject*);
|
void clipboard_control(Screen *self, PyObject*);
|
||||||
|
|||||||
@ -30,6 +30,7 @@ from .fast_data_types import (
|
|||||||
viewport_for_window
|
viewport_for_window
|
||||||
)
|
)
|
||||||
from .keys import defines, extended_key_event, keyboard_mode_name
|
from .keys import defines, extended_key_event, keyboard_mode_name
|
||||||
|
from .notify import NotificationCommand, handle_notification_cmd
|
||||||
from .options_stub import Options
|
from .options_stub import Options
|
||||||
from .rgb import to_color
|
from .rgb import to_color
|
||||||
from .terminfo import get_capabilities
|
from .terminfo import get_capabilities
|
||||||
@ -218,6 +219,7 @@ class Window:
|
|||||||
watchers: Optional[Watchers] = None
|
watchers: Optional[Watchers] = None
|
||||||
):
|
):
|
||||||
self.watchers = watchers or Watchers()
|
self.watchers = watchers or Watchers()
|
||||||
|
self.prev_osc99_cmd = NotificationCommand()
|
||||||
self.action_on_close: Optional[Callable] = None
|
self.action_on_close: Optional[Callable] = None
|
||||||
self.action_on_removal: Optional[Callable] = None
|
self.action_on_removal: Optional[Callable] = None
|
||||||
self.current_marker_spec: Optional[Tuple[str, Union[str, Tuple[Tuple[int, str], ...]]]] = None
|
self.current_marker_spec: Optional[Tuple[str, Union[str, Tuple[Tuple[int, str], ...]]]] = None
|
||||||
@ -434,6 +436,11 @@ class Window:
|
|||||||
self.override_title = title or None
|
self.override_title = title or None
|
||||||
self.title_updated()
|
self.title_updated()
|
||||||
|
|
||||||
|
def desktop_notify(self, osc_code: int, raw_data: str) -> None:
|
||||||
|
cmd = handle_notification_cmd(osc_code, raw_data, self.id, self.prev_osc99_cmd)
|
||||||
|
if cmd is not None and osc_code == 99:
|
||||||
|
self.prev_osc99_cmd = cmd
|
||||||
|
|
||||||
# screen callbacks {{{
|
# screen callbacks {{{
|
||||||
def use_utf8(self, on: bool) -> None:
|
def use_utf8(self, on: bool) -> None:
|
||||||
get_boss().child_monitor.set_iutf8_winid(self.id, on)
|
get_boss().child_monitor.set_iutf8_winid(self.id, on)
|
||||||
@ -511,6 +518,9 @@ class Window:
|
|||||||
b |= b << 8
|
b |= b << 8
|
||||||
self.screen.send_escape_code_to_child(OSC, '{};rgb:{:04x}/{:04x}/{:04x}'.format(code, r, g, b))
|
self.screen.send_escape_code_to_child(OSC, '{};rgb:{:04x}/{:04x}/{:04x}'.format(code, r, g, b))
|
||||||
|
|
||||||
|
def report_notification_activated(self, identifier: str) -> None:
|
||||||
|
self.screen.send_escape_code_to_child(OSC, f'99;i={identifier};')
|
||||||
|
|
||||||
def set_dynamic_color(self, code: int, value: Union[str, bytes]) -> None:
|
def set_dynamic_color(self, code: int, value: Union[str, bytes]) -> None:
|
||||||
if isinstance(value, bytes):
|
if isinstance(value, bytes):
|
||||||
value = value.decode('utf-8')
|
value = value.decode('utf-8')
|
||||||
|
|||||||
@ -37,10 +37,14 @@ class Callbacks:
|
|||||||
def use_utf8(self, on):
|
def use_utf8(self, on):
|
||||||
self.iutf8 = on
|
self.iutf8 = on
|
||||||
|
|
||||||
|
def desktop_notify(self, osc_code: int, raw_data: str) -> None:
|
||||||
|
self.notifications.append((osc_code, raw_data))
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
self.wtcbuf = b''
|
self.wtcbuf = b''
|
||||||
self.iconbuf = self.titlebuf = self.colorbuf = self.ctbuf = ''
|
self.iconbuf = self.titlebuf = self.colorbuf = self.ctbuf = ''
|
||||||
self.iutf8 = True
|
self.iutf8 = True
|
||||||
|
self.notifications = []
|
||||||
|
|
||||||
|
|
||||||
def filled_line_buf(ynum=5, xnum=5, cursor=Cursor()):
|
def filled_line_buf(ynum=5, xnum=5, cursor=Cursor()):
|
||||||
|
|||||||
@ -3,10 +3,15 @@
|
|||||||
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
|
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
from base64 import standard_b64encode
|
||||||
from binascii import hexlify
|
from binascii import hexlify
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
from kitty.fast_data_types import CURSOR_BLOCK, parse_bytes, parse_bytes_dump
|
from kitty.fast_data_types import CURSOR_BLOCK, parse_bytes, parse_bytes_dump
|
||||||
|
from kitty.notify import (
|
||||||
|
NotificationCommand, handle_notification_cmd, notification_activated,
|
||||||
|
reset_registry
|
||||||
|
)
|
||||||
|
|
||||||
from . import BaseTest
|
from . import BaseTest
|
||||||
|
|
||||||
@ -195,6 +200,7 @@ class TestParser(BaseTest):
|
|||||||
pb('\t\033[b', ('screen_tab',), ('screen_repeat_character', 1))
|
pb('\t\033[b', ('screen_tab',), ('screen_repeat_character', 1))
|
||||||
self.ae(str(s.line(0)), '\t')
|
self.ae(str(s.line(0)), '\t')
|
||||||
s.reset()
|
s.reset()
|
||||||
|
b']]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]'
|
||||||
|
|
||||||
def test_osc_codes(self):
|
def test_osc_codes(self):
|
||||||
s = self.create_screen()
|
s = self.create_screen()
|
||||||
@ -216,6 +222,74 @@ class TestParser(BaseTest):
|
|||||||
self.ae(c.titlebuf, '')
|
self.ae(c.titlebuf, '')
|
||||||
pb('\033]110\x07', ('set_dynamic_color', 110, ''))
|
pb('\033]110\x07', ('set_dynamic_color', 110, ''))
|
||||||
self.ae(c.colorbuf, '')
|
self.ae(c.colorbuf, '')
|
||||||
|
c.clear()
|
||||||
|
pb('\033]9;\x07', ('desktop_notify', 9, ''))
|
||||||
|
pb('\033]9;test it\x07', ('desktop_notify', 9, 'test it'))
|
||||||
|
pb('\033]99;moo=foo;test it\x07', ('desktop_notify', 99, 'moo=foo;test it'))
|
||||||
|
self.ae(c.notifications, [(9, ''), (9, 'test it'), (99, 'moo=foo;test it')])
|
||||||
|
|
||||||
|
def test_desktop_notify(self):
|
||||||
|
reset_registry()
|
||||||
|
notifications = []
|
||||||
|
activations = []
|
||||||
|
prev_cmd = NotificationCommand()
|
||||||
|
|
||||||
|
def reset():
|
||||||
|
nonlocal prev_cmd
|
||||||
|
reset_registry()
|
||||||
|
del notifications[:]
|
||||||
|
del activations[:]
|
||||||
|
prev_cmd = NotificationCommand()
|
||||||
|
|
||||||
|
def notify(title, body, identifier):
|
||||||
|
notifications.append((title, body, identifier))
|
||||||
|
|
||||||
|
def h(raw_data, osc_code=99, window_id=1):
|
||||||
|
nonlocal prev_cmd
|
||||||
|
x = handle_notification_cmd(osc_code, raw_data, window_id, prev_cmd, notify)
|
||||||
|
if x is not None and osc_code == 99:
|
||||||
|
prev_cmd = x
|
||||||
|
|
||||||
|
def activated(identifier, window_id, focus, report):
|
||||||
|
activations.append((identifier, window_id, focus, report))
|
||||||
|
|
||||||
|
h('test it', osc_code=9)
|
||||||
|
self.ae(notifications, [('test it', '', 'i0')])
|
||||||
|
notification_activated(notifications[-1][-1], activated)
|
||||||
|
self.ae(activations, [('0', 1, True, False)])
|
||||||
|
reset()
|
||||||
|
|
||||||
|
h('d=0:i=x;title')
|
||||||
|
h('d=1:i=x:p=body;body')
|
||||||
|
self.ae(notifications, [('title', 'body', 'i0')])
|
||||||
|
notification_activated(notifications[-1][-1], activated)
|
||||||
|
self.ae(activations, [('x', 1, True, False)])
|
||||||
|
reset()
|
||||||
|
|
||||||
|
h('i=x:p=body:a=-focus;body')
|
||||||
|
self.ae(notifications, [('body', '', 'i0')])
|
||||||
|
notification_activated(notifications[-1][-1], activated)
|
||||||
|
self.ae(activations, [])
|
||||||
|
reset()
|
||||||
|
|
||||||
|
h('i=x:e=1;' + standard_b64encode(b'title').decode('ascii'))
|
||||||
|
self.ae(notifications, [('title', '', 'i0')])
|
||||||
|
notification_activated(notifications[-1][-1], activated)
|
||||||
|
self.ae(activations, [('x', 1, True, False)])
|
||||||
|
reset()
|
||||||
|
|
||||||
|
h('d=0:i=x:a=-report;title')
|
||||||
|
h('d=1:i=x:a=report;body')
|
||||||
|
self.ae(notifications, [('titlebody', '', 'i0')])
|
||||||
|
notification_activated(notifications[-1][-1], activated)
|
||||||
|
self.ae(activations, [('x', 1, True, True)])
|
||||||
|
reset()
|
||||||
|
|
||||||
|
h(';title')
|
||||||
|
self.ae(notifications, [('title', '', 'i0')])
|
||||||
|
notification_activated(notifications[-1][-1], activated)
|
||||||
|
self.ae(activations, [('0', 1, True, False)])
|
||||||
|
reset()
|
||||||
|
|
||||||
def test_dcs_codes(self):
|
def test_dcs_codes(self):
|
||||||
s = self.create_screen()
|
s = self.create_screen()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user