#!/data/data/com.termux/files/usr/bin/env python
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>

import json
import math
import os
import re
import stat
import weakref
from collections import deque
from collections.abc import Callable, Generator, Iterable, Iterator, Sequence
from contextlib import suppress
from functools import wraps
from gettext import gettext as _
from typing import Any, Concatenate, Deque, Literal, NamedTuple, Optional, ParamSpec, TypeVar, cast

from .borders import Border, Borders
from .child import Child
from .cli_stub import CLIOptions, SaveAsSessionOptions
from .constants import appname
from .fast_data_types import (
    GLFW_MOUSE_BUTTON_LEFT,
    GLFW_MOUSE_BUTTON_MIDDLE,
    GLFW_PRESS,
    GLFW_RELEASE,
    add_tab,
    attach_window,
    buffer_keys_in_window,
    current_focused_os_window_id,
    detach_window,
    draw_single_line_of_text,
    get_boss,
    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,
    monotonic,
    next_window_id,
    remove_tab,
    remove_window,
    reorder_tabs,
    replace_c0_codes_except_nl_space_tab,
    request_callback_with_thumbnail,
    ring_bell,
    set_active_tab,
    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,
)
from .layout.base import DragOverlayMode, Layout
from .layout.interface import create_layout_object_for, evict_cached_layouts
from .progress import ProgressState
from .tab_bar import TabBar, TabBarData, apply_title_template
from .types import ac
from .typing_compat import EdgeLiteral, SessionTab, SessionType, TypedDict
from .utils import cmdline_for_hold, color_as_int, log_error, platform_window_id, resolved_shell, shlex_split, which
from .window import CwdRequest, Watchers, Window, WindowCreationSpec, WindowDict, global_watchers
from .window_list import WindowList

P = ParamSpec('P')
T = TypeVar('T')


def update_tab_bar_visibility(func: Callable[Concatenate['TabManager', P], T]) -> Callable[Concatenate['TabManager', P], T]:
    @wraps(func)
    def wrapper(self: 'TabManager', *args: P.args, **kwargs: P.kwargs) -> T:
        visible_before = is_tab_bar_visible(self.os_window_id)
        try:
            return func(self, *args, **kwargs)
        finally:
            if visible_before != self.tab_bar_should_be_visible:
                if not self.tab_bar_hidden:
                    self.layout_tab_bar()
                    self.resize(only_tabs=True)
    return cast(Callable[Concatenate['TabManager', P], T], wrapper)


class MouseEvent(NamedTuple):
    button: int
    modifiers: int
    is_press: bool
    at: float
    x: float
    y: float
    object_id: int = 0

    def distance_squared(self, other: 'MouseEvent') -> float:
        return (self.x - other.x) * (self.x - other.x) + (self.y - other.y) * (self.y - other.y)

    def is_click(self, prev: 'MouseEvent') -> bool:
        cur = self
        return (
            cur.button == prev.button and prev.is_press and not cur.is_press and
            cur.distance_squared(prev) < 25 and
            cur.object_id == prev.object_id and cur.at - prev.at <= get_click_interval()
        )


class MouseEvents(deque[MouseEvent]):

    def add(self, button: int, modifiers: int, action: int, x: float, y: float, object_id: int) -> None:
        super().append(MouseEvent(button, modifiers, action != GLFW_RELEASE, monotonic(), x, y, object_id))
        if len(self) > 5:
            self.popleft()

    def click_count(self, button: int = GLFW_MOUSE_BUTTON_LEFT) -> Literal[0, 1, 2]:
        if len(self) > 1 and self[-1].button == button and self[-1].is_click(self[-2]):
            if len(self) > 3 and self[-3].is_click(self[-4]) and \
                    self[-1].at - self[-4].at <= 2 * get_click_interval() and self[-2].distance_squared(self[-3]) < 2:
                return 2
            return 1
        return 0

    def dump(self) -> None:
        for x in self:
            print(x)


class TabDict(TypedDict):
    id: int
    is_focused: bool
    is_active: bool
    title: str
    title_overridden: bool
    layout: str
    layout_state: dict[str, Any]
    layout_opts: dict[str, Any]
    enabled_layouts: list[str]
    windows: list[WindowDict]
    groups: list[dict[str, Any]]
    active_window_history: list[int]


class SpecialWindowInstance(NamedTuple):
    cmd: list[str] | None
    stdin: bytes | None
    override_title: str | None
    cwd_from: CwdRequest | None
    cwd: str | None
    overlay_for: int | None
    env: dict[str, str] | None
    watchers: Watchers | None
    overlay_behind: bool
    hold: bool


def SpecialWindow(
    cmd: list[str] | None,
    stdin: bytes | None = None,
    override_title: str | None = None,
    cwd_from: CwdRequest | None = None,
    cwd: str | None = None,
    overlay_for: int | None = None,
    env: dict[str, str] | None = None,
    watchers: Watchers | None = None,
    overlay_behind: bool = False,
    hold: bool = False,
) -> SpecialWindowInstance:
    return SpecialWindowInstance(cmd, stdin, override_title, cwd_from, cwd, overlay_for, env, watchers, overlay_behind, hold)


def add_active_id_to_history(items: Deque[int], item_id: int, maxlen: int = 64) -> None:
    with suppress(ValueError):
        items.remove(item_id)
    items.append(item_id)
    if len(items) > maxlen:
        items.popleft()


