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.
This commit is contained in:
bvolpato
2026-04-12 00:08:21 -04:00
committed by Bruno Volpato
parent f42a5f89c3
commit 19ea73f047
11 changed files with 338 additions and 68 deletions

View File

@@ -2001,7 +2001,7 @@ class Boss:
window.on_drop(drop) window.on_drop(drop)
break break
elif tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom: 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) w.on_drop(drop)
def on_drag_source_finished( def on_drag_source_finished(

View File

@@ -987,6 +987,8 @@ mouse_region(bool detect_borders, bool detect_title_bar) {
const bool in_central = mouse_in_region(&central); const bool in_central = mouse_in_region(&central);
if (!in_central) { if (!in_central) {
if ( 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.top < central.top && w->mouse_y < central.top) ||
(tab_bar.bottom > central.bottom && w->mouse_y >= central.bottom) (tab_bar.bottom > central.bottom && w->mouse_y >= central.bottom)
) ans.in_tab_bar = true; ) ans.in_tab_bar = true;

View File

@@ -1616,18 +1616,22 @@ agr('tabbar', 'Tab bar')
opt('tab_bar_edge', 'bottom', opt('tab_bar_edge', 'bottom',
option_type='tab_bar_edge', ctype='int', 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', opt('tab_bar_margin_width', '0.0',
option_type='positive_float', 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', opt('tab_bar_margin_height', '0.0 0.0',
option_type='tab_bar_margin_height', ctype='!tab_bar_margin_height', option_type='tab_bar_margin_height', ctype='!tab_bar_margin_height',
long_text=''' 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 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. margin between the tab bar and the contents of the current tab.
''' '''
@@ -1678,7 +1682,8 @@ are automatically restricted to work only on matching tabs.
opt('tab_bar_align', 'left', opt('tab_bar_align', 'left',
choices=('left', 'center', 'right'), choices=('left', 'center', 'right'),
long_text=''' 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`. :code:`center`, :code:`right`.
''' '''
) )
@@ -1746,10 +1751,12 @@ this is rendered.
) )
opt('tab_title_max_length', '0', opt('tab_title_max_length', '0',
option_type='positive_int', option_type='positive_int', ctype='int',
long_text=''' long_text='''
The maximum number of cells that can be used to render the text in a tab. 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

@@ -1084,6 +1084,19 @@ convert_from_opts_tab_bar_edge(PyObject *py_opts, Options *opts) {
Py_DECREF(ret); 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 static void
convert_from_python_tab_bar_margin_height(PyObject *val, Options *opts) { convert_from_python_tab_bar_margin_height(PyObject *val, Options *opts) {
tab_bar_margin_height(val, 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; if (PyErr_Occurred()) return false;
convert_from_opts_tab_bar_edge(py_opts, opts); convert_from_opts_tab_bar_edge(py_opts, opts);
if (PyErr_Occurred()) return false; 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); convert_from_opts_tab_bar_margin_height(py_opts, opts);
if (PyErr_Occurred()) return false; if (PyErr_Occurred()) return false;
convert_from_opts_tab_bar_style(py_opts, opts); convert_from_opts_tab_bar_style(py_opts, opts);

View File

@@ -751,7 +751,12 @@ def tab_separator(x: str) -> str:
def tab_bar_edge(x: str) -> int: 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]: 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 void
os_window_regions(const OSWindow *os_window, Region *central, Region *tab_bar) { 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) { 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_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); 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; 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)) { 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->top = tab_bar_height;
central->bottom = os_window->viewport_height; central->bottom = os_window->viewport_height;
central->top = MIN(central->top, central->bottom); central->top = MIN(central->top, central->bottom);
tab_bar->top = margin_outer; 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; 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; central->top = 0;
long bottom = os_window->viewport_height - tab_bar_height; long bottom = os_window->viewport_height - tab_bar_height;
central->bottom = MAX(0, bottom); central->bottom = MAX(0, bottom);
tab_bar->top = central->bottom + margin_inner; 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; 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 { } else {
zero_at_ptr(tab_bar); zero_at_ptr(tab_bar);
central->left = 0; central->top = 0; central->right = os_window->viewport_width; 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; bool dynamic_background_opacity;
float inactive_text_alpha; float inactive_text_alpha;
Edge tab_bar_edge; Edge tab_bar_edge;
int tab_title_max_length;
DisableLigature disable_ligatures; DisableLigature disable_ligatures;
bool force_ltr; bool force_ltr;
bool resize_in_steps; bool resize_in_steps;

View File

@@ -17,8 +17,11 @@ from .fast_data_types import (
BOTTOM_EDGE, BOTTOM_EDGE,
DECAWM, DECAWM,
Color, Color,
LEFT_EDGE,
Region, Region,
RIGHT_EDGE,
Screen, Screen,
TOP_EDGE,
background_opacity_of, background_opacity_of,
cell_size_for_window, cell_size_for_window,
get_boss, get_boss,
@@ -99,6 +102,22 @@ def as_rgb(x: int) -> int:
return (x << 8) | 2 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 @lru_cache
def report_template_failure(template: str, e: str) -> None: def report_template_failure(template: str, e: str) -> None:
log_error(f'Invalid tab title template: "{template}" with error: {e}') log_error(f'Invalid tab title template: "{template}" with error: {e}')
@@ -338,7 +357,7 @@ def draw_tab_with_slant(
extra_data: ExtraData extra_data: ExtraData
) -> int: ) -> int:
orig_fg = screen.cursor.fg 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 tab_bg = screen.cursor.bg
slant_fg = as_rgb(color_as_int(draw_data.default_bg)) slant_fg = as_rgb(color_as_int(draw_data.default_bg))
@@ -563,10 +582,18 @@ class CellRange(NamedTuple):
class TabExtent(NamedTuple): class TabExtent(NamedTuple):
tab_id: int tab_id: int
cell_range: CellRange x: CellRange
y: CellRange = CellRange(0, 0)
def shifted(self, shift: int) -> 'TabExtent': def shifted(self, x: int = 0, y: int = 0) -> 'TabExtent':
return TabExtent(self.tab_id, CellRange(self.cell_range.start + shift, self.cell_range.end + shift)) 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: class TabBar:
@@ -584,6 +611,8 @@ class TabBar:
def apply_options(self) -> None: def apply_options(self) -> None:
opts = get_options() opts = get_options()
self.dirty = True 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.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) self.cell_width, cell_height = cell_size_for_window(self.os_window_id)
if not hasattr(self, 'screen'): if not hasattr(self, 'screen'):
@@ -614,7 +643,7 @@ class TabBar:
opts.active_tab_title_template, opts.active_tab_title_template,
opts.tab_activity_symbol, opts.tab_activity_symbol,
opts.tab_powerline_style, 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, opts.tab_title_max_length, self.os_window_id,
) )
ts = opts.tab_bar_style ts = opts.tab_bar_style
@@ -628,6 +657,12 @@ class TabBar:
self.draw_func = load_custom_draw_tab() self.draw_func = load_custom_draw_tab()
else: else:
self.draw_func = draw_tab_with_fade 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': if opts.tab_bar_align == 'center':
self.align: Callable[[], None] = partial(self.align_with_factor, 2) self.align: Callable[[], None] = partial(self.align_with_factor, 2)
elif opts.tab_bar_align == 'right': elif opts.tab_bar_align == 'right':
@@ -686,51 +721,96 @@ class TabBar:
blank_rects: list[Border] = [] blank_rects: list[Border] = []
bg = BorderColor.tab_bar_margin_color if opts.tab_bar_margin_color is not None else BorderColor.default_bg 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_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: if opts.tab_bar_margin_height.outer:
blank_rects.append(Border(0, tab_bar.bottom, vw, vh, bg)) blank_rects.append(Border(0, tab_bar.bottom, vw, vh, bg))
if opts.tab_bar_margin_height.inner: if opts.tab_bar_margin_height.inner:
blank_rects.append(Border(0, central.bottom, vw, tab_bar.top, bg)) blank_rects.append(Border(0, central.bottom, vw, tab_bar.top, bg))
else: # top else: # top
if opts.tab_bar_margin_height.outer: if opts.tab_bar_margin_height.outer:
blank_rects.append(Border(0, 0, vw, tab_bar.top, bg)) blank_rects.append(Border(0, 0, vw, tab_bar.top, bg))
if opts.tab_bar_margin_height.inner: if opts.tab_bar_margin_height.inner:
blank_rects.append(Border(0, tab_bar.bottom, vw, central.top, bg)) blank_rects.append(Border(0, tab_bar.bottom, vw, central.top, bg))
g = self.window_geometry g = self.window_geometry
left_bg = right_bg = bg if self.is_vertical:
if opts.tab_bar_margin_color is None and ( if g.left > tab_bar.left:
opacity := background_opacity_of(self.os_window_id)) is not None and opacity >= 1: blank_rects.append(Border(tab_bar.left, g.top, g.left, g.bottom, bg))
left_bg = BorderColor.tab_bar_left_edge_color if g.right < tab_bar.right:
right_bg = BorderColor.tab_bar_right_edge_color blank_rects.append(Border(g.right, g.top, tab_bar.right, g.bottom, bg))
if g.left > 0: if g.top > tab_bar.top:
blank_rects.append(Border(0, g.top, g.left, g.bottom, left_bg)) blank_rects.append(Border(g.left, tab_bar.top, g.right, g.top, bg))
if g.right < vw: if g.bottom < tab_bar.bottom:
blank_rects.append(Border(g.right, g.top, vw, g.bottom, right_bg)) 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) self.blank_rects = tuple(blank_rects)
def layout(self) -> None: def layout(self) -> None:
central, tab_bar, vw, vh, cell_width, cell_height = viewport_for_window(self.os_window_id) 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 return
self.cell_width = cell_width self.cell_width = cell_width
self.cell_height = cell_height
s = self.screen s = self.screen
available_width = tab_bar.width - 2 * self.margin_width if self.is_vertical:
ncells = max(4, available_width // cell_width) available_height = tab_bar.height - 2 * self.margin_width
s.resize(1, ncells) nlines = max(1, available_height // cell_height)
s.reset_mode(DECAWM) ncols = max(1, tab_bar.width // cell_width)
cell_area_width = ncells * cell_width s.resize(nlines, ncols)
available_width_for_left_margin = max(0, tab_bar.width - self.margin_width - cell_area_width) s.reset_mode(DECAWM)
extra_width = max(0, tab_bar.width - 2 * self.margin_width - cell_area_width) cell_area_height = nlines * cell_height
left_margin = min(self.margin_width + extra_width // 2, available_width_for_left_margin) 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.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) self.update_blank_rects(central, tab_bar, vw, vh)
set_tab_bar_render_data(self.os_window_id, self.screen, *g[:4]) set_tab_bar_render_data(self.os_window_id, self.screen, *g[:4])
def update(self, data: Sequence[TabBarData]) -> None: def update(self, data: Sequence[TabBarData]) -> None:
if not self.laid_out_once: if not self.laid_out_once:
return return
if self.is_vertical:
self.update_vertical(data)
return
s = self.screen s = self.screen
last_tab = data[-1] if data else None last_tab = data[-1] if data else None
ed = ExtraData() ed = ExtraData()
@@ -739,14 +819,14 @@ class TabBar:
def draw_tab(i: int, tab: TabBarData, cell_ranges: list[TabExtent], max_tab_length: int) -> 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.prev_tab = data[i - 1] if i > 0 else None
ed.next_tab = data[i + 1] if i + 1 < len(data) 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.bg = as_rgb(self.draw_data.tab_bg(tab))
s.cursor.fg = as_rgb(self.draw_data.tab_fg(t)) s.cursor.fg = as_rgb(self.draw_data.tab_fg(tab))
s.cursor.bold, s.cursor.italic = self.active_font_style if t.is_active else self.inactive_font_style s.cursor.bold, s.cursor.italic = self.active_font_style if tab.is_active else self.inactive_font_style
before = s.cursor.x 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 s.cursor.bg = s.cursor.fg = 0
cell_ranges.append(TabExtent(tab_id=tab.tab_id, cell_range=CellRange(before, end))) cell_ranges.append(TabExtent(tab_id=tab.tab_id, x=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]: 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 # Stop if there is no space for next tab
s.cursor.x = s.columns - 2 s.cursor.x = s.columns - 2
s.cursor.bg = as_rgb(color_as_int(self.draw_data.default_bg)) s.cursor.bg = as_rgb(color_as_int(self.draw_data.default_bg))
@@ -797,24 +877,72 @@ class TabBar:
self.align() self.align()
update_tab_bar_edge_colors(self.os_window_id) 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: def align_with_factor(self, factor: int = 1) -> None:
if not self.tab_extents: if not self.tab_extents:
return return
end = self.tab_extents[-1].cell_range[1] end = self.tab_extents[-1].x.end
if end < self.screen.columns - 1: if end < self.screen.columns - 1:
shift = (self.screen.columns - end) // factor shift = (self.screen.columns - end) // factor
self.screen.cursor.x = 0 self.screen.cursor.x = 0
self.screen.insert_characters(shift) 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: def destroy(self) -> None:
self.screen.reset_callbacks() self.screen.reset_callbacks()
del self.screen 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: 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: 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 te.tab_id
return 0 return 0
def drag_axis_coordinate(self, x: int, y: int) -> int:
return y if self.is_vertical else x

View File

@@ -1160,7 +1160,7 @@ class Tab: # {{{
class TabBeingDropped(NamedTuple): class TabBeingDropped(NamedTuple):
data: TabBarData data: TabBarData
tab_ids: Sequence[int] = () tab_ids: Sequence[int] = ()
last_drop_move_x: int = -1 last_drop_move_coordinate: int = -1
class WindowBeingDropped(NamedTuple): class WindowBeingDropped(NamedTuple):
@@ -1692,27 +1692,29 @@ class TabManager: # {{{
tab_data = tab.data_for_tab_bar(tab is get_boss().active_tab) tab_data = tab.data_for_tab_bar(tab is get_boss().active_tab)
if tab_id not in all_tabs: if tab_id not in all_tabs:
all_tabs.append(tab_id) all_tabs.append(tab_id)
_, _, start_x, _ = get_tab_being_dragged() _, _, start_x, start_y = get_tab_being_dragged()
self.tab_being_dropped = TabBeingDropped(data=tab_data, tab_ids=all_tabs, last_drop_move_x=int(start_x)) start_coordinate = self.tab_bar.drag_axis_coordinate(int(start_x), int(start_y))
mouse_moved_left = False self.tab_being_dropped = TabBeingDropped(data=tab_data, tab_ids=all_tabs, last_drop_move_coordinate=start_coordinate)
force_update = True 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 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 old_tab_ids = self.tab_being_dropped.tab_ids
idx_under_mouse = -1 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): with suppress(Exception):
idx_under_mouse = old_tab_ids.index(tab_id_under_mouse) idx_under_mouse = old_tab_ids.index(tab_id_under_mouse)
if idx_under_mouse < 0: 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) 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 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 = 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] 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: if force_update or self.tab_being_dropped.tab_ids != old_tab_ids:
self.layout_tab_bar() self.layout_tab_bar()
@@ -1784,9 +1786,9 @@ class TabManager: # {{{
self.recent_tab_bar_mouse_events.clear() self.recent_tab_bar_mouse_events.clear()
return return
tab_id_at_x = self.tab_bar.tab_id_at(int(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_x) self.recent_tab_bar_mouse_events.add(button, modifiers, action, x, y, tab_id_at_pointer)
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 self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 1: if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 1:
self.new_tab() self.new_tab()
self.recent_tab_bar_mouse_events.clear() self.recent_tab_bar_mouse_events.clear()
@@ -1794,7 +1796,7 @@ class TabManager: # {{{
drag_started = get_tab_being_dragged()[1] drag_started = get_tab_being_dragged()[1]
if drag_started: if drag_started:
return return
tab = self.tab_for_id(tab_id_at_x) tab = self.tab_for_id(tab_id_at_pointer)
if tab is None: if tab is None:
if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 2: if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 2:
self.new_tab() self.new_tab()
@@ -1954,7 +1956,7 @@ class TabManager: # {{{
tab_bar = viewport_for_window(self.os_window_id)[1] 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: 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_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 return
self._set_drag_target_tab(0) self._set_drag_target_tab(0)
dest_window = self._find_window_at(x, y) dest_window = self._find_window_at(x, y)
@@ -2009,7 +2011,7 @@ class TabManager: # {{{
# Case 1: Drop on tab bar → move to that tab # 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 in_tab_bar = tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom
if in_tab_bar: 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) boss._move_window_to(w, target_tab_id=dest_tab.id)
else: else:
boss._move_window_to(w, target_tab_id='new') boss._move_window_to(w, target_tab_id='new')

View File

@@ -220,6 +220,7 @@ def launcher(self):
def conf_parsing(self): def conf_parsing(self):
from kitty.config import defaults, load_config from kitty.config import defaults, load_config
from kitty.constants import is_macos 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.fonts import FontModification, ModificationType, ModificationUnit, ModificationValue
from kitty.options.utils import to_modifiers from kitty.options.utils import to_modifiers
bad_lines = [] bad_lines = []
@@ -254,6 +255,10 @@ def conf_parsing(self):
self.ae(opts.url_excluded_characters, "'''") self.ae(opts.url_excluded_characters, "'''")
opts = p("url_excluded_characters abc'") opts = p("url_excluded_characters abc'")
self.ae(opts.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') opts = p('clear_all_shortcuts y', 'map f1 next_window')
self.ae(len(opts.keyboard_modes[''].keymap), 1) 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') opts = p('clear_all_mouse_actions y', 'mouse_map left click ungrabbed mouse_click_url_or_select')

58
kitty_tests/tab_bar.py Normal file
View File

@@ -0,0 +1,58 @@
#!/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), 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)