diff --git a/docs/changelog.rst b/docs/changelog.rst index ce14f06c9..6387c0653 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -84,10 +84,12 @@ consumption to do the same tasks. Detailed list of changes ------------------------------------- -0.38.2 [future] +0.39.0 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -- diff kitten: Automatically use dark/light color scheme based on the color scheme of the parent terminal. Can be controlled via the new :opt:`kitten-diff.color_scheme` option. Note that this is a **behavior change** by default. (:iss:`8170`) +- diff kitten: Automatically use dark/light color scheme based on the color scheme of the parent terminal. Can be controlled via the new :opt:`kitten-diff.color_scheme` option. Note that this is a **default behavior change** (:iss:`8170`) + +- When a program running in kitty reports progress of a task display it as a percentage in the tab title. Controlled by the :opt:`tab_title_template` option - When mapping a custom kitten allow using shell escaping for the kitten path (:iss:`8178`) diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 3b0d2ae60..d32316f0b 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1400,7 +1400,7 @@ A value of zero means that no limit is applied. ''' ) -opt('tab_title_template', '"{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title}"', +opt('tab_title_template', '"{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{tab.last_focused_progress_percent}{title}"', option_type='tab_title_template', long_text=''' A template to render the tab title. The default just renders the title with @@ -1431,6 +1431,12 @@ use :code:`{sup.index}`. All data available is: The maximum title length available. :code:`keyboard_mode` The name of the current :ref:`keyboard mode ` or the empty string if no keyboard mode is active. +:code:`tab.last_focused_progress_percent` + If a command running in a window reports the progress for a task, show this progress as a percentage + from the most recently focused window in the tab. Empty string if no progress is reported. +:code:`tab.progress_percent` + If a command running in a window reports the progress for a task, show this progress as a percentage + from all windows in the tab, averaged. Empty string is no progress is reported. Note that formatting is done by Python's string formatting machinery, so you can use, for instance, :code:`{layout_name[:2].upper()}` to show only the first two diff --git a/kitty/options/types.py b/kitty/options/types.py index 44929ddb5..a9bc54b28 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -607,7 +607,7 @@ class Options: tab_separator: str = ' ┇' tab_switch_strategy: choices_for_tab_switch_strategy = 'previous' tab_title_max_length: int = 0 - tab_title_template: str = '{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{title}' + tab_title_template: str = '{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.tab}{tab.last_focused_progress_percent}{title}' term: str = 'xterm-kitty' terminfo_type: choices_for_terminfo_type = 'path' text_composition_strategy: str = 'platform' @@ -1039,6 +1039,7 @@ defaults.mouse_map = [ MouseMapping(button=1, mods=5, definition='mouse_show_command_output'), ] + nullable_colors = frozenset({ 'cursor' 'cursor_text_color' diff --git a/kitty/progress.py b/kitty/progress.py new file mode 100644 index 000000000..432caf804 --- /dev/null +++ b/kitty/progress.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# License: GPLv3 Copyright: 2025, Kovid Goyal + + +from enum import Enum + +from .fast_data_types import monotonic +from .utils import log_error + + +class ProgressState(Enum): + unset = 0 + set = 1 + error = 2 + indeterminate = 3 + paused = 4 + + +class Progress: + + state: ProgressState = ProgressState.unset + percent: int = 0 + last_update_at: float = 0. + clear_timeout: float = 60.0 + finished_clear_timeout: float = 5.0 + + def update(self, st: int, percent: int = -1) -> None: + self.last_update_at = monotonic() + if st == 0: + self.state = ProgressState.unset + self.percent = 0 + elif st == 1: + self.state = ProgressState.set + self.percent = max(0, min(percent, 100)) + elif st == 2: + self.state = ProgressState.error + self.percent = 0 + elif st == 3: + self.state = ProgressState.indeterminate + self.percent = 0 + elif st == 4: + self.state = ProgressState.paused + if percent > -1: + self.percent = max(0, min(percent, 100)) + else: + log_error(f'Unknown OSC 9;4 state: {st}') + + def clear_progress(self) -> bool: + time_since_last_update = monotonic() - self.last_update_at + threshold = self.finished_clear_timeout if self.percent == 100 and self.state is ProgressState.set else self.clear_timeout + if time_since_last_update >= threshold: + self.state = ProgressState.unset + self.percent = 0 + return True + return False diff --git a/kitty/tab_bar.py b/kitty/tab_bar.py index 8ab27fc5c..db425b3c6 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -29,6 +29,7 @@ from .fast_data_types import ( viewport_for_window, wcswidth, ) +from .progress import ProgressState from .rgb import alpha_blend, color_as_sgr, color_from_int, to_color from .types import WindowGeometry, run_once from .typing import EdgeLiteral, PowerlineStyle @@ -48,6 +49,9 @@ class TabBarData(NamedTuple): active_bg: Optional[int] inactive_fg: Optional[int] inactive_bg: Optional[int] + num_of_windows_with_progress: int + total_progress: int + last_focused_window_with_progress_id: int class DrawData(NamedTuple): @@ -219,6 +223,36 @@ class TabAccessor: tab = get_boss().tab_for_id(self.tab_id) return os.path.basename((tab.get_exe_of_active_window(oldest=True) if tab else '') or '') + @property + def last_focused_progress_percent(self) -> str: + tab = get_boss().tab_for_id(self.tab_id) + if not tab or not tab.last_focused_window_with_progress_id: + return '' + w = get_boss().window_id_map.get(tab.last_focused_window_with_progress_id) + if w is None: + return '' + if w.progress.state is ProgressState.error: + return '\u26a0\ufe0f ' + if w.progress.state is ProgressState.unset: + return '' + if w.progress.state is ProgressState.indeterminate: + return '🔄 ' + p = f'{w.progress.percent}% ' + if w.progress.state is ProgressState.paused: + return f'⏸ {p}' + return p + + @property + def progress_percent(self) -> str: + tab = get_boss().tab_for_id(self.tab_id) + if not tab or not tab.last_focused_window_with_progress_id: + return '' + if tab.num_of_windows_with_progress <= 1: + return self.last_focused_progress_percent + p = int(tab.total_progress / tab.num_of_windows_with_progress) + return f'{p}% ' + + safe_builtins = { 'max': max, 'min': min, 'str': str, 'repr': repr, 'abs': abs, 'len': len, 'chr': chr, 'ord': ord, 're': re, diff --git a/kitty/tabs.py b/kitty/tabs.py index 9ca7a5948..64b415fbe 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -52,6 +52,7 @@ from .fast_data_types import ( ) from .layout.base import Layout from .layout.interface import create_layout_object_for, evict_cached_layouts +from .progress import ProgressState from .tab_bar import TabBar, TabBarData from .types import ac from .typing import EdgeLiteral, SessionTab, SessionType, TypedDict @@ -125,6 +126,9 @@ class Tab: # {{{ inactive_fg: Optional[int] = None inactive_bg: Optional[int] = None confirm_close_window_id: int = 0 + num_of_windows_with_progress: int = 0 + total_progress: int = 0 + last_focused_window_with_progress_id: int = 0 def __init__( self, @@ -163,6 +167,23 @@ class Tab: # {{{ self._set_current_layout(l0) self.startup(session_tab) + def update_progress(self) -> None: + self.num_of_windows_with_progress = 0 + self.total_progress = 0 + self.last_focused_window_with_progress_id = 0 + focused_at = 0. + for window in self: + p = window.progress + if p.state is ProgressState.unset: + continue + if p.state in (ProgressState.set, ProgressState.paused): + self.total_progress += p.percent + self.num_of_windows_with_progress += 1 + if window.last_focused_at > focused_at or (not window.last_focused_at and window.id > self.last_focused_window_with_progress_id): + focused_at = window.last_focused_at + self.last_focused_window_with_progress_id = window.id + self.mark_tab_bar_dirty() + def has_single_window_visible(self) -> bool: if self.current_layout.only_active_window_visible: return True @@ -1245,7 +1266,8 @@ class TabManager: # {{{ title, t is at, needs_attention, t.id, len(t), t.num_window_groups, t.current_layout.name or '', has_activity_since_last_focus, t.active_fg, t.active_bg, - t.inactive_fg, t.inactive_bg + t.inactive_fg, t.inactive_bg, t.num_of_windows_with_progress, + t.total_progress, t.last_focused_window_with_progress_id, )) return ans diff --git a/kitty/window.py b/kitty/window.py index 65578b6d5..9f87f8294 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -93,6 +93,7 @@ from .rgb import to_color from .terminfo import get_capabilities from .types import MouseEvent, OverlayType, WindowGeometry, ac, run_once from .typing import BossType, ChildType, EdgeLiteral, TabType, TypedDict +from .progress import Progress from .utils import ( color_as_int, docs_url, @@ -627,6 +628,7 @@ class Window: self.keys_redirected_till_ready_from: int = 0 self.last_focused_at = 0. self.is_focused: bool = False + self.progress = Progress() self.last_resized_at = 0. self.started_at = monotonic() self.created_at = time_ns() @@ -1107,14 +1109,30 @@ class Window: return # unknown OSC 777 raw_data = raw_data[len('notify;'):] if osc_code == 9 and raw_data.startswith('4;'): - # This is probably the Windows Terminal "progress reporting" conflicting + # This is probably the ConEmu "progress reporting" conflicting # implementation which sadly some thoughtless people have - # implemented in unix CLI programs. So ignore it rather than - # spamming the user with continuous notifications. See for example: - # https://github.com/kovidgoyal/kitty/issues/8011 + # implemented in unix CLI programs. + # See for example: https://github.com/kovidgoyal/kitty/issues/8011 + try: + parts = tuple(map(int, raw_data.split(';')))[1:] + except Exception: + log_error(f'Ignoring malmormed OSC 9;4 progress report: {raw_data!r}') + return + self.progress.update(*parts[:2]) + if (tab := self.tabref()) is not None: + tab.update_progress() + self.clear_progress_if_needed() return get_boss().notification_manager.handle_notification_cmd(self.id, osc_code, raw_data) + def clear_progress_if_needed(self, timer_id: Optional[int] = None) -> None: + # Clear stuck or completed progress + if self.progress.clear_progress(): + if (tab := self.tabref()) is not None: + tab.update_progress() + else: + add_timer(self.clear_progress_if_needed, 1.0, False) + def on_mouse_event(self, event: dict[str, Any]) -> bool: event['mods'] = event.get('mods', 0) & mod_mask ev = MouseEvent(**event)