#!/usr/bin/env python # License: GPL v3 Copyright: 2016, Kovid Goyal 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 operator import attrgetter 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) -> 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: 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 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) 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: 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) @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: if self.tab_bar_should_be_visible and not self.tab_bar_hidden: mark_tab_bar_dirty(self.os_window_id) 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.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: 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 def filtered_tabs(self, filter_expression: str) -> Iterator[Tab]: yield from get_boss().match_tabs(filter_expression, all_tabs=self) @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(self.filtered_tabs(f)) 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.mark_tab_bar_dirty() self.tab_bar.layout() # }}}