mirror of
https://github.com/kovidgoyal/kitty.git
synced 2025-12-13 20:36:22 +01:00
1574 lines
62 KiB
Python
1574 lines
62 KiB
Python
#!/usr/bin/env python
|
|
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
|
|
|
|
import json
|
|
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 gettext import gettext as _
|
|
from typing import (
|
|
Any,
|
|
Deque,
|
|
NamedTuple,
|
|
Optional,
|
|
)
|
|
|
|
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,
|
|
get_boss,
|
|
get_click_interval,
|
|
get_options,
|
|
last_focused_os_window_id,
|
|
mark_tab_bar_dirty,
|
|
monotonic,
|
|
next_window_id,
|
|
remove_tab,
|
|
remove_window,
|
|
ring_bell,
|
|
set_active_tab,
|
|
set_active_window,
|
|
set_redirect_keys_to_overlay,
|
|
swap_tabs,
|
|
sync_os_window_title,
|
|
)
|
|
from .layout.base import Layout
|
|
from .layout.interface import create_layout_object_for, evict_cached_layouts
|
|
from .progress import ProgressState
|
|
from .tab_bar import TabBar, TabBarData
|
|
from .types import ac
|
|
from .typing_compat import EdgeLiteral, SessionTab, SessionType, TypedDict
|
|
from .utils import cmdline_for_hold, 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
|
|
|
|
|
|
class TabMouseEvent(NamedTuple):
|
|
button: int
|
|
modifiers: int
|
|
action: int
|
|
at: float
|
|
tab_id: int = 0
|
|
|
|
|
|
class TabDict(TypedDict):
|
|
id: int
|
|
is_focused: bool
|
|
is_active: bool
|
|
title: str
|
|
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
|
|
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)
|
|
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 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 title_changed(self, window: Window) -> None:
|
|
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.current_layout(self.windows)
|
|
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
|
|
)
|
|
|
|
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'
|
|
|
|
@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()
|
|
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:
|
|
self.windows.set_active_window_group_for(x, for_keep_focus=for_keep_focus)
|
|
|
|
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')
|
|
def next_window(self) -> None:
|
|
self._next_window()
|
|
|
|
@ac('win', 'Focus the previous window in the current tab')
|
|
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:
|
|
if field == 'title':
|
|
return re.search(query, self.effective_title) is not None
|
|
if field == 'id':
|
|
return query == str(self.id)
|
|
if field in ('window_id', 'window_title'):
|
|
field = field.partition('_')[-1]
|
|
for w in self:
|
|
if w.matches_query(field, query):
|
|
return True
|
|
return False
|
|
if field == '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
|
|
if field == 'recent':
|
|
if active_tab_manager and len(active_tab_manager.tabs):
|
|
return self is active_tab_manager.nth_active_tab(int(query))
|
|
return False
|
|
if field == 'state':
|
|
if query == 'active':
|
|
tm = self.tab_manager_ref()
|
|
return tm is not None and self is tm.active_tab
|
|
if query == '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()
|
|
if query == 'needs_attention':
|
|
for w in self:
|
|
if w.needs_attention:
|
|
return True
|
|
if query == 'parent_active':
|
|
return active_tab_manager is not None and self.tab_manager_ref() is active_tab_manager
|
|
if query == '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()
|
|
if query == 'focused_os_window':
|
|
return self.os_window_id == last_focused_os_window_id()
|
|
return False
|
|
if field == '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)
|
|
# }}}
|
|
|
|
|
|
class TabManager: # {{{
|
|
|
|
confirm_close_window_id: int = 0
|
|
num_of_windows_with_progress: int = 0
|
|
total_progress: int = 0
|
|
has_indeterminate_progress: 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_mouse_events: Deque[TabMouseEvent] = deque()
|
|
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)
|
|
|
|
def add_tabs_from_session(self, session: SessionType, session_name: str = '') -> None:
|
|
active_tab = self.active_tab
|
|
for i, t in enumerate(session.tabs):
|
|
tab = Tab(self, session_tab=t, session_name=session_name or self.created_in_session_name)
|
|
self._add_tab(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(self.tabs) - 1))
|
|
active_tab = self.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)
|
|
|
|
@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:
|
|
count = get_options().tab_bar_min_tabs
|
|
if count < 1:
|
|
return True
|
|
for t in self.tabs_to_be_shown_in_tab_bar:
|
|
count -= 1
|
|
if count < 1:
|
|
return True
|
|
return count < 1
|
|
|
|
def _add_tab(self, tab: Tab) -> None:
|
|
visible_before = self.tab_bar_should_be_visible
|
|
self.tabs.append(tab)
|
|
if not visible_before and self.tab_bar_should_be_visible:
|
|
self.tabbar_visibility_changed()
|
|
|
|
def _remove_tab(self, tab: Tab) -> None:
|
|
visible_before = self.tab_bar_should_be_visible
|
|
remove_tab(self.os_window_id, tab.id)
|
|
self.tabs.remove(tab)
|
|
if visible_before and not self.tab_bar_should_be_visible:
|
|
self.tabbar_visibility_changed()
|
|
|
|
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()
|
|
|
|
def tabbar_visibility_changed(self) -> None:
|
|
if not self.tab_bar_hidden:
|
|
self.layout_tab_bar()
|
|
self.resize(only_tabs=True)
|
|
|
|
@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 = self.tab_bar_should_be_visible and not self.tab_bar_hidden
|
|
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()
|
|
|
|
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 = set(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
|
|
idx = (len(tabs) + self.active_tab_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,
|
|
'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:
|
|
idx = self.active_tab_idx
|
|
new_active_tab = tabs[(idx + len(tabs) + delta) % len(tabs)]
|
|
nidx = self.tabs.index(new_active_tab)
|
|
step = 1 if idx < nidx else -1
|
|
for i in range(idx, nidx, step):
|
|
self.tabs[i], self.tabs[i + step] = self.tabs[i + step], self.tabs[i]
|
|
swap_tabs(self.os_window_id, i, i + step)
|
|
self._set_active_tab(nidx)
|
|
self.mark_tab_bar_dirty()
|
|
|
|
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
|
|
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._add_tab(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.tabs[i], self.tabs[i-1] = self.tabs[i-1], self.tabs[i]
|
|
swap_tabs(self.os_window_id, i, i-1)
|
|
idx = desired_idx
|
|
self._set_active_tab(idx)
|
|
self.mark_tab_bar_dirty()
|
|
return t
|
|
|
|
def remove(self, removed_tab: Tab) -> None:
|
|
active_tab_before_removal = self.active_tab
|
|
tabs = tuple(self.tabs_to_be_shown_in_tab_bar)
|
|
self._remove_tab(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:
|
|
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) -> list[TabBarData]:
|
|
at = self.active_tab
|
|
ans = []
|
|
for t in self.tabs_to_be_shown_in_tab_bar:
|
|
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
|
|
ans.append(TabBarData(
|
|
title, t is at, needs_attention, t.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,
|
|
))
|
|
return ans
|
|
|
|
def handle_click_on_tab(self, x: int, button: int, modifiers: int, action: int) -> None:
|
|
tab = self.tab_for_id(self.tab_bar.tab_id_at(x))
|
|
now = monotonic()
|
|
if tab is None:
|
|
if button == GLFW_MOUSE_BUTTON_LEFT and action == GLFW_RELEASE and len(self.recent_mouse_events) > 2:
|
|
ci = get_click_interval()
|
|
prev, prev2 = self.recent_mouse_events[-1], self.recent_mouse_events[-2]
|
|
if (
|
|
prev.button == button and prev2.button == button and
|
|
prev.action == GLFW_PRESS and prev2.action == GLFW_RELEASE and
|
|
prev.tab_id == 0 and prev2.tab_id == 0 and
|
|
now - prev.at <= ci and now - prev2.at <= 2 * ci
|
|
): # double click
|
|
self.new_tab()
|
|
self.recent_mouse_events.clear()
|
|
return
|
|
else:
|
|
if action == GLFW_PRESS and button == GLFW_MOUSE_BUTTON_LEFT:
|
|
self.set_active_tab(tab)
|
|
elif button == GLFW_MOUSE_BUTTON_MIDDLE and action == GLFW_RELEASE and self.recent_mouse_events:
|
|
p = self.recent_mouse_events[-1]
|
|
if p.button == button and p.action == GLFW_PRESS and p.tab_id == tab.id:
|
|
get_boss().close_tab(tab)
|
|
self.recent_mouse_events.append(TabMouseEvent(button, modifiers, action, now, tab.id if tab else 0))
|
|
if len(self.recent_mouse_events) > 5:
|
|
self.recent_mouse_events.popleft()
|
|
|
|
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()
|
|
# }}}
|