class Tab:  # {{{

    active_fg: int | None = None
    active_bg: int | None = None
    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
    has_indeterminate_progress: bool = False
    last_focused_window_with_progress_id: int = 0
    allow_relayouts: bool = True

    def __init__(
        self,
        tab_manager: 'TabManager',
        session_tab: Optional['SessionTab'] = None,
        special_window: SpecialWindowInstance | None = None,
        cwd_from: CwdRequest | None = None,
        no_initial_window: bool = False,
        session_name: str = '',
    ):
        self.created_in_session_name = session_name
        self.tab_manager_ref = weakref.ref(tab_manager)
        self.os_window_id: int = tab_manager.os_window_id
        self.id: int = add_tab(self.os_window_id)
        if not self.id:
            raise Exception(f'No OS window with id {self.os_window_id} found, or tab counter has wrapped')
        self.args = tab_manager.args
        self.name = getattr(session_tab, 'name', '')
        self.enabled_layouts = [x.lower() for x in getattr(session_tab, 'enabled_layouts', None) or get_options().enabled_layouts]
        self.borders = Borders(self.os_window_id, self.id)
        self.windows: WindowList = WindowList(self)
        self._last_used_layout: str | None = None
        self._current_layout_name: str | None = None
        self.cwd = self.args.directory
        if no_initial_window:
            self._set_current_layout(self.enabled_layouts[0])
        elif session_tab is None:
            sl = self.enabled_layouts[0]
            self._set_current_layout(sl)
            if special_window is None:
                self.new_window(cwd_from=cwd_from)
            else:
                self.new_special_window(special_window)
        else:
            if session_tab.cwd:
                self.cwd = session_tab.cwd
            l0 = session_tab.layout
            self._set_current_layout(l0)
            self.startup(session_tab)

    def update_progress(self) -> None:
        self.num_of_windows_with_progress = 0
        self.total_progress = 0
        self.last_focused_window_with_progress_id = 0
        self.has_indeterminate_progress = False
        focused_at = 0.
        for window in self:
            p = window.progress
            if p.state is ProgressState.unset:
                continue
            if p.state in (ProgressState.set, ProgressState.paused):
                self.total_progress += p.percent
                self.num_of_windows_with_progress += 1
            elif p.state is ProgressState.indeterminate:
                self.has_indeterminate_progress = True
            if window.last_focused_at > focused_at or (not window.last_focused_at and window.id > self.last_focused_window_with_progress_id):
                focused_at = window.last_focused_at
                self.last_focused_window_with_progress_id = window.id
        self.mark_tab_bar_dirty()
        tm = self.tab_manager_ref()
        if tm is not None:
            tm.update_progress()

    def has_single_window_visible(self) -> bool:
        if self.current_layout.only_active_window_visible:
            return True
        for i, g in enumerate(self.windows.iter_all_layoutable_groups(only_visible=True)):
            if i > 0:
                return False
        return True

    def set_enabled_layouts(self, val: Iterable[str]) -> None:
        self.enabled_layouts = [x.lower() for x in val] or ['tall']
        if self.current_layout.name not in self.enabled_layouts:
            self._set_current_layout(self.enabled_layouts[0])
        self.relayout()

    def apply_options(self, is_active: bool) -> None:
        aw = self.active_window
        for window in self:
            window.apply_options(is_active and aw is window)
        self.set_enabled_layouts(get_options().enabled_layouts)

    def take_over_from(self, other_tab: 'Tab') -> None:
        self.name, self.cwd = other_tab.name, other_tab.cwd
        self.enabled_layouts = list(other_tab.enabled_layouts)
        self._last_used_layout = other_tab._last_used_layout
        if clname := other_tab._current_layout_name:
            cl = other_tab.current_layout
            other_tab._set_current_layout(clname)
            cl.set_owner(self.os_window_id, self.id)
            self.current_layout: Layout = cl
            self._current_layout_name = clname
            self.mark_tab_bar_dirty()
        for window in other_tab.windows:
            detach_window(other_tab.os_window_id, other_tab.id, window.id)
        self.windows = other_tab.windows
        self.windows.change_tab(self)
        other_tab.windows = WindowList(other_tab)
        for window in self.windows:
            window.change_tab(self)
            attach_window(self.os_window_id, self.id, window.id)
        self.active_window_changed()
        self.relayout()

    def _set_current_layout(self, layout_name: str) -> None:
        self._last_used_layout = self._current_layout_name
        self.current_layout = self.create_layout_object(layout_name)
        self._current_layout_name = layout_name
        self.mark_tab_bar_dirty()

    def startup(self, session_tab: SessionTab) -> None:
        self.allow_relayouts = False
        try:
            self._startup(session_tab)
        finally:
            self.allow_relayouts = True
        self.relayout()

    def _startup(self, session_tab: SessionTab) -> None:
        target_tab = self
        boss = get_boss()
        active_window_id = 0
        did_focus_matching_spec = False
        first_window_id = 0
        for i, window in enumerate(session_tab.windows):
            spec = window.launch_spec
            launched_window: Window | None = None
            if isinstance(spec, SpecialWindowInstance):
                launched_window = self.new_special_window(spec)
                if launched_window is not None:
                    launched_window.created_in_session_name = self.created_in_session_name
            else:
                from .launch import launch
                spec.opts.add_to_session = self.created_in_session_name
                launched_window = launch(
                    boss, spec.opts, spec.args, target_tab=target_tab, force_target_tab=True,
                    startup_command_via_shell_integration=window.run_command_at_shell_startup)
                if launched_window is not None:
                    launched_window.serialized_id = window.serialized_id
            if launched_window is not None:
                if not first_window_id:
                    first_window_id = launched_window.id
                if session_tab.active_window_idx == i:
                    active_window_id = launched_window.id
                    did_focus_matching_spec = False
            if window.resize_spec is not None:
                self.resize_window(*window.resize_spec)
            if window.focus_matching_window_spec:
                # include windows from this tab when matching windows
                all_windows = list(boss.all_windows)
                awq = {w.id for w in all_windows}
                all_windows.extend(w for w in self if w.id not in awq)
                for w in boss.match_windows(
                    window.focus_matching_window_spec, launched_window or boss.active_window, all_windows
                ):
                    tab = w.tabref()
                    if tab:
                        did_focus_matching_spec = True
                        active_window_id = 0
                        target_tab = tab or self
                        tm = tab.tab_manager_ref()
                        if tm and boss.active_tab is not target_tab:
                            tm.set_active_tab(target_tab)
                        if target_tab.active_window is not w:
                            target_tab.set_active_window(w)
                        boss.focus_os_window(w.os_window_id)

        if not did_focus_matching_spec and not active_window_id:
            active_window_id = first_window_id
        if active_window_id and not did_focus_matching_spec:
            self.windows.set_active_window_group_for(active_window_id)
        if session_tab.layout_state:
            self.current_layout.unserialize(session_tab.layout_state, self.windows)

    def serialize_state(self) -> dict[str, Any]:
        return {
            'version': 1,
            'id': self.id,
            'window_list': self.windows.serialize_state(),
            'current_layout': self._current_layout_name,
            'last_used_layout': self._last_used_layout,
            'layout_state': self.current_layout.serialize(self.windows),
            'enabled_layouts': self.enabled_layouts,
            'name': self.name,
        }

    def serialize_state_as_session(self, session_path: str, matched_windows: frozenset[Window] | None, ser_opts: SaveAsSessionOptions) -> list[str]:
        import shlex
        launch_cmds = []
        active_idx = self.windows.active_group_idx
        groups = tuple(self.windows.iter_all_layoutable_groups())
        session_base_dir = os.path.dirname(session_path) if session_path else ''
        def make_relative(cwd: str) -> str:
            if session_base_dir and ser_opts.relocatable:
                cwd = os.path.relpath(cwd, session_base_dir)
            return cwd
        most_common_cwd = ''
        cwds = {w.id: make_relative(w.cwd_for_serialization) for g in groups for w in g}
        if cwds:
            from collections import Counter
            most_common_cwd, _ = Counter(cwds.values()).most_common(1)[0]
        for i, g in enumerate(groups):
            gw: list[str] = []
            for window in g:
                if matched_windows is not None and window not in matched_windows:
                    continue
                cwd = cwds[window.id]
                lc = window.as_launch_command(ser_opts, '' if cwd == most_common_cwd else cwd, is_overlay=bool(gw))
                if lc:
                    gw.append(shlex.join(lc))
            if gw:
                launch_cmds.extend(gw)
                if i == active_idx:
                    launch_cmds.append('focus')
        if launch_cmds:
            enabled_layouts = list(self.enabled_layouts)
            layout = self._current_layout_name
            if layout not in enabled_layouts:
                enabled_layouts.append(layout)
            return [
                '',
                f'new_tab {self.name}'.rstrip(),
                f'layout {layout}',
                f'enabled_layouts {",".join(enabled_layouts)}',
                f'set_layout_state {json.dumps(self.current_layout.serialize(self.windows))}',
                f'cd {most_common_cwd}',
                ''
            ] + launch_cmds
        return []

    def data_for_tab_bar(self, is_active: bool) -> TabBarData:
        t = self
        title = t.name or t.title or appname
        needs_attention = False
        has_activity_since_last_focus = False
        for w in t:
            if w.needs_attention:
                needs_attention = True
            if w.has_activity_since_last_focus:
                has_activity_since_last_focus = True
        return TabBarData(
            title, is_active, needs_attention, t.id, t.os_window_id,
            len(t), t.num_window_groups, t.current_layout.name or '',
            has_activity_since_last_focus, t.active_fg, t.active_bg,
            t.inactive_fg, t.inactive_bg, t.num_of_windows_with_progress,
            t.total_progress, t.last_focused_window_with_progress_id,
            t.created_in_session_name, t.active_session_name,
        )

    def active_window_changed(self) -> None:
        w = self.active_window
        set_active_window(self.os_window_id, self.id, 0 if w is None else w.id)
        self.mark_tab_bar_dirty()
        self.relayout_borders()
        self.current_layout.update_visibility(self.windows)

    def mark_tab_bar_dirty(self) -> None:
        tm = self.tab_manager_ref()
        if tm is not None:
            tm.mark_tab_bar_dirty()

    @property
    def active_window(self) -> Window | None:
        return self.windows.active_window

    @property
    def active_window_for_cwd(self) -> Window | None:
        return self.windows.active_group_main

    @property
    def title(self) -> str:
        w = self.active_window
        return w.title if w else appname

    @property
    def effective_title(self) -> str:
        return self.name or self.title

    def get_cwd_of_active_window(self, oldest: bool = False) -> str | None:
        w = self.active_window
        return w.get_cwd_of_child(oldest) if w else None

    def get_exe_of_active_window(self, oldest: bool = False) -> str | None:
        w = self.active_window
        return w.get_exe_of_child(oldest) if w else None

    def set_title(self, title: str) -> None:
        self.name = title or ''
        self.mark_tab_bar_dirty()

    def update_window_title_bars(self) -> None:
        active_group = self.windows.active_group
        for wg in self.windows.iter_all_layoutable_groups(only_visible=True):
            is_active = wg is active_group
            for w in wg.windows:
                w.update_title_bar(is_active=is_active)

    def title_changed(self, window: Window) -> None:
        self.update_window_title_bars()
        if window is self.active_window:
            tm = self.tab_manager_ref()
            if tm is not None:
                tm.title_changed(self)

    def on_bell(self, window: Window) -> None:
        self.mark_tab_bar_dirty()

    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:
        tm = self.tab_manager_ref()
        if tm is not None:
            ly = self.current_layout
            opts = get_options()
            draw_borders = (
                ly.must_draw_borders or opts.draw_window_borders_for_single_window or
                (ly.needs_window_borders and self.windows.has_more_than_one_visible_group)
            )
            self.borders(
                all_windows=self.windows,
                current_layout=ly, tab_bar_rects=tm.tab_bar_rects,
                draw_window_borders=draw_borders
            )
            self.update_window_title_bars()

    def create_layout_object(self, name: str) -> Layout:
        return create_layout_object_for(name, self.os_window_id, self.id)

    @ac('lay', 'Go to the next enabled layout. Can optionally supply an integer to jump by the specified number.')
    def next_layout(self, delta: int = 1) -> None:
        if len(self.enabled_layouts) > 1:
            for i, layout_name in enumerate(self.enabled_layouts):
                if layout_name == self.current_layout.full_name:
                    idx = i
                    break
            else:
                idx = -1
            if abs(delta) >= len(self.enabled_layouts):
                mult = -1 if delta < 0 else 1
                delta = mult * (abs(delta) % len(self.enabled_layouts))
            nl = self.enabled_layouts[(idx + delta + len(self.enabled_layouts)) % len(self.enabled_layouts)]
            self._set_current_layout(nl)
            self.relayout()

    @ac('lay', 'Go to the previously used layout')
    def last_used_layout(self) -> None:
        if len(self.enabled_layouts) > 1 and self._last_used_layout and self._last_used_layout != self._current_layout_name:
            self._set_current_layout(self._last_used_layout)
            self.relayout()

    @ac('lay', '''
        Switch to the named layout
        In case there are multiple layouts with the same name and different options,
        specify the full layout definition or a unique prefix of the full definition.

        For example::

            map f1 goto_layout tall
            map f2 goto_layout fat:bias=20
        ''')
    def goto_layout(self, layout_name: str, raise_exception: bool = False) -> None:
        layout_name = layout_name.lower()
        q, has_colon, rest = layout_name.partition(':')
        matches = []
        prefix_matches = []
        matched_layout = ''
        for candidate in self.enabled_layouts:
            x, _, _ = candidate.partition(':')
            if x == q:
                if candidate == layout_name:
                    matched_layout = candidate
                    break
                if candidate.startswith(layout_name):
                    prefix_matches.append(candidate)
                matches.append(x)

        if not matched_layout:
            if len(prefix_matches) == 1:
                matched_layout = prefix_matches[0]
            elif len(matches) == 1:
                matched_layout = matches[0]
        if matched_layout:
            self._set_current_layout(matched_layout)
            self.relayout()
        else:
            if len(matches) == 0:
                if raise_exception:
                    raise ValueError(layout_name)
                log_error(f'Unknown or disabled layout: {layout_name}')
            elif len(matches) != 1:
                if raise_exception:
                    raise ValueError(layout_name)
                log_error(f'Multiple layouts match: {layout_name}')

    @ac('lay', '''
        Toggle the named layout

        Switches to the named layout if another layout is current, otherwise
        switches to the last used layout. Useful to "zoom" a window temporarily
        by switching to the stack layout. See also :opt:`scrollback_fill_enlarged_window`
        if you would like content from the scrollback buffer to scroll down into the
        zoomed window. For example::

            map f1 toggle_layout stack
        ''')
    def toggle_layout(self, layout_name: str) -> None:
        if self._current_layout_name == layout_name:
            self.last_used_layout()
        else:
            self.goto_layout(layout_name)

    def resize_window_by(self, window_id: int, increment: float, is_horizontal: bool) -> str | None:
        increment_as_percent = self.current_layout.bias_increment_for_cell(self.windows, is_horizontal) * increment
        if self.current_layout.modify_size_of_window(self.windows, window_id, increment_as_percent, is_horizontal):
            self.relayout()
            return None
        return 'Could not resize'

    def drag_resize_window(self, object_id: int, increment: float, is_horizontal: bool) -> bool:
        increment_as_percent = self.current_layout.bias_increment_for_cell(self.windows, is_horizontal) * increment
        if resized := self.current_layout.drag_resize_window(self.windows, object_id, increment_as_percent, is_horizontal):
            self.relayout()
        return resized

    @ac('win', '''
        Resize the active window by the specified amount

        See :ref:`window_resizing` for details.
        ''')
    def resize_window(self, quality: str, increment: int) -> None:
        if quality == 'reset':
            self.reset_window_sizes()
            return
        if increment < 1:
            raise ValueError(increment)
        is_horizontal = quality in ('wider', 'narrower')
        increment *= 1 if quality in ('wider', 'taller') else -1
        w = self.active_window
        if w is not None and self.resize_window_by(
                w.id, increment, is_horizontal) is not None:
            if get_options().enable_audio_bell:
                ring_bell(self.os_window_id)

    @ac('win', 'Reset window sizes undoing any dynamic resizing of windows')
    def reset_window_sizes(self) -> None:
        if self.current_layout.remove_all_biases():
            self.relayout()

    @ac('lay', 'Perform a layout specific action. See :doc:`layouts` for details')
    def layout_action(self, action_name: str, args: Sequence[str]) -> None:
        ret = self.current_layout.layout_action(action_name, args, self.windows)
        if ret is None:
            if get_options().enable_audio_bell:
                ring_bell(self.os_window_id)
            return
        self.relayout()

    def launch_child(
        self,
        use_shell: bool = False,
        cmd: list[str] | None = None,
        stdin: bytes | None = None,
        cwd_from: CwdRequest | None = None,
        cwd: str | None = None,
        env: dict[str, str] | None = None,
        is_clone_launch: str = '',
        add_listen_on_env_var: bool = True,
        hold: bool = False,
        pass_fds: tuple[int, ...] = (),
        remote_control_fd: int = -1,
        hold_after_ssh: bool = False,
        startup_command_via_shell_integration: Sequence[str] | str = (),
    ) -> Child:
        check_for_suitability = True
        if cmd is None:
            if use_shell:
                cmd = resolved_shell(get_options())
                check_for_suitability = False
            else:
                if self.args.args:
                    cmd = list(self.args.args)
                else:
                    cmd = resolved_shell(get_options())
                    check_for_suitability = False
        if check_for_suitability:
            old_exe = cmd[0]
            if not os.path.isabs(old_exe):
                actual_exe = which(old_exe)
                old_exe = actual_exe if actual_exe else os.path.abspath(old_exe)
            try:
                is_executable = os.access(old_exe, os.X_OK)
            except OSError:
                pass
            else:
                try:
                    st = os.stat(old_exe)
                except OSError:
                    pass
                else:
                    if stat.S_ISDIR(st.st_mode):
                        cwd = old_exe
                        cmd = resolved_shell(get_options())
                    elif not is_executable:
                        with suppress(OSError):
                            with open(old_exe) as f:
                                if f.read(2) == '#!':
                                    line = f.read(4096).splitlines()[0]
                                    cmd[:0] = shlex_split(line)
                                else:
                                    cmd[:0] = [resolved_shell(get_options())[0]]
                                cmd[0] = which(cmd[0]) or cmd[0]
                                cmd = cmdline_for_hold(cmd)
        fenv: dict[str, str] = {}
        if env:
            fenv.update(env)
        fenv['KITTY_WINDOW_ID'] = str(next_window_id())
        pwid = platform_window_id(self.os_window_id)
        if pwid is not None:
            fenv['WINDOWID'] = str(pwid)
        ans = Child(
                cmd, cwd or self.cwd, stdin, fenv, cwd_from, is_clone_launch=is_clone_launch,
                add_listen_on_env_var=add_listen_on_env_var, hold=hold, pass_fds=pass_fds,
                remote_control_fd=remote_control_fd, hold_after_ssh=hold_after_ssh,
                startup_command_via_shell_integration=startup_command_via_shell_integration)
        ans.fork()
        return ans

    def _add_window(
        self, window: Window, location: str | None = None, overlay_for: int | None = None,
        overlay_behind: bool = False, bias: float | None = None, next_to: Window | None = None,
    ) -> None:
        self.current_layout.add_window(self.windows, window, location, overlay_for, put_overlay_behind=overlay_behind, bias=bias, next_to=next_to)
        if overlay_behind and (w := self.active_window):
            set_redirect_keys_to_overlay(self.os_window_id, self.id, w.id, window.id)
            buffer_keys_in_window(self.os_window_id, self.id, window.id, True)
            window.keys_redirected_till_ready_from = w.id
        self.mark_tab_bar_dirty()
        self.relayout()

    def new_window(
        self,
        use_shell: bool = True,
        cmd: list[str] | None = None,
        stdin: bytes | None = None,
        override_title: str | None = None,
        cwd_from: CwdRequest | None = None,
        cwd: str | None = None,
        overlay_for: int | None = None,
        env: dict[str, str] | None = None,
        location: str | None = None,
        copy_colors_from: Window | None = None,
        allow_remote_control: bool = False,
        marker: str | None = None,
        watchers: Watchers | None = None,
        overlay_behind: bool = False,
        is_clone_launch: str = '',
        remote_control_passwords: dict[str, Sequence[str]] | None = None,
        hold: bool = False,
        bias: float | None = None,
        pass_fds: tuple[int, ...] = (),
        remote_control_fd: int = -1,
        next_to: Window | None = None,
        hold_after_ssh: bool = False,
        startup_command_via_shell_integration: Sequence[str] | str = (),
    ) -> Window:
        cs = WindowCreationSpec(
            use_shell=use_shell, cmd=cmd, has_stdin=bool(stdin), override_title=override_title, cwd_from=cwd_from,
            cwd=cwd, overlay_for=overlay_for, env=None if env is None else tuple(env.items()), location=location,
            copy_colors_from=None if copy_colors_from is None else copy_colors_from.id,
            allow_remote_control=allow_remote_control,
            remote_control_passwords=None if remote_control_passwords is None else remote_control_passwords.copy(),
            marker=marker, overlay_behind=overlay_behind, is_clone_launch=is_clone_launch, hold=hold, bias=bias,
            hold_after_ssh=hold_after_ssh,
        )
        child = self.launch_child(
            use_shell=use_shell, cmd=cmd, stdin=stdin, cwd_from=cwd_from, cwd=cwd, env=env,
            is_clone_launch=is_clone_launch, add_listen_on_env_var=False if allow_remote_control and remote_control_passwords else True,
            hold=hold, pass_fds=pass_fds, remote_control_fd=remote_control_fd, hold_after_ssh=hold_after_ssh,
            startup_command_via_shell_integration=startup_command_via_shell_integration,
        )
        window = Window(
            self, child, self.args, override_title=override_title,
            copy_colors_from=copy_colors_from, watchers=watchers,
            allow_remote_control=allow_remote_control, remote_control_passwords=remote_control_passwords
        )
        window.creation_spec = cs
        # Must add child before laying out so that resize_pty succeeds
        get_boss().add_child(window)
        self._add_window(window, location=location, overlay_for=overlay_for, overlay_behind=overlay_behind, bias=bias, next_to=next_to)
        if marker:
            try:
                window.set_marker(marker)
            except Exception:
                import traceback
                traceback.print_exc()
        return window

    def new_special_window(
            self,
            special_window: SpecialWindowInstance,
            location: str | None = None,
            copy_colors_from: Window | None = None,
            allow_remote_control: bool = False,
            remote_control_passwords: dict[str, Sequence[str]] | None = None,
            pass_fds: tuple[int, ...] = (),
            remote_control_fd: int = -1,
    ) -> Window:
        return self.new_window(
            use_shell=False, cmd=special_window.cmd, stdin=special_window.stdin,
            override_title=special_window.override_title,
            cwd_from=special_window.cwd_from, cwd=special_window.cwd, overlay_for=special_window.overlay_for,
            env=special_window.env, location=location, copy_colors_from=copy_colors_from,
            allow_remote_control=allow_remote_control, watchers=special_window.watchers, overlay_behind=special_window.overlay_behind,
            hold=special_window.hold, remote_control_passwords=remote_control_passwords, pass_fds=pass_fds, remote_control_fd=remote_control_fd,
        )

    @ac('win', 'Close all windows in the tab other than the currently active window')
    def close_other_windows_in_tab(self) -> None:
        if len(self.windows) > 1:
            active_window = self.active_window
            for window in tuple(self.windows):
                if window is not active_window:
                    self.remove_window(window)

    def move_window_to_top_of_group(self, window: Window) -> bool:
        return self.windows.move_window_to_top_of_group(window)

    def overlay_parent(self, window: Window) -> Window | None:
        prev: Window | None = None
        for x in self.windows.windows_in_group_of(window):
            if x is window:
                break
            prev = x
        return prev

    def remove_window(self, window: Window, destroy: bool = True, do_post_removal_update: bool = True) -> None:
        self.windows.remove_window(window)
        if destroy:
            remove_window(self.os_window_id, self.id, window.id)
        else:
            detach_window(self.os_window_id, self.id, window.id)
        if do_post_removal_update:
            self.post_window_removal_update()

    def post_window_removal_update(self) -> None:
        self.mark_tab_bar_dirty()
        self.relayout()  # prunes the closed window from the layout's internal tree
        # equalize_on_close rebalances the pruned tree, requiring a second relayout
        if self.current_layout.on_window_removed(self.windows):
            self.relayout()
        active_window = self.active_window
        if active_window:
            self.title_changed(active_window)
        set_active_window(self.os_window_id, self.id, active_window.id if active_window else 0)

    def detach_window(self, window: Window) -> tuple[Window, ...]:
        windows = list(self.windows.windows_in_group_of(window))
        for w in reversed(windows):
            self.remove_window(w, destroy=False, do_post_removal_update=False)
        self.post_window_removal_update()
        return tuple(windows)

    def attach_window(self, window: Window, overlay_for: int | None = None) -> None:
        window.change_tab(self)
        attach_window(self.os_window_id, self.id, window.id)
        self._add_window(window, overlay_for=overlay_for)

    def attach_windows(self, windows: Iterable[Window]) -> None:
        overlay_for: int | None = None
        for window in windows:
            self.attach_window(window, overlay_for)
            overlay_for = window.id

    def set_active_window(self, x: Window | int, for_keep_focus: Window | None = None) -> None:
        if (w := self.windows.window_for_id(x) if isinstance(x, int) else x) is not None:
            self.windows.set_active_window_group_for(w, for_keep_focus=for_keep_focus)
            self.windows.move_window_to_top_of_group(w)

    def get_nth_window(self, n: int) -> Window | None:
        if self.windows:
            return self.current_layout.nth_window(self.windows, n)
        return None

    @ac('win', '''
        Focus the nth window if positive or the previously active windows if negative. When the number is larger
        than the number of windows focus the last window. For example::

            # focus the previously active window
            map ctrl+p nth_window -1
            # focus the first window
            map ctrl+1 nth_window 0
        ''')
    def nth_window(self, num: int = 0) -> None:
        if self.windows:
            if num < 0:
                self.windows.make_previous_group_active(-num)
            elif self.windows.num_groups:
                self.current_layout.activate_nth_window(self.windows, min(num, self.windows.num_groups - 1))
            self.relayout_borders()

    @ac('win', 'Focus the first window')
    def first_window(self) -> None:
        self.nth_window(0)

    @ac('win', 'Focus the second window')
    def second_window(self) -> None:
        self.nth_window(1)

    @ac('win', 'Focus the third window')
    def third_window(self) -> None:
        self.nth_window(2)

    @ac('win', 'Focus the fourth window')
    def fourth_window(self) -> None:
        self.nth_window(3)

    @ac('win', 'Focus the fifth window')
    def fifth_window(self) -> None:
        self.nth_window(4)

    @ac('win', 'Focus the sixth window')
    def sixth_window(self) -> None:
        self.nth_window(5)

    @ac('win', 'Focus the seventh window')
    def seventh_window(self) -> None:
        self.nth_window(6)

    @ac('win', 'Focus the eighth window')
    def eighth_window(self) -> None:
        self.nth_window(7)

    @ac('win', 'Focus the ninth window')
    def ninth_window(self) -> None:
        self.nth_window(8)

    @ac('win', 'Focus the tenth window')
    def tenth_window(self) -> None:
        self.nth_window(9)

    def _next_window(self, delta: int = 1) -> None:
        if len(self.windows) > 1:
            self.current_layout.next_window(self.windows, delta)
            self.relayout_borders()

    @ac('win', 'Focus the next window in the current tab. Does not traverse overlay windows.')
    def next_window(self) -> None:
        self._next_window()

    @ac('win', 'Focus the previous window in the current tab. Does not traverse overlay windows.')
    def previous_window(self) -> None:
        self._next_window(-1)

    prev_window = previous_window

    def most_recent_group(self, groups: Sequence[int]) -> int | None:
        groups_set = frozenset(groups)

        for window_id in reversed(self.windows.active_window_history):
            group = self.windows.group_for_window(window_id)
            if group and group.id in groups_set:
                return group.id

        if groups:
            return groups[0]
        return None

    def nth_active_window_id(self, n: int = 0) -> int:
        if n <= 0:
            return self.active_window.id if self.active_window else 0
        ids = tuple(reversed(self.windows.active_window_history))
        return ids[min(n - 1, len(ids) - 1)] if ids else 0

    def neighboring_group_id(self, which: EdgeLiteral) -> int | None:
        neighbors = self.current_layout.neighbors(self.windows)
        candidates = neighbors.get(which)
        if candidates:
            return self.most_recent_group(candidates)
        return None

    @ac('win', '''
        Focus the neighboring window in the current tab

        For example::

            map ctrl+left neighboring_window left
            map ctrl+down neighboring_window bottom
        ''')
    def neighboring_window(self, which: EdgeLiteral) -> None:
        neighbor = self.neighboring_group_id(which)
        if neighbor:
            self.windows.set_active_group(neighbor)

    @ac('win', '''
        Move the window in the specified direction

        For example::

            map ctrl+left move_window left
            map ctrl+down move_window bottom
        ''')
    def move_window(self, delta: EdgeLiteral | int = 1) -> None:
        if isinstance(delta, int):
            if self.current_layout.move_window(self.windows, delta):
                self.relayout()
        elif isinstance(delta, str):
            neighbor = self.neighboring_group_id(delta)
            if neighbor:
                if self.current_layout.move_window_to_group(self.windows, neighbor):
                    self.relayout()

    def swap_active_window_with(self, window_id: int) -> None:
        group = self.windows.group_for_window(window_id)
        if group is not None:
            w = self.active_window
            if w is not None and w.id != window_id:
                if self.current_layout.move_window_to_group(self.windows, group.id):
                    self.relayout()

    @property
    def all_window_ids_except_active_window(self) -> set[int]:
        all_window_ids = {w.id for w in self}
        aw = self.active_window
        if aw is not None:
            all_window_ids.discard(aw.id)
        return all_window_ids

    @ac('win', '''
        Focus a visible window by pressing the number of the window. Window numbers are displayed
        over the windows for easy selection in this mode. See :opt:`visual_window_select_characters`.
        ''')
    def focus_visible_window(self) -> None:
        def callback(tab: Tab | None, window: Window | None) -> None:
            if tab and window:
                tab.set_active_window(window)

        get_boss().visual_window_select_action(self, callback, 'Choose window to switch to', only_window_ids=self.all_window_ids_except_active_window)

    @ac('win', 'Swap the current window with another window in the current tab, selected visually. See :opt:`visual_window_select_characters`')
    def swap_with_window(self) -> None:
        def callback(tab: Tab | None, window: Window | None) -> None:
            if tab and window:
                tab.swap_active_window_with(window.id)
        get_boss().visual_window_select_action(self, callback, 'Choose window to swap with', only_window_ids=self.all_window_ids_except_active_window)

    @ac('win', 'Move active window to the top (make it the first window)')
    def move_window_to_top(self) -> None:
        n = self.windows.active_group_idx
        if n > 0:
            self.move_window(-n)

    @ac('win', 'Move active window forward (swap it with the next window)')
    def move_window_forward(self) -> None:
        self.move_window()

    @ac('win', 'Move active window backward (swap it with the previous window)')
    def move_window_backward(self) -> None:
        self.move_window(-1)

    def list_windows(self, self_window: Window | None = None, window_filter: Callable[[Window], bool] | None = None) -> Generator[WindowDict, None, None]:
        active_window = self.active_window
        cl = self.current_layout
        for w in self:
            if window_filter is None or window_filter(w):
                yield w.as_dict(
                    is_active=w is active_window,
                    is_focused=w.os_window_id == current_focused_os_window_id() and w is active_window,
                    is_self=w is self_window,
                    neighbors_map=cl.neighbors_for_window(w, self.windows)
                )

    def list_groups(self) -> list[dict[str, Any]]:
        return [g.as_simple_dict() for g in self.windows.groups]

    def matches_query(
        self, field: str, query: str, active_tab_manager: Optional['TabManager'] = None,
        active_session: str = '', most_recent_session: str = ''
    ) -> bool:
        match field:
            case 'title':
                return re.search(query, self.effective_title) is not None
            case 'id':
                return query == str(self.id)
            case 'window_id' | 'window_title':
                field = field.partition('_')[-1]
                for w in self:
                    if w.matches_query(field, query):
                        return True
                return False
            case 'var' | 'env':
                for w in self:
                    if w.matches_query(field, query):
                        return True
                return False
            case 'index':
                if active_tab_manager and len(active_tab_manager.tabs):
                    idx = (int(query) + len(active_tab_manager.tabs)) % len(active_tab_manager.tabs)
                    return active_tab_manager.tabs[idx] is self
                return False
            case 'recent':
                if active_tab_manager and len(active_tab_manager.tabs):
                    return self is active_tab_manager.nth_active_tab(int(query))
                return False
            case 'state':
                match query:
                    case 'active':
                        tm = self.tab_manager_ref()
                        return tm is not None and self is tm.active_tab
                    case 'focused':
                        return active_tab_manager is not None and self is active_tab_manager.active_tab and self.os_window_id == last_focused_os_window_id()
                    case 'needs_attention':
                        for w in self:
                            if w.needs_attention:
                                return True
                    case 'parent_active':
                        return active_tab_manager is not None and self.tab_manager_ref() is active_tab_manager
                    case 'parent_focused':
                        return active_tab_manager is not None and \
                                self.tab_manager_ref() is active_tab_manager and self.os_window_id == last_focused_os_window_id()
                    case 'focused_os_window':
                        return self.os_window_id == last_focused_os_window_id()
                return False
            case 'session':
                match query:
                    case '.':
                        return self.created_in_session_name == active_session
                    case '~':
                        return self.created_in_session_name == active_session or self.created_in_session_name == most_recent_session
                return re.search(query, self.created_in_session_name) is not None
        return False

    def __iter__(self) -> Iterator[Window]:
        return iter(self.windows)

    def __len__(self) -> int:
        return len(self.windows)

    @property
    def num_window_groups(self) -> int:
        return self.windows.num_groups

    @property
    def active_session_name(self) -> str:
        w = self.active_window
        return '' if w is None else w.created_in_session_name

    def __contains__(self, window: Window) -> bool:
        return window in self.windows

    def destroy(self) -> None:
        evict_cached_layouts(self.id)
        for w in self.windows:
            w.destroy()
        self.windows = WindowList(self)

    def __repr__(self) -> str:
        return f'Tab(title={self.effective_title}, id={self.id})'

    def make_active(self) -> None:
        tm = self.tab_manager_ref()
        if tm is not None:
            tm.set_active_tab(self)

    def swap_windows(self, window_a: Window, window_b: Window) -> None:
        if (wg_b := self.windows.group_for_window(window_b)) is None:
            return
        with get_boss().suppress_focus_change_events():
            self.windows.set_active_window_group_for(window_a)
            self.current_layout.move_window_to_group(self.windows, wg_b.id)
            self.relayout()
