Merge branch 'feat-draggable-window-title-bars' of https://github.com/mcrmck/kitty

This commit is contained in:
Kovid Goyal 2026-03-28 10:02:14 +05:30
commit 9e79d3be9c
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
22 changed files with 595 additions and 35 deletions

View file

@ -928,14 +928,15 @@ - (instancetype)initWithGlfwWindow:(_GLFWwindow *)initWindow
self.identifier = @"kitty-content-view";
[self updateTrackingAreas];
char tab_mime[64];
char tab_mime[64], window_mime[64];
snprintf(tab_mime, sizeof(tab_mime), "application/net.kovidgoyal.kitty-tab-%d", getpid());
snprintf(window_mime, sizeof(window_mime), "application/net.kovidgoyal.kitty-window-%d", getpid());
NSMutableArray *types = [NSMutableArray arrayWithObjects:
NSPasteboardTypeFileURL, NSPasteboardTypeString, NSPasteboardTypeURL, NSPasteboardTypeColor,
NSPasteboardTypeFont, NSPasteboardTypeHTML, NSPasteboardTypePDF, NSPasteboardTypePNG,
NSPasteboardTypeRTF, NSPasteboardTypeSound, NSPasteboardTypeTIFF,
UTTypeData.identifier, UTTypeItem.identifier, UTTypeContent.identifier,
mime_to_uti(tab_mime),
mime_to_uti(tab_mime), mime_to_uti(window_mime),
nil];
// Add file promise types
[types addObjectsFromArray:[NSFilePromiseReceiver readableDraggedTypes]];
@ -1632,6 +1633,10 @@ - (BOOL)performDragOperation:(id <NSDraggingInfo>)sender
for (size_t i = 0; i < num_accepted; i++)
_glfwPlatformRequestDropData(window, d->copy_mimes[i]);
}
// Restore first-responder status after native DnD; the drag operation can
// displace the content view from first responder, silently breaking keyboard
// input even though osw->is_focused remains true.
[window->ns.object makeFirstResponder:window->ns.view];
return YES;
}

View file

