Merge branch 'bvolpato/vertical-tab-bar-side' of https://github.com/bvolpato/kitty

This commit is contained in:
Kovid Goyal 2026-06-21 13:18:10 +05:30
commit 54a7841d18
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
13 changed files with 397 additions and 78 deletions

View file

@ -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(

View file

@ -1010,6 +1010,8 @@ mouse_region(bool detect_borders, bool detect_title_bar) {
const bool in_central = mouse_in_region(&central);
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;

View file

@ -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.
'''
)

View file

@ -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)

View file

@ -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);

View file

@ -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 = ''

View file

@ -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]:

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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')

View file

@ -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')

89
kitty_tests/tab_bar.py Normal file
View file

@ -0,0 +1,89 @@
#!/usr/bin/env python
# License: GPL v3 Copyright: 2026, Kovid Goyal <kovid at kovidgoyal.net>
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)