diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 191cdca21..a606302dc 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1679,12 +1679,12 @@ tab navigation actions such as :ac:`goto_tab`, :ac:`next_tab`, :ac:`previous_tab 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. For vertical tab bars this controls the -alignment of each tab title within the sidebar. 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. ''' ) 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 @@ class Parser: 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 @@ class Parser: 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 8d19954ec..6d601cc75 100644 --- a/kitty/options/to-c-generated.h +++ b/kitty/options/to-c-generated.h @@ -1084,19 +1084,6 @@ convert_from_opts_tab_bar_edge(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_margin_height(PyObject *val, Options *opts) { tab_bar_margin_height(val, opts); @@ -1123,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); @@ -1668,12 +1668,12 @@ convert_opts_from_python_opts(PyObject *py_opts, Options *opts) { if (PyErr_Occurred()) return false; convert_from_opts_tab_bar_edge(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_margin_height(py_opts, 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 bbe05b840..d1bc7fa86 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -32,7 +32,7 @@ choices_for_pointer_shape_when_grabbed = choices_for_default_pointer_shape 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_undercurl_style = typing.Literal['thin-sparse', 'thin-dense', 'thick 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/tab_bar.py b/kitty/tab_bar.py index 9945d4b21..77ddccc85 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -103,6 +103,7 @@ def as_rgb(x: int) -> int: VERTICAL_EDGES = frozenset({LEFT_EDGE, RIGHT_EDGE}) +MAX_VERTICAL_TAB_LINES = 2 def is_vertical_edge(edge: int) -> bool: @@ -118,6 +119,14 @@ def edge_name(edge: int) -> EdgeLiteral: }.get(edge, 'bottom') +def normalized_tab_bar_align(align: str) -> str: + if align == 'left': + return 'start' + if align == 'right': + return 'end' + return align + + @lru_cache def report_template_failure(template: str, e: str) -> None: log_error(f'Invalid tab title template: "{template}" with error: {e}') @@ -657,15 +666,16 @@ class TabBar: self.draw_func = load_custom_draw_tab() else: self.draw_func = draw_tab_with_fade - if opts.tab_bar_align == 'center': + self.tab_bar_align = normalized_tab_bar_align(opts.tab_bar_align) + if self.tab_bar_align == 'center': self.align_factor = 2 - elif opts.tab_bar_align == 'right': + elif self.tab_bar_align == 'end': self.align_factor = 1 else: self.align_factor = 0 - if opts.tab_bar_align == 'center': + if self.tab_bar_align == 'center': self.align: Callable[[], None] = partial(self.align_with_factor, 2) - elif opts.tab_bar_align == 'right': + elif self.tab_bar_align == 'end': self.align = self.align_with_factor else: self.align = lambda: None @@ -886,23 +896,33 @@ class TabBar: if not data: return max_tab_length = max(1, s.columns - 1) - rows_to_draw = min(len(data), s.lines) - draw_ellipsis = len(data) > s.lines and s.lines > 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 - s.cursor.y = i + 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 - end = self.draw_func(self.draw_data, s, t, 0, max_tab_length, i + 1, True, ExtraData()) - self.align_row(i, end) - cr.append(TabExtent(tab_id=t.tab_id, x=CellRange(0, s.columns - 1), y=CellRange(i, i))) + 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 = s.lines - 1 + 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('…') @@ -918,16 +938,6 @@ class TabBar: self.screen.insert_characters(shift) self.tab_extents = tuple(te.shifted(x=shift) for te in self.tab_extents) - def align_row(self, row: int, end: int) -> None: - if not self.align_factor: - return - if end < self.screen.columns - 1: - shift = (self.screen.columns - end) // self.align_factor - if shift > 0: - self.screen.cursor.y = row - self.screen.cursor.x = 0 - self.screen.insert_characters(shift) - def destroy(self) -> None: self.screen.reset_callbacks() del self.screen diff --git a/kitty_tests/options.py b/kitty_tests/options.py index 1d9a3a987..5e1631b00 100644 --- a/kitty_tests/options.py +++ b/kitty_tests/options.py @@ -259,6 +259,14 @@ def conf_parsing(self): 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 index 562985e75..f20af348a 100644 --- a/kitty_tests/tab_bar.py +++ b/kitty_tests/tab_bar.py @@ -52,7 +52,38 @@ class TestTabBar(BaseTest): 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), 2) - self.ae(tb.tab_id_at(60, 55), 3) - self.ae(tb.tab_id_at(60, 95), 0) + 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)