Refactor state tracking for tab drag

Drop into same window now works
This commit is contained in:
Kovid Goyal 2026-02-21 13:31:04 +05:30
parent 0b6636eb0f
commit c56d4223af
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
7 changed files with 176 additions and 118 deletions

View file

@ -111,6 +111,7 @@
set_os_window_chrome,
set_os_window_size,
set_os_window_title,
set_tab_being_dragged,
start_drag_with_data,
thread_write,
toggle_fullscreen,
@ -1894,10 +1895,11 @@ def update_tab_bar_data(self, os_window_id: int) -> None:
def on_drop_move(self, os_window_id: int, x: int, y: int, from_self: bool) -> None:
if (tm := self.os_window_map.get(os_window_id)) is None:
return
if from_self and (tab_id := get_tab_being_dragged()) and (tab := self.tab_for_id(tab_id)):
if (source_tm := tab.tab_manager_ref()) and (state := source_tm.tab_drag_state) and state.tab_being_dragged:
for tm in self.all_tab_managers:
tm.on_tab_drop_move(state.tab_being_dragged, x, y)
if from_self:
tab_id, drag_started = get_tab_being_dragged()[:2]
if drag_started:
for q in self.all_tab_managers:
q.on_tab_drop_move(tab_id, 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):
@ -1910,6 +1912,12 @@ 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
if f'application/net.kovidgoyal.kitty-tab-{os.getpid()}' in drop:
tm.on_tab_drop(x, y)
set_tab_being_dragged()
for tm in self.all_tab_managers:
tm.on_tab_drop_move(0, False, 0, 0)
return
central, tab_bar = viewport_for_window(os_window_id)[:2]
if central.left <= x < central.right and central.top <= y < central.bottom:
x -= central.left

View file

@ -1019,9 +1019,8 @@ def cocoa_window_id(os_window_id: int) -> int:
pass
def swap_tabs(os_window_id: int, a: int, b: int) -> None:
pass
def swap_tabs(os_window_id: int, a: int, b: int) -> None: ...
def reorder_tabs(os_window_id: int, *tab_ids: int) -> None: ...
def set_active_tab(os_window_id: int, a: int) -> None:
pass
@ -1819,8 +1818,8 @@ def start_drag_with_data(
operations: int = GLFW_DRAG_OPERATION_MOVE
) -> None: ...
def set_tab_being_dragged(tab_id: int) -> None: ...
def get_tab_being_dragged() -> int: ...
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 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

