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

@@ -987,6 +987,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.
'''
@@ -1678,7 +1682,8 @@ are automatically restricted to work only on matching tabs.
opt('tab_bar_align', 'left',
choices=('left', 'center', 'right'),
long_text='''
The horizontal alignment of the tab bar, can be one of: :code:`left`,
The horizontal alignment of the tab bar. For vertical tab bars this controls the
alignment of each tab title within the sidebar. Can be one of: :code:`left`,
:code:`center`, :code:`right`.
'''
)
@@ -1746,10 +1751,12 @@ 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

@@ -1084,6 +1084,19 @@ convert_from_opts_tab_bar_edge(PyObject *py_opts, Options *opts) {
Py_DECREF(ret);
}
static void
convert_from_python_tab_title_max_length(PyObject *val, Options *opts) {
opts->tab_title_max_length = PyLong_AsLong(val);
}
static void
convert_from_opts_tab_title_max_length(PyObject *py_opts, Options *opts) {
PyObject *ret = PyObject_GetAttrString(py_opts, "tab_title_max_length");
if (ret == NULL) return;
convert_from_python_tab_title_max_length(ret, opts);
Py_DECREF(ret);
}
static void
convert_from_python_tab_bar_margin_height(PyObject *val, Options *opts) {
tab_bar_margin_height(val, opts);
@@ -1655,6 +1668,8 @@ convert_opts_from_python_opts(PyObject *py_opts, Options *opts) {
if (PyErr_Occurred()) return false;
convert_from_opts_tab_bar_edge(py_opts, opts);
if (PyErr_Occurred()) return false;
convert_from_opts_tab_title_max_length(py_opts, opts);
if (PyErr_Occurred()) return false;
convert_from_opts_tab_bar_margin_height(py_opts, opts);
if (PyErr_Occurred()) return false;
convert_from_opts_tab_bar_style(py_opts, opts);

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,
@@ -99,6 +102,22 @@ def as_rgb(x: int) -> int:
return (x << 8) | 2
VERTICAL_EDGES = frozenset({LEFT_EDGE, RIGHT_EDGE})
def is_vertical_edge(edge: int) -> bool:
return edge in VERTICAL_EDGES
def edge_name(edge: int) -> EdgeLiteral:
return {
LEFT_EDGE: 'left',
TOP_EDGE: 'top',
RIGHT_EDGE: 'right',
BOTTOM_EDGE: 'bottom',
}.get(edge, 'bottom')
@lru_cache
def report_template_failure(template: str, e: str) -> None:
log_error(f'Invalid tab title template: "{template}" with error: {e}')
@@ -338,7 +357,7 @@ def draw_tab_with_slant(
extra_data: ExtraData
) -> int:
orig_fg = screen.cursor.fg
left_sep, right_sep = ('', '') if draw_data.tab_bar_edge == 'top' else ('', '')
left_sep, right_sep = ('', '') if draw_data.tab_bar_edge in ('top', 'left') else ('', '')
tab_bg = screen.cursor.bg
slant_fg = as_rgb(color_as_int(draw_data.default_bg))
@@ -563,10 +582,18 @@ class CellRange(NamedTuple):
class TabExtent(NamedTuple):
tab_id: int
cell_range: CellRange
x: CellRange
y: CellRange = CellRange(0, 0)
def shifted(self, 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:
@@ -584,6 +611,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'):
@@ -614,7 +643,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
@@ -628,6 +657,12 @@ class TabBar:
self.draw_func = load_custom_draw_tab()
else:
self.draw_func = draw_tab_with_fade
if opts.tab_bar_align == 'center':
self.align_factor = 2
elif opts.tab_bar_align == 'right':
self.align_factor = 1
else:
self.align_factor = 0
if opts.tab_bar_align == 'center':
self.align: Callable[[], None] = partial(self.align_with_factor, 2)
elif opts.tab_bar_align == 'right':
@@ -686,51 +721,96 @@ 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()
@@ -739,14 +819,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))
@@ -797,24 +877,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)
rows_to_draw = min(len(data), s.lines)
draw_ellipsis = len(data) > s.lines and s.lines > 1
if draw_ellipsis:
rows_to_draw -= 1
cr: list[TabExtent] = []
for i, t in enumerate(data[:rows_to_draw]):
s.cursor.x = 0
s.cursor.y = i
s.cursor.bg = as_rgb(self.draw_data.tab_bg(t))
s.cursor.fg = as_rgb(self.draw_data.tab_fg(t))
s.cursor.bold, s.cursor.italic = self.active_font_style if t.is_active else self.inactive_font_style
end = self.draw_func(self.draw_data, s, t, 0, max_tab_length, i + 1, True, ExtraData())
self.align_row(i, end)
cr.append(TabExtent(tab_id=t.tab_id, x=CellRange(0, s.columns - 1), y=CellRange(i, i)))
if draw_ellipsis:
s.cursor.x = 0
s.cursor.y = s.lines - 1
s.cursor.bg = as_rgb(color_as_int(self.draw_data.default_bg))
s.cursor.fg = as_rgb(0xff0000)
s.draw('')
self.tab_extents = tuple(cr)
def align_with_factor(self, factor: int = 1) -> None:
if not self.tab_extents:
return
end = self.tab_extents[-1].cell_range[1]
end = self.tab_extents[-1].x.end
if end < self.screen.columns - 1:
shift = (self.screen.columns - end) // factor
self.screen.cursor.x = 0
self.screen.insert_characters(shift)
self.tab_extents = tuple(te.shifted(shift) for te in self.tab_extents)
self.tab_extents = tuple(te.shifted(x=shift) for te in self.tab_extents)
def align_row(self, row: int, end: int) -> None:
if not self.align_factor:
return
if end < self.screen.columns - 1:
shift = (self.screen.columns - end) // self.align_factor
if shift > 0:
self.screen.cursor.y = row
self.screen.cursor.x = 0
self.screen.insert_characters(shift)
def destroy(self) -> None:
self.screen.reset_callbacks()
del self.screen
def tab_id_at(self, x: int) -> int:
def tab_id_at(self, x: int, y: int = 0) -> int:
if self.laid_out_once:
x = (x - self.window_geometry.left) // self.cell_width
g = self.window_geometry
if not (g.left <= x < g.right and g.top <= y < g.bottom):
return 0
x = (x - g.left) // self.cell_width
y = (y - g.top) // self.cell_height
for te in self.tab_extents:
if te.cell_range.start <= x <= te.cell_range.end:
if te.contains(x, y):
return te.tab_id
return 0
def drag_axis_coordinate(self, x: int, y: int) -> int:
return y if self.is_vertical else x

View File

@@ -1160,7 +1160,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):
@@ -1692,27 +1692,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()
@@ -1784,9 +1786,9 @@ 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)
if tab_id_at_x < 0: # synthetic tab (e.g. "+" new-tab button)
tab_id_at_pointer = self.tab_bar.tab_id_at(int(x), int(y))
self.recent_tab_bar_mouse_events.add(button, modifiers, action, x, y, tab_id_at_pointer)
if tab_id_at_pointer < 0: # synthetic tab (e.g. "+" new-tab button)
if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 1:
self.new_tab()
self.recent_tab_bar_mouse_events.clear()
@@ -1794,7 +1796,7 @@ class TabManager: # {{{
drag_started = get_tab_being_dragged()[1]
if drag_started:
return
tab = self.tab_for_id(tab_id_at_x)
tab = self.tab_for_id(tab_id_at_pointer)
if tab is None:
if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 2:
self.new_tab()
@@ -1954,7 +1956,7 @@ 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)
@@ -2009,7 +2011,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,10 @@ 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('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')

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)