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 @@ def on_drop(self, os_window_id: int, drop: dict[str, bytes] | int, from_self: bo
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

@ -1010,6 +1010,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 @@
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.
''' '''
@ -1675,11 +1679,12 @@
are automatically restricted to work only on matching tabs. are automatically restricted to work only on matching tabs.
''') ''')
opt('tab_bar_align', 'left', opt('tab_bar_align', 'start',
choices=('left', 'center', 'right'), choices=('start', 'center', 'end', 'left', 'right'),
long_text=''' long_text='''
The horizontal alignment of the tab bar, can be one of: :code:`left`, The alignment of the tab bar, can be one of: :code:`start`, :code:`center`,
:code:`center`, :code:`right`. :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 @@
) )
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

@ -1356,7 +1356,7 @@ def tab_bar_align(self, val: str, ans: dict[str, typing.Any]) -> None:
raise ValueError(f"The value {val} is not a valid choice for tab_bar_align") raise ValueError(f"The value {val} is not a valid choice for tab_bar_align")
ans["tab_bar_align"] = val 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: def tab_bar_background(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['tab_bar_background'] = to_color_or_none(val) ans['tab_bar_background'] = to_color_or_none(val)
@ -1558,7 +1558,7 @@ def window_title_bar_align(self, val: str, ans: dict[str, typing.Any]) -> None:
raise ValueError(f"The value {val} is not a valid choice for window_title_bar_align") raise ValueError(f"The value {val} is not a valid choice for window_title_bar_align")
ans["window_title_bar_align"] = val 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: 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) 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); 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_background(PyObject *val, Options *opts) { convert_from_python_tab_bar_background(PyObject *val, Options *opts) {
opts->tab_bar_background = color_or_none_as_int(val); 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; if (PyErr_Occurred()) return false;
convert_from_opts_tab_bar_style(py_opts, opts); convert_from_opts_tab_bar_style(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_background(py_opts, opts); convert_from_opts_tab_bar_background(py_opts, opts);
if (PyErr_Occurred()) return false; if (PyErr_Occurred()) return false;
convert_from_opts_tab_bar_margin_color(py_opts, opts); convert_from_opts_tab_bar_margin_color(py_opts, opts);

View file

@ -32,7 +32,7 @@
choices_for_progress_bar = typing.Literal['left', 'right', 'top', 'bottom', 'hidden'] 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_scrollbar = typing.Literal['scrolled', 'always', 'never', 'hovered', 'scrolled-and-hovered']
choices_for_strip_trailing_spaces = typing.Literal['always', 'never', 'smart'] 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_bar_style = typing.Literal['fade', 'hidden', 'powerline', 'separator', 'slant', 'custom']
choices_for_tab_powerline_style = typing.Literal['angled', 'round', 'slanted'] choices_for_tab_powerline_style = typing.Literal['angled', 'round', 'slanted']
choices_for_tab_switch_strategy = typing.Literal['last', 'left', 'previous', 'right'] choices_for_tab_switch_strategy = typing.Literal['last', 'left', 'previous', 'right']
@ -41,7 +41,7 @@
choices_for_underline_hyperlinks = typing.Literal['hover', 'always', 'never'] choices_for_underline_hyperlinks = typing.Literal['hover', 'always', 'never']
choices_for_window_logo_position = choices_for_placement_strategy choices_for_window_logo_position = choices_for_placement_strategy
choices_for_window_title_bar = typing.Literal['top', 'bottom'] 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 = ( option_names = (
'action_alias', 'action_alias',
@ -666,7 +666,7 @@ class Options:
strip_trailing_spaces: choices_for_strip_trailing_spaces = 'never' strip_trailing_spaces: choices_for_strip_trailing_spaces = 'never'
sync_to_monitor: bool = True sync_to_monitor: bool = True
tab_activity_symbol: str = '' 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_background: kitty.fast_data_types.Color | None = None
tab_bar_edge: int = 8 tab_bar_edge: int = 8
tab_bar_filter: str = '' tab_bar_filter: str = ''

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;
break;
}
tab_bar->left = central->left; tab_bar->right = central->right; tab_bar->left = central->left; tab_bar->right = central->right;
tab_bar->bottom = tab_bar->top + os_window->fonts_data->fcm.cell_height; tab_bar->bottom = tab_bar->top + os_window->fonts_data->fcm.cell_height;
break;
}
}
} 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 @@
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,
@ -101,6 +104,31 @@ def as_rgb(x: int) -> int:
return (x << 8) | 2 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 @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}')
@ -342,7 +370,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))
@ -567,10 +595,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:
@ -588,6 +624,8 @@ def __init__(self, os_window_id: int):
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'):
@ -618,7 +656,7 @@ def apply_options(self) -> None:
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
@ -632,9 +670,16 @@ def apply_options(self) -> None:
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.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) 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 self.align = self.align_with_factor
else: else:
self.align = lambda: None self.align = lambda: None
@ -690,7 +735,18 @@ def update_blank_rects(self, central: Region, tab_bar: Region, vw: int, vh: int)
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:
@ -701,23 +757,54 @@ def update_blank_rects(self, central: Region, tab_bar: Region, vw: int, vh: int)
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
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 left_bg = right_bg = bg
if opts.tab_bar_margin_color is None and ( if opts.tab_bar_margin_color is None and (
opacity := background_opacity_of(self.os_window_id)) is not None and opacity >= 1: opacity := background_opacity_of(self.os_window_id)) is not None and opacity >= 1:
left_bg = BorderColor.tab_bar_left_edge_color left_bg = BorderColor.tab_bar_left_edge_color
right_bg = BorderColor.tab_bar_right_edge_color right_bg = BorderColor.tab_bar_right_edge_color
if g.left > 0: if g.left > tab_bar.left:
blank_rects.append(Border(0, g.top, g.left, g.bottom, left_bg)) blank_rects.append(Border(tab_bar.left, g.top, g.left, g.bottom, left_bg))
if g.right < vw: if g.right < tab_bar.right:
blank_rects.append(Border(g.right, g.top, vw, g.bottom, right_bg)) 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
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 available_width = tab_bar.width - 2 * self.margin_width
ncells = max(4, available_width // cell_width) ncells = max(4, available_width // cell_width)
s.resize(1, ncells) s.resize(1, ncells)
@ -726,15 +813,18 @@ def layout(self) -> None:
available_width_for_left_margin = max(0, tab_bar.width - self.margin_width - cell_area_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) 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) left_margin = min(self.margin_width + extra_width // 2, available_width_for_left_margin)
self.laid_out_once = True
self.window_geometry = g = WindowGeometry( self.window_geometry = g = WindowGeometry(
left_margin, tab_bar.top, left_margin + cell_area_width, tab_bar.bottom, s.columns, s.lines) left_margin, tab_bar.top, left_margin + cell_area_width, tab_bar.bottom, s.columns, s.lines)
self.laid_out_once = True
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()
@ -743,14 +833,14 @@ def update(self, data: Sequence[TabBarData]) -> None:
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))
@ -801,24 +891,72 @@ def draw_tab(i: int, tab: TabBarData, cell_ranges: list[TabExtent], max_tab_leng
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)
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: 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 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