@ -868,7 +868,7 @@ handle_tab_bar_mouse(int button, int modifiers, int action) {
set_currently_hovered_window(0, modifiers);
OSWindow *w = global_state.callback_os_window;
// dont report motion events, as they are expensive and useless
if (w && (button > -1 || global_state.tab_being_dragged)) {
if (w && (button > -1 || global_state.tab_being_dragged.id)) {
call_boss(handle_tab_bar_mouse, "Kddiii", w->id, w->mouse_x, w->mouse_y, button, modifiers, action);
}
}
@ -1132,7 +1132,7 @@ mouse_event(const int button, int modifiers, int action) {
w = window_for_event(&window_idx, &in_tab_bar);
set_currently_hovered_window(w ? w->id : 0, modifiers);
if (in_tab_bar || global_state.tab_being_dragged) {
if (in_tab_bar || global_state.tab_being_dragged.id) {
mouse_cursor_shape = POINTER_POINTER;
handle_tab_bar_mouse(button, modifiers, action);
debug("handled by tab bar\n");

View file

@ -569,6 +569,37 @@ swap_tabs(id_type os_window_id, unsigned int a, unsigned int b) {
END_WITH_OS_WINDOW
}
static PyObject*
pyreorder_tabs(PyObject *self UNUSED, PyObject *args) {
if (PyTuple_GET_SIZE(args) < 2) Py_RETURN_NONE;
id_type os_window_id = PyLong_AsUnsignedLongLong(PyTuple_GET_ITEM(args, 0));
WITH_OS_WINDOW(os_window_id)
if (PyTuple_GET_SIZE(args) != os_window->num_tabs + 1) { PyErr_SetString(PyExc_ValueError, "number of tabs not correct"); return NULL; }
if (!os_window->num_tabs) Py_RETURN_NONE;
RAII_ALLOC(Tab, tabs, calloc(os_window->capacity, sizeof(Tab)));
RAII_ALLOC(char, used, calloc(os_window->num_tabs, sizeof(char)));
if (!tabs || !used) { return PyErr_NoMemory(); }
for (Py_ssize_t i = 1; i < PyTuple_GET_SIZE(args); i++) {
id_type tab_id = PyLong_AsUnsignedLongLong(PyTuple_GET_ITEM(args, i));
bool found = false;
for (size_t t = 0; t < os_window->num_tabs; t++) {
if (os_window->tabs[t].id == tab_id) {
tabs[i-1] = os_window->tabs[t];
found = true;
if (used[t]) {
PyErr_SetString(PyExc_ValueError, "duplicate tab ids found"); return NULL;
}
used[t] = 1;
break;
}
}
if (!found) { PyErr_SetString(PyExc_ValueError, "tab id not found"); return NULL; }
}
free(os_window->tabs); os_window->tabs = tabs; tabs = NULL;
END_WITH_OS_WINDOW
Py_RETURN_NONE;
}
static PyObject*
pyset_borders_rects(PyObject *self UNUSED, PyObject *args) {
id_type os_window_id, tab_id;
@ -1483,17 +1514,19 @@ get_mouse_data_for_window(PyObject *self UNUSED, PyObject *args) {
Py_RETURN_NONE;
}
#define tbd global_state.tab_being_dragged
static PyObject*
set_tab_being_dragged(PyObject *self UNUSED, PyObject *args) {
if (!PyLong_Check(args)) { PyErr_SetString(PyExc_TypeError, "tab id must be integer"); return NULL; }
global_state.tab_being_dragged = PyLong_AsUnsignedLongLong(args);
zero_at_ptr(&tbd);
if (!PyArg_ParseTuple(args, "|Kpdd", &tbd.id, &tbd.drag_started, &tbd.x, &tbd.y)) return NULL;
Py_RETURN_NONE;
}
static PyObject*
get_tab_being_dragged(PyObject *self UNUSED, PyObject *args UNUSED) {
return PyLong_FromUnsignedLongLong(global_state.tab_being_dragged);
return Py_BuildValue("KOdd", tbd.id, tbd.drag_started ? Py_True : Py_False, tbd.x, tbd.y);
}
#undef tbd
static PyObject*
request_callback_with_thumbnail(PyObject *self UNUSED, PyObject *args) {
@ -1521,7 +1554,7 @@ static PyMethodDef module_methods[] = {
M(os_window_focus_counters, METH_NOARGS),
M(get_mouse_data_for_window, METH_VARARGS),
M(request_callback_with_thumbnail, METH_VARARGS),
M(set_tab_being_dragged, METH_O),
M(set_tab_being_dragged, METH_VARARGS),
M(get_tab_being_dragged, METH_NOARGS),
MW(update_pointer_shape, METH_VARARGS),
MW(current_os_window, METH_NOARGS),
@ -1554,6 +1587,7 @@ static PyMethodDef module_methods[] = {
MW(buffer_keys_in_window, METH_VARARGS),
MW(set_active_window, METH_VARARGS),
MW(swap_tabs, METH_VARARGS),
MW(reorder_tabs, METH_VARARGS),
MW(set_borders_rects, METH_VARARGS),
MW(set_tab_bar_render_data, METH_VARARGS),
MW(set_window_render_data, METH_VARARGS),

View file

@ -388,13 +388,16 @@ typedef struct GlobalState {
int action;
PyObject *drag_data;
} drag_source;
id_type tab_being_dragged;
struct {
id_type os_window, window;
char callback[32];
bool include_tab_bar;
double scale; unsigned max_width;
} thumbnail_callback;
struct {
id_type id; bool drag_started;
double x, y;
} tab_being_dragged;
} GlobalState;
extern GlobalState global_state;

View file

@ -55,10 +55,6 @@ class TabBarData(NamedTuple):
session_name: str
active_session_name: str
is_being_moved: bool = False
drop_idx: int = 0
pending_drop_idx: int = -1
class DrawData(NamedTuple):
leading_spaces: int
@ -291,6 +287,7 @@ def apply_title_template(draw_data: DrawData, tab: TabBarData, index: int, max_t
'num_window_groups': tab.num_window_groups,
'title': tab.title,
'tab': ta,
'tab_id': tab.tab_id,
'fmt': Formatter,
'sup': SupSub(data),
'sub': SupSub(data, True),

View file

@ -31,6 +31,7 @@
get_boss,
get_click_interval,
get_options,
get_tab_being_dragged,
is_tab_bar_visible,
last_focused_os_window_id,
mark_tab_bar_dirty,
@ -38,6 +39,7 @@
next_window_id,
remove_tab,
remove_window,
reorder_tabs,
replace_c0_codes_except_nl_space_tab,
request_callback_with_thumbnail,
ring_bell,
@ -85,15 +87,6 @@ class TabMouseEvent(NamedTuple):
tab_id: int = 0
class TabDragState(NamedTuple):
tab_id: int
start_x: float
start_y: float
original_index: int
drag_started: bool = False # True if drag threshold exceeded
tab_being_dragged: TabBarData | None = None # This is not None once the tab is handed off to the OS
class TabDict(TypedDict):
id: int
is_focused: bool
@ -1096,15 +1089,22 @@ def make_active(self) -> None:
# }}}
ignore_left_button_release: bool = False
class TabBeingDropped(NamedTuple):
data: TabBarData
tab_ids: Sequence[int] = ()
last_drop_move_x: int = -1
class TabManager: # {{{
confirm_close_window_id: int = 0
num_of_windows_with_progress: int = 0
total_progress: int = 0
has_indeterminate_progress: bool = False
tab_drag_state: TabDragState | None = None
tab_being_dropped: TabBarData | None = None
last_drop_move_x: int = -1
tab_being_dropped: TabBeingDropped | None = None
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
@ -1201,7 +1201,8 @@ def tab_bar_should_be_visible(self) -> bool:
count = get_options().tab_bar_min_tabs
if count < 1:
return True
if self.tab_drag_state is not None and self.tab_drag_state.drag_started:
tab_id, drag_started = get_tab_being_dragged()[:2]
if drag_started and self.tab_for_id(tab_id) is not None:
count += 1
for t in self.tabs_to_be_shown_in_tab_bar:
count -= 1
@ -1420,8 +1421,7 @@ def move_tab(self, delta: int = 1) -> None:
nidx = self.tabs.index(new_active_tab)
step = 1 if idx < nidx else -1
for i in range(idx, nidx, step):
self.tabs[i], self.tabs[i + step] = self.tabs[i + step], self.tabs[i]
swap_tabs(self.os_window_id, i, i + step)
self.swap_tabs(i, i + step)
self._set_active_tab(nidx)
self.mark_tab_bar_dirty()
@ -1463,8 +1463,7 @@ def new_tab(
desired_idx = self.tabs.index(tabs[desired_idx])
if idx != desired_idx:
for i in range(idx, desired_idx, -1):
self.tabs[i], self.tabs[i-1] = self.tabs[i-1], self.tabs[i]
swap_tabs(self.os_window_id, i, i-1)
self.swap_tabs(i, i-1)
idx = desired_idx
self._set_active_tab(idx)
self.mark_tab_bar_dirty()
@ -1540,100 +1539,117 @@ def previous_active_tab() -> Tab | None:
@property
def tab_bar_data(self) -> Sequence[TabBarData]:
at = self.active_tab
state = self.tab_drag_state
dropped_tab_idx = dragged_tab_id = -1
tab_being_dragged_from_here = False
if state is not None and state.drag_started:
dragged_tab_id = state.tab_id
tab_being_dragged_from_here = True
if self.tab_being_dropped is None and not tab_being_dragged_from_here:
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
if self.tab_being_dropped is None:
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)
if self.tab_being_dropped is not None and self.tab_being_dropped.os_window_id == self.os_window_id:
dropped_tab_idx = self.tab_being_dropped.drop_idx
if dropped_tab_idx < 0:
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)
assert self.tab_being_dropped is not None
ans: list[TabBarData] = []
drop_idx = -1
for i, tab in enumerate(self.tabs_to_be_shown_in_tab_bar):
if i == dropped_tab_idx:
drop_idx = len(ans)
ans.append(self.tab_being_dropped)
if tab.id != dragged_tab_id:
ans.append(tab.data_for_tab_bar(at is tab))
if drop_idx < 0:
drop_idx = len(ans)
ans.append(self.tab_being_dropped)
if (tgt := self.tab_being_dropped.pending_drop_idx) > -1:
self.tab_being_dropped = self.tab_being_dropped._replace(pending_drop_idx=-1)
tgt = max(0, min(tgt, len(ans)-1))
if tgt != drop_idx:
ans[drop_idx], ans[tgt] = ans[tgt], ans[drop_idx]
tmap = {t.id:t for t in self.tabs}
at = self.active_tab
ans = []
for tid in self.tab_being_dropped.tab_ids:
if tid == dragged_tab_id:
ans.append(self.tab_being_dropped.data)
else:
tab = tmap[tid]
ans.append(tab.data_for_tab_bar(tab is at))
return ans
def on_tab_drop_move(self, tab_data: TabBarData, x: int, y: int) -> None:
if tab_data.os_window_id == self.os_window_id:
tid = self.tab_bar.tab_id_at(x)
all_tabs = tuple(t.tab_id for t in self.tab_bar.last_laid_out_tabs)
try:
idx = all_tabs.index(tid)
except ValueError:
idx = -1
if idx < 0:
idx = len(all_tabs) if x > 20 else 0
if self.tab_being_dropped is None:
tab_data = tab_data._replace(drop_idx=idx)
self.last_drop_move_x = x
else:
if x == self.last_drop_move_x:
return
mouse_moved_left = x < self.last_drop_move_x
self.last_drop_move_x = x
tab_data = tab_data._replace(pending_drop_idx=idx)
cur_tab_data, self.tab_being_dropped = self.tab_being_dropped, tab_data
new_tabs = tuple(t.tab_id for t in self.tab_bar_data)
self.tab_being_dropped = cur_tab_data
if new_tabs == all_tabs:
return
try:
old_idx = all_tabs.index(tab_data.tab_id)
except Exception:
pass
else:
new_idx = new_tabs.index(tab_data.tab_id)
tab_moved_left = new_idx < old_idx
if mouse_moved_left != tab_moved_left or new_idx == old_idx:
return
tab_data = tab_data._replace(pending_drop_idx=idx)
self.tab_being_dropped = tab_data
self.layout_tab_bar()
elif self.tab_being_dropped is not None:
self.tab_being_dropped = None
def apply_tab_ordering(self, tab_ids: Sequence[int]) -> None:
id_map = {t.id:t for t in self.tabs}
ordered_ids = frozenset(tab_ids)
positions = (i for i, t in enumerate(self.tabs) if t.id in ordered_ids)
for pos, tab_id in zip(positions, tab_ids):
self.tabs[pos] = id_map[tab_id]
reorder_tabs(self.os_window_id, *(t.id for t in self.tabs))
def on_tab_drop_move(self, tab_id: int, is_dest: bool, x: int, y: int) -> None:
if not is_dest:
if self.tab_being_dropped:
self.tab_being_dropped = None
self.layout_tab_bar()
return
all_tabs = [t.tab_id for t in self.tab_bar.last_laid_out_tabs]
if self.tab_being_dropped is None:
tab = get_boss().tab_for_id(tab_id)
if tab is None:
return
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
if x == self.tab_being_dropped.last_drop_move_x:
return
mouse_moved_left = x < self.tab_being_dropped.last_drop_move_x
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)):
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
old_idx_under_mouse = old_tab_ids.index(tab_id)
idx_moved_left = old_idx_under_mouse > idx_under_mouse
new_tab_ids = old_tab_ids
if mouse_moved_left == idx_moved_left:
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)
if self.tab_being_dropped.tab_ids != old_tab_ids:
self.layout_tab_bar()
def start_tab_drag(self, pixels: bytes, width: int, height: int) -> None:
if (state := self.tab_drag_state) is None:
def on_tab_drop(self, x: int, y: int) -> None:
if (td := self.tab_being_dropped) is None:
return
if (tab := get_boss().tab_for_id(td.data.tab_id)) is None:
return
self.on_tab_drop_move(td.data.tab_id, True, x, y)
if (td := self.tab_being_dropped) is None:
return
self.tab_being_dropped = None
atid = self.active_tab.id if self.active_tab else 0
if tab.os_window_id != self.os_window_id:
get_boss()._move_tab_to(tab, self.os_window_id)
self.apply_tab_ordering(td.tab_ids)
if atid and tab.os_window_id == self.os_window_id and (tab := self.tab_for_id(atid)):
idx = self.tabs.index(tab)
self._set_active_tab(idx, store_in_history=False)
self.layout_tab_bar()
def swap_tabs(self, idx: int, nidx: int) -> None:
if idx != nidx:
self.tabs[idx], self.tabs[nidx] = self.tabs[nidx], self.tabs[idx]
swap_tabs(self.os_window_id, idx, nidx)
def start_tab_drag(self, pixels: bytes, width: int, height: int) -> None:
dragged_tab_id = get_tab_being_dragged()[0]
for i, tab in enumerate(self.tabs_to_be_shown_in_tab_bar):
if tab.id == state.tab_id:
td = tab.data_for_tab_bar(tab is self.active_tab)._replace(
is_being_moved=True, drop_idx=i
)
if tab.id == dragged_tab_id:
td = tab.data_for_tab_bar(tab is self.active_tab)
title = apply_title_template(self.tab_bar.draw_data, td, i+1)
title = re.sub(r'\x1b\[.+?[a-zA-Z]', '', title).strip() # strip CSI codes
title = re.sub(r'\x1b\[.+?[a-zA-Z]', '', title).strip() # strip CSI codes ]
title = replace_c0_codes_except_nl_space_tab(title.encode()).decode()
title = re.sub(r'\n', ' ', title)
# TODO: Add the title to the drag icon
drag_data = {
f'application/net.kovidgoyal.kitty-tab-{os.getpid()}': str(tab.id).encode(),
}
start_drag_with_data(self.os_window_id, drag_data, pixels, width, height)
self.tab_drag_state = state._replace(tab_being_dragged=td)
break
else:
set_tab_being_dragged()
def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int, action: int) -> None:
if button == -1: # motion
if (state := self.tab_drag_state) is not None and not state.drag_started:
if math.sqrt((x-state.start_x)**2 + (y-state.start_y)**2) > 5:
self.tab_drag_state = state._replace(drag_started=True)
dragged_tab_id, drag_started, start_x, start_y = get_tab_being_dragged()
if dragged_tab_id and self.tab_for_id(dragged_tab_id) is not None and not drag_started:
if math.sqrt((x-start_x)**2 + (y-start_y)**2) > 5:
set_tab_being_dragged(dragged_tab_id, True, start_x, start_y)
request_callback_with_thumbnail("start_tab_drag", self.os_window_id)
return
@ -1654,14 +1670,15 @@ def handle_tab_bar_mouse(self, x: float, y: float, button: int, modifiers: int,
return
else:
if button == GLFW_MOUSE_BUTTON_LEFT:
global ignore_left_button_release
if action == GLFW_PRESS:
if (idx := self.tabs.index(tab) if tab in self.tabs else -1) > -1:
set_tab_being_dragged(tab.id)
self.tab_drag_state = TabDragState(
tab_id=tab.id, start_x=x, start_y=y, original_index=idx)
set_tab_being_dragged(tab.id, False, x, y)
ignore_left_button_release = True
else:
if self.tab_drag_state is None or not self.tab_drag_state.drag_started:
ignore, ignore_left_button_release = ignore_left_button_release, False
if not ignore:
self.set_active_tab(tab)
set_tab_being_dragged()
elif button == GLFW_MOUSE_BUTTON_MIDDLE:
if action == GLFW_RELEASE and self.recent_mouse_events:
p = self.recent_mouse_events[-1]