# }}}


class TabBeingDropped(NamedTuple):
    data: TabBarData
    tab_ids: Sequence[int] = ()
    last_drop_move_coordinate: 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
    num_of_windows_with_progress: int = 0
    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
    window_drag_over_me: bool = False

    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
        self.wm_class = wm_class
        self.created_in_session_name = startup_session.session_name if startup_session else ''
        self.recent_tab_bar_mouse_events = MouseEvents()
        self.recent_title_bar_mouse_events = MouseEvents()
        self.wm_name = wm_name
        self.args = args
        self.tab_bar_hidden = get_options().tab_bar_style == 'hidden'
        self.tabs: list[Tab] = []
        self.active_tab_history: Deque[int] = deque()
        self.tab_bar = TabBar(self.os_window_id)
        self._active_tab_idx = 0

        if startup_session is not None:
            self.add_tabs_from_session(startup_session)

    @update_tab_bar_visibility
    def add_tabs_from_session(self, session: SessionType, session_name: str = '') -> None:
        active_tab = self.active_tab
        added_tabs = []
        for i, t in enumerate(session.tabs):
            tab = Tab(self, session_tab=t, session_name=session_name or self.created_in_session_name)
            self.tabs.append(tab)
            added_tabs.append(tab)
            if i == session.active_tab_idx:
                active_tab = tab

        # Handle focus_tab_spec if specified
        if session.focus_tab_spec is not None:
            spec = session.focus_tab_spec.strip()
            # Try to parse as a plain number (index)
            try:
                idx = int(spec)
                # Clamp to valid range
                idx = max(0, min(idx, len(added_tabs) - 1))
                active_tab = added_tabs[idx]
            except ValueError:
                # Not a plain number, treat as match expression
                from .fast_data_types import get_boss
                boss = get_boss()
                matched_tabs = list(boss.match_tabs(spec, self.tabs))
                if matched_tabs:
                    active_tab = matched_tabs[0]

        if active_tab is not None:
            idx = self.tabs.index(active_tab)
            self._set_active_tab(idx)
            # We need to update last_focused_at so that switch_to_session is
            # called after the session is created respects the result of
            # focus_tab.
            if (at := self.active_tab) and (w := at.active_window):
                w.last_focused_at = monotonic()
        active_tab = self.active_tab
        for tab in added_tabs:
            w = tab.active_window
            for q in tab:
                q.focus_changed(w is q and tab is active_tab)

    @property
    def active_tab_idx(self) -> int:
        return self._active_tab_idx

    @active_tab_idx.setter
    def active_tab_idx(self, val: int) -> None:
        new_active_tab_idx = max(0, min(val, len(self.tabs) - 1))
        if new_active_tab_idx == self._active_tab_idx:
            return
        try:
            old_active_tab: Tab | None = self.tabs[self._active_tab_idx]
        except Exception:
            old_active_tab = None
        else:
            assert old_active_tab is not None
            add_active_id_to_history(self.active_tab_history, old_active_tab.id)
        self._active_tab_idx = new_active_tab_idx
        try:
            new_active_tab: Tab | None = self.tabs[self._active_tab_idx]
        except Exception:
            new_active_tab = None
        if old_active_tab is not new_active_tab:
            if old_active_tab is not None:
                w = old_active_tab.active_window
                if w is not None:
                    w.focus_changed(False)
            if new_active_tab is not None:
                w = new_active_tab.active_window
                if w is not None:
                    w.focus_changed(True)

    def refresh_sprite_positions(self) -> None:
        if not self.tab_bar_hidden:
            self.tab_bar.screen.refresh_sprite_positions()

    @property
    def tab_bar_should_be_visible(self) -> bool:
        if self.tab_being_dropped is not None or self.window_drag_over_me:
            return True  # keep tab bar visible in the dest
        count = get_options().tab_bar_min_tabs
        if count < 1:
            return True
        tab_id, drag_started = get_tab_being_dragged()[:2]
        if drag_started and self.tab_for_id(tab_id) is not None:
            return True  # keep tab bar visible in the source
        for t in self.tabs_to_be_shown_in_tab_bar:
            count -= 1
            if count < 1:
                return True
        return count < 1

    def _set_active_tab(self, idx: int, store_in_history: bool = True) -> None:
        if store_in_history:
            self.active_tab_idx = idx
        else:
            self._active_tab_idx = idx
        set_active_tab(self.os_window_id, idx)

    def layout_tab_bar(self) -> None:
        # set tab_bar_should_be_visible so that tab_bar.layout() gets correct dimensions
        self.mark_tab_bar_dirty()
        self.tab_bar.layout()

    @property
    def any_window(self) -> Window | None:
        for t in self:
            for w in t:
                return w
        return None

    def mark_tab_bar_dirty(self) -> None:
        should_be_shown = not self.tab_bar_hidden and self.tab_bar_should_be_visible
        mark_tab_bar_dirty(self.os_window_id, should_be_shown)
        w = self.active_window or self.any_window
        if w is not None:
            data = {'tab_manager': self}
            boss = get_boss()
            for watcher in global_watchers().on_tab_bar_dirty:
                watcher(boss, w, data)

    def update_tab_bar_data(self) -> None:
        if self.tab_bar.update(self.tab_bar_data):
            for tab in self.tabs:
                tab.relayout_borders()

    def title_changed(self, tab: Tab) -> None:
        self.mark_tab_bar_dirty()
        if tab is self.active_tab:
            sync_os_window_title(self.os_window_id)

    def resize(self, only_tabs: bool = False) -> None:
        if not only_tabs:
            if not self.tab_bar_hidden:
                self.layout_tab_bar()
        for tab in self.tabs:
            tab.relayout()

    def set_active_tab_idx(self, idx: int) -> None:
        self._set_active_tab(idx)
        tab = self.active_tab
        if tab is not None:
            tab.relayout_borders()
        self.mark_tab_bar_dirty()

    @update_tab_bar_visibility
    def set_active_tab(self, tab: Tab, for_keep_focus: Tab | None = None) -> bool:
        try:
            idx = self.tabs.index(tab)
        except Exception:
            return False
        self.set_active_tab_idx(idx)
        h = self.active_tab_history
        if for_keep_focus and len(h) > 2 and h[-2] == for_keep_focus.id and h[-1] != for_keep_focus.id:
            h.pop()
            h.pop()
        return True

    @property
    def tabs_to_be_shown_in_tab_bar(self) -> Iterable[Tab]:
        f = get_options().tab_bar_filter
        if f:
            at = self.active_tab
            m = frozenset(get_boss().match_tabs(f, all_tabs=self))
            return (t for t in self if t is at or t in m)
        return self.tabs

    def next_tab(self, delta: int = 1) -> None:
        if (len(tabs := tuple(self.tabs_to_be_shown_in_tab_bar))) == len(self.tabs):
            if (num := len(tabs)) > 1:
                self.set_active_tab_idx((self.active_tab_idx + num + delta) % num)
        else:
            num = len(tabs)
            at = self.active_tab
            if at is not None:
                active_idx = tabs.index(at)
                new_active_tab = (active_idx + num + delta) % num
                self.set_active_tab(tabs[new_active_tab])

    def toggle_tab(self, match_expression: str) -> None:
        tabs = set(get_boss().match_tabs(match_expression, all_tabs=self))
        if not tabs:
            get_boss().show_error(_('No matching tab'), _('No tab found matching the expression: {}').format(match_expression))
            return
        if self.active_tab and self.active_tab in tabs:
            self.goto_tab(-1)
        else:
            for x in tabs:
                self.set_active_tab(x)
                break

    def tab_at_location(self, loc: str) -> Tab | None:
        tabs = tuple(self.tabs_to_be_shown_in_tab_bar)
        if loc == 'prev':
            if self.active_tab_history:
                return self.tab_for_id(self.active_tab_history[-1])
        elif loc in ('left', 'right'):
            delta = -1 if loc == 'left' else 1
            if (at := self.active_tab) is not None:
                try:
                    active_idx = tabs.index(at)
                except ValueError:
                    return None
                idx = (len(tabs) + active_idx + delta) % len(tabs)
                return tabs[idx]
        return None

    def goto_tab(self, tab_num: int) -> None:
        tabs = tuple(self.tabs_to_be_shown_in_tab_bar)
        if tab_num >= len(tabs):
            tab_num = max(0, len(tabs) - 1)
        if tab_num >= 0:
            self.set_active_tab(tabs[tab_num])
        elif self.active_tab_history:
            try:
                old_active_tab_id = self.active_tab_history[tab_num]
            except IndexError:
                old_active_tab_id = self.active_tab_history[0]
            if tab := self.tab_for_id(old_active_tab_id):
                self.set_active_tab(tab)

    def nth_active_tab(self, n: int = 0) -> Tab | None:
        if n <= 0:
            return self.active_tab
        tab_ids = tuple(reversed(self.active_tab_history))
        return self.tab_for_id(tab_ids[min(n - 1, len(tab_ids) - 1)]) if tab_ids else None

    def __iter__(self) -> Iterator[Tab]:
        return iter(self.tabs)

    def __len__(self) -> int:
        return len(self.tabs)

    def list_tabs(
        self, self_window: Window | None = None,
        tab_filter: Callable[[Tab], bool] | None = None,
        window_filter: Callable[[Window], bool] | None = None
    ) -> Generator[TabDict, None, None]:
        active_tab = self.active_tab
        for tab in self:
            if tab_filter is None or tab_filter(tab):
                windows = list(tab.list_windows(self_window, window_filter))
                if windows:
                    yield {
                        'id': tab.id,
                        'is_focused': tab is active_tab and tab.os_window_id == current_focused_os_window_id(),
                        'is_active': tab is active_tab,
                        'title': tab.name or tab.title,
                        'title_overridden': bool(tab.name),
                        'layout': str(tab.current_layout.name),
                        'layout_state': tab.current_layout.serialize(tab.windows),
                        'layout_opts': tab.current_layout.layout_opts.serialized(),
                        'enabled_layouts': tab.enabled_layouts,
                        'windows': windows,
                        'groups': tab.list_groups(),
                        'active_window_history': list(tab.windows.active_window_history),
                    }

    def serialize_state(self) -> dict[str, Any]:
        return {
            'version': 1,
            'id': self.os_window_id,
            'tabs': [tab.serialize_state() for tab in self],
            'active_tab_idx': self.active_tab_idx,
        }

    def serialize_state_as_session(
        self, session_path: str, matched_windows: frozenset[Window] | None, ser_opts: SaveAsSessionOptions,
        is_first: bool = False
    ) -> list[str]:
        ans = []
        active_tab_index = -1
        for i, tab in enumerate(self.tabs):
            if tab is self.active_tab:
                active_tab_index = i
            ans.extend(tab.serialize_state_as_session(session_path, matched_windows, ser_opts))
        if ans:
            prefix = [] if is_first else ['', '', 'new_os_window']
            if self.wm_class and self.wm_class != appname:
                prefix.append(f'os_window_class {self.wm_class}')
            if self.wm_name and self.wm_name != appname:
                prefix.append(f'os_window_name {self.wm_name}')
            ans = prefix + ans
            # Add focus_tab command to preserve the active tab
            if active_tab_index >= 0:
                ans.append('')
                ans.append(f'focus_tab {active_tab_index}')
        return ans

    @property
    def active_tab(self) -> Tab | None:
        return self.tabs[self.active_tab_idx] if 0 <= self.active_tab_idx < len(self.tabs) else None

    @property
    def active_window(self) -> Window | None:
        return t.active_window if (t := self.active_tab) else None

    def tab_for_id(self, tab_id: int) -> Tab | None:
        for t in self.tabs:
            if t.id == tab_id:
                return t
        return None

    def move_tab(self, delta: int = 1) -> None:
        tabs = tuple(self.tabs_to_be_shown_in_tab_bar)
        if len(tabs) > 1:
            if (at := self.active_tab) is None:
                return
            try:
                filtered_idx = tabs.index(at)
            except ValueError:
                return
            new_active_tab = tabs[(filtered_idx + len(tabs) + delta) % len(tabs)]
            idx = self.tabs.index(at)
            nidx = self.tabs.index(new_active_tab)
            step = 1 if idx < nidx else -1
            for i in range(idx, nidx, step):
                self.swap_tabs(i, i + step)
            self._set_active_tab(nidx)
            self.mark_tab_bar_dirty()

    @update_tab_bar_visibility
    def new_tab(
        self,
        special_window: SpecialWindowInstance | None = None,
        cwd_from: CwdRequest | None = None,
        as_neighbor: bool = False,
        empty_tab: bool = False,
        location: str = 'last',
    ) -> Tab:
        idx = len(self.tabs)
        tabs = tuple(self.tabs_to_be_shown_in_tab_bar)
        orig_active_tab_idx = 0
        with suppress(ValueError):
            orig_active_tab_idx = tabs.index(self.active_tab)
        session_name = ''
        if cwd_from is not None and (sw := cwd_from.window):
            session_name = sw.created_in_session_name
            if not session_name and (sw_tab := sw.tabref()):
                session_name = sw_tab.created_in_session_name
        t = Tab(self, no_initial_window=True, session_name=session_name) if empty_tab else Tab(
                self, special_window=special_window, cwd_from=cwd_from, session_name=session_name)
        if not empty_tab and session_name:
            for w in t:
                w.created_in_session_name = session_name
        self.tabs.append(t)
        tabs = tabs + (t,)
        if as_neighbor:
            location = 'after'
        if location == 'neighbor':
            location = 'after'
        if location == 'default':
            location = 'last'
        if len(tabs) > 1 and location != 'last':
            if location == 'first':
                desired_idx = 0
            else:
                desired_idx = orig_active_tab_idx + (0 if location == 'before' else 1)
            desired_idx = self.tabs.index(tabs[desired_idx])
            if idx != desired_idx:
                for i in range(idx, desired_idx, -1):
                    self.swap_tabs(i, i-1)
                idx = desired_idx
        self._set_active_tab(idx)
        self.mark_tab_bar_dirty()
        return t

    @update_tab_bar_visibility
    def remove(self, removed_tab: Tab) -> None:
        active_tab_before_removal = self.active_tab
        tabs = tuple(self.tabs_to_be_shown_in_tab_bar)
        try:
            idx_before_removal = tabs.index(active_tab_before_removal)
        except Exception:
            idx_before_removal = -1
        remove_tab(self.os_window_id, removed_tab.id)
        self.tabs.remove(removed_tab)
        while True:
            try:
                self.active_tab_history.remove(removed_tab.id)
            except ValueError:
                break

        def remove_from_end_of_active_history(tab: Tab) -> None:
            while self.active_tab_history and self.active_tab_history[-1] == tab.id:
                self.active_tab_history.pop()

        def previous_active_tab() -> Tab | None:
            while self.active_tab_history:
                tab_id = self.active_tab_history.pop()
                if tab_id != removed_tab.id:
                    if (ans := self.tab_for_id(tab_id)) is not None:
                        return ans
            return self.tabs[0] if self.tabs else None

        if active_tab_before_removal is removed_tab:
            if len(tabs) == 0 or (len(tabs) == 1 and removed_tab is tabs[0]):
                tab = previous_active_tab()
                if tab is None:
                    self._active_tab_idx = 0
                else:
                    self._set_active_tab(self.tabs.index(tab), store_in_history=False)
            else:
                next_active_tab: Tab | None = None
                match get_options().tab_switch_strategy:
                    case 'previous':
                        while self.active_tab_history and next_active_tab is None:
                            tab_id = self.active_tab_history.pop()
                            next_active_tab = self.tab_for_id(tab_id)
                            if next_active_tab not in tabs:
                                next_active_tab = None
                    case 'left':
                        tab_id = tabs.index(active_tab_before_removal)
                        if tab_id > 0:
                            next_active_tab = tabs[tab_id - 1]
                            remove_from_end_of_active_history(next_active_tab)
                    case 'right':
                        tab_id = tabs.index(active_tab_before_removal)
                        if tab_id < len(tabs) - 1:
                            next_active_tab = tabs[tab_id + 1]
                            remove_from_end_of_active_history(next_active_tab)
                    case 'last':
                        next_active_tab = tabs[-1]
                        remove_from_end_of_active_history(next_active_tab)
                if next_active_tab not in self.tabs:
                    if idx_before_removal > -1 and (left_tabs := tuple(t for t in tabs if t is not removed_tab)):
                        next_active_tab = left_tabs[max(0, min(idx_before_removal, len(left_tabs) - 1))]
                    else:
                        next_active_tab = self.tabs[max(0, min(self.active_tab_idx, len(self.tabs) - 1))]
                self._set_active_tab(self.tabs.index(next_active_tab), store_in_history=False)
        else:
            if len(self.tabs):
                if active_tab_before_removal is None:
                    self._set_active_tab(0, store_in_history=False)
                else:
                    self._set_active_tab(self.tabs.index(active_tab_before_removal), store_in_history=False)
            else:
                self._active_tab_idx = 0
        self.mark_tab_bar_dirty()
        removed_tab.destroy()

    @property
    def tab_bar_data(self) -> Sequence[TabBarData]:
        at = self.active_tab
        tab_being_dragged_from_here = False
        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:
                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 + (TabBarData(
                    title='+', is_active=self.window_drag_target_tab_id == -1, os_window_id=self.os_window_id),)
            return tabs
        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 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))

    @update_tab_bar_visibility
    def on_tab_drop_move(self, tab_id: int = 0, is_dest: bool = False, x: int = 0, y: int = 0) -> None:
        if not is_dest:
            if self.tab_being_dropped:
                self.tab_being_dropped = None
                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 if t.tab_id >= 0]
        else:
            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)
            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, 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
        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_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, y)):
            with suppress(Exception):
                idx_under_mouse = old_tab_ids.index(tab_id_under_mouse)
        if idx_under_mouse < 0:
            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_towards_start = old_idx_under_mouse > idx_under_mouse
        new_tab_ids = old_tab_ids
        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_coordinate=coordinate, tab_ids=new_tab_ids)
        if force_update or self.tab_being_dropped.tab_ids != old_tab_ids:
            self.layout_tab_bar()

    @update_tab_bar_visibility
    def on_tab_drop(self, x: int, y: int, bypass_move: bool = False) -> None:
        if (td := self.tab_being_dropped) is None:
            return
        if (tab := get_boss().tab_for_id(td.data.tab_id)) is None:
            self.tab_being_dropped = None
            set_tab_being_dragged()
            self.layout_tab_bar()
            return
        if not bypass_move:
            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
        set_tab_being_dragged()
        if tab.os_window_id != self.os_window_id:
            if (t := get_boss()._move_tab_to(tab, self.os_window_id)) is not None:
                n = list(td.tab_ids)
                idx = n.index(td.data.tab_id)
                n[idx] = t.id
                td = td._replace(tab_ids=n)
        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 == 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 = replace_c0_codes_except_nl_space_tab(title.encode()).decode()
                title = re.sub(r'\n', ' ', title)
                opts = get_options()
                if td.is_active:
                    fg = color_as_int(opts.active_tab_foreground)
                    bg = color_as_int(opts.active_tab_background)
                else:
                    fg = color_as_int(opts.inactive_tab_foreground)
                    bg = color_as_int(opts.inactive_tab_background)
                title_pixels, width = draw_single_line_of_text(self.os_window_id, title, 0xff000000 | fg, 0xff000000 | bg, width)
                title_height = len(title_pixels) // (width * 4)
                thumbnails = ((title_pixels, width, title_height), (title_pixels + pixels, width, title_height + height))
                drag_data = {
                    f'application/net.kovidgoyal.kitty-tab-{os.getpid()}': str(tab.id).encode(),
                }
                try:
                    start_drag_with_data(self.os_window_id, drag_data, thumbnails)
                except OSError as e:
                    log_error(f'Failed to start tab drag: {e}')
                    set_tab_being_dragged()
                    self.mark_tab_bar_dirty()  # re-render the tab bar in case it was drawn without the dragged tab
                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
            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:
                threshold = get_options().drag_threshold
                if threshold and math.sqrt((x-start_x)**2 + (y-start_y)**2) > threshold:
                    set_tab_being_dragged(dragged_tab_id, True, start_x, start_y)
                    request_callback_with_thumbnail("start_tab_drag", self.os_window_id)
                    self.recent_tab_bar_mouse_events.clear()
            return

        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)
        drag_started = get_tab_being_dragged()[1]
        is_left_release = button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE
        if tab_id_at_pointer < 0:  # synthetic tab (e.g. "+" new-tab button)
            if is_left_release and not drag_started:
                set_tab_being_dragged()  # clear potential drag from a press on a tab
            if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 1:
                self.new_tab()
                self.recent_tab_bar_mouse_events.clear()
            return
        if drag_started:
            return
        tab = self.tab_for_id(tab_id_at_pointer)
        if tab is None:
            if is_left_release:
                set_tab_being_dragged()  # clear potential drag from a press on a tab
            if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT) == 2:
                self.new_tab()
                self.recent_tab_bar_mouse_events.clear()
            return
        if button == GLFW_MOUSE_BUTTON_LEFT:
            if action == GLFW_PRESS:
                set_tab_being_dragged(tab.id, False, x, y)
                return
            match self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_LEFT):
                case 2:
                    self.set_active_tab(tab)
                    get_boss().set_tab_title()
                    set_tab_being_dragged()
                    self.recent_tab_bar_mouse_events.clear()
                case 1:
                    if self.active_tab is not tab:
                        self.set_active_tab(tab)
                        self.recent_tab_bar_mouse_events.clear()
            set_tab_being_dragged()
            return
        if button == GLFW_MOUSE_BUTTON_MIDDLE:
            if self.recent_tab_bar_mouse_events.click_count(GLFW_MOUSE_BUTTON_MIDDLE) == 1:
                get_boss().close_tab(tab)
                self.recent_tab_bar_mouse_events.clear()
            return

    def handle_window_title_bar_mouse(self, window_id: int, x: float, y: float, button: int, modifiers: int, action: int) -> None:
        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().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)
                    request_callback_with_thumbnail("start_window_drag", self.os_window_id, dragged_window_id)
                    self.recent_title_bar_mouse_events.clear()
            return
        self.recent_title_bar_mouse_events.add(button, modifiers, action, x, y, window_id)
        if button != GLFW_MOUSE_BUTTON_LEFT:
            return
        if action == GLFW_PRESS:
            if (w := boss.window_id_map.get(window_id)) is not None:
                boss.set_active_window(w, switch_os_window_if_needed=True)
            threshold = get_options().drag_threshold
            if threshold:
                set_window_being_dragged(window_id, False, x, y)
            return

        dragged_window_id, drag_started = get_window_being_dragged()[:2]
        set_window_being_dragged()
        if not drag_started and self.recent_title_bar_mouse_events.click_count() == 2:
            self.recent_title_bar_mouse_events.clear()
            if (w := boss.window_id_map.get(window_id)) is not None:
                w.set_window_title()

    def start_window_drag(self, pixels: bytes, width: int, height: int) -> None:
        window_id = get_window_being_dragged()[0]
        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)
        title_pixels, width = draw_single_line_of_text(self.os_window_id, title, 0xff000000 | fg, 0xff000000 | bg, width)
        title_height = len(title_pixels) // (width * 4)
        thumbnails = ((title_pixels + pixels, width, title_height + 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)
            if self.window_drag_over_me:
                self.window_drag_over_me = False
                if not self.tab_bar_hidden:
                    self.layout_tab_bar()
                    self.resize(only_tabs=True)
            return
        if not self.window_drag_over_me:
            self.window_drag_over_me = True
            if not self.tab_bar_hidden:
                self.layout_tab_bar()
                self.resize(only_tabs=True)
        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, y))
            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:
                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}
                match active_tab.current_layout.drag_overlay_mode:
                    case DragOverlayMode.axis_y:
                        direction = 'bottom' if dy > 0 else 'top'
                    case DragOverlayMode.axis_x:
                        direction = 'right' if dx > 0 else 'left'
                    case DragOverlayMode.free:
                        direction = ('right' if dx > 0 else 'left') if abs(dx) >= abs(dy) else ('bottom' if dy > 0 else 'top')
                    case DragOverlayMode.full:
                        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()
        # A window is being dropped on this tab manager, so the tab bar must have been
        # visible to the user (showing the "+" button). Ensure it stays visible for the
        # drop position calculation. This is needed because on_drag_source_finished can
        # run before the on_drop data transfer completes, clearing window_drag_over_me
        # and hiding the tab bar for the single-tab case before we get here.
        self.window_drag_over_me = True
        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, 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')
            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 (src_tab := w.tabref()) is dest_window.tabref() and src_tab is not None:
                # Same tab: swap positions
                src_tab.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
            match active_tab.current_layout.drag_overlay_mode:
                case DragOverlayMode.axis_y:
                    direction: Literal['left', 'right', 'top', 'bottom'] = 'bottom' if dy > 0 else 'top'
                case DragOverlayMode.axis_x:
                    direction = 'right' if dx > 0 else 'left'
                case DragOverlayMode.free | DragOverlayMode.full:
                    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
        self.has_indeterminate_progress = False
        for tab in self:
            if tab.num_of_windows_with_progress:
                self.total_progress += tab.total_progress
                self.num_of_windows_with_progress += tab.num_of_windows_with_progress
            if tab.has_indeterminate_progress:
                self.has_indeterminate_progress = True
        get_boss().update_progress_in_dock()

    @property
    def tab_bar_rects(self) -> tuple[Border, ...]:
        return self.tab_bar.blank_rects if self.tab_bar_should_be_visible else ()

    def destroy(self) -> None:
        for t in self:
            t.destroy()
        self.tab_bar.destroy()
        del self.tab_bar
        del self.tabs

    def apply_options(self) -> None:
        at = self.active_tab
        for tab in self:
            tab.apply_options(at is tab)
        self.tab_bar_hidden = get_options().tab_bar_style == 'hidden'
        self.tab_bar.apply_options()
        self.update_tab_bar_data()
        self.layout_tab_bar()
# }}}
