#!/usr/bin/env python3 # License: GPL v3 Copyright: 2016, Kovid Goyal 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, kitty_exe from .fast_data_types import ( GLFW_MOUSE_BUTTON_LEFT, GLFW_MOUSE_BUTTON_MIDDLE, GLFW_PRESS, GLFW_RELEASE, add_tab, attach_window, current_os_window, detach_window, get_boss, get_click_interval, get_options, 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 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 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: 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] = [kitty_exe(), '+hold'] 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 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, active_window: Optional[Window], self_window: Optional[Window] = None) -> Generator[WindowDict, None, None]: for w in self: yield w.as_dict(is_focused=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': return active_tab_manager is not None and self is active_tab_manager.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 == current_os_window() 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 == current_os_window() 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={hex(id(self))})' 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, active_tab: Optional[Tab], active_window: Optional[Window], self_window: Optional[Window] = None) -> Generator[TabDict, None, None]: for tab in self: yield { 'id': tab.id, 'is_focused': 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(active_window, 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() # }}}