diff --git a/docs/changelog.rst b/docs/changelog.rst index b18d01a62..d11d395e2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,12 @@ To update |kitty|, :doc:`follow the instructions `. Recent major new features --------------------------- +Vertical tabs [0.48] +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +kitty now has support for :pull:`vertical tabs <9855>` along the left or right edge of the OS +Window. Useful for people that have wide aspect ratio windows. + Drag and drop for terminal programs [0.47] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -173,9 +179,11 @@ consumption to do the same tasks. Detailed list of changes ------------------------------------- -0.50.0 [future] +0.48.0 [future] ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +- Implement vertical tabs by setting :opt:`tab_bar_edge` to ``left`` or ``right`` (:pull:`9855`) + - Graphics protocol: Add a new :ref:`transient usage hint ` that clients can send to terminals to indicate an image is meant for only short duration use (:pull:`10092`) - kitten @ get-text: Add support for :code:`alternate` and :code:`alternate_scrollback` extents to fetch text from the alternate screen buffer (:iss:`10165`) diff --git a/go.mod b/go.mod index 3644bc92c..7a855bb20 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/kovidgoyal/go-parallel v1.1.1 github.com/kovidgoyal/go-shm v1.0.0 github.com/kovidgoyal/imaging v1.8.21 - github.com/nwaples/rardecode/v2 v2.2.3 + github.com/nwaples/rardecode/v2 v2.2.5 github.com/seancfoley/ipaddress-go v1.7.1 github.com/sgtdi/fswatcher v1.3.0 github.com/shirou/gopsutil/v4 v4.26.5 diff --git a/go.sum b/go.sum index bd2242770..a96348ae1 100644 --- a/go.sum +++ b/go.sum @@ -44,8 +44,8 @@ github.com/kovidgoyal/imaging v1.8.21 h1:95S2+dowTeKJJHNpf6lnScvIennTr2H0zQotu+p github.com/kovidgoyal/imaging v1.8.21/go.mod h1:976F+zjiQeZ7sd87Pxlm0a64S/w9bImSIWg3sSk1rdQ= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= -github.com/nwaples/rardecode/v2 v2.2.3 h1:qaVuy3ChZDbAQZshPLjHeNJKF3Cru8uo9jmgveKIy2A= -github.com/nwaples/rardecode/v2 v2.2.3/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/nwaples/rardecode/v2 v2.2.5 h1:L5doqgGfQwI7qADJMqnkrSB86rpPsqQDrHeO0HWa5JY= +github.com/nwaples/rardecode/v2 v2.2.5/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= diff --git a/kitty/boss.py b/kitty/boss.py index ece948347..8ce021a4b 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2017,7 +2017,7 @@ def on_drop(self, os_window_id: int, drop: dict[str, bytes] | int, from_self: bo window.on_drop(drop) break elif tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom: - if (tab_id := tm.tab_bar.tab_id_at(x)) and (tab := self.tab_for_id(tab_id)) and (w := tab.active_window): + if (tab_id := tm.tab_bar.tab_id_at(x, y)) and (tab := self.tab_for_id(tab_id)) and (w := tab.active_window): w.on_drop(drop) def on_drag_source_finished( diff --git a/kitty/mouse.c b/kitty/mouse.c index c4baf48e9..a7a9a3d9d 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -1010,6 +1010,8 @@ mouse_region(bool detect_borders, bool detect_title_bar) { const bool in_central = mouse_in_region(¢ral); if (!in_central) { if ( + (tab_bar.left < central.left && w->mouse_x < central.left) || + (tab_bar.right > central.right && w->mouse_x >= central.right) || (tab_bar.top < central.top && w->mouse_y < central.top) || (tab_bar.bottom > central.bottom && w->mouse_y >= central.bottom) ) ans.in_tab_bar = true; diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 5b0d1a573..6e42a6bfd 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1616,18 +1616,22 @@ opt('tab_bar_edge', 'bottom', option_type='tab_bar_edge', ctype='int', - long_text='The edge to show the tab bar on, :code:`top` or :code:`bottom`.' + long_text='The edge to show the tab bar on, :code:`top`, :code:`bottom`, :code:`left` or :code:`right`.' ) opt('tab_bar_margin_width', '0.0', option_type='positive_float', - long_text='The margin to the left and right of the tab bar (in pts).' + long_text=''' +The margin perpendicular to the tab bar edge (in pts). For tab bars on the +top or bottom this is the margin to the left and right. For tab bars on the +left or right this is the margin above and below. +''' ) opt('tab_bar_margin_height', '0.0 0.0', option_type='tab_bar_margin_height', ctype='!tab_bar_margin_height', long_text=''' -The margin above and below the tab bar (in pts). The first number is the margin +The margin along the tab bar edge (in pts). The first number is the margin between the edge of the OS Window and the tab bar. The second number is the margin between the tab bar and the contents of the current tab. ''' @@ -1675,11 +1679,12 @@ are automatically restricted to work only on matching tabs. ''') -opt('tab_bar_align', 'left', - choices=('left', 'center', 'right'), +opt('tab_bar_align', 'start', + choices=('start', 'center', 'end', 'left', 'right'), long_text=''' -The horizontal alignment of the tab bar, can be one of: :code:`left`, -:code:`center`, :code:`right`. +The alignment of the tab bar, can be one of: :code:`start`, :code:`center`, +:code:`end`, :code:`left`, :code:`right`. The values :code:`left` and +:code:`right` are aliases for :code:`start` and :code:`end` respectively. ''' ) @@ -1746,10 +1751,12 @@ ) opt('tab_title_max_length', '0', - option_type='positive_int', + option_type='positive_int', ctype='int', long_text=''' The maximum number of cells that can be used to render the text in a tab. -A value of zero means that no limit is applied. +A value of zero means that no limit is applied. For vertical tab bars, kitty +uses a default sidebar width sized for about twenty title cells when this is +left unset. ''' ) diff --git a/kitty/options/parse.py b/kitty/options/parse.py index cf2870707..e062ebf31 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -1356,7 +1356,7 @@ def tab_bar_align(self, val: str, ans: dict[str, typing.Any]) -> None: raise ValueError(f"The value {val} is not a valid choice for tab_bar_align") ans["tab_bar_align"] = val - choices_for_tab_bar_align = frozenset(('left', 'center', 'right')) + choices_for_tab_bar_align = frozenset(('start', 'center', 'end', 'left', 'right')) def tab_bar_background(self, val: str, ans: dict[str, typing.Any]) -> None: ans['tab_bar_background'] = to_color_or_none(val) @@ -1558,7 +1558,7 @@ def window_title_bar_align(self, val: str, ans: dict[str, typing.Any]) -> None: 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 + choices_for_window_title_bar_align = frozenset(('left', 'center', 'right')) 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) diff --git a/kitty/options/to-c-generated.h b/kitty/options/to-c-generated.h index eb149fb61..6d601cc75 100644 --- a/kitty/options/to-c-generated.h +++ b/kitty/options/to-c-generated.h @@ -1110,6 +1110,19 @@ convert_from_opts_tab_bar_style(PyObject *py_opts, Options *opts) { Py_DECREF(ret); } +static void +convert_from_python_tab_title_max_length(PyObject *val, Options *opts) { + opts->tab_title_max_length = PyLong_AsLong(val); +} + +static void +convert_from_opts_tab_title_max_length(PyObject *py_opts, Options *opts) { + PyObject *ret = PyObject_GetAttrString(py_opts, "tab_title_max_length"); + if (ret == NULL) return; + convert_from_python_tab_title_max_length(ret, opts); + Py_DECREF(ret); +} + static void convert_from_python_tab_bar_background(PyObject *val, Options *opts) { opts->tab_bar_background = color_or_none_as_int(val); @@ -1659,6 +1672,8 @@ convert_opts_from_python_opts(PyObject *py_opts, Options *opts) { if (PyErr_Occurred()) return false; convert_from_opts_tab_bar_style(py_opts, opts); if (PyErr_Occurred()) return false; + convert_from_opts_tab_title_max_length(py_opts, opts); + if (PyErr_Occurred()) return false; convert_from_opts_tab_bar_background(py_opts, opts); if (PyErr_Occurred()) return false; convert_from_opts_tab_bar_margin_color(py_opts, opts); diff --git a/kitty/options/types.py b/kitty/options/types.py index 2766de82d..a271ca046 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -32,7 +32,7 @@ choices_for_progress_bar = typing.Literal['left', 'right', 'top', 'bottom', 'hidden'] choices_for_scrollbar = typing.Literal['scrolled', 'always', 'never', 'hovered', 'scrolled-and-hovered'] choices_for_strip_trailing_spaces = typing.Literal['always', 'never', 'smart'] -choices_for_tab_bar_align = typing.Literal['left', 'center', 'right'] +choices_for_tab_bar_align = typing.Literal['start', 'center', 'end', 'left', 'right'] choices_for_tab_bar_style = typing.Literal['fade', 'hidden', 'powerline', 'separator', 'slant', 'custom'] choices_for_tab_powerline_style = typing.Literal['angled', 'round', 'slanted'] choices_for_tab_switch_strategy = typing.Literal['last', 'left', 'previous', 'right'] @@ -41,7 +41,7 @@ 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 +choices_for_window_title_bar_align = typing.Literal['left', 'center', 'right'] option_names = ( 'action_alias', @@ -666,7 +666,7 @@ class Options: strip_trailing_spaces: choices_for_strip_trailing_spaces = 'never' sync_to_monitor: bool = True tab_activity_symbol: str = '' - tab_bar_align: choices_for_tab_bar_align = 'left' + tab_bar_align: choices_for_tab_bar_align = 'start' tab_bar_background: kitty.fast_data_types.Color | None = None tab_bar_edge: int = 8 tab_bar_filter: str = '' diff --git a/kitty/options/utils.py b/kitty/options/utils.py index c5b850e71..18ef6c4ab 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -751,7 +751,15 @@ def tab_separator(x: str) -> str: def tab_bar_edge(x: str) -> int: - return {'top': defines.TOP_EDGE, 'bottom': defines.BOTTOM_EDGE}.get(x.lower(), defines.BOTTOM_EDGE) + match x.lower(): + case 'top': + return defines.TOP_EDGE + case 'left': + return defines.LEFT_EDGE + case 'right': + return defines.RIGHT_EDGE + case _: + return defines.BOTTOM_EDGE def tab_font_style(x: str) -> tuple[bool, bool]: diff --git a/kitty/state.c b/kitty/state.c index d4a122e47..d0bdaf732 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -691,29 +691,76 @@ pyset_borders_rects(PyObject *self UNUSED, PyObject *args) { } +static unsigned +vertical_tab_bar_cols(const OSWindow *os_window, long margin_outer, long margin_inner) { + unsigned cell_width = MAX(1u, os_window->fonts_data->fcm.cell_width); + long available_width = (long)os_window->viewport_width - margin_outer - margin_inner; + if (available_width <= 0) return 0; + unsigned available_cols = MAX(1u, (unsigned)available_width / cell_width); + unsigned title_cols = OPT(tab_title_max_length) > 0 ? (unsigned)OPT(tab_title_max_length) : 20u; + unsigned desired_cols = title_cols + 8u; + unsigned soft_max = available_cols / 3u; + if (soft_max < 6u) soft_max = available_cols; + return MAX(1u, MIN(available_cols, MIN(desired_cols, MAX(1u, soft_max)))); +} + void os_window_regions(const OSWindow *os_window, Region *central, Region *tab_bar) { if (!OPT(tab_bar_hidden) && os_window->num_tabs && !os_window->has_too_few_tabs) { long margin_outer = pt_to_px_for_os_window(OPT(tab_bar_margin_height.outer), os_window); long margin_inner = pt_to_px_for_os_window(OPT(tab_bar_margin_height.inner), os_window); central->left = 0; central->right = os_window->viewport_width; - unsigned tab_bar_height = os_window->fonts_data->fcm.cell_height + margin_inner + margin_outer; + central->top = 0; central->bottom = os_window->viewport_height; switch(OPT(tab_bar_edge)) { - case TOP_EDGE: + case TOP_EDGE: { + unsigned tab_bar_height = os_window->fonts_data->fcm.cell_height + margin_inner + margin_outer; central->top = tab_bar_height; central->bottom = os_window->viewport_height; central->top = MIN(central->top, central->bottom); tab_bar->top = margin_outer; + tab_bar->left = central->left; tab_bar->right = central->right; + tab_bar->bottom = tab_bar->top + os_window->fonts_data->fcm.cell_height; break; - default: + } + case LEFT_EDGE: { + unsigned left_cols = vertical_tab_bar_cols(os_window, margin_outer, margin_inner); + if (!left_cols) { + zero_at_ptr(tab_bar); + return; + } + unsigned left_width = left_cols * os_window->fonts_data->fcm.cell_width; + central->left = MIN((long)(left_width + margin_inner + margin_outer), (long)central->right); + tab_bar->left = margin_outer; + tab_bar->right = tab_bar->left + left_width; + tab_bar->top = central->top; + tab_bar->bottom = central->bottom; + break; + } + case RIGHT_EDGE: { + unsigned right_cols = vertical_tab_bar_cols(os_window, margin_outer, margin_inner); + if (!right_cols) { + zero_at_ptr(tab_bar); + return; + } + unsigned right_width = right_cols * os_window->fonts_data->fcm.cell_width; + central->right = MAX(0, (long)os_window->viewport_width - (long)(right_width + margin_inner + margin_outer)); + tab_bar->left = central->right + margin_inner; + tab_bar->right = tab_bar->left + right_width; + tab_bar->top = central->top; + tab_bar->bottom = central->bottom; + break; + } + default: { + unsigned tab_bar_height = os_window->fonts_data->fcm.cell_height + margin_inner + margin_outer; central->top = 0; long bottom = os_window->viewport_height - tab_bar_height; central->bottom = MAX(0, bottom); tab_bar->top = central->bottom + margin_inner; + tab_bar->left = central->left; tab_bar->right = central->right; + tab_bar->bottom = tab_bar->top + os_window->fonts_data->fcm.cell_height; break; + } } - tab_bar->left = central->left; tab_bar->right = central->right; - tab_bar->bottom = tab_bar->top + os_window->fonts_data->fcm.cell_height; } else { zero_at_ptr(tab_bar); central->left = 0; central->top = 0; central->right = os_window->viewport_width; diff --git a/kitty/state.h b/kitty/state.h index ce0307e70..2eecb356c 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -110,6 +110,7 @@ typedef struct Options { bool dynamic_background_opacity; float inactive_text_alpha; Edge tab_bar_edge; + int tab_title_max_length; DisableLigature disable_ligatures; bool force_ltr; bool resize_in_steps; diff --git a/kitty/tab_bar.py b/kitty/tab_bar.py index 0db2b8c10..94db2f69a 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -8,6 +8,7 @@ from string import Formatter as StringFormatter from typing import ( Any, + Literal, NamedTuple, ) @@ -16,6 +17,9 @@ from .fast_data_types import ( BOTTOM_EDGE, DECAWM, + LEFT_EDGE, + RIGHT_EDGE, + TOP_EDGE, Color, Region, Screen, @@ -101,6 +105,31 @@ def as_rgb(x: int) -> int: return (x << 8) | 2 +VERTICAL_EDGES = frozenset({LEFT_EDGE, RIGHT_EDGE}) +MAX_VERTICAL_TAB_LINES = 2 + + +def is_vertical_edge(edge: int) -> bool: + return edge in VERTICAL_EDGES + + +edge_name_map: dict[int, EdgeLiteral] = { + LEFT_EDGE: 'left', + TOP_EDGE: 'top', + RIGHT_EDGE: 'right', + BOTTOM_EDGE: 'bottom', +} + + +def normalized_tab_bar_align(align: str) -> Literal['start', 'end', 'center']: + match align: + case 'left' | 'start': + return 'start' + case 'right' | 'end': + return 'end' + return 'center' + + @lru_cache def report_template_failure(template: str, e: str) -> None: log_error(f'Invalid tab title template: "{template}" with error: {e}') @@ -342,7 +371,7 @@ def draw_tab_with_slant( extra_data: ExtraData ) -> int: orig_fg = screen.cursor.fg - left_sep, right_sep = ('', '') if draw_data.tab_bar_edge == 'top' else ('', '') + left_sep, right_sep = ('', '') if draw_data.tab_bar_edge in ('top', 'left') else ('', '') tab_bg = screen.cursor.bg slant_fg = as_rgb(color_as_int(draw_data.default_bg)) @@ -567,10 +596,18 @@ class CellRange(NamedTuple): class TabExtent(NamedTuple): tab_id: int - cell_range: CellRange + x: CellRange + y: CellRange = CellRange(0, 0) - def shifted(self, shift: int) -> 'TabExtent': - return TabExtent(self.tab_id, CellRange(self.cell_range.start + shift, self.cell_range.end + shift)) + def shifted(self, x: int = 0, y: int = 0) -> 'TabExtent': + return TabExtent( + self.tab_id, + CellRange(self.x.start + x, self.x.end + x), + CellRange(self.y.start + y, self.y.end + y), + ) + + def contains(self, x: int, y: int) -> bool: + return self.x.start <= x <= self.x.end and self.y.start <= y <= self.y.end class TabBar: @@ -588,6 +625,8 @@ def __init__(self, os_window_id: int): def apply_options(self) -> None: opts = get_options() self.dirty = True + self.tab_bar_edge = opts.tab_bar_edge + self.is_vertical = is_vertical_edge(opts.tab_bar_edge) self.margin_width = pt_to_px(opts.tab_bar_margin_width, self.os_window_id) self.cell_width, cell_height = cell_size_for_window(self.os_window_id) if not hasattr(self, 'screen'): @@ -618,7 +657,7 @@ def apply_options(self) -> None: opts.active_tab_title_template, opts.tab_activity_symbol, opts.tab_powerline_style, - 'bottom' if opts.tab_bar_edge == BOTTOM_EDGE else 'top', + edge_name_map[opts.tab_bar_edge], opts.tab_title_max_length, self.os_window_id, ) ts = opts.tab_bar_style @@ -632,12 +671,14 @@ def apply_options(self) -> None: self.draw_func = load_custom_draw_tab() else: self.draw_func = draw_tab_with_fade - if opts.tab_bar_align == 'center': - self.align: Callable[[], None] = partial(self.align_with_factor, 2) - elif opts.tab_bar_align == 'right': - self.align = self.align_with_factor - else: - self.align = lambda: None + self.tab_bar_align = normalized_tab_bar_align(opts.tab_bar_align) + match self.tab_bar_align: + case 'center': + self.align: Callable[[], None] = partial(self.align_with_factor, 2) + case 'end': + self.align = self.align_with_factor + case 'start': + self.align = lambda: None def patch_colors(self, spec: dict[str, int | None]) -> None: opts = get_options() @@ -690,51 +731,96 @@ def update_blank_rects(self, central: Region, tab_bar: Region, vw: int, vh: int) blank_rects: list[Border] = [] bg = BorderColor.tab_bar_margin_color if opts.tab_bar_margin_color is not None else BorderColor.default_bg if opts.tab_bar_margin_height: - if opts.tab_bar_edge == BOTTOM_EDGE: + if self.is_vertical: + if opts.tab_bar_edge == LEFT_EDGE: + if opts.tab_bar_margin_height.outer: + blank_rects.append(Border(0, 0, tab_bar.left, vh, bg)) + if opts.tab_bar_margin_height.inner: + blank_rects.append(Border(tab_bar.right, 0, central.left, vh, bg)) + else: + if opts.tab_bar_margin_height.outer: + blank_rects.append(Border(tab_bar.right, 0, vw, vh, bg)) + if opts.tab_bar_margin_height.inner: + blank_rects.append(Border(central.right, 0, tab_bar.left, vh, bg)) + elif opts.tab_bar_edge == BOTTOM_EDGE: if opts.tab_bar_margin_height.outer: blank_rects.append(Border(0, tab_bar.bottom, vw, vh, bg)) if opts.tab_bar_margin_height.inner: blank_rects.append(Border(0, central.bottom, vw, tab_bar.top, bg)) - else: # top + else: # top if opts.tab_bar_margin_height.outer: blank_rects.append(Border(0, 0, vw, tab_bar.top, bg)) if opts.tab_bar_margin_height.inner: blank_rects.append(Border(0, tab_bar.bottom, vw, central.top, bg)) g = self.window_geometry - left_bg = right_bg = bg - if opts.tab_bar_margin_color is None and ( - opacity := background_opacity_of(self.os_window_id)) is not None and opacity >= 1: - left_bg = BorderColor.tab_bar_left_edge_color - right_bg = BorderColor.tab_bar_right_edge_color - if g.left > 0: - blank_rects.append(Border(0, g.top, g.left, g.bottom, left_bg)) - if g.right < vw: - blank_rects.append(Border(g.right, g.top, vw, g.bottom, right_bg)) + if self.is_vertical: + if g.left > tab_bar.left: + blank_rects.append(Border(tab_bar.left, g.top, g.left, g.bottom, bg)) + if g.right < tab_bar.right: + blank_rects.append(Border(g.right, g.top, tab_bar.right, g.bottom, bg)) + if g.top > tab_bar.top: + blank_rects.append(Border(g.left, tab_bar.top, g.right, g.top, bg)) + if g.bottom < tab_bar.bottom: + blank_rects.append(Border(g.left, g.bottom, g.right, tab_bar.bottom, bg)) + else: + left_bg = right_bg = bg + if opts.tab_bar_margin_color is None and ( + opacity := background_opacity_of(self.os_window_id)) is not None and opacity >= 1: + left_bg = BorderColor.tab_bar_left_edge_color + right_bg = BorderColor.tab_bar_right_edge_color + if g.left > tab_bar.left: + blank_rects.append(Border(tab_bar.left, g.top, g.left, g.bottom, left_bg)) + if g.right < tab_bar.right: + blank_rects.append(Border(g.right, g.top, tab_bar.right, g.bottom, right_bg)) + if g.top > tab_bar.top: + blank_rects.append(Border(g.left, tab_bar.top, g.right, g.top, bg)) + if g.bottom < tab_bar.bottom: + blank_rects.append(Border(g.left, g.bottom, g.right, tab_bar.bottom, bg)) self.blank_rects = tuple(blank_rects) def layout(self) -> None: central, tab_bar, vw, vh, cell_width, cell_height = viewport_for_window(self.os_window_id) - if tab_bar.width < 2: + if self.is_vertical: + if tab_bar.width < cell_width or tab_bar.height < cell_height: + return + elif tab_bar.width < 2: return self.cell_width = cell_width + self.cell_height = cell_height s = self.screen - available_width = tab_bar.width - 2 * self.margin_width - ncells = max(4, available_width // cell_width) - s.resize(1, ncells) - s.reset_mode(DECAWM) - cell_area_width = ncells * cell_width - available_width_for_left_margin = max(0, tab_bar.width - self.margin_width - cell_area_width) - extra_width = max(0, tab_bar.width - 2 * self.margin_width - cell_area_width) - left_margin = min(self.margin_width + extra_width // 2, available_width_for_left_margin) + if self.is_vertical: + available_height = tab_bar.height - 2 * self.margin_width + nlines = max(1, available_height // cell_height) + ncols = max(1, tab_bar.width // cell_width) + s.resize(nlines, ncols) + s.reset_mode(DECAWM) + cell_area_height = nlines * cell_height + available_height_for_top_margin = max(0, tab_bar.height - self.margin_width - cell_area_height) + extra_height = max(0, tab_bar.height - 2 * self.margin_width - cell_area_height) + top_margin = min(self.margin_width + extra_height // 2, available_height_for_top_margin) + self.window_geometry = g = WindowGeometry( + tab_bar.left, tab_bar.top + top_margin, tab_bar.right, tab_bar.top + top_margin + cell_area_height, s.columns, s.lines) + else: + available_width = tab_bar.width - 2 * self.margin_width + ncells = max(4, available_width // cell_width) + s.resize(1, ncells) + s.reset_mode(DECAWM) + cell_area_width = ncells * cell_width + available_width_for_left_margin = max(0, tab_bar.width - self.margin_width - cell_area_width) + extra_width = max(0, tab_bar.width - 2 * self.margin_width - cell_area_width) + left_margin = min(self.margin_width + extra_width // 2, available_width_for_left_margin) + self.window_geometry = g = WindowGeometry( + left_margin, tab_bar.top, left_margin + cell_area_width, tab_bar.bottom, s.columns, s.lines) self.laid_out_once = True - self.window_geometry = g = WindowGeometry( - left_margin, tab_bar.top, left_margin + cell_area_width, tab_bar.bottom, s.columns, s.lines) self.update_blank_rects(central, tab_bar, vw, vh) set_tab_bar_render_data(self.os_window_id, self.screen, *g[:4]) def update(self, data: Sequence[TabBarData]) -> None: if not self.laid_out_once: return + if self.is_vertical: + self.update_vertical(data) + return s = self.screen last_tab = data[-1] if data else None ed = ExtraData() @@ -743,14 +829,14 @@ def update(self, data: Sequence[TabBarData]) -> None: def draw_tab(i: int, tab: TabBarData, cell_ranges: list[TabExtent], max_tab_length: int) -> None: ed.prev_tab = data[i - 1] if i > 0 else None ed.next_tab = data[i + 1] if i + 1 < len(data) else None - s.cursor.bg = as_rgb(self.draw_data.tab_bg(t)) - s.cursor.fg = as_rgb(self.draw_data.tab_fg(t)) - s.cursor.bold, s.cursor.italic = self.active_font_style if t.is_active else self.inactive_font_style + s.cursor.bg = as_rgb(self.draw_data.tab_bg(tab)) + s.cursor.fg = as_rgb(self.draw_data.tab_fg(tab)) + s.cursor.bold, s.cursor.italic = self.active_font_style if tab.is_active else self.inactive_font_style before = s.cursor.x - end = self.draw_func(self.draw_data, s, t, before, max_tab_length, i + 1, t is last_tab, ed) + end = self.draw_func(self.draw_data, s, tab, before, max_tab_length, i + 1, tab is last_tab, ed) s.cursor.bg = s.cursor.fg = 0 - cell_ranges.append(TabExtent(tab_id=tab.tab_id, cell_range=CellRange(before, end))) - if not ed.for_layout and t is not last_tab and s.cursor.x > s.columns - max_tab_lengths[i+1]: + cell_ranges.append(TabExtent(tab_id=tab.tab_id, x=CellRange(before, end))) + if not ed.for_layout and tab is not last_tab and s.cursor.x > s.columns - max_tab_lengths[i+1]: # Stop if there is no space for next tab s.cursor.x = s.columns - 2 s.cursor.bg = as_rgb(color_as_int(self.draw_data.default_bg)) @@ -801,24 +887,72 @@ def draw_tab(i: int, tab: TabBarData, cell_ranges: list[TabExtent], max_tab_leng self.align() update_tab_bar_edge_colors(self.os_window_id) + def update_vertical(self, data: Sequence[TabBarData]) -> None: + s = self.screen + self.last_laid_out_tabs = data + self.tab_extents = () + s.cursor.x = s.cursor.y = 0 + s.erase_in_display(2, False) + if not data: + return + max_tab_length = max(1, s.columns - 1) + tab_line_height = max(1, min(MAX_VERTICAL_TAB_LINES, s.lines // max(1, len(data)))) + rows_to_draw = min(len(data), max(1, s.lines // tab_line_height)) + draw_ellipsis = len(data) > rows_to_draw and s.lines > 1 + if draw_ellipsis: + tab_line_height = 1 + rows_to_draw = min(len(data), s.lines) + rows_to_draw -= 1 + total_lines = rows_to_draw * tab_line_height + int(draw_ellipsis) + if self.tab_bar_align == 'center': + start_row = max(0, (s.lines - total_lines) // 2) + elif self.tab_bar_align == 'end': + start_row = max(0, s.lines - total_lines) + else: + start_row = 0 + cr: list[TabExtent] = [] + for i, t in enumerate(data[:rows_to_draw]): + s.cursor.x = 0 + row = start_row + i * tab_line_height + s.cursor.y = row + s.cursor.bg = as_rgb(self.draw_data.tab_bg(t)) + s.cursor.fg = as_rgb(self.draw_data.tab_fg(t)) + s.cursor.bold, s.cursor.italic = self.active_font_style if t.is_active else self.inactive_font_style + self.draw_func(self.draw_data, s, t, 0, max_tab_length, i + 1, True, ExtraData()) + cr.append(TabExtent(tab_id=t.tab_id, x=CellRange(0, s.columns - 1), y=CellRange(row, min(s.lines - 1, row + tab_line_height - 1)))) + if draw_ellipsis: + s.cursor.x = 0 + s.cursor.y = start_row + rows_to_draw * tab_line_height + s.cursor.bg = as_rgb(color_as_int(self.draw_data.default_bg)) + s.cursor.fg = as_rgb(0xff0000) + s.draw('…') + self.tab_extents = tuple(cr) + def align_with_factor(self, factor: int = 1) -> None: if not self.tab_extents: return - end = self.tab_extents[-1].cell_range[1] + end = self.tab_extents[-1].x.end if end < self.screen.columns - 1: shift = (self.screen.columns - end) // factor self.screen.cursor.x = 0 self.screen.insert_characters(shift) - self.tab_extents = tuple(te.shifted(shift) for te in self.tab_extents) + self.tab_extents = tuple(te.shifted(x=shift) for te in self.tab_extents) def destroy(self) -> None: self.screen.reset_callbacks() del self.screen - def tab_id_at(self, x: int) -> int: + def tab_id_at(self, x: int, y: int) -> int: if self.laid_out_once: - x = (x - self.window_geometry.left) // self.cell_width + g = self.window_geometry + if not (g.left <= x < g.right and g.top <= y < g.bottom): + return 0 + x = (x - g.left) // self.cell_width + y = (y - g.top) // self.cell_height for te in self.tab_extents: - if te.cell_range.start <= x <= te.cell_range.end: + if te.contains(x, y): return te.tab_id return 0 + + def drag_axis_coordinate(self, x: int, y: int) -> int: + return y if self.is_vertical else x diff --git a/kitty/tabs.py b/kitty/tabs.py index 04e8a8266..97e741799 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1171,7 +1171,7 @@ def swap_windows(self, window_a: Window, window_b: Window) -> None: class TabBeingDropped(NamedTuple): data: TabBarData tab_ids: Sequence[int] = () - last_drop_move_x: int = -1 + last_drop_move_coordinate: int = -1 class WindowBeingDropped(NamedTuple): @@ -1703,27 +1703,29 @@ def on_tab_drop_move(self, tab_id: int = 0, is_dest: bool = False, x: int = 0, y tab_data = tab.data_for_tab_bar(tab is get_boss().active_tab) if tab_id not in all_tabs: all_tabs.append(tab_id) - _, _, start_x, _ = get_tab_being_dragged() - self.tab_being_dropped = TabBeingDropped(data=tab_data, tab_ids=all_tabs, last_drop_move_x=int(start_x)) - mouse_moved_left = False + _, _, start_x, start_y = get_tab_being_dragged() + start_coordinate = self.tab_bar.drag_axis_coordinate(int(start_x), int(start_y)) + self.tab_being_dropped = TabBeingDropped(data=tab_data, tab_ids=all_tabs, last_drop_move_coordinate=start_coordinate) force_update = True - if x == self.tab_being_dropped.last_drop_move_x and not force_update: + coordinate = self.tab_bar.drag_axis_coordinate(x, y) + if coordinate == self.tab_being_dropped.last_drop_move_coordinate and not force_update: return - mouse_moved_left = x < self.tab_being_dropped.last_drop_move_x + mouse_moved_towards_start = coordinate < self.tab_being_dropped.last_drop_move_coordinate old_tab_ids = self.tab_being_dropped.tab_ids idx_under_mouse = -1 - if (tab_id_under_mouse := self.tab_bar.tab_id_at(x)): + if (tab_id_under_mouse := self.tab_bar.tab_id_at(x, y)): with suppress(Exception): idx_under_mouse = old_tab_ids.index(tab_id_under_mouse) if idx_under_mouse < 0: - idx_under_mouse = 0 if x < 20 else len(old_tab_ids) - 1 + start = self.tab_bar.window_geometry.top if self.tab_bar.is_vertical else self.tab_bar.window_geometry.left + idx_under_mouse = 0 if coordinate < start else len(old_tab_ids) - 1 old_idx_under_mouse = old_tab_ids.index(tab_id) - idx_moved_left = old_idx_under_mouse > idx_under_mouse + idx_moved_towards_start = old_idx_under_mouse > idx_under_mouse new_tab_ids = old_tab_ids - if mouse_moved_left == idx_moved_left: + if mouse_moved_towards_start == idx_moved_towards_start: new_tab_ids = list(old_tab_ids) new_tab_ids[idx_under_mouse], new_tab_ids[old_idx_under_mouse] = new_tab_ids[old_idx_under_mouse], new_tab_ids[idx_under_mouse] - self.tab_being_dropped = self.tab_being_dropped._replace(last_drop_move_x=x, tab_ids=new_tab_ids) + self.tab_being_dropped = self.tab_being_dropped._replace(last_drop_move_coordinate=coordinate, tab_ids=new_tab_ids) if force_update or self.tab_being_dropped.tab_ids != old_tab_ids: self.layout_tab_bar() @@ -1803,11 +1805,11 @@ def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int, self.recent_tab_bar_mouse_events.clear() return - tab_id_at_x = self.tab_bar.tab_id_at(int(x)) - self.recent_tab_bar_mouse_events.add(button, modifiers, action, x, y, tab_id_at_x) + tab_id_at_pointer = self.tab_bar.tab_id_at(int(x), int(y)) + self.recent_tab_bar_mouse_events.add(button, modifiers, action, x, y, tab_id_at_pointer) drag_started = get_tab_being_dragged()[1] is_left_release = button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE - if tab_id_at_x < 0: # synthetic tab (e.g. "+" new-tab button) + if tab_id_at_pointer < 0: # synthetic tab (e.g. "+" new-tab button) if is_left_release and not drag_started: set_tab_being_dragged() # clear potential drag from a press on a tab if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 1: @@ -1816,7 +1818,7 @@ def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int, return if drag_started: return - tab = self.tab_for_id(tab_id_at_x) + tab = self.tab_for_id(tab_id_at_pointer) if tab is None: if is_left_release: set_tab_being_dragged() # clear potential drag from a press on a tab @@ -1978,7 +1980,7 @@ def on_window_drop_move(self, window_id: int = 0, is_dest: bool = False, x: int tab_bar = viewport_for_window(self.os_window_id)[1] if tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom: self._set_drag_target_window(0) - self._set_drag_target_tab(self.tab_bar.tab_id_at(x)) + self._set_drag_target_tab(self.tab_bar.tab_id_at(x, y)) return self._set_drag_target_tab(0) dest_window = self._find_window_at(x, y) @@ -2033,7 +2035,7 @@ def on_window_drop(self, x: int, y: int, window_id: int) -> None: # Case 1: Drop on tab bar → move to that tab in_tab_bar = tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom if in_tab_bar: - if (tab_id := self.tab_bar.tab_id_at(x)) and (dest_tab := self.tab_for_id(tab_id)): + if (tab_id := self.tab_bar.tab_id_at(x, y)) and (dest_tab := self.tab_for_id(tab_id)): boss._move_window_to(w, target_tab_id=dest_tab.id) else: boss._move_window_to(w, target_tab_id='new') diff --git a/kitty_tests/options.py b/kitty_tests/options.py index d1190a87c..5e1631b00 100644 --- a/kitty_tests/options.py +++ b/kitty_tests/options.py @@ -220,6 +220,7 @@ def pn(cmdline, **assertions): def conf_parsing(self): from kitty.config import defaults, load_config from kitty.constants import is_macos + from kitty.fast_data_types import LEFT_EDGE, RIGHT_EDGE from kitty.fonts import FontModification, ModificationType, ModificationUnit, ModificationValue from kitty.options.utils import to_modifiers bad_lines = [] @@ -254,6 +255,18 @@ def keys_for_func(opts, name): self.ae(opts.url_excluded_characters, "'''") opts = p("url_excluded_characters abc'") self.ae(opts.url_excluded_characters, "abc'") + opts = p('tab_bar_edge left') + self.ae(opts.tab_bar_edge, LEFT_EDGE) + opts = p('tab_bar_edge right') + self.ae(opts.tab_bar_edge, RIGHT_EDGE) + opts = p('tab_bar_align start') + self.ae(opts.tab_bar_align, 'start') + opts = p('tab_bar_align end') + self.ae(opts.tab_bar_align, 'end') + opts = p('tab_bar_align left') + self.ae(opts.tab_bar_align, 'left') + opts = p('tab_bar_align right') + self.ae(opts.tab_bar_align, 'right') opts = p('clear_all_shortcuts y', 'map f1 next_window') self.ae(len(opts.keyboard_modes[''].keymap), 1) opts = p('clear_all_mouse_actions y', 'mouse_map left click ungrabbed mouse_click_url_or_select') diff --git a/kitty_tests/tab_bar.py b/kitty_tests/tab_bar.py new file mode 100644 index 000000000..f20af348a --- /dev/null +++ b/kitty_tests/tab_bar.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python +# License: GPL v3 Copyright: 2026, Kovid Goyal + +from unittest.mock import patch + +from kitty.fast_data_types import LEFT_EDGE, Region +from kitty.tab_bar import TabBar, TabBarData + +from . import BaseTest + + +def region(left: int, top: int, right: int, bottom: int) -> Region: + return Region((left, top, right, bottom, right - left, bottom - top)) + + +class DummyBoss: + class mappings: + current_keyboard_mode_name = '' + + def tab_for_id(self, tab_id: int) -> None: + return None + + +class TestTabBar(BaseTest): + + def test_vertical_tab_bar_hit_testing(self) -> None: + self.set_options({ + 'tab_bar_edge': LEFT_EDGE, + 'tab_bar_style': 'separator', + 'tab_title_template': '{title}', + }) + central = region(120, 0, 400, 160) + tab_bar = region(0, 0, 120, 160) + geometries: list[tuple[int, int, int, int]] = [] + boss = DummyBoss() + + with ( + patch('kitty.tab_bar.cell_size_for_window', return_value=(10, 20)), + patch('kitty.tab_bar.viewport_for_window', return_value=(central, tab_bar, 400, 160, 10, 20)), + patch('kitty.tab_bar.set_tab_bar_render_data', side_effect=lambda *args: geometries.append(args[2:6])), + patch('kitty.tab_bar.get_boss', return_value=boss), + ): + tb = TabBar(1) + tb.layout() + tb.update(( + TabBarData(title='one', tab_id=1, is_active=True), + TabBarData(title='two', tab_id=2), + TabBarData(title='three', tab_id=3), + )) + + self.assertTrue(tb.is_vertical) + self.ae(geometries[-1], (0, 0, 120, 160)) + self.ae(tb.drag_axis_coordinate(5, 35), 35) + self.ae(tb.tab_id_at(5, 10), 1) + self.ae(tb.tab_id_at(110, 35), 1) + self.ae(tb.tab_id_at(60, 55), 2) + self.ae(tb.tab_id_at(60, 95), 3) + self.ae(tb.tab_id_at(60, 135), 0) + self.ae(tb.tab_id_at(180, 10), 0) + + def test_vertical_tab_bar_alignment(self) -> None: + self.set_options({ + 'tab_bar_align': 'end', + 'tab_bar_edge': LEFT_EDGE, + 'tab_bar_style': 'separator', + 'tab_title_template': '{title}', + }) + central = region(120, 0, 400, 160) + tab_bar = region(0, 0, 120, 160) + boss = DummyBoss() + + with ( + patch('kitty.tab_bar.cell_size_for_window', return_value=(10, 20)), + patch('kitty.tab_bar.viewport_for_window', return_value=(central, tab_bar, 400, 160, 10, 20)), + patch('kitty.tab_bar.set_tab_bar_render_data'), + patch('kitty.tab_bar.get_boss', return_value=boss), + ): + tb = TabBar(1) + tb.layout() + tb.update(( + TabBarData(title='one', tab_id=1, is_active=True), + TabBarData(title='two', tab_id=2), + )) + + self.ae(tb.tab_extents[0].y, (4, 5)) + self.ae(tb.tab_extents[1].y, (6, 7)) + self.ae(tb.tab_id_at(5, 10), 0) + self.ae(tb.tab_id_at(5, 110), 1) + self.ae(tb.tab_id_at(5, 150), 2)