diff --git a/kitty/child-monitor.c b/kitty/child-monitor.c index f41d46591..bb85e9576 100644 --- a/kitty/child-monitor.c +++ b/kitty/child-monitor.c @@ -787,7 +787,6 @@ prepare_to_render_os_window(OSWindow *os_window, monotonic_t now, unsigned int * set_maximum_wait(OPT(cursor_trail) - now + WD.screen->cursor->position_changed_by_client_at); } } - } else { if (WD.screen->cursor_render_info.render_even_when_unfocused) { if (collect_cursor_info(&WD.screen->cursor_render_info, w, now, os_window)) needs_render = true; @@ -812,6 +811,12 @@ prepare_to_render_os_window(OSWindow *os_window, monotonic_t now, unsigned int * } if (send_cell_data_to_gpu(WD.vao_idx, WD.screen, os_window)) needs_render = true; if (WD.screen->start_visual_bell_at != 0) needs_render = true; + // Prepare window title bar screen data for GPU + WindowRenderData *trd = &w->window_title_render_data; + if (trd->screen) { + trd->screen->cursor_render_info.is_visible = false; + if (send_cell_data_to_gpu(trd->vao_idx, trd->screen, os_window)) needs_render = true; + } } } return needs_render || was_previously_rendered_with_layers != os_window->needs_layers; @@ -872,6 +877,9 @@ render_prepared_os_window(OSWindow *os_window, unsigned int active_window_id, co if (is_active_window) active_window = w; draw_cells(&WD, os_window, is_active_window, false, num_of_visible_windows == 1, w); if (WD.screen->start_visual_bell_at != 0) set_maximum_wait(ANIMATION_SAMPLE_WAIT); + WindowRenderData *trd = &w->window_title_render_data; + if (trd->screen && num_visible_windows > 1 && trd->geometry.right > trd->geometry.left && trd->geometry.bottom > trd->geometry.top) + draw_cells(trd, os_window, i == tab->active_window, true, false, NULL); } } setup_os_window_for_rendering(os_window, tab, active_window, false); diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index d826597e7..13fec807c 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -1404,6 +1404,13 @@ def set_tab_bar_render_data( pass +def set_window_title_bar_render_data( + os_window_id: int, tab_id: int, window_id: int, screen: Screen, + left: int, top: int, right: int, bottom: int +) -> None: + pass + + def set_window_render_data( os_window_id: int, tab_id: int, window_id: int, screen: Screen, left: int, top: int, right: int, bottom: int, diff --git a/kitty/layout/base.py b/kitty/layout/base.py index b58e7cd0c..a42b6652d 100644 --- a/kitty/layout/base.py +++ b/kitty/layout/base.py @@ -368,6 +368,14 @@ class Layout: self._set_dimensions(all_windows) self.update_visibility(all_windows) self.blank_rects = [] + # Set show_title_bar flag on each visible window before layout + opts = get_options() + min_windows = opts.window_title_bar_min_windows + visible_groups = list(all_windows.iter_all_layoutable_groups(only_visible=True)) + num_visible = len(visible_groups) + for wg in visible_groups: + for w in wg.windows: + w.show_title_bar = min_windows > 0 and num_visible >= min_windows self.do_layout(all_windows) def layout_single_window_group(self, wg: WindowGroup, add_blank_rects: bool = True) -> None: diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 9c285a5bf..61423ce74 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1466,6 +1466,81 @@ dragging of borders. Note that because kitty uses layouts, dragging borders does actually resize the window itself, but instead, the layout row/column/slot, which can result in multiple windows getting resized. ''') + +opt('window_title_bar', 'top', + choices=('top', 'bottom'), + long_text=''' +Control the position of the window title bar relative to the window content. +Use :opt:`window_title_bar_min_windows` to control when title bars are shown. +''' + ) + +opt('window_title_bar_min_windows', '0', + option_type='positive_int', + long_text=''' +The minimum number of visible windows in a tab before window title bars +are shown. A value of :code:`0` means never show. :code:`1` means always +show. :code:`2` or more means show only when at least that many windows +are visible. Similar to :opt:`tab_bar_min_tabs` for the tab bar. +''' +) + +opt('window_title_template', '"{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.window}{progress_percent}{title}"', + option_type='tab_title_template', + long_text=''' +A template to render the window title bar text. Uses the same template syntax as +:opt:`tab_title_template`. Available variables include: :code:`{title}`, +:code:`{bell_symbol}`, :code:`{activity_symbol}`, :code:`{progress_percent}`, +:code:`{custom}`, :code:`{fmt}`, :code:`{is_active}`. +You can also provide a custom :code:`draw_window_title(data)` function in +:file:`window_title_bar.py` in the kitty config directory, exposed as :code:`{custom}`. +''' + ) + +opt('active_window_title_template', 'none', + option_type='tab_title_template', + long_text=''' +Template to use for the active window title bar. If not set (the value +:code:`none`), the :opt:`window_title_template` is used. +''' + ) + +opt('window_title_bar_active_foreground', 'none', + option_type='to_color_or_none', ctype='color_or_none_as_int', + long_text=''' +Foreground color for the active window title bar. Defaults to +the corresponding tab bar color (:code:`active_tab_foreground`) when set to :code:`none`. +''' + ) + +opt('window_title_bar_active_background', 'none', + option_type='to_color_or_none', ctype='color_or_none_as_int', + long_text=''' +Background color for the active window title bar. Defaults to +the corresponding tab bar color (:code:`active_tab_background`) when set to :code:`none`. +''' + ) + +opt('window_title_bar_inactive_foreground', 'none', + option_type='to_color_or_none', ctype='color_or_none_as_int', + long_text=''' +Foreground color for inactive window title bars. Defaults to +the corresponding tab bar color (:code:`inactive_tab_foreground`) when set to :code:`none`. +''' + ) + +opt('window_title_bar_inactive_background', 'none', + option_type='to_color_or_none', ctype='color_or_none_as_int', + long_text=''' +Background color for inactive window title bars. Defaults to +the corresponding tab bar color (:code:`inactive_tab_background`) when set to :code:`none`. +''' + ) + +opt('window_title_bar_align', 'center', + choices=('left', 'center', 'right'), + long_text='Horizontal alignment of the text in window title bars.' + ) egr() # }}} diff --git a/kitty/options/parse.py b/kitty/options/parse.py index ff7e3f0aa..37c1c7572 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -48,6 +48,9 @@ class Parser: def active_tab_title_template(self, val: str, ans: dict[str, typing.Any]) -> None: ans['active_tab_title_template'] = active_tab_title_template(val) + def active_window_title_template(self, val: str, ans: dict[str, typing.Any]) -> None: + ans['active_window_title_template'] = tab_title_template(val) + def allow_cloning(self, val: str, ans: dict[str, typing.Any]) -> None: val = val.lower() if val not in self.choices_for_allow_cloning: @@ -1504,6 +1507,40 @@ class Parser: def window_resize_step_lines(self, val: str, ans: dict[str, typing.Any]) -> None: ans['window_resize_step_lines'] = positive_int(val) + def window_title_bar(self, val: str, ans: dict[str, typing.Any]) -> None: + val = val.lower() + if val not in self.choices_for_window_title_bar: + raise ValueError(f"The value {val} is not a valid choice for window_title_bar") + ans["window_title_bar"] = val + + choices_for_window_title_bar = frozenset(('top', 'bottom')) + + def window_title_bar_min_windows(self, val: str, ans: dict[str, typing.Any]) -> None: + ans['window_title_bar_min_windows'] = positive_int(val) + + def window_title_bar_active_background(self, val: str, ans: dict[str, typing.Any]) -> None: + ans['window_title_bar_active_background'] = to_color_or_none(val) + + def window_title_bar_active_foreground(self, val: str, ans: dict[str, typing.Any]) -> None: + ans['window_title_bar_active_foreground'] = to_color_or_none(val) + + def window_title_bar_align(self, val: str, ans: dict[str, typing.Any]) -> None: + val = val.lower() + if val not in self.choices_for_window_title_bar_align: + raise ValueError(f"The value {val} is not a valid choice for window_title_bar_align") + ans["window_title_bar_align"] = val + + choices_for_window_title_bar_align = choices_for_tab_bar_align + + def window_title_bar_inactive_background(self, val: str, ans: dict[str, typing.Any]) -> None: + ans['window_title_bar_inactive_background'] = to_color_or_none(val) + + def window_title_bar_inactive_foreground(self, val: str, ans: dict[str, typing.Any]) -> None: + ans['window_title_bar_inactive_foreground'] = to_color_or_none(val) + + def window_title_template(self, val: str, ans: dict[str, typing.Any]) -> None: + ans['window_title_template'] = tab_title_template(val) + def x11_hide_window_decorations(self, val: str, ans: dict[str, typing.Any]) -> None: deprecated_hide_window_decorations_aliases('x11_hide_window_decorations', val, ans) diff --git a/kitty/options/to-c-generated.h b/kitty/options/to-c-generated.h index 72d5637fa..5a36c5415 100644 --- a/kitty/options/to-c-generated.h +++ b/kitty/options/to-c-generated.h @@ -993,6 +993,58 @@ convert_from_opts_window_drag_tolerance(PyObject *py_opts, Options *opts) { Py_DECREF(ret); } +static void +convert_from_python_window_title_bar_active_foreground(PyObject *val, Options *opts) { + opts->window_title_bar_active_foreground = color_or_none_as_int(val); +} + +static void +convert_from_opts_window_title_bar_active_foreground(PyObject *py_opts, Options *opts) { + PyObject *ret = PyObject_GetAttrString(py_opts, "window_title_bar_active_foreground"); + if (ret == NULL) return; + convert_from_python_window_title_bar_active_foreground(ret, opts); + Py_DECREF(ret); +} + +static void +convert_from_python_window_title_bar_active_background(PyObject *val, Options *opts) { + opts->window_title_bar_active_background = color_or_none_as_int(val); +} + +static void +convert_from_opts_window_title_bar_active_background(PyObject *py_opts, Options *opts) { + PyObject *ret = PyObject_GetAttrString(py_opts, "window_title_bar_active_background"); + if (ret == NULL) return; + convert_from_python_window_title_bar_active_background(ret, opts); + Py_DECREF(ret); +} + +static void +convert_from_python_window_title_bar_inactive_foreground(PyObject *val, Options *opts) { + opts->window_title_bar_inactive_foreground = color_or_none_as_int(val); +} + +static void +convert_from_opts_window_title_bar_inactive_foreground(PyObject *py_opts, Options *opts) { + PyObject *ret = PyObject_GetAttrString(py_opts, "window_title_bar_inactive_foreground"); + if (ret == NULL) return; + convert_from_python_window_title_bar_inactive_foreground(ret, opts); + Py_DECREF(ret); +} + +static void +convert_from_python_window_title_bar_inactive_background(PyObject *val, Options *opts) { + opts->window_title_bar_inactive_background = color_or_none_as_int(val); +} + +static void +convert_from_opts_window_title_bar_inactive_background(PyObject *py_opts, Options *opts) { + PyObject *ret = PyObject_GetAttrString(py_opts, "window_title_bar_inactive_background"); + if (ret == NULL) return; + convert_from_python_window_title_bar_inactive_background(ret, opts); + Py_DECREF(ret); +} + static void convert_from_python_tab_bar_edge(PyObject *val, Options *opts) { opts->tab_bar_edge = PyLong_AsLong(val); @@ -1550,6 +1602,14 @@ convert_opts_from_python_opts(PyObject *py_opts, Options *opts) { if (PyErr_Occurred()) return false; convert_from_opts_window_drag_tolerance(py_opts, opts); if (PyErr_Occurred()) return false; + convert_from_opts_window_title_bar_active_foreground(py_opts, opts); + if (PyErr_Occurred()) return false; + convert_from_opts_window_title_bar_active_background(py_opts, opts); + if (PyErr_Occurred()) return false; + convert_from_opts_window_title_bar_inactive_foreground(py_opts, opts); + if (PyErr_Occurred()) return false; + convert_from_opts_window_title_bar_inactive_background(py_opts, opts); + if (PyErr_Occurred()) return false; convert_from_opts_tab_bar_edge(py_opts, opts); if (PyErr_Occurred()) return false; convert_from_opts_tab_bar_margin_height(py_opts, opts); diff --git a/kitty/options/types.py b/kitty/options/types.py index f0fa4b89e..5d8216cee 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -37,6 +37,8 @@ choices_for_terminfo_type = typing.Literal['path', 'direct', 'none'] choices_for_undercurl_style = typing.Literal['thin-sparse', 'thin-dense', 'thick-sparse', 'thick-dense'] choices_for_underline_hyperlinks = typing.Literal['hover', 'always', 'never'] choices_for_window_logo_position = choices_for_placement_strategy +choices_for_window_title_bar = typing.Literal['top', 'bottom'] +choices_for_window_title_bar_align = choices_for_tab_bar_align option_names = ( 'action_alias', @@ -45,6 +47,7 @@ option_names = ( 'active_tab_font_style', 'active_tab_foreground', 'active_tab_title_template', + 'active_window_title_template', 'allow_cloning', 'allow_hyperlinks', 'allow_remote_control', @@ -497,6 +500,14 @@ option_names = ( 'window_padding_width', 'window_resize_step_cells', 'window_resize_step_lines', + 'window_title_bar', + 'window_title_bar_min_windows', + 'window_title_bar_active_background', + 'window_title_bar_active_foreground', + 'window_title_bar_align', + 'window_title_bar_inactive_background', + 'window_title_bar_inactive_foreground', + 'window_title_template', ) @@ -506,6 +517,7 @@ class Options: active_tab_font_style: tuple[bool, bool] = (True, True) active_tab_foreground: Color = Color(0, 0, 0) active_tab_title_template: str | None = None + active_window_title_template: str = 'none' allow_cloning: choices_for_allow_cloning = 'ask' allow_hyperlinks: int = 1 allow_remote_control: choices_for_allow_remote_control = 'no' @@ -689,6 +701,14 @@ class Options: window_padding_width: FloatEdges = FloatEdges(left=0, top=0, right=0, bottom=0) window_resize_step_cells: int = 2 window_resize_step_lines: int = 2 + window_title_bar: choices_for_window_title_bar = 'top' + window_title_bar_min_windows: int = 0 + window_title_bar_active_background: kitty.fast_data_types.Color | None = None + window_title_bar_active_foreground: kitty.fast_data_types.Color | None = None + window_title_bar_align: choices_for_window_title_bar_align = 'center' + window_title_bar_inactive_background: kitty.fast_data_types.Color | None = None + window_title_bar_inactive_foreground: kitty.fast_data_types.Color | None = None + window_title_template: str = '{fmt.fg.red}{bell_symbol}{activity_symbol}{fmt.fg.window}{progress_percent}{title}' action_alias: dict[str, str] = {} env: dict[str, str] = {} exe_search_path: dict[str, str] = {} @@ -1111,6 +1131,10 @@ nullable_colors = frozenset({ 'cursor_trail_color', 'visual_bell_color', 'active_border_color', + 'window_title_bar_active_foreground', + 'window_title_bar_active_background', + 'window_title_bar_inactive_foreground', + 'window_title_bar_inactive_background', 'tab_bar_background', 'tab_bar_margin_color', 'selection_foreground', diff --git a/kitty/state.c b/kitty/state.c index 5a5927602..b46ad48f4 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -255,12 +255,16 @@ add_tab(id_type os_window_id) { static void create_gpu_resources_for_window(Window *w) { w->render_data.vao_idx = create_cell_vao(); + w->window_title_render_data.vao_idx = create_cell_vao(); } static void release_gpu_resources_for_window(Window *w) { if (w->render_data.vao_idx > -1) remove_vao(w->render_data.vao_idx); w->render_data.vao_idx = -1; + if (w->window_title_render_data.vao_idx > -1) remove_vao(w->window_title_render_data.vao_idx); + w->window_title_render_data.vao_idx = -1; + Py_CLEAR(w->window_title_render_data.screen); } static bool @@ -860,6 +864,17 @@ PYWRAP1(set_tab_bar_render_data) { Py_RETURN_NONE; } +PYWRAP1(set_window_title_bar_render_data) { + WindowGeometry g; + id_type os_window_id, tab_id, window_id; + Screen *screen; + PA("KKKOIIII", &os_window_id, &tab_id, &window_id, &screen, &g.left, &g.top, &g.right, &g.bottom); + WITH_WINDOW(os_window_id, tab_id, window_id) + init_window_render_data(&window->window_title_render_data, g, screen); + END_WITH_WINDOW + Py_RETURN_NONE; +} + static PyTypeObject RegionType; static PyStructSequence_Field region_fields[] = { {"left", ""}, {"top", ""}, {"right", ""}, {"bottom", ""}, {"width", ""}, {"height", ""}, {NULL, NULL} @@ -1597,6 +1612,7 @@ static PyMethodDef module_methods[] = { MW(reorder_tabs, METH_VARARGS), MW(set_borders_rects, METH_VARARGS), MW(set_tab_bar_render_data, METH_VARARGS), + MW(set_window_title_bar_render_data, METH_VARARGS), MW(set_window_render_data, METH_VARARGS), MW(set_window_padding, METH_VARARGS), MW(viewport_for_window, METH_VARARGS), diff --git a/kitty/state.h b/kitty/state.h index 71e986c38..261263221 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -62,7 +62,8 @@ typedef struct Options { bool scrollback_fill_enlarged_window; char_type *select_by_word_characters; char_type *select_by_word_characters_forward; - color_type url_color, background, foreground, active_border_color, inactive_border_color, bell_border_color, tab_bar_background, tab_bar_margin_color; + color_type url_color, background, foreground, active_border_color, inactive_border_color, bell_border_color, tab_bar_background, tab_bar_margin_color, + window_title_bar_active_foreground, window_title_bar_active_background, window_title_bar_inactive_foreground, window_title_bar_inactive_background; monotonic_t repaint_delay, input_delay; bool focus_follows_mouse; unsigned int hide_window_decorations; @@ -208,6 +209,7 @@ typedef struct Window { bool visible; PyObject *title; WindowRenderData render_data; + WindowRenderData window_title_render_data; WindowLogoRenderData window_logo; MousePosition mouse_pos; struct { diff --git a/kitty/tabs.py b/kitty/tabs.py index 78e98d6f6..0f8d1dc70 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -61,6 +61,7 @@ from .typing_compat import EdgeLiteral, SessionTab, SessionType, TypedDict from .utils import cmdline_for_hold, color_as_int, log_error, platform_window_id, resolved_shell, shlex_split, which from .window import CwdRequest, Watchers, Window, WindowCreationSpec, WindowDict, global_watchers from .window_list import WindowList +from .window_title_bar import WindowTitleBarManager P = ParamSpec('P') T = TypeVar('T') @@ -170,6 +171,7 @@ class Tab: # {{{ self.name = getattr(session_tab, 'name', '') self.enabled_layouts = [x.lower() for x in getattr(session_tab, 'enabled_layouts', None) or get_options().enabled_layouts] self.borders = Borders(self.os_window_id, self.id) + self.window_title_bar_manager = WindowTitleBarManager(self.os_window_id, self.id) self.windows: WindowList = WindowList(self) self._last_used_layout: str | None = None self._current_layout_name: str | None = None @@ -440,6 +442,7 @@ class Tab: # {{{ self.mark_tab_bar_dirty() def title_changed(self, window: Window) -> None: + self.window_title_bar_manager.update(self.windows) if window is self.active_window: tm = self.tab_manager_ref() if tm is not None: @@ -468,6 +471,7 @@ class Tab: # {{{ current_layout=ly, tab_bar_rects=tm.tab_bar_rects, draw_window_borders=draw_borders ) + self.window_title_bar_manager.update(self.windows) def create_layout_object(self, name: str) -> Layout: return create_layout_object_for(name, self.os_window_id, self.id) diff --git a/kitty/window.py b/kitty/window.py index 2b5ae0a0c..818d94de8 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -84,6 +84,7 @@ from .fast_data_types import ( set_window_logo, set_window_padding, set_window_render_data, + set_window_title_bar_render_data, update_ime_position_for_window, update_pointer_shape, update_window_title, @@ -732,6 +733,8 @@ class Window: self.tabref: Callable[[], TabType | None] = weakref.ref(tab) self.destroyed = False self.geometry: WindowGeometry = WindowGeometry(0, 0, 0, 0, 0, 0) + self.show_title_bar: bool = False + self._title_bar_screen: Any = None self.needs_layout = True self.is_visible_in_layout: bool = True self.child = child @@ -977,13 +980,36 @@ class Window: def set_geometry(self, new_geometry: WindowGeometry) -> None: if self.destroyed: return - if self.needs_layout or new_geometry.xnum != self.screen.columns or new_geometry.ynum != self.screen.lines: - self.screen.resize(max(0, new_geometry.ynum), max(0, new_geometry.xnum)) + # Determine if we need a title bar and compute adjusted dimensions + opts = get_options() + position = opts.window_title_bar + show_tb = self.show_title_bar and new_geometry.ynum > 1 + + if show_tb: + render_ynum = new_geometry.ynum - 1 + cell_width, cell_height = cell_size_for_window(self.os_window_id) + if position == 'top': + render_top = new_geometry.top + cell_height + render_bottom = new_geometry.bottom + tb_top = new_geometry.top + tb_bottom = new_geometry.top + cell_height + else: + render_top = new_geometry.top + render_bottom = new_geometry.bottom - cell_height + tb_top = new_geometry.bottom - cell_height + tb_bottom = new_geometry.bottom + else: + render_ynum = new_geometry.ynum + render_top = new_geometry.top + render_bottom = new_geometry.bottom + + if self.needs_layout or new_geometry.xnum != self.screen.columns or render_ynum != self.screen.lines: + self.screen.resize(max(0, render_ynum), max(0, new_geometry.xnum)) self.needs_layout = False call_watchers(weakref.ref(self), 'on_resize', {'old_geometry': self.geometry, 'new_geometry': new_geometry}) current_pty_size = ( self.screen.lines, self.screen.columns, - max(0, new_geometry.right - new_geometry.left), max(0, new_geometry.bottom - new_geometry.top)) + max(0, new_geometry.right - new_geometry.left), max(0, render_bottom - render_top)) update_ime_position = False if current_pty_size != self.last_reported_pty_size: if self._pause_resize_notifications_to_child is None: @@ -993,14 +1019,78 @@ class Window: else: mark_os_window_dirty(self.os_window_id) + # Store original geometry for borders/padding calculations self.geometry = g = new_geometry + # Set C-side render data with adjusted top/bottom for content area set_window_render_data(self.os_window_id, self.tab_id, self.id, self.screen, - g.left, g.top, g.right, g.bottom, + g.left, render_top, g.right, render_bottom, g.spaces.left, g.spaces.top, g.spaces.right, g.spaces.bottom) self.update_effective_padding() + + # Handle title bar screen + if show_tb: + from .window_title_bar import WindowTitleBarScreen + if self._title_bar_screen is None: + self._title_bar_screen = WindowTitleBarScreen(self.os_window_id, cell_width, cell_height) + tb_geom = WindowGeometry( + left=g.left, top=tb_top, right=g.right, bottom=tb_bottom, + xnum=0, ynum=1, + ) + self._title_bar_screen.layout(tb_geom) + set_window_title_bar_render_data( + self.os_window_id, self.tab_id, self.id, self._title_bar_screen.screen, + tb_geom.left, tb_geom.top, tb_geom.right, tb_geom.bottom, + ) + elif self._title_bar_screen is not None: + # Clear title bar render data + set_window_title_bar_render_data( + self.os_window_id, self.tab_id, self.id, self._title_bar_screen.screen, + 0, 0, 0, 0, + ) + self._title_bar_screen = None + if update_ime_position: update_ime_position_for_window(self.id, True) + def update_title_bar(self, is_active: bool = False) -> None: + pts = self._title_bar_screen + if pts is None: + return + from .progress import ProgressState + from .window_title_bar import WindowTitleData + + progress_percent = '' + if self.progress.state is not ProgressState.unset: + if self.progress.state is ProgressState.indeterminate: + progress_percent = '[…] ' + elif self.progress.percent > 0: + progress_percent = f'[{self.progress.percent}%] ' + + has_activity = self.has_activity_since_last_focus + + data = WindowTitleData( + title=self.title or '', + is_active=is_active, + window_id=self.id, + tab_id=self.tab_id, + needs_attention=self.needs_attention, + has_activity_since_last_focus=has_activity, + ) + rendered_title = pts.render(data, progress_percent) + + # If template evaluates to empty string, zero title bar geometry to hide it + if not rendered_title: + set_window_title_bar_render_data( + self.os_window_id, self.tab_id, self.id, pts.screen, + 0, 0, 0, 0, + ) + else: + g = pts.geometry + set_window_title_bar_render_data( + self.os_window_id, self.tab_id, self.id, pts.screen, + g.left, g.top, g.right, g.bottom, + ) + def close(self) -> None: get_boss().mark_window_for_close(self) diff --git a/kitty/window_title_bar.py b/kitty/window_title_bar.py new file mode 100644 index 000000000..921e7a35a --- /dev/null +++ b/kitty/window_title_bar.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python +# License: GPL v3 Copyright: 2024, kitty contributors + +import os +from functools import lru_cache +from typing import Any, NamedTuple + +from .constants import config_dir +from .fast_data_types import ( + DECAWM, + Screen, + get_options, +) +from .rgb import color_as_sgr, color_from_int, to_color +from .types import WindowGeometry, run_once +from .utils import color_as_int, log_error, sgr_sanitizer_pat +from .window_list import WindowList + + +@lru_cache +def _report_template_failure(template: str, e: str) -> None: + log_error(f'Invalid window title template: "{template}" with error: {e}') + + +@lru_cache +def _compile_template(template: str) -> Any: + try: + return compile('f"""' + template + '"""', '', 'eval') + except Exception as e: + _report_template_failure(template, str(e)) + + +safe_builtins = { + 'max': max, 'min': min, 'str': str, 'repr': repr, 'abs': abs, + 'len': len, 'chr': chr, 'ord': ord, +} + + +def _resolve_color(opt_val: Any, fallback_val: Any) -> Any: + if opt_val is None: + return fallback_val + return opt_val + + +class WindowTitleColorFormatter: + is_active: bool = False + + def __init__(self, which: str): + self.which = which + + def __getattr__(self, name: str) -> str: + q = name + if q == 'default': + ans = '9' + elif q == 'window': + opts = get_options() + if self.is_active: + fg_color = _resolve_color(opts.window_title_bar_active_foreground, opts.active_tab_foreground) + bg_color = _resolve_color(opts.window_title_bar_active_background, opts.active_tab_background) + col = color_from_int(color_as_int(fg_color if self.which == '3' else bg_color)) + else: + fg_color = _resolve_color(opts.window_title_bar_inactive_foreground, opts.inactive_tab_foreground) + bg_color = _resolve_color(opts.window_title_bar_inactive_background, opts.inactive_tab_background) + col = color_from_int(color_as_int(fg_color if self.which == '3' else bg_color)) + ans = f'8{color_as_sgr(col)}' + elif q.startswith('color'): + ans = f'8:5:{int(q[5:])}' + else: + if name.startswith('_'): + q = f'#{name[1:]}' + c = to_color(q) + if c is None: + raise AttributeError(f'{name} is not a valid color') + ans = f'8{color_as_sgr(c)}' + return f'\x1b[{self.which}{ans}m' + + +class WindowTitleFormatter: + reset = '\x1b[0m' + fg = WindowTitleColorFormatter('3') + bg = WindowTitleColorFormatter('4') + bold = '\x1b[1m' + nobold = '\x1b[22m' + italic = '\x1b[3m' + noitalic = '\x1b[23m' + + +def _draw_attributed_string(title: str, screen: Screen) -> None: + if '\x1b' in title: + for x in sgr_sanitizer_pat(for_splitting=True).split(title): + if x.startswith('\x1b') and x.endswith('m'): + screen.apply_sgr(x[2:-1]) + else: + screen.draw(x) + else: + screen.draw(title) + + +class WindowTitleData(NamedTuple): + title: str + is_active: bool + window_id: int + tab_id: int + needs_attention: bool = False + has_activity_since_last_focus: bool = False + + +@run_once +def load_custom_window_title_bar_module() -> dict[str, Any]: + import runpy + import traceback + try: + return runpy.run_path(os.path.join(config_dir, 'window_title_bar.py')) + except FileNotFoundError: + return {} + except Exception as e: + traceback.print_exc() + log_error(f'Failed to load custom window_title_bar.py module with error: {e}') + return {} + + +def _get_custom_draw_result(data: WindowTitleData) -> str | None: + m = load_custom_window_title_bar_module() + func = m.get('draw_window_title') + if func is None: + return None + try: + return str(func(data)) + except Exception as e: + log_error(f'Custom draw_window_title function failed with error: {e}') + return None + + +def clear_caches() -> None: + load_custom_window_title_bar_module.clear_cached() + + +class WindowTitleBarScreen: + def __init__(self, os_window_id: int, cell_width: int, cell_height: int): + self.os_window_id = os_window_id + self.cell_width = cell_width + self.screen = Screen(None, 1, 10, 0, cell_width, cell_height) + self.screen.reset_mode(DECAWM) + + def layout(self, geometry: WindowGeometry) -> None: + ncells = max(4, (geometry.right - geometry.left) // self.cell_width) + self.screen.resize(1, ncells) + self.geometry = geometry + + def render(self, data: WindowTitleData, progress_percent: str) -> str: + opts = get_options() + s = self.screen + s.cursor.x = 0 + s.erase_in_line(2, False) + + is_active = data.is_active + if is_active: + fg_color = _resolve_color(opts.window_title_bar_active_foreground, opts.active_tab_foreground) + bg_color = _resolve_color(opts.window_title_bar_active_background, opts.active_tab_background) + else: + fg_color = _resolve_color(opts.window_title_bar_inactive_foreground, opts.inactive_tab_foreground) + bg_color = _resolve_color(opts.window_title_bar_inactive_background, opts.inactive_tab_background) + + s.color_profile.default_fg = fg_color + s.color_profile.default_bg = bg_color + fg = (color_as_int(fg_color) << 8) | 2 + bg = (color_as_int(bg_color) << 8) | 2 + + s.cursor.fg = fg + s.cursor.bg = bg + + template = opts.window_title_template + if is_active and opts.active_window_title_template and opts.active_window_title_template != 'none': + template = opts.active_window_title_template + + WindowTitleColorFormatter.is_active = is_active + + bell_symbol = opts.bell_on_tab if data.needs_attention else '' + activity_symbol = opts.tab_activity_symbol if data.has_activity_since_last_focus else '' + + custom_result = _get_custom_draw_result(data) + + eval_locals = { + 'title': data.title, + 'is_active': is_active, + 'fmt': WindowTitleFormatter, + 'bell_symbol': bell_symbol, + 'activity_symbol': activity_symbol, + 'progress_percent': progress_percent, + 'custom': custom_result or '', + } + try: + title = eval(_compile_template(template), {'__builtins__': safe_builtins}, eval_locals) + except Exception as e: + _report_template_failure(template, str(e)) + title = data.title + + title_str = str(title) + align = opts.window_title_bar_align + + if align == 'left': + _draw_attributed_string(title_str, s) + else: + # Measure the title length by drawing to cursor position 0 + # and checking where the cursor ends up + _draw_attributed_string(title_str, s) + title_len = s.cursor.x + s.cursor.x = 0 + s.erase_in_line(2, False) + s.cursor.fg = fg + s.cursor.bg = bg + + if align == 'center': + pad = max(0, (s.columns - title_len) // 2) + else: # right + pad = max(0, s.columns - title_len) + + for _ in range(pad): + s.draw(' ') + _draw_attributed_string(title_str, s) + + # Fill remaining cells with background + while s.cursor.x < s.columns: + s.draw(' ') + + return title_str + + +class WindowTitleBarManager: + + def __init__(self, os_window_id: int, tab_id: int): + self.os_window_id = os_window_id + self.tab_id = tab_id + + def update(self, all_windows: WindowList) -> None: + active_group = all_windows.active_group + for wg in all_windows.iter_all_layoutable_groups(only_visible=True): + is_active = wg is active_group + for w in wg.windows: + w.update_title_bar(is_active=is_active) + + def destroy(self) -> None: + pass diff --git a/tools/cmd/at/set_colors.go b/tools/cmd/at/set_colors.go index 6557cfe1d..6370ada3b 100644 --- a/tools/cmd/at/set_colors.go +++ b/tools/cmd/at/set_colors.go @@ -15,15 +15,19 @@ import ( var nullable_colors = map[string]bool{ // generated by gen-config.py do not edit // NULLABLE_COLORS_START - "active_border_color": true, - "cursor": true, - "cursor_text_color": true, - "cursor_trail_color": true, - "selection_background": true, - "selection_foreground": true, - "tab_bar_background": true, - "tab_bar_margin_color": true, - "visual_bell_color": true, + "active_border_color": true, + "cursor": true, + "cursor_text_color": true, + "cursor_trail_color": true, + "selection_background": true, + "selection_foreground": true, + "tab_bar_background": true, + "tab_bar_margin_color": true, + "visual_bell_color": true, + "window_title_bar_active_background": true, + "window_title_bar_active_foreground": true, + "window_title_bar_inactive_background": true, + "window_title_bar_inactive_foreground": true, // NULLABLE_COLORS_END } diff --git a/tools/themes/collection.go b/tools/themes/collection.go index 6191c92e6..355c6d77f 100644 --- a/tools/themes/collection.go +++ b/tools/themes/collection.go @@ -35,290 +35,294 @@ var _ = fmt.Print var AllColorSettingNames = map[string]bool{ // {{{ // generated by gen-config.py do not edit // ALL_COLORS_START - "active_border_color": true, - "active_tab_background": true, - "active_tab_foreground": true, - "background": true, - "bell_border_color": true, - "color0": true, - "color1": true, - "color10": true, - "color100": true, - "color101": true, - "color102": true, - "color103": true, - "color104": true, - "color105": true, - "color106": true, - "color107": true, - "color108": true, - "color109": true, - "color11": true, - "color110": true, - "color111": true, - "color112": true, - "color113": true, - "color114": true, - "color115": true, - "color116": true, - "color117": true, - "color118": true, - "color119": true, - "color12": true, - "color120": true, - "color121": true, - "color122": true, - "color123": true, - "color124": true, - "color125": true, - "color126": true, - "color127": true, - "color128": true, - "color129": true, - "color13": true, - "color130": true, - "color131": true, - "color132": true, - "color133": true, - "color134": true, - "color135": true, - "color136": true, - "color137": true, - "color138": true, - "color139": true, - "color14": true, - "color140": true, - "color141": true, - "color142": true, - "color143": true, - "color144": true, - "color145": true, - "color146": true, - "color147": true, - "color148": true, - "color149": true, - "color15": true, - "color150": true, - "color151": true, - "color152": true, - "color153": true, - "color154": true, - "color155": true, - "color156": true, - "color157": true, - "color158": true, - "color159": true, - "color16": true, - "color160": true, - "color161": true, - "color162": true, - "color163": true, - "color164": true, - "color165": true, - "color166": true, - "color167": true, - "color168": true, - "color169": true, - "color17": true, - "color170": true, - "color171": true, - "color172": true, - "color173": true, - "color174": true, - "color175": true, - "color176": true, - "color177": true, - "color178": true, - "color179": true, - "color18": true, - "color180": true, - "color181": true, - "color182": true, - "color183": true, - "color184": true, - "color185": true, - "color186": true, - "color187": true, - "color188": true, - "color189": true, - "color19": true, - "color190": true, - "color191": true, - "color192": true, - "color193": true, - "color194": true, - "color195": true, - "color196": true, - "color197": true, - "color198": true, - "color199": true, - "color2": true, - "color20": true, - "color200": true, - "color201": true, - "color202": true, - "color203": true, - "color204": true, - "color205": true, - "color206": true, - "color207": true, - "color208": true, - "color209": true, - "color21": true, - "color210": true, - "color211": true, - "color212": true, - "color213": true, - "color214": true, - "color215": true, - "color216": true, - "color217": true, - "color218": true, - "color219": true, - "color22": true, - "color220": true, - "color221": true, - "color222": true, - "color223": true, - "color224": true, - "color225": true, - "color226": true, - "color227": true, - "color228": true, - "color229": true, - "color23": true, - "color230": true, - "color231": true, - "color232": true, - "color233": true, - "color234": true, - "color235": true, - "color236": true, - "color237": true, - "color238": true, - "color239": true, - "color24": true, - "color240": true, - "color241": true, - "color242": true, - "color243": true, - "color244": true, - "color245": true, - "color246": true, - "color247": true, - "color248": true, - "color249": true, - "color25": true, - "color250": true, - "color251": true, - "color252": true, - "color253": true, - "color254": true, - "color255": true, - "color26": true, - "color27": true, - "color28": true, - "color29": true, - "color3": true, - "color30": true, - "color31": true, - "color32": true, - "color33": true, - "color34": true, - "color35": true, - "color36": true, - "color37": true, - "color38": true, - "color39": true, - "color4": true, - "color40": true, - "color41": true, - "color42": true, - "color43": true, - "color44": true, - "color45": true, - "color46": true, - "color47": true, - "color48": true, - "color49": true, - "color5": true, - "color50": true, - "color51": true, - "color52": true, - "color53": true, - "color54": true, - "color55": true, - "color56": true, - "color57": true, - "color58": true, - "color59": true, - "color6": true, - "color60": true, - "color61": true, - "color62": true, - "color63": true, - "color64": true, - "color65": true, - "color66": true, - "color67": true, - "color68": true, - "color69": true, - "color7": true, - "color70": true, - "color71": true, - "color72": true, - "color73": true, - "color74": true, - "color75": true, - "color76": true, - "color77": true, - "color78": true, - "color79": true, - "color8": true, - "color80": true, - "color81": true, - "color82": true, - "color83": true, - "color84": true, - "color85": true, - "color86": true, - "color87": true, - "color88": true, - "color89": true, - "color9": true, - "color90": true, - "color91": true, - "color92": true, - "color93": true, - "color94": true, - "color95": true, - "color96": true, - "color97": true, - "color98": true, - "color99": true, - "cursor": true, - "cursor_text_color": true, - "cursor_trail_color": true, - "foreground": true, - "inactive_border_color": true, - "inactive_tab_background": true, - "inactive_tab_foreground": true, - "macos_titlebar_color": true, - "mark1_background": true, - "mark1_foreground": true, - "mark2_background": true, - "mark2_foreground": true, - "mark3_background": true, - "mark3_foreground": true, - "scrollbar_handle_color": true, - "scrollbar_track_color": true, - "selection_background": true, - "selection_foreground": true, - "tab_bar_background": true, - "tab_bar_margin_color": true, - "url_color": true, - "visual_bell_color": true, - "wayland_titlebar_color": true, // ALL_COLORS_END + "active_border_color": true, + "active_tab_background": true, + "active_tab_foreground": true, + "background": true, + "bell_border_color": true, + "color0": true, + "color1": true, + "color10": true, + "color100": true, + "color101": true, + "color102": true, + "color103": true, + "color104": true, + "color105": true, + "color106": true, + "color107": true, + "color108": true, + "color109": true, + "color11": true, + "color110": true, + "color111": true, + "color112": true, + "color113": true, + "color114": true, + "color115": true, + "color116": true, + "color117": true, + "color118": true, + "color119": true, + "color12": true, + "color120": true, + "color121": true, + "color122": true, + "color123": true, + "color124": true, + "color125": true, + "color126": true, + "color127": true, + "color128": true, + "color129": true, + "color13": true, + "color130": true, + "color131": true, + "color132": true, + "color133": true, + "color134": true, + "color135": true, + "color136": true, + "color137": true, + "color138": true, + "color139": true, + "color14": true, + "color140": true, + "color141": true, + "color142": true, + "color143": true, + "color144": true, + "color145": true, + "color146": true, + "color147": true, + "color148": true, + "color149": true, + "color15": true, + "color150": true, + "color151": true, + "color152": true, + "color153": true, + "color154": true, + "color155": true, + "color156": true, + "color157": true, + "color158": true, + "color159": true, + "color16": true, + "color160": true, + "color161": true, + "color162": true, + "color163": true, + "color164": true, + "color165": true, + "color166": true, + "color167": true, + "color168": true, + "color169": true, + "color17": true, + "color170": true, + "color171": true, + "color172": true, + "color173": true, + "color174": true, + "color175": true, + "color176": true, + "color177": true, + "color178": true, + "color179": true, + "color18": true, + "color180": true, + "color181": true, + "color182": true, + "color183": true, + "color184": true, + "color185": true, + "color186": true, + "color187": true, + "color188": true, + "color189": true, + "color19": true, + "color190": true, + "color191": true, + "color192": true, + "color193": true, + "color194": true, + "color195": true, + "color196": true, + "color197": true, + "color198": true, + "color199": true, + "color2": true, + "color20": true, + "color200": true, + "color201": true, + "color202": true, + "color203": true, + "color204": true, + "color205": true, + "color206": true, + "color207": true, + "color208": true, + "color209": true, + "color21": true, + "color210": true, + "color211": true, + "color212": true, + "color213": true, + "color214": true, + "color215": true, + "color216": true, + "color217": true, + "color218": true, + "color219": true, + "color22": true, + "color220": true, + "color221": true, + "color222": true, + "color223": true, + "color224": true, + "color225": true, + "color226": true, + "color227": true, + "color228": true, + "color229": true, + "color23": true, + "color230": true, + "color231": true, + "color232": true, + "color233": true, + "color234": true, + "color235": true, + "color236": true, + "color237": true, + "color238": true, + "color239": true, + "color24": true, + "color240": true, + "color241": true, + "color242": true, + "color243": true, + "color244": true, + "color245": true, + "color246": true, + "color247": true, + "color248": true, + "color249": true, + "color25": true, + "color250": true, + "color251": true, + "color252": true, + "color253": true, + "color254": true, + "color255": true, + "color26": true, + "color27": true, + "color28": true, + "color29": true, + "color3": true, + "color30": true, + "color31": true, + "color32": true, + "color33": true, + "color34": true, + "color35": true, + "color36": true, + "color37": true, + "color38": true, + "color39": true, + "color4": true, + "color40": true, + "color41": true, + "color42": true, + "color43": true, + "color44": true, + "color45": true, + "color46": true, + "color47": true, + "color48": true, + "color49": true, + "color5": true, + "color50": true, + "color51": true, + "color52": true, + "color53": true, + "color54": true, + "color55": true, + "color56": true, + "color57": true, + "color58": true, + "color59": true, + "color6": true, + "color60": true, + "color61": true, + "color62": true, + "color63": true, + "color64": true, + "color65": true, + "color66": true, + "color67": true, + "color68": true, + "color69": true, + "color7": true, + "color70": true, + "color71": true, + "color72": true, + "color73": true, + "color74": true, + "color75": true, + "color76": true, + "color77": true, + "color78": true, + "color79": true, + "color8": true, + "color80": true, + "color81": true, + "color82": true, + "color83": true, + "color84": true, + "color85": true, + "color86": true, + "color87": true, + "color88": true, + "color89": true, + "color9": true, + "color90": true, + "color91": true, + "color92": true, + "color93": true, + "color94": true, + "color95": true, + "color96": true, + "color97": true, + "color98": true, + "color99": true, + "cursor": true, + "cursor_text_color": true, + "cursor_trail_color": true, + "foreground": true, + "inactive_border_color": true, + "inactive_tab_background": true, + "inactive_tab_foreground": true, + "macos_titlebar_color": true, + "mark1_background": true, + "mark1_foreground": true, + "mark2_background": true, + "mark2_foreground": true, + "mark3_background": true, + "mark3_foreground": true, + "scrollbar_handle_color": true, + "scrollbar_track_color": true, + "selection_background": true, + "selection_foreground": true, + "tab_bar_background": true, + "tab_bar_margin_color": true, + "url_color": true, + "visual_bell_color": true, + "wayland_titlebar_color": true, + "window_title_bar_active_background": true, + "window_title_bar_active_foreground": true, + "window_title_bar_inactive_background": true, + "window_title_bar_inactive_foreground": true, // ALL_COLORS_END } // }}} type JSONMetadata struct {