From 19ea73f0470a0ae8f36b13cb6c8b20a5fda847a5 Mon Sep 17 00:00:00 2001 From: bvolpato Date: Sun, 12 Apr 2026 00:08:21 -0400 Subject: [PATCH 1/2] Add vertical tab bars on the left and right Teach tab_bar_edge about left and right sidebars and route tab layout, hit-testing, and drag/drop through the vertical axis when needed. --- kitty/boss.py | 2 +- kitty/mouse.c | 2 + kitty/options/definition.py | 19 ++- kitty/options/to-c-generated.h | 15 +++ kitty/options/utils.py | 7 +- kitty/state.c | 57 ++++++++- kitty/state.h | 1 + kitty/tab_bar.py | 204 +++++++++++++++++++++++++++------ kitty/tabs.py | 36 +++--- kitty_tests/options.py | 5 + kitty_tests/tab_bar.py | 58 ++++++++++ 11 files changed, 338 insertions(+), 68 deletions(-) create mode 100644 kitty_tests/tab_bar.py diff --git a/kitty/boss.py b/kitty/boss.py index 9c18f162189..eeff248229d 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -2001,7 +2001,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 9f40b0bfb65..f955f44349f 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -987,6 +987,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 66545faf4f5..191cdca211c 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. ''' @@ -1678,7 +1682,8 @@ opt('tab_bar_align', 'left', choices=('left', 'center', 'right'), long_text=''' -The horizontal alignment of the tab bar, can be one of: :code:`left`, +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`. ''' ) @@ -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/to-c-generated.h b/kitty/options/to-c-generated.h index eb149fb6198..8d19954ec72 100644 --- a/kitty/options/to-c-generated.h +++ b/kitty/options/to-c-generated.h @@ -1084,6 +1084,19 @@ 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); @@ -1655,6 +1668,8 @@ 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); diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 7cf8db700ca..34285b017b5 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -751,7 +751,12 @@ 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) + return { + 'left': defines.LEFT_EDGE, + 'top': defines.TOP_EDGE, + 'right': defines.RIGHT_EDGE, + 'bottom': defines.BOTTOM_EDGE, + }.get(x.lower(), defines.BOTTOM_EDGE) def tab_font_style(x: str) -> tuple[bool, bool]: diff --git a/kitty/state.c b/kitty/state.c index a610834b6dc..3c5e7dad129 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; + } + 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; - default: + } + 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 551bc9811f1..fae6de610fb 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 ccc121b3b31..9945d4b2170 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -17,8 +17,11 @@ BOTTOM_EDGE, DECAWM, Color, + LEFT_EDGE, Region, + RIGHT_EDGE, Screen, + TOP_EDGE, background_opacity_of, cell_size_for_window, get_boss, @@ -99,6 +102,22 @@ def as_rgb(x: int) -> int: return (x << 8) | 2 +VERTICAL_EDGES = frozenset({LEFT_EDGE, RIGHT_EDGE}) + + +def is_vertical_edge(edge: int) -> bool: + return edge in VERTICAL_EDGES + + +def edge_name(edge: int) -> EdgeLiteral: + return { + LEFT_EDGE: 'left', + TOP_EDGE: 'top', + RIGHT_EDGE: 'right', + BOTTOM_EDGE: 'bottom', + }.get(edge, 'bottom') + + @lru_cache def report_template_failure(template: str, e: str) -> None: log_error(f'Invalid tab title template: "{template}" with error: {e}') @@ -338,7 +357,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)) @@ -563,10 +582,18 @@ class CellRange(NamedTuple): class TabExtent(NamedTuple): tab_id: int - cell_range: CellRange + x: CellRange + y: CellRange = CellRange(0, 0) + + 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 shifted(self, shift: int) -> 'TabExtent': - return TabExtent(self.tab_id, CellRange(self.cell_range.start + shift, self.cell_range.end + shift)) + 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: @@ -584,6 +611,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'): @@ -614,7 +643,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(opts.tab_bar_edge), opts.tab_title_max_length, self.os_window_id, ) ts = opts.tab_bar_style @@ -628,6 +657,12 @@ 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_factor = 2 + elif opts.tab_bar_align == 'right': + self.align_factor = 1 + else: + self.align_factor = 0 if opts.tab_bar_align == 'center': self.align: Callable[[], None] = partial(self.align_with_factor, 2) elif opts.tab_bar_align == 'right': @@ -686,51 +721,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() @@ -739,14 +819,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)) @@ -797,24 +877,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) + rows_to_draw = min(len(data), s.lines) + draw_ellipsis = len(data) > s.lines and s.lines > 1 + if draw_ellipsis: + rows_to_draw -= 1 + cr: list[TabExtent] = [] + for i, t in enumerate(data[:rows_to_draw]): + s.cursor.x = 0 + s.cursor.y = i + 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))) + if draw_ellipsis: + s.cursor.x = 0 + s.cursor.y = s.lines - 1 + 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 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 - def tab_id_at(self, x: int) -> int: + def tab_id_at(self, x: int, y: int = 0) -> 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 a1026681ffb..41dc28f5ce2 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1160,7 +1160,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): @@ -1692,27 +1692,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() @@ -1784,9 +1786,9 @@ 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) - if tab_id_at_x < 0: # synthetic tab (e.g. "+" new-tab button) + 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) + if tab_id_at_pointer < 0: # synthetic tab (e.g. "+" new-tab button) if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 1: self.new_tab() self.recent_tab_bar_mouse_events.clear() @@ -1794,7 +1796,7 @@ def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int, drag_started = get_tab_being_dragged()[1] 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 self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 2: self.new_tab() @@ -1954,7 +1956,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) @@ -2009,7 +2011,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 d1190a87c91..1d9a3a987e7 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,10 @@ 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('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 00000000000..562985e7540 --- /dev/null +++ b/kitty_tests/tab_bar.py @@ -0,0 +1,58 @@ +#!/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), 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(180, 10), 0) From 8d935486efbe006263777c3fd8bfb58fef120c5c Mon Sep 17 00:00:00 2001 From: Bruno Volpato Date: Sat, 25 Apr 2026 15:21:50 -0400 Subject: [PATCH 2/2] tabbar: address vertical alignment feedback --- kitty/options/definition.py | 10 +++---- kitty/options/parse.py | 4 +-- kitty/options/to-c-generated.h | 30 ++++++++++---------- kitty/options/types.py | 6 ++-- kitty/tab_bar.py | 52 ++++++++++++++++++++-------------- kitty_tests/options.py | 8 ++++++ kitty_tests/tab_bar.py | 37 ++++++++++++++++++++++-- 7 files changed, 98 insertions(+), 49 deletions(-) diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 191cdca211c..a606302dc2b 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -1679,12 +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. 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 cf287070755..e062ebf3151 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 8d19954ec72..6d601cc7569 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 bbe05b84021..d1bc7fa8621 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/tab_bar.py b/kitty/tab_bar.py index 9945d4b2170..77ddccc85de 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 @@ 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.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 @@ def update_vertical(self, data: Sequence[TabBarData]) -> None: 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 @@ def align_with_factor(self, factor: int = 1) -> None: 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 1d9a3a987e7..5e1631b0084 100644 --- a/kitty_tests/options.py +++ b/kitty_tests/options.py @@ -259,6 +259,14 @@ def keys_for_func(opts, name): 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 562985e7540..f20af348abb 100644 --- a/kitty_tests/tab_bar.py +++ b/kitty_tests/tab_bar.py @@ -52,7 +52,38 @@ def test_vertical_tab_bar_hit_testing(self) -> None: 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)