@ -1171,7 +1171,7 @@ def swap_windows(self, window_a: Window, window_b: Window) -> None:
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):
@ -1703,27 +1703,29 @@ def on_tab_drop_move(self, tab_id: int = 0, is_dest: bool = False, x: int = 0, y
tab_data = tab.data_for_tab_bar(tab is get_boss().active_tab) 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()
@ -1803,11 +1805,11 @@ def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int,
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)
drag_started = get_tab_being_dragged()[1] drag_started = get_tab_being_dragged()[1]
is_left_release = button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE 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: if is_left_release and not drag_started:
set_tab_being_dragged() # clear potential drag from a press on a tab 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: if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 1:
@ -1816,7 +1818,7 @@ def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int,
return return
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 is_left_release: if is_left_release:
set_tab_being_dragged() # clear potential drag from a press on a tab set_tab_being_dragged() # clear potential drag from a press on a tab
@ -1978,7 +1980,7 @@ def on_window_drop_move(self, window_id: int = 0, is_dest: bool = False, x: int
tab_bar = viewport_for_window(self.os_window_id)[1] 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)
@ -2033,7 +2035,7 @@ def on_window_drop(self, x: int, y: int, window_id: int) -> None:
# 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 pn(cmdline, **assertions):
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,18 @@ def keys_for_func(opts, name):
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('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') 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')

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)