mirror of
https://github.com/kovidgoyal/kitty.git
synced 2026-07-03 11:12:30 +08:00
2127 lines
89 KiB
Python
2127 lines
89 KiB
Python
#!/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:
|
|
self.tab_bar.update(self.tab_bar_data)
|
|
|
|
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()
|
|
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()
|
|
# }}}
|