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 @@ class Boss: 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 @@ agr('tabbar', 'Tab bar') 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 @@ 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, 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 @@ this is rendered. ) 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 @@ 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 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_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/options/utils.py b/kitty/options/utils.py index c5b850e71..d47f2a7af 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 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..5292e42d1 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -17,8 +17,11 @@ from .fast_data_types import ( BOTTOM_EDGE, DECAWM, Color, + LEFT_EDGE, Region, + RIGHT_EDGE, Screen, + TOP_EDGE, background_opacity_of, cell_size_for_window, get_boss, @@ -101,6 +104,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 + + +def edge_name(edge: int) -> EdgeLiteral: + return { + LEFT_EDGE: 'left', + TOP_EDGE: 'top', + RIGHT_EDGE: 'right', + BOTTOM_EDGE: 'bottom', + }.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}') @@ -342,7 +370,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 +595,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 +624,8 @@ class TabBar: 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 +656,7 @@ class TabBar: 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 @@ -632,9 +670,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 self.tab_bar_align == 'end': + self.align_factor = 1 + else: + self.align_factor = 0 + 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 @@ -690,51 +735,96 @@ class TabBar: 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 +833,14 @@ class TabBar: 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 +891,72 @@ class TabBar: 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 = 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 04e8a8266..97e741799 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -1171,7 +1171,7 @@ class Tab: # {{{ 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 @@ class TabManager: # {{{ 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 @@ class TabManager: # {{{ 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 @@ class TabManager: # {{{ 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 @@ class TabManager: # {{{ 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 @@ class TabManager: # {{{ # 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 launcher(self): 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 conf_parsing(self): 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)