Files
kitty/kitty/tabs.py
Kovid Goyal 6b8e5ea225 Rename kitty-tool to kitten
It's easier to type, and cuter.

Also, most, if not all of the TUI parts of
kitty's kittens will eventually be re-written into kitten.

The only downside I can see is that we cant tab complete kitty
anymore, but hopefully there will be less reason to run kitty
from the shell as command line tools migrate to kitten.

Meowrrrr!!!
2023-01-14 15:44:52 +05:30

1196 lines
44 KiB
Python

#!/usr/bin/env python3
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
import os
import re
import stat
import weakref
from collections import deque
from contextlib import suppress
from operator import attrgetter
from time import monotonic
from typing import (
Any,
Deque,
Dict,
Generator,
Iterable,
Iterator,
List,
NamedTuple,
Optional,
Sequence,
Set,
Tuple,
Union,
)
from .borders import Border, Borders
from .child import Child
from .cli_stub import CLIOptions
from .constants import appname, kitten_exe
from .fast_data_types import (
GLFW_MOUSE_BUTTON_LEFT,
GLFW_MOUSE_BUTTON_MIDDLE,
GLFW_PRESS,
GLFW_RELEASE,
add_tab,
attach_window,
current_focused_os_window_id,
detach_window,
get_boss,
get_click_interval,
get_options,
last_focused_os_window_id,
mark_tab_bar_dirty,
next_window_id,
remove_tab,
remove_window,
ring_bell,
set_active_tab,
set_active_window,
swap_tabs,
sync_os_window_title,
)
from .layout.base import Layout
from .layout.interface import create_layout_object_for, evict_cached_layouts
from .tab_bar import TabBar, TabBarData
from .types import ac
from .typing import EdgeLiteral, SessionTab, SessionType, TypedDict
from .utils import log_error, platform_window_id, resolved_shell
from .window import CwdRequest, Watchers, Window, WindowDict
from .window_list import WindowList
class TabMouseEvent(NamedTuple):
button: int
modifiers: int
action: int
at: float
tab_idx: Optional[int]
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]
active_window_history: List[int]
class SpecialWindowInstance(NamedTuple):
cmd: Optional[List[str]]
stdin: Optional[bytes]
override_title: Optional[str]
cwd_from: Optional[CwdRequest]
cwd: Optional[str]
overlay_for: Optional[int]
env: Optional[Dict[str, str]]
watchers: Optional[Watchers]
overlay_behind: bool
def SpecialWindow(
cmd: Optional[List[str]],
stdin: Optional[bytes] = None,
override_title: Optional[str] = None,
cwd_from: Optional[CwdRequest] = None,
cwd: Optional[str] = None,
overlay_for: Optional[int] = None,
env: Optional[Dict[str, str]] = None,
watchers: Optional[Watchers] = None,
overlay_behind: bool = False
) -> SpecialWindowInstance:
return SpecialWindowInstance(cmd, stdin, override_title, cwd_from, cwd, overlay_for, env, watchers, overlay_behind)
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: Optional[int] = None
active_bg: Optional[int] = None
inactive_fg: Optional[int] = None
inactive_bg: Optional[int] = None
def __init__(
self,
tab_manager: 'TabManager',
session_tab: Optional['SessionTab'] = None,
special_window: Optional[SpecialWindowInstance] = None,
cwd_from: Optional[CwdRequest] = None,
no_initial_window: bool = False
):
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(self)
self._last_used_layout: Optional[str] = None
self._current_layout_name: Optional[str] = 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 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) -> None:
for window in self:
window.apply_options()
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)
if other_tab._current_layout_name:
self._set_current_layout(other_tab._current_layout_name)
self._last_used_layout = other_tab._last_used_layout
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:
for window in session_tab.windows:
spec = window.launch_spec
if isinstance(spec, SpecialWindowInstance):
self.new_special_window(spec)
else:
from .launch import launch
launch(get_boss(), spec.opts, spec.args, target_tab=self, force_target_tab=True)
if window.resize_spec is not None:
self.resize_window(*window.resize_spec)
self.windows.set_active_window_group_for(self.windows.all_windows[session_tab.active_window_idx])
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_opts': self.current_layout.layout_opts,
'layout_state': self.current_layout.layout_state,
'enabled_layouts': self.enabled_layouts,
'name': self.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) -> Optional[Window]:
return self.windows.active_window
@property
def active_window_for_cwd(self) -> Optional[Window]:
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
@property
def number_of_windows_with_running_programs(self) -> int:
ans = 0
for window in self:
if window.has_running_program:
ans += 1
return ans
def get_cwd_of_active_window(self, oldest: bool = False) -> Optional[str]:
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) -> Optional[str]:
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.windows:
self.current_layout(self.windows)
self.relayout_borders()
def relayout_borders(self) -> None:
tm = self.tab_manager_ref()
if tm is not None:
w = self.active_window
ly = self.current_layout
self.borders(
all_windows=self.windows,
current_layout=ly, tab_bar_rects=tm.tab_bar_rects,
draw_window_borders=(ly.needs_window_borders and self.windows.num_visble_groups > 1) or ly.must_draw_borders
)
if w is not None:
w.change_titlebar_color()
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')
def next_layout(self) -> 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
nl = self.enabled_layouts[(idx + 1) % 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
For example::
map f1 goto_layout tall
''')
def goto_layout(self, layout_name: str, raise_exception: bool = False) -> None:
layout_name = layout_name.lower()
if layout_name not in self.enabled_layouts:
if raise_exception:
raise ValueError(layout_name)
log_error(f'Unknown or disabled layout: {layout_name}')
return
self._set_current_layout(layout_name)
self.relayout()
@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. 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) -> Optional[str]:
increment_as_percent = self.current_layout.bias_increment_for_cell(self.windows, window_id, 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()
@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()
return
self.relayout()
def launch_child(
self,
use_shell: bool = False,
cmd: Optional[List[str]] = None,
stdin: Optional[bytes] = None,
cwd_from: Optional[CwdRequest] = None,
cwd: Optional[str] = None,
env: Optional[Dict[str, str]] = None,
is_clone_launch: str = '',
add_listen_on_env_var: bool = True,
) -> 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):
from .utils import which
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:
import shlex
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] = [kitten_exe(), '__hold_till_enter__']
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)
ans.fork()
return ans
def _add_window(self, window: Window, location: Optional[str] = None, overlay_for: Optional[int] = None, overlay_behind: bool = False) -> None:
self.current_layout.add_window(self.windows, window, location, overlay_for, put_overlay_behind=overlay_behind)
self.mark_tab_bar_dirty()
self.relayout()
def new_window(
self,
use_shell: bool = True,
cmd: Optional[List[str]] = None,
stdin: Optional[bytes] = None,
override_title: Optional[str] = None,
cwd_from: Optional[CwdRequest] = None,
cwd: Optional[str] = None,
overlay_for: Optional[int] = None,
env: Optional[Dict[str, str]] = None,
location: Optional[str] = None,
copy_colors_from: Optional[Window] = None,
allow_remote_control: bool = False,
marker: Optional[str] = None,
watchers: Optional[Watchers] = None,
overlay_behind: bool = False,
is_clone_launch: str = '',
remote_control_passwords: Optional[Dict[str, Sequence[str]]] = None,
) -> Window:
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
)
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
)
# 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)
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: Optional[str] = None,
copy_colors_from: Optional[Window] = None,
allow_remote_control: bool = False,
) -> 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
)
@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) -> Optional[Window]:
prev: Optional[Window] = 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) -> 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)
self.mark_tab_bar_dirty()
self.relayout()
active_window = self.active_window
if active_window:
self.title_changed(active_window)
def detach_window(self, window: Window) -> Tuple[Window, ...]:
windows = list(self.windows.windows_in_group_of(window))
windows.sort(key=attrgetter('id')) # since ids increase in order of creation
for w in reversed(windows):
self.remove_window(w, destroy=False)
return tuple(windows)
def attach_window(self, window: Window) -> None:
window.change_tab(self)
attach_window(self.os_window_id, self.id, window.id)
self._add_window(window)
def set_active_window(self, x: Union[Window, int], for_keep_focus: Optional[Window] = None) -> None:
self.windows.set_active_window_group_for(x, for_keep_focus=for_keep_focus)
def get_nth_window(self, n: int) -> Optional[Window]:
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]) -> Optional[int]:
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) -> Optional[int]:
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: Union[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: Optional[Tab], window: Optional[Window]) -> 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: Optional[Tab], window: Optional[Window]) -> 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: Optional[Window] = None) -> Generator[WindowDict, None, None]:
active_window = self.active_window
for w in self:
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)
def matches_query(self, field: str, query: str, active_tab_manager: Optional['TabManager'] = None) -> 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()
return False
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
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: # {{{
def __init__(self, os_window_id: int, args: CLIOptions, wm_class: str, wm_name: str, startup_session: Optional[SessionType] = None):
self.os_window_id = os_window_id
self.wm_class = wm_class
self.recent_mouse_events: Deque[TabMouseEvent] = deque()
self.wm_name = wm_name
self.last_active_tab_id = None
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:
for t in startup_session.tabs:
self._add_tab(Tab(self, session_tab=t))
self._set_active_tab(max(0, min(startup_session.active_tab_idx, len(self.tabs) - 1)))
@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: Optional[Tab] = 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: Optional[Tab] = 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:
return len(self.tabs) >= get_options().tab_bar_min_tabs
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 tabbar_visibility_changed(self) -> None:
if not self.tab_bar_hidden:
self.tab_bar.layout()
self.resize(only_tabs=True)
def mark_tab_bar_dirty(self) -> None:
if self.tab_bar_should_be_visible and not self.tab_bar_hidden:
mark_tab_bar_dirty(self.os_window_id)
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.tab_bar.layout()
self.mark_tab_bar_dirty()
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: Optional[Tab] = 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
def next_tab(self, delta: int = 1) -> None:
if len(self.tabs) > 1:
self.set_active_tab_idx((self.active_tab_idx + len(self.tabs) + delta) % len(self.tabs))
def tab_at_location(self, loc: str) -> Optional[Tab]:
if loc == 'prev':
if self.active_tab_history:
old_active_tab_id = self.active_tab_history[-1]
for idx, tab in enumerate(self.tabs):
if tab.id == old_active_tab_id:
return tab
elif loc in ('left', 'right'):
delta = -1 if loc == 'left' else 1
idx = (len(self.tabs) + self.active_tab_idx + delta) % len(self.tabs)
return self.tabs[idx]
return None
def goto_tab(self, tab_num: int) -> None:
if tab_num >= len(self.tabs):
tab_num = max(0, len(self.tabs) - 1)
if tab_num >= 0:
self.set_active_tab_idx(tab_num)
else:
try:
old_active_tab_id = self.active_tab_history[tab_num]
except IndexError:
return
for idx, tab in enumerate(self.tabs):
if tab.id == old_active_tab_id:
self.set_active_tab_idx(idx)
break
def nth_active_tab(self, n: int = 0) -> Optional[Tab]:
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: Optional[Window] = None) -> Generator[TabDict, None, None]:
active_tab = self.active_tab
for tab in self:
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.layout_state(),
'layout_opts': tab.current_layout.layout_opts.serialized(),
'enabled_layouts': tab.enabled_layouts,
'windows': list(tab.list_windows(self_window)),
'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,
}
@property
def active_tab(self) -> Optional[Tab]:
try:
return self.tabs[self.active_tab_idx] if self.tabs else None
except Exception:
return None
@property
def active_window(self) -> Optional[Window]:
t = self.active_tab
if t is not None:
return t.active_window
return None
@property
def number_of_windows_with_running_programs(self) -> int:
count = 0
for tab in self:
count += tab.number_of_windows_with_running_programs
return count
@property
def number_of_windows(self) -> int:
count = 0
for tab in self:
count += len(tab)
return count
def tab_for_id(self, tab_id: int) -> Optional[Tab]:
for t in self.tabs:
if t.id == tab_id:
return t
return None
def move_tab(self, delta: int = 1) -> None:
if len(self.tabs) > 1:
idx = self.active_tab_idx
nidx = (idx + len(self.tabs) + delta) % len(self.tabs)
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: Optional[SpecialWindowInstance] = None,
cwd_from: Optional[CwdRequest] = None,
as_neighbor: bool = False,
empty_tab: bool = False,
location: str = 'last'
) -> Tab:
idx = len(self.tabs)
orig_active_tab_idx = self.active_tab_idx
self._add_tab(Tab(self, no_initial_window=True) if empty_tab else Tab(self, special_window=special_window, cwd_from=cwd_from))
if as_neighbor:
location = 'after'
if location == 'neighbor':
location = 'after'
if location == 'default':
location = 'last'
if len(self.tabs) > 1 and location != 'last':
if location == 'first':
desired_idx = 0
else:
desired_idx = orig_active_tab_idx + (0 if location == 'before' else 1)
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 self.tabs[idx]
def remove(self, tab: Tab) -> None:
active_tab_before_removal = self.active_tab
active_tab_needs_to_change = active_tab_before_removal is tab
self._remove_tab(tab)
while True:
try:
self.active_tab_history.remove(tab.id)
except ValueError:
break
def idx_for_id(tab_id: int) -> int:
for idx, qtab in enumerate(self.tabs):
if qtab.id == tab_id:
return idx
return -1
if active_tab_needs_to_change:
next_active_tab = -1
tss = get_options().tab_switch_strategy
if tss == 'previous':
while self.active_tab_history and next_active_tab < 0:
tab_id = self.active_tab_history.pop()
next_active_tab = idx_for_id(tab_id)
elif tss == 'left':
next_active_tab = max(0, self.active_tab_idx - 1)
elif tss == 'right':
next_active_tab = min(self.active_tab_idx, len(self.tabs) - 1)
elif tss == 'last':
next_active_tab = len(self.tabs) - 1
if next_active_tab < 0:
next_active_tab = max(0, min(self.active_tab_idx, len(self.tabs) - 1))
self._set_active_tab(next_active_tab, store_in_history=False)
elif active_tab_before_removal is not None:
try:
idx = self.tabs.index(active_tab_before_removal)
except Exception:
pass
else:
self._active_tab_idx = idx
self.mark_tab_bar_dirty()
tab.destroy()
@property
def tab_bar_data(self) -> List[TabBarData]:
at = self.active_tab
ans = []
for t in self.tabs:
title = (t.name or t.title or appname).strip()
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
))
return ans
def handle_click_on_tab(self, x: int, button: int, modifiers: int, action: int) -> None:
i = self.tab_bar.tab_at(x)
now = monotonic()
if i 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_idx is None and prev2.tab_idx is None 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_idx(i)
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_idx == i:
tab = self.tabs[i]
get_boss().close_tab(tab)
self.recent_mouse_events.append(TabMouseEvent(button, modifiers, action, now, i))
if len(self.recent_mouse_events) > 5:
self.recent_mouse_events.popleft()
@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:
for tab in self:
tab.apply_options()
self.tab_bar_hidden = get_options().tab_bar_style == 'hidden'
self.tab_bar.apply_options()
self.update_tab_bar_data()
self.mark_tab_bar_dirty()
self.tab_bar.layout()
# }}}