diff --git a/docs/changelog.rst b/docs/changelog.rst index f6ce8b2e7..eef4a814b 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -89,6 +89,8 @@ Detailed list of changes - Allow :ref:`specifying individual color themes ` to use so that kitty changes colors automatically following the OS dark/light mode +- :opt:`notify_on_cmd_finish`: Automatically remove notifications when the window gains focus or the next notification is shown. Clearing behavior can be configured (:pull:`8100`) + - Discard OSC 9 notifications that start with :code:`4;` because some misguided software is using it for "progress reporting" (:iss:`8011`) - Wayland GNOME: Workaround bug in mutter causing double tap on titlebar to not always work (:iss:`8054`) diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 5d90208f2..7d92a5e74 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -3304,7 +3304,13 @@ and exits will spam a notification. Second, the action to perform. The default is :code:`notify`. The possible values are: :code:`notify` - Send a desktop notification. + Send a desktop notification. The subsequent arguments are optional and specify when + the notification is automatically cleared. The set of possible events when the notification is + cleared are: :code:`focus` and :code:`next`. :code:`focus` means that when the notification + policy is :code:`unfocused` or :code:`invisible` the notification is automatically cleared + when the window regains focus. The value of :code:`next` means that the previous notification + is cleared when the next notification is shown. The default when no arguments are specified + is: :code:`focus next`. :code:`bell` Ring the terminal bell. @@ -3323,6 +3329,9 @@ Some more examples:: # Run 'notify-send' when a command takes more than 10 seconds in a invisible window # Here %c is replaced by the current command line and %s by the job exit code notify_on_cmd_finish invisible 10.0 command notify-send "job finished with status: %s" %c + # Do not clear previous notification when next command finishes or window regains focus + notify_on_cmd_finish invisible 5.0 notify + ''' ) diff --git a/kitty/options/types.py b/kitty/options/types.py index c690580ab..f605e7172 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -565,7 +565,7 @@ class Options: mark3_background: Color = Color(242, 116, 188) mark3_foreground: Color = Color(0, 0, 0) mouse_hide_wait: float = 0.0 if is_macos else 3.0 - notify_on_cmd_finish: NotifyOnCmdFinish = NotifyOnCmdFinish(when='never', duration=5.0, action='notify', cmdline=()) + notify_on_cmd_finish: NotifyOnCmdFinish = NotifyOnCmdFinish(when='never', duration=5.0, action='notify', cmdline=(), clear_on=('focus', 'next')) open_url_with: typing.List[str] = ['default'] paste_actions: typing.FrozenSet[str] = frozenset({'confirm', 'quote-urls-at-prompt'}) placement_strategy: choices_for_placement_strategy = 'center' diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 0272f0e37..85d842665 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -26,6 +26,7 @@ from typing import ( Tuple, TypeVar, Union, + cast, get_args, ) @@ -763,11 +764,17 @@ def active_tab_title_template(x: str) -> Optional[str]: return None if x == 'none' else x +ClearOn = Literal['next', 'focus'] +default_clear_on: tuple[ClearOn, ...] = 'focus', 'next' +all_clear_on = get_args(ClearOn) + + class NotifyOnCmdFinish(NamedTuple): - when: str - duration: float - action: str - cmdline: Tuple[str, ...] + when: str = 'never' + duration: float = 5.0 + action: str = 'notify' + cmdline: Tuple[str, ...] = () + clear_on: tuple[ClearOn, ...] = default_clear_on def notify_on_cmd_finish(x: str) -> NotifyOnCmdFinish: @@ -780,16 +787,26 @@ def notify_on_cmd_finish(x: str) -> NotifyOnCmdFinish: duration = float(parts[1]) action = 'notify' cmdline: Tuple[str, ...] = () + clear_on = default_clear_on if len(parts) > 2: if parts[2] not in ('notify', 'bell', 'command'): raise ValueError(f'Unknown notify_on_cmd_finish action: {parts[2]}') action = parts[2] - if action == 'command': + if action == 'notify': + if len(parts) > 3: + con: list[ClearOn] = [] + for x in parts[3].split(): + if x not in all_clear_on: + raise ValueError( + f'notify_on_cmd_finish: notify clear_on value "{x}" is invalid. Valid values are: {", ".join(all_clear_on)}') + con.append(cast(ClearOn, x)) + clear_on = tuple(con) + elif action == 'command': if len(parts) > 3: cmdline = tuple(to_cmdline(parts[3])) else: raise ValueError('notify_on_cmd_finish `command` action needs a command line') - return NotifyOnCmdFinish(when, duration, action, cmdline) + return NotifyOnCmdFinish(when, duration, action, cmdline, clear_on) def config_or_absolute_path(x: str, env: Optional[Dict[str, str]] = None) -> Optional[str]: diff --git a/kitty/window.py b/kitty/window.py index c31bf30cc..18140e1e3 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -117,6 +117,7 @@ if TYPE_CHECKING: from .fast_data_types import MousePosition from .file_transmission import FileTransmission + from .notifications import OnlyWhen class CwdRequestType(Enum): @@ -630,7 +631,7 @@ class Window: self.current_mouse_event_button = 0 self.current_clipboard_read_ask: Optional[bool] = None self.last_cmd_output_start_time = 0. - self.last_notification_id: Optional[int] = None + self.last_cmd_end_notification: Optional[tuple[int, 'OnlyWhen']] = None self.open_url_handler: 'OpenUrlHandler' = None self.last_cmd_cmdline = '' self.last_cmd_exit_status = 0 @@ -1214,13 +1215,12 @@ class Window: tab = self.tabref() if tab is not None: tab.relayout_borders() - if self.last_notification_id: - # Notification id is saved withing handle_cmd_end so it - # configured to be close upon focus is gained and visibility - # change. When window is focused, it is visible for sure. - nm = get_boss().notification_manager - nm.close_notification(self.last_notification_id) - self.last_notification_id = None + if self.last_cmd_end_notification is not None: + from .notifications import OnlyWhen + opts = get_options() + if self.last_cmd_end_notification[1] in (OnlyWhen.unfocused, OnlyWhen.invisible) and 'focus' in opts.notify_on_cmd_finish.clear_on: + get_boss().notification_manager.close_notification(self.last_cmd_end_notification[0]) + self.last_cmd_end_notification = None elif self.os_window_id == current_focused_os_window_id(): # Cancel IME composition after loses focus update_ime_position_for_window(self.id, False, -1) @@ -1518,7 +1518,7 @@ class Window: "is_start": False, "time": end_time, 'cmdline': self.last_cmd_cmdline, 'exit_status': self.last_cmd_exit_status}) opts = get_options() - when, duration, action, notify_cmdline = opts.notify_on_cmd_finish + when, duration, action, notify_cmdline, _ = opts.notify_on_cmd_finish if last_cmd_output_duration >= duration and when != 'never': from .notifications import OnlyWhen @@ -1531,20 +1531,13 @@ class Window: if not nm.is_notification_allowed(cmd, self.id): return if action == 'notify': - # Notification id is saved, so configuration was checked on - # previous pass and saved to be cleared upon focus. But that - # action was missed somehow and we should clear it here. - if self.last_notification_id: - nm.close_notification(self.last_notification_id) - self.last_notification_id = None + if self.last_cmd_end_notification is not None: + if 'next' in opts.notify_on_cmd_finish.clear_on: + nm.close_notification(self.last_cmd_end_notification[0]) + self.last_cmd_end_notification = None notification_id = nm.notify_with_command(cmd, self.id) - # Saving notification id only when we are going to close it in - # future. - # TODO(Shvedov): We should close notification not only when - # gather focus, but when window become visible if `when` equals - # to "invisible". - if cmd.only_when is OnlyWhen.unfocused or cmd.only_when is OnlyWhen.invisible: - self.last_notification_id = notification_id + if notification_id is not None: + self.last_cmd_end_notification = notification_id, cmd.only_when elif action == 'bell': self.screen.bell() elif action == 'command':