From c56d4223afb523944f087f9941bedd6e30fdd4cf Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 21 Feb 2026 13:31:04 +0530 Subject: [PATCH] Refactor state tracking for tab drag Drop into same window now works --- kitty/boss.py | 16 ++- kitty/fast_data_types.pyi | 9 +- kitty/mouse.c | 4 +- kitty/state.c | 42 +++++++- kitty/state.h | 5 +- kitty/tab_bar.py | 5 +- kitty/tabs.py | 213 ++++++++++++++++++++------------------ 7 files changed, 176 insertions(+), 118 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index 2251f2d58..1cc964b20 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -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 diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index 3de3b9ac7..d109d6e94 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -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 diff --git a/kitty/mouse.c b/kitty/mouse.c index 98d6fc975..180b54856 100644 --- a/kitty/mouse.c +++ b/kitty/mouse.c @@ -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"); diff --git a/kitty/state.c b/kitty/state.c index 438867865..de3b6b768 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -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), diff --git a/kitty/state.h b/kitty/state.h index ec4dcb14a..90892de13 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -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; diff --git a/kitty/tab_bar.py b/kitty/tab_bar.py index d44f54c8a..4ecd9633e 100644 --- a/kitty/tab_bar.py +++ b/kitty/tab_bar.py @@ -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), diff --git a/kitty/tabs.py b/kitty/tabs.py index d66db66ca..3deb12065 100644 --- a/kitty/tabs.py +++ b/kitty/tabs.py @@ -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]