@ -95,6 +95,7 @@
get_options,
get_os_window_size,
get_tab_being_dragged,
get_window_being_dragged,
glfw_get_monitor_workarea,
global_font_size,
grab_keyboard,
@ -120,6 +121,7 @@
set_os_window_size,
set_os_window_title,
set_tab_being_dragged,
set_window_being_dragged,
start_drag_with_data,
thread_write,
toggle_fullscreen,
@ -1393,9 +1395,9 @@ def start(self, first_os_window_id: int, startup_sessions: Iterable[Session]) ->
run_update_check(get_options().update_check_interval * 60 * 60)
self.update_check_started = True
def handle_window_title_bar_mouse(self, os_window_id: int, window_id: int, button: int, modifiers: int, action: int) -> None:
def handle_window_title_bar_mouse(self, os_window_id: int, window_id: int, x: float, y: float, button: int, modifiers: int, action: int) -> None:
if tm := self.os_window_map.get(os_window_id):
tm.handle_window_title_bar_mouse(window_id, button, modifiers, action)
tm.handle_window_title_bar_mouse(window_id, x, y, button, modifiers, action)
def handle_tab_bar_mouse(self, os_window_id: int, x: float, y: float, button: int, modifiers: int, action: int) -> None:
if tm := self.os_window_map.get(os_window_id):
@ -1929,6 +1931,10 @@ def on_drop_move(self, os_window_id: int, x: int, y: int, from_self: bool, is_le
for q in self.all_tab_managers:
is_dest = q is tm and (in_tab_bar or os_window_id != tab.os_window_id) and not is_leave
q.on_tab_drop_move(tab_id, is_dest, x, y)
window_id, drag_started = get_window_being_dragged()[:2]
if window_id and drag_started:
for q in self.all_tab_managers:
q.on_window_drop_move(window_id, (not is_leave) and (q is tm), x, y)
def on_drop(self, os_window_id: int, drop: dict[str, bytes] | int, from_self: bool, x: int, y: int) -> None:
if isinstance(drop, int):
@ -1941,6 +1947,14 @@ def on_drop(self, os_window_id: int, drop: dict[str, bytes] | int, from_self: bo
return
if (tm := self.os_window_map.get(os_window_id)) is None:
return
window_mime_key = f'application/net.kovidgoyal.kitty-window-{os.getpid()}'
if (widb := drop.get(window_mime_key)):
window_id = int(widb)
tm.on_window_drop(x, y, window_id)
set_window_being_dragged()
for q in self.all_tab_managers:
q.on_window_drop_move()
return
if (tidb := drop.get(f'application/net.kovidgoyal.kitty-tab-{os.getpid()}')) and (tab := self.tab_for_id(int(tidb))):
central, tab_bar = viewport_for_window(os_window_id)[:2]
in_tab_bar = tab_bar.left <= x < tab_bar.right and tab_bar.top <= y < tab_bar.bottom
@ -1972,6 +1986,27 @@ def on_drag_source_finished(
self, was_dropped: bool, was_canceled: bool, accepted_mime_type: str, action: int, data: dict[str, bytes] | None,
needs_toplevel_on_wayland: bool
) -> None:
window_mime_key = f'application/net.kovidgoyal.kitty-window-{os.getpid()}'
if (wid_bytes := (data or {}).get(window_mime_key)):
window_id = int(wid_bytes.decode())
if get_window_being_dragged()[0] == window_id:
# Drop was not handled by on_drop (e.g. dropped outside kitty or on Wayland)
for tm in self.all_tab_managers:
for t in tm:
if t.force_show_title_bars:
t.force_show_title_bars = False
t.relayout()
set_window_being_dragged()
for tm in self.all_tab_managers:
tm.on_window_drop_move()
if was_dropped and not was_canceled:
if (window := self.window_id_map.get(window_id)):
src_tab = window.tabref()
src_tm = self.os_window_map.get(src_tab.os_window_id) if src_tab else None
total_windows = sum(len(t) for t in src_tm) if src_tm else 0
if total_windows > 1:
self._move_window_to(window, target_os_window_id='new')
return
if (tab_id := int((data or {}).get(f'application/net.kovidgoyal.kitty-tab-{os.getpid()}', b'0').decode())
) and get_tab_being_dragged()[0] == tab_id and (tab := self.tab_for_id(tab_id)):
if needs_toplevel_on_wayland:
@ -3277,6 +3312,40 @@ def _move_window_to(
self._cleanup_tab_after_window_removal(src_tab)
target_tab.make_active()
def _swap_windows(self, window_a: Window, window_b: Window) -> None:
tab = window_a.tabref()
if tab is None or tab is not window_b.tabref():
return
wg_b = tab.windows.group_for_window(window_b)
if wg_b is None:
return
with self.suppress_focus_change_events():
tab.windows.set_active_window_group_for(window_a)
tab.current_layout.move_window_to_group(tab.windows, wg_b.id)
tab.relayout()
def _insert_window_in_direction(
self, window: Window, dest_window: Window,
direction: str
) -> None:
src_tab = window.tabref()
dest_tab = dest_window.tabref()
if src_tab is None or dest_tab is None:
return
with self.suppress_focus_change_events():
if src_tab is not dest_tab:
target_tab_id = dest_tab.id
self._move_window_to(window, target_tab_id=target_tab_id)
dest_tab_fresh = self.tab_for_id(dest_tab.id)
if dest_tab_fresh is None:
return
src_tab = dest_tab_fresh
layout = src_tab.current_layout
horizontal = direction in ('left', 'right')
after = direction in ('right', 'bottom')
layout.insert_window_next_to(src_tab.windows, window, dest_window, horizontal, after)
src_tab.relayout()
def _move_tab_to(self, tab: Tab | None = None, target_os_window_id: int | None = None) -> Tab | None:
tab = tab or self.active_tab
if tab is None:
@ -3368,6 +3437,32 @@ def format_tab_title(tab: Tab) -> str:
chosen
)
@ac('win', '''
Temporarily show window title bars to allow drag-to-reorder
When window title bars are hidden (because :opt:`window_title_bar_min_windows`
is not met), this action forces them temporarily visible so that they can be
dragged to reorder windows. After any drag operation completes, the bars are
automatically hidden again. Press again to cancel before dragging.
Has no effect on tabs where title bars are already naturally visible.
Map an action to this, then press it before dragging a window title bar.
''')
def toggle_window_title_bars(self) -> None:
tm = self.active_tab_manager
if tm is None:
return
opts = get_options()
min_w = opts.window_title_bar_min_windows
currently_forced = any(t.force_show_title_bars for t in tm)
for t in tm:
visible = sum(1 for _ in t.windows.iter_all_layoutable_groups(only_visible=True))
naturally_visible = min_w > 0 and visible >= min_w
if not naturally_visible:
t.force_show_title_bars = not currently_forced
t.relayout()
@ac('win', '''
Detach a window, moving it to another tab or OS Window

View file

@ -853,6 +853,7 @@ prepare_to_render_os_window(OSWindow *os_window, monotonic_t now, unsigned int *
}
if (send_cell_data_to_gpu(WD.vao_idx, WD.screen, os_window)) needs_render = true;
if (WD.screen->start_visual_bell_at != 0) needs_render = true;
if (WD.screen->start_drag_overlay_at != 0) needs_render = true;
// Prepare window title bar screen data for GPU
WindowRenderData *trd = &w->window_title_render_data;
if (trd->screen && trd->geometry.bottom > trd->geometry.top && trd->geometry.right > trd->geometry.left) {
@ -919,6 +920,7 @@ render_prepared_os_window(OSWindow *os_window, unsigned int active_window_id, co
if (is_active_window) active_window = w;
draw_cells(&WD, os_window, is_active_window, false, num_of_visible_windows == 1, w);
if (WD.screen->start_visual_bell_at != 0) set_maximum_wait(ANIMATION_SAMPLE_WAIT);
if (WD.screen->start_drag_overlay_at != 0) set_maximum_wait(ANIMATION_SAMPLE_WAIT);
WindowRenderData *trd = &w->window_title_render_data;
if (trd->screen && trd->geometry.right > trd->geometry.left && trd->geometry.bottom > trd->geometry.top)
draw_cells(trd, os_window, i == tab->active_window, true, false, NULL);

View file

@ -1540,6 +1540,9 @@ def spawn(
pass
def set_window_drag_overlay(os_window_id: int, tab_id: int, window_id: int, quadrant: int) -> None: ...
def set_window_padding(os_window_id: int, tab_id: int, window_id: int, left: int, top: int, right: int, bottom: int) -> None:
pass
@ -1837,6 +1840,8 @@ def change_drag_thumbnail(os_window_id: int, idx: int = -1) -> None: ...
def draw_single_line_of_text(os_window_id: int, text: str, fg: int, bg: int, width: int, padding_y: int = 2) -> bytes: ...
def set_tab_being_dragged(tab_id: int = 0, drag_started: bool = False, x: float = 0, y: float = 0) -> None: ...
def get_tab_being_dragged() -> tuple[int, bool, float, float]: ...
def set_window_being_dragged(window_id: int = 0, drag_started: bool = False, x: float = 0.0, y: float = 0.0) -> None: ...
def get_window_being_dragged() -> tuple[int, bool, float, float]: ...
def request_callback_with_thumbnail(
callback: str, os_window_id: int, window_id: int = 0, include_tab_bar: bool = False,
scale: float = 0.25, max_width: int = 480

View file

@ -669,12 +669,16 @@ window_focus_callback(GLFWwindow *w, int focused) {
}
#define TAB_DRAG_MIME_NUMBER 400
#define WINDOW_DRAG_MIME_NUMBER 401
static int
is_droppable_mime(const char *mime) {
static char tab_mime[64] = {0};
if (!tab_mime[0]) snprintf(tab_mime, sizeof(tab_mime), "application/net.kovidgoyal.kitty-tab-%d", getpid());
if (strcmp(mime, tab_mime) == 0) return TAB_DRAG_MIME_NUMBER;
static char window_mime[64] = {0};
if (!window_mime[0]) snprintf(window_mime, sizeof(window_mime), "application/net.kovidgoyal.kitty-window-%d", getpid());
if (strcmp(mime, window_mime) == 0) return WINDOW_DRAG_MIME_NUMBER;
if (strcmp(mime, "text/uri-list") == 0) return 3;
if (strcmp(mime, "text/plain;charset=utf-8") == 0) return 2;
if (strcmp(mime, "text/plain") == 0) return 1;
@ -2977,8 +2981,11 @@ start_drag_with_data(PyObject *self UNUSED, PyObject *args, PyObject *kw) {
GLFWDragSourceItem *item = items + num++;
item->mime_type = PyUnicode_AsUTF8(key);
item->optional_data = PyBytes_AS_STRING(value); item->data_size = PyBytes_GET_SIZE(value);
if (global_state.is_wayland && is_droppable_mime(item->mime_type) == TAB_DRAG_MIME_NUMBER)
needs_toplevel_on_wayland = true;
if (global_state.is_wayland) {
int mime_num = is_droppable_mime(item->mime_type);
if (mime_num == TAB_DRAG_MIME_NUMBER || mime_num == WINDOW_DRAG_MIME_NUMBER)
needs_toplevel_on_wayland = true;
}
}
free_drag_source();
global_state.drag_source.is_active = true;

View file

@ -4,7 +4,7 @@
from collections.abc import Generator, Iterable, Iterator, Sequence
from functools import partial
from itertools import repeat
from typing import Any, Callable, NamedTuple
from typing import Any, Callable, ClassVar, Literal, NamedTuple
from kitty.borders import BorderColor
from kitty.fast_data_types import BOTTOM_EDGE, RIGHT_EDGE, Region, get_options, set_active_window, viewport_for_window
@ -232,6 +232,12 @@ class Layout:
must_draw_borders = False # can be overridden to customize behavior from kittens
layout_opts = LayoutOpts({})
only_active_window_visible = False
# Controls drag-and-drop overlay display and valid direction axis for body drops.
# 'full' full-window overlay, positional swap (Stack and any unrecognised layout)
# 'axis_y' top/bottom halves only (Vertical, Tall, Grid)
# 'axis_x' left/right halves only (Horizontal, Fat)
# 'free' 4-way free direction (Splits; handled by its own insert_window_next_to override)
drag_overlay_mode: ClassVar[Literal['full', 'axis_y', 'axis_x', 'free']] = 'full'
def __init__(self, os_window_id: int, tab_id: int, layout_opts: str = '') -> None:
self.set_owner(os_window_id, tab_id)
@ -303,6 +309,31 @@ def move_window(self, all_windows: WindowList, delta: int = 1) -> bool:
def move_window_to_group(self, all_windows: WindowList, group: int) -> bool:
return all_windows.move_window_group(to_group=group)
def insert_window_next_to(
self,
all_windows: WindowList,
window: WindowType,
next_to: WindowType,
horizontal: bool,
after: bool,
) -> None:
"""Reposition window as a linear neighbour of next_to.
For axis_x/axis_y layouts this performs a positional insert that preserves
the order of all other groups. For 'full' layouts it falls back to a swap.
The Splits layout overrides this with tree-based logic.
"""
src_wg = all_windows.group_for_window(window)
dest_wg = all_windows.group_for_window(next_to)
if src_wg is None or dest_wg is None or src_wg.id == dest_wg.id:
return
all_windows.set_active_window_group_for(window)
if self.drag_overlay_mode in ('axis_x', 'axis_y'):
all_windows.insert_window_group_next_to(dest_wg.id, after)
else:
# 'full' fallback: swap (preserves existing behaviour for Stack etc.)
self.move_window_to_group(all_windows, dest_wg.id)
def add_window(
self, all_windows: WindowList, window: WindowType, location: str | None = None,
overlay_for: int | None = None, put_overlay_behind: bool = False, bias: float | None = None,
@ -371,7 +402,8 @@ def __call__(self, all_windows: WindowList) -> None:
# Set show_title_bar flag on each visible window before layout
min_windows = get_options().window_title_bar_min_windows
visible_groups = tuple(all_windows.iter_all_layoutable_groups(only_visible=True))
show_title_bar = min_windows > 0 and len(visible_groups) >= min_windows
force_show = getattr(all_windows, '_force_show_title_bars', False)
show_title_bar = force_show or (min_windows > 0 and len(visible_groups) >= min_windows)
for wg in visible_groups:
for w in wg.windows:
w.show_title_bar = show_title_bar

View file

@ -34,6 +34,7 @@ class Grid(Layout):
name: str = 'grid'
no_minimal_window_borders = True
drag_overlay_mode = 'axis_y'
def remove_all_biases(self) -> bool:
self.biased_rows: dict[int, float] = {}

View file

@ -561,6 +561,7 @@ class Splits(Layout):
needs_all_windows = True
layout_opts = SplitsLayoutOpts({})
no_minimal_window_borders = True
drag_overlay_mode = 'free'
@property
def default_axis_is_horizontal(self) -> bool | None:
@ -722,6 +723,28 @@ def move_window_to_group(self, all_windows: WindowList, group: int) -> bool:
self.pairs_root.swap_windows(before.id, after.id)
return moved
def insert_window_next_to(
self,
all_windows: WindowList,
window: 'WindowType',
next_to: 'WindowType',
horizontal: bool,
after: bool
) -> None:
"""Reposition an existing window as a split adjacent to next_to."""
src_wg = all_windows.group_for_window(window)
dest_wg = all_windows.group_for_window(next_to)
if src_wg is None or dest_wg is None or src_wg.id == dest_wg.id:
return
# Remove from current position in pairs_root
self.remove_windows(src_wg.id)
# Re-insert next to dest
pair = self.pairs_root.pair_for_window(dest_wg.id)
if pair is not None:
pair.split_and_add(dest_wg.id, src_wg.id, horizontal, after)
else:
self.pairs_root.balanced_add(src_wg.id)
def layout_action(self, action_name: str, args: Sequence[str], all_windows: WindowList) -> bool | None:
if action_name == 'rotate':
args = args or ('90',)

View file

@ -4,7 +4,7 @@
import sys
from collections.abc import Generator, Iterator, Sequence
from itertools import islice, repeat
from typing import Any
from typing import Any, ClassVar, Literal
from kitty.borders import BorderColor
from kitty.conf.utils import to_bool
@ -136,6 +136,7 @@ class Tall(Layout):
name = 'tall'
main_is_horizontal = True
no_minimal_window_borders = True
drag_overlay_mode: ClassVar[Literal['full', 'axis_y', 'axis_x', 'free']] = 'axis_y'
layout_opts = TallLayoutOpts({})
main_axis_layout = Layout.xlayout
perp_axis_layout = Layout.ylayout
@ -381,5 +382,6 @@ class Fat(Tall):
name = 'fat'
main_is_horizontal = False
drag_overlay_mode = 'axis_x'
main_axis_layout = Layout.ylayout
perp_axis_layout = Layout.xlayout

View file

@ -2,7 +2,7 @@
# License: GPLv3 Copyright: 2020, Kovid Goyal <kovid at kovidgoyal.net>
from collections.abc import Generator, Iterable
from typing import Any
from typing import Any, ClassVar, Literal
from kitty.borders import BorderColor
from kitty.types import Edges, NeighborsMap, WindowMapper
@ -64,6 +64,7 @@ class Vertical(Layout):
name = 'vertical'
main_is_horizontal = False
no_minimal_window_borders = True
drag_overlay_mode: ClassVar[Literal['full', 'axis_y', 'axis_x', 'free']] = 'axis_y'
main_axis_layout = Layout.ylayout
perp_axis_layout = Layout.xlayout
@ -155,5 +156,6 @@ class Horizontal(Vertical):
name = 'horizontal'
main_is_horizontal = True
drag_overlay_mode = 'axis_x'
main_axis_layout = Layout.xlayout
perp_axis_layout = Layout.ylayout

View file

@ -898,8 +898,10 @@ HANDLER(handle_event) {
static void
handle_window_title_bar_mouse(Window *w, int button, int modifiers, int action) {
OSWindow *osw = global_state.callback_os_window;
if (osw && button > -1) {
call_boss(handle_window_title_bar_mouse, "KKiii", osw->id, w->id, button, modifiers, action);
if (!osw) return;
if (button > -1 || global_state.window_being_dragged.id) {
call_boss(handle_window_title_bar_mouse, "KKddiii",
osw->id, w->id, osw->mouse_x, osw->mouse_y, button, modifiers, action);
}
}
@ -1310,9 +1312,13 @@ mouse_event(const int button, int modifiers, int action) {
mouse_cursor_shape = POINTER_POINTER;
handle_tab_bar_mouse(button, modifiers, action);
debug("handled by tab bar\n");
} else if (r.in_title_bar && r.window) {
} else if ((r.in_title_bar && r.window) || global_state.window_being_dragged.id) {
mouse_cursor_shape = POINTER_POINTER;
handle_window_title_bar_mouse(r.window, button, modifiers, action);
Window *tw = r.window;
if (!tw && global_state.window_being_dragged.id) {
tw = window_for_window_id(global_state.window_being_dragged.id);
}
if (tw) handle_window_title_bar_mouse(tw, button, modifiers, action);
debug("handled by window title bar\n");
} else if (r.window_border) {
debug("window border: %s window id: %llu\n", border_name(r.window_border), w ? w->id : 0);

View file

@ -1562,6 +1562,16 @@
choices=('left', 'center', 'right'),
long_text='Horizontal alignment of the text in window title bars.'
)
opt('window_title_bar_drag_threshold', '5',
option_type='positive_int',
long_text='''
Pixel distance the mouse must move before a window title bar drag begins.
Zero disables dragging. Drop on a title bar swaps positions; drop on a
window body inserts in the quadrant direction (left/right/top/bottom).
Drop on the tab bar moves the window to that tab; drop outside kitty
detaches it to a new OS window.
''')
egr() # }}}
@ -1637,6 +1647,16 @@
'''
)
opt('tab_bar_show_new_tab_button', 'no', option_type='to_bool', ctype='bool',
long_text='''
When set to :code:`yes`, a :code:`+` button is always shown at the end of the
tab bar as a clickable shortcut to open a new tab. When set to :code:`no`
(the default), the button is hidden at rest but still appears temporarily
while a window is being dragged, so it can be used as a drop target to open
the window in a new tab.
'''
)
opt('tab_bar_min_tabs', '2', option_type='tab_bar_min_tabs',
long_text='The minimum number of tabs that must exist before the tab bar is shown.'
)

View file

@ -1359,6 +1359,9 @@ def tab_bar_margin_width(self, val: str, ans: dict[str, typing.Any]) -> None:
def tab_bar_min_tabs(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['tab_bar_min_tabs'] = tab_bar_min_tabs(val)
def tab_bar_show_new_tab_button(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['tab_bar_show_new_tab_button'] = to_bool(val)
def tab_bar_style(self, val: str, ans: dict[str, typing.Any]) -> None:
val = val.lower()
if val not in self.choices_for_tab_bar_style:
@ -1537,6 +1540,9 @@ def window_title_bar_align(self, val: str, ans: dict[str, typing.Any]) -> None:
choices_for_window_title_bar_align = choices_for_tab_bar_align
def window_title_bar_drag_threshold(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['window_title_bar_drag_threshold'] = positive_int(val)
def window_title_bar_inactive_background(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['window_title_bar_inactive_background'] = to_color_or_none(val)

View file

@ -462,6 +462,7 @@
'tab_bar_margin_height',
'tab_bar_margin_width',
'tab_bar_min_tabs',
'tab_bar_show_new_tab_button',
'tab_bar_style',
'tab_fade',
'tab_powerline_style',
@ -506,6 +507,7 @@
'window_title_bar_active_background',
'window_title_bar_active_foreground',
'window_title_bar_align',
'window_title_bar_drag_threshold',
'window_title_bar_inactive_background',
'window_title_bar_inactive_foreground',
'window_title_bar_min_windows',
@ -665,6 +667,7 @@ class Options:
tab_bar_margin_height: TabBarMarginHeight = TabBarMarginHeight(outer=0, inner=0)
tab_bar_margin_width: float = 0
tab_bar_min_tabs: int = 2
tab_bar_show_new_tab_button: bool = False
tab_bar_style: choices_for_tab_bar_style = 'fade'
tab_fade: tuple[float, ...] = (0.25, 0.5, 0.75, 1.0)
tab_powerline_style: choices_for_tab_powerline_style = 'angled'
@ -708,6 +711,7 @@ class Options:
window_title_bar_active_background: kitty.fast_data_types.Color | None = None
window_title_bar_active_foreground: kitty.fast_data_types.Color | None = None
window_title_bar_align: choices_for_window_title_bar_align = 'center'
window_title_bar_drag_threshold: int = 5
window_title_bar_inactive_background: kitty.fast_data_types.Color | None = None
window_title_bar_inactive_foreground: kitty.fast_data_types.Color | None = None
window_title_bar_min_windows: int = 0

View file

@ -138,6 +138,8 @@ typedef struct {
ScreenModes modes, saved_modes;
ColorProfile *color_profile;
monotonic_t start_visual_bell_at;
monotonic_t start_drag_overlay_at; // 0 = inactive
uint8_t drag_overlay_quadrant; // 1=left 2=right 3=top 4=bottom 0=none
uint8_t *write_buf;
size_t write_buf_sz, write_buf_used;

View file

@ -787,6 +787,37 @@ draw_visual_bell(const UIRenderData *ui) {
#undef COLOR
}
static void
draw_drag_preview_overlay(const UIRenderData *ui) {
Screen *screen = ui->screen;
if (!screen->start_drag_overlay_at || !screen->drag_overlay_quadrant) return;
const monotonic_t elapsed = monotonic() - screen->start_drag_overlay_at;
const monotonic_t fade_ms = ms_to_monotonic_t(150ll);
float intensity = elapsed >= fade_ms ? 1.0f : (float)elapsed / (float)fade_ms;
GLfloat left = -1.f, top = 1.f, right = 1.f, bottom = -1.f;
switch (screen->drag_overlay_quadrant) {
case 1: right = 0.f; break; // left half
case 2: left = 0.f; break; // right half
case 3: bottom = 0.f; break; // top half
case 4: top = 0.f; break; // bottom half
case 5: break; // full window + title bar highlight (title bar hover)
case 6: break; // full window, no title bar highlight (body hover)
default: return;
}
bind_program(TINT_PROGRAM);
float a = intensity * 0.25f;
#define COLOR(name, fallback) colorprofile_to_color_with_fallback(screen->color_profile, \
screen->color_profile->overridden.name, screen->color_profile->configured.name, \
screen->color_profile->overridden.fallback, screen->color_profile->configured.fallback)
color_type hint = !IS_SPECIAL_COLOR(highlight_bg) ? COLOR(visual_bell_color, highlight_bg) : COLOR(visual_bell_color, default_fg);
#undef COLOR
#define C(shift) (srgb_color((hint >> shift) & 0xFF) * a)
glUniform4f(tint_program_layout.uniforms.tint_color, C(16), C(8), C(0), a);
#undef C
glUniform4f(tint_program_layout.uniforms.edges, left, top, right, bottom);
draw_quad(true, 0);
}
static bool
has_scrollbar(Window *w, Screen *screen) {
if (screen->linebuf != screen->main_linebuf || !screen->historybuf->count) return false;
@ -1225,6 +1256,7 @@ draw_cells(const WindowRenderData *srd, OSWindow *os_window, bool is_active_wind
ui.screen_left, ui.screen_top, ui.screen_width, ui.screen_height, ui.full_framebuffer_height);
if (ui.os_window->needs_layers) draw_cells_with_layers(&ui, srd->vao_idx);
else draw_cells_without_layers(&ui, srd->vao_idx);
draw_drag_preview_overlay(&ui);
restore_viewport();
}
// }}}

View file

@ -866,6 +866,25 @@ PYWRAP1(set_tab_bar_render_data) {
Py_RETURN_NONE;
}
PYWRAP1(set_window_drag_overlay) {
id_type os_window_id, tab_id, window_id;
int quadrant;
PA("KKKi", &os_window_id, &tab_id, &window_id, &quadrant);
WITH_WINDOW(os_window_id, tab_id, window_id)
Screen *s = window->render_data.screen;
if (s) {
if (quadrant == 0) {
s->start_drag_overlay_at = 0;
s->drag_overlay_quadrant = 0;
} else if (s->drag_overlay_quadrant != (uint8_t)quadrant) {
s->start_drag_overlay_at = monotonic();
s->drag_overlay_quadrant = (uint8_t)quadrant;
}
}
END_WITH_WINDOW
Py_RETURN_NONE;
}
PYWRAP1(set_window_title_bar_render_data) {
WindowGeometry g = {0};
id_type os_window_id, tab_id, window_id;
@ -1554,6 +1573,20 @@ get_tab_being_dragged(PyObject *self UNUSED, PyObject *args UNUSED) {
}
#undef tbd
#define wbd global_state.window_being_dragged
static PyObject*
set_window_being_dragged(PyObject *self UNUSED, PyObject *args) {
zero_at_ptr(&wbd);
if (!PyArg_ParseTuple(args, "|Kpdd", &wbd.id, &wbd.drag_started, &wbd.x, &wbd.y)) return NULL;
Py_RETURN_NONE;
}
static PyObject*
get_window_being_dragged(PyObject *self UNUSED, PyObject *args UNUSED) {
return Py_BuildValue("KOdd", wbd.id, wbd.drag_started ? Py_True : Py_False, wbd.x, wbd.y);
}
#undef wbd
static PyObject*
request_callback_with_thumbnail(PyObject *self UNUSED, PyObject *args) {
unsigned long long os_window_id, window_id = 0;
@ -1582,6 +1615,8 @@ static PyMethodDef module_methods[] = {
M(request_callback_with_thumbnail, METH_VARARGS),
M(set_tab_being_dragged, METH_VARARGS),
M(get_tab_being_dragged, METH_NOARGS),
M(set_window_being_dragged, METH_VARARGS),
M(get_window_being_dragged, METH_NOARGS),
MW(update_pointer_shape, METH_VARARGS),
MW(current_os_window, METH_NOARGS),
MW(next_window_id, METH_NOARGS),
@ -1618,6 +1653,7 @@ static PyMethodDef module_methods[] = {
MW(set_tab_bar_render_data, METH_VARARGS),
MW(set_window_title_bar_render_data, METH_VARARGS),
MW(set_window_render_data, METH_VARARGS),
MW(set_window_drag_overlay, METH_VARARGS),
MW(set_window_padding, METH_VARARGS),
MW(viewport_for_window, METH_VARARGS),
MW(cell_size_for_window, METH_VARARGS),

View file

@ -439,6 +439,10 @@ typedef struct GlobalState {
id_type id; bool drag_started;
double x, y;
} tab_being_dragged;
struct {
id_type id; bool drag_started;
double x, y;
} window_being_dragged;
struct {
uint32_t texture_id, framebuffer_id, texture_generation;
int width, height;

View file

@ -265,6 +265,8 @@ def progress_percent(self) -> str:
def apply_title_template(draw_data: DrawData, tab: TabBarData, index: int, max_title_length: int = 0) -> str:
if tab.tab_id < 0:
return tab.title # synthetic tab — render title literally, skip user template
ta = TabAccessor(tab.tab_id)
data = {
'index': index,

View file

@ -33,6 +33,7 @@
get_click_interval,
get_options,
get_tab_being_dragged,
get_window_being_dragged,
is_tab_bar_visible,
last_focused_os_window_id,
mark_tab_bar_dirty,
@ -48,6 +49,7 @@
set_active_window,
set_redirect_keys_to_overlay,
set_tab_being_dragged,
set_window_being_dragged,
start_drag_with_data,
swap_tabs,
sync_os_window_title,
@ -146,6 +148,7 @@ class Tab: # {{{
inactive_fg: int | None = None
inactive_bg: int | None = None
confirm_close_window_id: int = 0
force_show_title_bars: bool = False
renaming_in_window: int = 0
num_of_windows_with_progress: int = 0
total_progress: int = 0
@ -463,7 +466,9 @@ def on_bell(self, window: Window) -> None:
def relayout(self) -> None:
if self.allow_relayouts:
if self.windows:
self.windows._force_show_title_bars = self.force_show_title_bars
self.current_layout(self.windows)
self.windows._force_show_title_bars = False
self.relayout_borders()
def relayout_borders(self) -> None:
@ -1117,6 +1122,11 @@ class TabBeingDropped(NamedTuple):
last_drop_move_x: int = -1
class WindowBeingDropped(NamedTuple):
window_id: int # the window whose title bar is currently highlighted as a drop target
quadrant: int = 0 # 0=none, 1=left, 2=right, 3=top, 4=bottom, 5=full+titlebar, 6=full
class TabManager: # {{{
confirm_close_window_id: int = 0
@ -1124,6 +1134,8 @@ class TabManager: # {{{
total_progress: int = 0
has_indeterminate_progress: bool = False
tab_being_dropped: TabBeingDropped | None = None
window_being_dropped: WindowBeingDropped | None = None
window_drag_target_tab_id: int = 0
def __init__(self, os_window_id: int, args: CLIOptions, wm_class: str, wm_name: str, startup_session: SessionType | None = None):
self.os_window_id = os_window_id
@ -1582,6 +1594,12 @@ def previous_active_tab() -> Tab | None:
self.mark_tab_bar_dirty()
removed_tab.destroy()
def _new_tab_drop_indicator(self) -> TabBarData:
return TabBarData(
'+', self.window_drag_target_tab_id == -1, False, -1, self.os_window_id,
0, 0, '', False, None, None, None, None, 0, 0, 0, '', '',
)
@property
def tab_bar_data(self) -> Sequence[TabBarData]:
at = self.active_tab
@ -1589,10 +1607,16 @@ def tab_bar_data(self) -> Sequence[TabBarData]:
dragged_tab_id, drag_started = get_tab_being_dragged()[:2]
if drag_started:
tab_being_dragged_from_here = self.tab_for_id(dragged_tab_id) is not None
window_drag_active = get_window_being_dragged()[1]
if self.tab_being_dropped is None:
wdtt = self.window_drag_target_tab_id
if tab_being_dragged_from_here:
return tuple(t.data_for_tab_bar(t is at) for t in self.tabs_to_be_shown_in_tab_bar if t.id != dragged_tab_id)
return tuple(t.data_for_tab_bar(t is at) for t in self.tabs_to_be_shown_in_tab_bar)
tabs = tuple(t.data_for_tab_bar(t is at or t.id == wdtt) for t in self.tabs_to_be_shown_in_tab_bar if t.id != dragged_tab_id)
else:
tabs = tuple(t.data_for_tab_bar(t is at or t.id == wdtt) for t in self.tabs_to_be_shown_in_tab_bar)
if window_drag_active or get_options().tab_bar_show_new_tab_button:
tabs = tabs + (self._new_tab_drop_indicator(),)
return tabs
tmap = {t.id:t for t in self.tabs}
at = self.active_tab
ans = []
@ -1620,9 +1644,9 @@ def on_tab_drop_move(self, tab_id: int = 0, is_dest: bool = False, x: int = 0, y
self.layout_tab_bar()
return
if self.tab_bar_should_be_visible:
all_tabs = [t.tab_id for t in self.tab_bar.last_laid_out_tabs]
all_tabs = [t.tab_id for t in self.tab_bar.last_laid_out_tabs if t.tab_id >= 0]
else:
all_tabs = [t.tab_id for t in self.tab_bar_data]
all_tabs = [t.tab_id for t in self.tab_bar_data if t.tab_id >= 0]
force_update = False
if self.tab_being_dropped is None:
tab = get_boss().tab_for_id(tab_id)
@ -1722,7 +1746,12 @@ def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int,
request_callback_with_thumbnail("start_tab_drag", self.os_window_id)
return
tab = self.tab_for_id(self.tab_bar.tab_id_at(int(x)))
tab_id_at_x = self.tab_bar.tab_id_at(int(x))
if tab_id_at_x < 0: # synthetic tab (e.g. "+" new-tab button)
if button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE:
self.new_tab()
return
tab = self.tab_for_id(tab_id_at_x)
now = monotonic()
if tab is None:
if button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE and len(self.recent_mouse_events) > 2:
@ -1769,30 +1798,251 @@ def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int,
if len(self.recent_mouse_events) > 5:
self.recent_mouse_events.popleft()
def handle_window_title_bar_mouse(self, window_id: int, button: int, modifiers: int, action: int) -> None:
def handle_window_title_bar_mouse(self, window_id: int, x: float, y: float, button: int, modifiers: int, action: int) -> None:
now = monotonic()
boss = get_boss()
if button == -1: # motion event
dragged_window_id, drag_started, start_x, start_y = get_window_being_dragged()
if dragged_window_id and not drag_started:
threshold = get_options().window_title_bar_drag_threshold
dist_sq = (x - start_x)**2 + (y - start_y)**2
if threshold and dist_sq > threshold * threshold:
set_window_being_dragged(dragged_window_id, True, start_x, start_y)
self.start_window_drag(dragged_window_id)
return
if button == GLFW_MOUSE_BUTTON_LEFT:
if action == GLFW_PRESS:
if (w := boss.window_id_map.get(window_id)) is not None:
get_boss().set_active_window(w, switch_os_window_if_needed=True)
elif action == GLFW_RELEASE and len(self.recent_title_bar_mouse_events) > 2:
ci = get_click_interval()
prev, prev2 = self.recent_title_bar_mouse_events[-1], self.recent_title_bar_mouse_events[-2]
if (
prev.button == button and prev2.button == button and
prev.action == GLFW_PRESS and prev2.action == GLFW_RELEASE and
prev.tab_id == window_id and prev2.tab_id == window_id and
now - prev.at <= ci and now - prev2.at <= 2 * ci
): # double click on window title bar
if (w := boss.window_id_map.get(window_id)) is not None:
w.set_window_title()
self.recent_title_bar_mouse_events.clear()
return
boss.set_active_window(w, switch_os_window_if_needed=True)
threshold = get_options().window_title_bar_drag_threshold
if threshold:
set_window_being_dragged(window_id, False, x, y)
elif action == GLFW_RELEASE:
dragged_window_id, drag_started = get_window_being_dragged()[:2]
set_window_being_dragged()
if not drag_started and len(self.recent_title_bar_mouse_events) > 2:
ci = get_click_interval()
prev, prev2 = self.recent_title_bar_mouse_events[-1], self.recent_title_bar_mouse_events[-2]
if (
prev.button == button and prev2.button == button and
prev.action == GLFW_PRESS and prev2.action == GLFW_RELEASE and
prev.tab_id == window_id and prev2.tab_id == window_id and
now - prev.at <= ci and now - prev2.at <= 2 * ci
): # double click on window title bar
if (w := boss.window_id_map.get(window_id)) is not None:
w.set_window_title()
self.recent_title_bar_mouse_events.clear()
return
self.recent_title_bar_mouse_events.append(TabMouseEvent(button, modifiers, action, now, window_id))
if len(self.recent_title_bar_mouse_events) > 5:
self.recent_title_bar_mouse_events.popleft()
def start_window_drag(self, window_id: int) -> None:
boss = get_boss()
if (w := boss.window_id_map.get(window_id)) is None:
set_window_being_dragged()
return
opts = get_options()
min_w = opts.window_title_bar_min_windows
for tm in boss.all_tab_managers:
tm.mark_tab_bar_dirty()
for t in tm:
visible = sum(1 for _ in t.windows.iter_all_layoutable_groups(only_visible=True))
if not (min_w > 0 and visible >= min_w):
t.force_show_title_bars = True
t.relayout()
title = str(w.title or '')
fg = color_as_int(opts.window_title_bar_active_foreground or opts.active_tab_foreground)
bg = color_as_int(opts.window_title_bar_active_background or opts.active_tab_background)
thumb_width = 480
title_pixels = draw_single_line_of_text(self.os_window_id, title, 0xff000000 | fg, 0xff000000 | bg, thumb_width)
title_height = len(title_pixels) // (thumb_width * 4)
thumbnails = ((title_pixels, thumb_width, title_height),)
drag_data = {f'application/net.kovidgoyal.kitty-window-{os.getpid()}': str(window_id).encode()}
try:
start_drag_with_data(self.os_window_id, drag_data, thumbnails)
except OSError as e:
log_error(f'Failed to start window drag: {e}')
set_window_being_dragged()
self._clear_force_show_title_bars()
def _set_drag_target_tab(self, tab_id: int) -> None:
if self.window_drag_target_tab_id == tab_id:
return
self.window_drag_target_tab_id = tab_id
self.mark_tab_bar_dirty()
def _clear_force_show_title_bars(self) -> None:
boss = get_boss()
for tm in boss.all_tab_managers:
tm._set_drag_target_window(0)
tm._set_drag_target_tab(0)
for tab in tm:
if tab.force_show_title_bars:
tab.force_show_title_bars = False
tab.relayout()
def _find_window_at(self, x: int, y: int) -> 'Window | None':
from .fast_data_types import viewport_for_window
central = viewport_for_window(self.os_window_id)[0]
if not (central.left <= x < central.right and central.top <= y < central.bottom):
return None
rel_x = x - central.left
rel_y = y - central.top
if (active_tab := self.active_tab) is None:
return None
for win in active_tab:
g = win.geometry
if g.left <= rel_x < g.right and g.top <= rel_y < g.bottom:
return win
return None
def _set_drag_target_window(self, window_id: int, quadrant: int = 0) -> None:
"""Highlight window_id's title bar as the drop target; 0 clears. quadrant!=0 shows quadrant overlay instead."""
from .fast_data_types import set_window_drag_overlay
boss = get_boss()
prev_id = self.window_being_dropped.window_id if self.window_being_dropped else 0
prev_quadrant = self.window_being_dropped.quadrant if self.window_being_dropped else 0
if prev_id == window_id and prev_quadrant == quadrant:
return
if prev_id and (prev_w := boss.window_id_map.get(prev_id)):
prev_w.is_drag_target = False
set_window_drag_overlay(self.os_window_id, prev_w.tab_id, prev_id, 0)
if prev_w._title_bar_screen is not None:
tab = prev_w.tabref()
prev_w.update_title_bar(is_active=tab is not None and tab.active_window is prev_w)
if window_id and (new_w := boss.window_id_map.get(window_id)):
if quadrant == 5:
new_w.is_drag_target = True
new_w.update_title_bar(is_active=True)
set_window_drag_overlay(self.os_window_id, new_w.tab_id, window_id, quadrant)
self.window_being_dropped = WindowBeingDropped(window_id=window_id, quadrant=quadrant)
else:
self.window_being_dropped = None
def on_window_drop_move(self, window_id: int = 0, is_dest: bool = False, x: int = 0, y: int = 0) -> None:
if not is_dest:
self._set_drag_target_window(0)
self._set_drag_target_tab(0)
return
from .fast_data_types import viewport_for_window
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))
return
self._set_drag_target_tab(0)
dest_window = self._find_window_at(x, y)
if dest_window and dest_window.id != window_id:
from .fast_data_types import viewport_for_window as _vfw
central = _vfw(self.os_window_id)[0]
rel_y = y - central.top
if dest_window.show_title_bar:
from .fast_data_types import cell_size_for_window
_, ch = cell_size_for_window(self.os_window_id)
g = dest_window.geometry
opts = get_options()
tb_top = g.top if opts.window_title_bar == 'top' else g.bottom - ch
if tb_top <= rel_y < tb_top + ch:
# Title bar hover: full window + title bar highlight (swap)
self._set_drag_target_window(dest_window.id, 5)
return
active_tab = self.active_tab
if active_tab is not None:
mode = active_tab.current_layout.drag_overlay_mode
rel_x = x - central.left
g = dest_window.geometry
dx = rel_x - (g.left + g.right) / 2
dy = rel_y - (g.top + g.bottom) / 2
quad_map = {'left': 1, 'right': 2, 'top': 3, 'bottom': 4}
if mode == 'axis_y':
direction = 'bottom' if dy > 0 else 'top'
elif mode == 'axis_x':
direction = 'right' if dx > 0 else 'left'
elif mode == 'free':
direction = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top')
else: # 'full' (Stack, etc.): full-window overlay, no directional highlight
self._set_drag_target_window(dest_window.id, 6)
return
self._set_drag_target_window(dest_window.id, quad_map[direction])
else:
self._set_drag_target_window(0)
else:
self._set_drag_target_window(0)
def on_window_drop(self, x: int, y: int, window_id: int) -> None:
from .fast_data_types import cell_size_for_window, viewport_for_window
boss = get_boss()
self._clear_force_show_title_bars()
w = boss.window_id_map.get(window_id)
if w is None:
return
set_window_being_dragged()
self.mark_tab_bar_dirty()
central, tab_bar = viewport_for_window(self.os_window_id)[:2]
# 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)):
boss._move_window_to(w, target_tab_id=dest_tab.id)
else:
boss._move_window_to(w, target_tab_id='new')
return
# Case 2: Drop in central area
in_central = central.left <= x < central.right and central.top <= y < central.bottom
if not in_central:
return
rel_x = x - central.left
rel_y = y - central.top
if (active_tab := self.active_tab) is None:
return
dest_window = None
dest_in_title_bar = False
opts = get_options()
cw, ch = cell_size_for_window(self.os_window_id)
for win in active_tab:
g = win.geometry
if opts.window_title_bar == 'top':
tb_top, tb_bottom = g.top, g.top + ch
else:
tb_top, tb_bottom = g.bottom - ch, g.bottom
if g.left <= rel_x < g.right and g.top <= rel_y < g.bottom:
dest_window = win
dest_in_title_bar = getattr(win, 'show_title_bar', False) and (tb_top <= rel_y < tb_bottom)
break
if dest_window is None or dest_window.id == window_id:
# Dropped on empty space or self; if different tab, move there
if active_tab is not w.tabref():
boss._move_window_to(w, target_tab_id=active_tab.id)
return
if dest_in_title_bar:
if w.tabref() is dest_window.tabref():
# Same tab: swap positions
boss._swap_windows(w, dest_window)
else:
# Cross-tab title bar drop: move to the destination tab
boss._move_window_to(w, target_tab_id=active_tab.id)
else:
g = dest_window.geometry
dx = rel_x - (g.left + g.right) / 2
dy = rel_y - (g.top + g.bottom) / 2
mode = active_tab.current_layout.drag_overlay_mode
if mode == 'axis_y':
direction: str = 'bottom' if dy > 0 else 'top'
elif mode == 'axis_x':
direction = 'right' if dx > 0 else 'left'
else: # 'free' (Splits) or 'full' (swap fallback)
direction = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top')
boss._insert_window_in_direction(w, dest_window, direction)
def update_progress(self) -> None:
self.num_of_windows_with_progress = 0
self.total_progress = 0

View file

@ -673,6 +673,7 @@ class Window:
created_in_session_name: str = ''
serialized_id: int = 0
show_title_bar: bool = False # must be set before calling set_geometry
is_drag_target: bool = False # highlight this window's title bar as a drop target
@classmethod
@contextmanager
@ -1082,7 +1083,7 @@ def update_title_bar(self, is_active: bool = False) -> None:
data = WindowTitleData(
title=self.title or '',
is_active=is_active,
is_active=is_active or self.is_drag_target,
window_id=self.id,
tab_id=self.tab_id,
needs_attention=self.needs_attention,

View file

@ -166,6 +166,8 @@ def is_visible_in_layout(self) -> bool:
class WindowList:
_force_show_title_bars: bool = False
def __init__(self, tab: TabType) -> None:
self.all_windows: list[WindowType] = []
self.id_map: dict[int, WindowType] = {}
@ -517,6 +519,27 @@ def move_window_group(self, by: int | None = None, to_group: int | None = None)
return True
return False
def insert_window_group_next_to(self, target_group_id: int, after: bool) -> bool:
"""Move the active window group immediately before or after target_group_id.
Unlike move_window_group (which swaps), this is a positional insert that
preserves the relative order of all other groups.
"""
src_idx = self.active_group_idx
if src_idx < 0 or not self.groups:
return False
target_idx = next((i for i, g in enumerate(self.groups) if g.id == target_group_id), -1)
if target_idx < 0 or src_idx == target_idx:
return False
group = self.groups.pop(src_idx)
# All indices above src shift down by one after the pop
if src_idx < target_idx:
target_idx -= 1
insert_pos = target_idx + (1 if after else 0)
self.groups.insert(insert_pos, group)
self.set_active_group_idx(insert_pos)
return True
def compute_needs_borders_map(self, draw_active_borders: bool) -> dict[int, bool]:
ag = self.active_group
return {gr.id: ((gr is ag and draw_active_borders) or gr.needs_attention) for gr in self.groups}