#!/usr/bin/env python3 # vim:fileencoding=utf-8 # License: GPL v3 Copyright: 2016, Kovid Goyal import weakref from collections import deque, namedtuple from contextlib import suppress from functools import partial from .borders import Borders from .child import Child from .constants import appname, get_boss, is_macos, is_wayland from .fast_data_types import ( add_tab, attach_window, detach_window, mark_tab_bar_dirty, next_window_id, pt_to_px, remove_tab, remove_window, ring_bell, set_active_tab, swap_tabs, x11_window_id ) from .layout import create_layout_object_for, evict_cached_layouts from .tab_bar import TabBar, TabBarData from .utils import log_error, resolved_shell from .window import Window SpecialWindowInstance = namedtuple('SpecialWindow', 'cmd stdin override_title cwd_from cwd overlay_for env') def SpecialWindow(cmd, stdin=None, override_title=None, cwd_from=None, cwd=None, overlay_for=None, env=None): return SpecialWindowInstance(cmd, stdin, override_title, cwd_from, cwd, overlay_for, env) def add_active_id_to_history(items, item_id, maxlen=64): with suppress(ValueError): items.remove(item_id) items.append(item_id) if len(items) > maxlen: items.popleft() class Tab: # {{{ def __init__(self, tab_manager, session_tab=None, special_window=None, cwd_from=None, no_initial_window=False): self._active_window_idx = 0 self.tab_manager_ref = weakref.ref(tab_manager) self.os_window_id = tab_manager.os_window_id self.id = add_tab(self.os_window_id) self.active_window_history = deque() if not self.id: raise Exception('No OS window with id {} found, or tab counter has wrapped'.format(self.os_window_id)) self.opts, self.args = tab_manager.opts, tab_manager.args self.recalculate_sizes(update_layout=False) self.name = getattr(session_tab, 'name', '') self.enabled_layouts = [x.lower() for x in getattr(session_tab, 'enabled_layouts', None) or self.opts.enabled_layouts] self.borders = Borders(self.os_window_id, self.id, self.opts) self.windows = deque() for i, which in enumerate('first second third fourth fifth sixth seventh eighth ninth tenth'.split()): setattr(self, which + '_window', partial(self.nth_window, num=i)) self._last_used_layout = self._current_layout_name = 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 recalculate_sizes(self, update_layout=True): self.margin_width, self.padding_width, self.single_window_margin_width = map( lambda x: pt_to_px(getattr(self.opts, x), self.os_window_id), ( 'window_margin_width', 'window_padding_width', 'single_window_margin_width')) self.border_width = pt_to_px(self.opts.window_border_width, self.os_window_id) if update_layout and self.current_layout: self.current_layout.update_sizes( self.margin_width, self.single_window_margin_width, self.padding_width, self.border_width) def take_over_from(self, other_tab): self.name, self.cwd = other_tab.name, other_tab.cwd self.enabled_layouts = list(other_tab.enabled_layouts) self._set_current_layout(other_tab._current_layout_name) self._last_used_layout = other_tab._last_used_layout orig_windows = deque(other_tab.windows) orig_history = deque(other_tab.active_window_history) orig_active = other_tab._active_window_idx for window in other_tab.windows: detach_window(other_tab.os_window_id, other_tab.id, window.id) other_tab.windows = deque() other_tab._active_window_idx = 0 self.active_window_history = orig_history self.windows = orig_windows self._active_window_idx = orig_active for window in self.windows: window.change_tab(self) attach_window(self.os_window_id, self.id, window.id) self.relayout() def _set_current_layout(self, layout_name): self._last_used_layout = self._current_layout_name self.current_layout = self.create_layout_object(layout_name) self._current_layout_name = layout_name def startup(self, session_tab): for cmd in session_tab.windows: if isinstance(cmd, (SpecialWindowInstance,)): self.new_special_window(cmd) else: self.new_window(cmd=cmd) self.set_active_window_idx(session_tab.active_window_idx) @property def active_window_idx(self): return self._active_window_idx @active_window_idx.setter def active_window_idx(self, val): try: old_active_window = self.windows[self._active_window_idx] except Exception: old_active_window = None else: wid = old_active_window.id if old_active_window.overlay_for is None else old_active_window.overlay_for add_active_id_to_history(self.active_window_history, wid) self._active_window_idx = max(0, min(val, len(self.windows) - 1)) try: new_active_window = self.windows[self._active_window_idx] except Exception: new_active_window = None if old_active_window is not new_active_window: if old_active_window is not None: old_active_window.focus_changed(False) if new_active_window is not None: new_active_window.focus_changed(True) tm = self.tab_manager_ref() if tm is not None: self.relayout_borders() tm.mark_tab_bar_dirty() @property def active_window(self): return self.windows[self.active_window_idx] if self.windows else None @property def title(self): return getattr(self.active_window, 'title', appname) def set_title(self, title): self.name = title or '' tm = self.tab_manager_ref() if tm is not None: tm.mark_tab_bar_dirty() def title_changed(self, window): if window is self.active_window: tm = self.tab_manager_ref() if tm is not None: tm.mark_tab_bar_dirty() def on_bell(self, window): tm = self.tab_manager_ref() if tm is not None: self.relayout_borders() tm.mark_tab_bar_dirty() def visible_windows(self): for w in self.windows: if w.is_visible_in_layout: yield w def relayout(self): if self.windows: self.active_window_idx = self.current_layout(self.windows, self.active_window_idx) self.relayout_borders() def relayout_borders(self): tm = self.tab_manager_ref() if tm is not None: visible_windows = [w for w in self.windows if w.is_visible_in_layout] w = self.active_window self.borders( windows=visible_windows, active_window=w, current_layout=self.current_layout, extra_blank_rects=tm.blank_rects, padding_width=self.padding_width, border_width=self.border_width, draw_window_borders=self.current_layout.needs_window_borders and len(visible_windows) > 1 ) if w is not None: w.change_titlebar_color() def create_layout_object(self, name): return create_layout_object_for( name, self.os_window_id, self.id, self.margin_width, self.single_window_margin_width, self.padding_width, self.border_width) def next_layout(self): 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() def last_used_layout(self): 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() def goto_layout(self, layout_name, raise_exception=False): layout_name = layout_name.lower() if layout_name not in self.enabled_layouts: if raise_exception: raise ValueError(layout_name) log_error('Unknown or disabled layout: {}'.format(layout_name)) return self._set_current_layout(layout_name) self.relayout() def resize_window_by(self, window_id, increment, is_horizontal): increment_as_percent = self.current_layout.bias_increment_for_cell(is_horizontal) * increment if self.current_layout.modify_size_of_window(self.windows, window_id, increment_as_percent, is_horizontal): self.relayout() return return 'Could not resize' def resize_window(self, quality, increment): if increment < 1: raise ValueError(increment) is_horizontal = quality in ('wider', 'narrower') increment *= 1 if quality in ('wider', 'taller') else -1 if self.resize_window_by( self.windows[self.active_window_idx].id, increment, is_horizontal) is not None: ring_bell() def reset_window_sizes(self): if self.current_layout.remove_all_biases(): self.relayout() def layout_action(self, action_name, args): ret = self.current_layout.layout_action(action_name, args, self.windows, self.active_window_idx) if ret is None: ring_bell() return if isinstance(ret, int) and not isinstance(ret, bool): self.active_window_idx = ret self.relayout() def launch_child(self, use_shell=False, cmd=None, stdin=None, cwd_from=None, cwd=None, env=None, allow_remote_control=False): if cmd is None: if use_shell: cmd = resolved_shell(self.opts) else: cmd = self.args.args or resolved_shell(self.opts) fenv = {} if env: fenv.update(env) fenv['KITTY_WINDOW_ID'] = str(next_window_id()) if not is_macos and not is_wayland(): try: fenv['WINDOWID'] = str(x11_window_id(self.os_window_id)) except Exception: import traceback traceback.print_exc() ans = Child(cmd, cwd or self.cwd, self.opts, stdin, fenv, cwd_from, allow_remote_control=allow_remote_control) ans.fork() return ans def _add_window(self, window, location=None): self.active_window_idx = self.current_layout.add_window(self.windows, window, self.active_window_idx, location) self.relayout_borders() def new_window( self, use_shell=True, cmd=None, stdin=None, override_title=None, cwd_from=None, cwd=None, overlay_for=None, env=None, location=None, copy_colors_from=None, allow_remote_control=False, marker=None ): child = self.launch_child( use_shell=use_shell, cmd=cmd, stdin=stdin, cwd_from=cwd_from, cwd=cwd, env=env, allow_remote_control=allow_remote_control) window = Window(self, child, self.opts, self.args, override_title=override_title, copy_colors_from=copy_colors_from) if overlay_for is not None: overlaid = next(w for w in self.windows if w.id == overlay_for) window.overlay_for = overlay_for overlaid.overlay_window_id = window.id # Must add child before laying out so that resize_pty succeeds get_boss().add_child(window) self._add_window(window, location=location) if marker: try: window.set_marker(marker) except Exception: import traceback traceback.print_exc() return window def new_special_window(self, special_window, location=None, copy_colors_from=None, allow_remote_control=False): return self.new_window(False, *special_window, location=location, copy_colors_from=copy_colors_from, allow_remote_control=allow_remote_control) def close_window(self): if self.windows: self.remove_window(self.windows[self.active_window_idx]) def previous_active_window_idx(self, num): try: old_window_id = self.active_window_history[-num] except IndexError: return for idx, w in enumerate(self.windows): if w.id == old_window_id: return idx def remove_window(self, window, destroy=True): idx = self.previous_active_window_idx(1) next_window_id = None if idx is not None: next_window_id = self.windows[idx].id active_window_idx = self.current_layout.remove_window(self.windows, window, self.active_window_idx) if destroy: remove_window(self.os_window_id, self.id, window.id) else: detach_window(self.os_window_id, self.id, window.id) if window.overlay_for is not None: for idx, q in enumerate(self.windows): if q.id == window.overlay_for: active_window_idx = idx next_window_id = q.id break if next_window_id is None and active_window_idx is not None and len(self.windows) > active_window_idx: next_window_id = self.windows[active_window_idx].id if next_window_id is not None: for idx, window in enumerate(self.windows): if window.id == next_window_id: self.active_window_idx = self.current_layout.set_active_window(self.windows, idx) break else: self.active_window_idx = active_window_idx else: self.active_window_idx = active_window_idx self.relayout_borders() active_window = self.active_window if active_window: self.title_changed(active_window) def detach_window(self, window): underlaid_window = None overlaid_window = window if window.overlay_for: for x in self.windows: if x.id == window.overlay_for: underlaid_window = x break elif window.overlay_window_id: underlaid_window = window overlaid_window = None for x in self.windows: if x.id == window.overlay_window_id: overlaid_window = x break if overlaid_window is not None: self.remove_window(overlaid_window, destroy=False) if underlaid_window is not None: self.remove_window(underlaid_window, destroy=False) return underlaid_window, overlaid_window def attach_window(self, window): window.change_tab(self) attach_window(self.os_window_id, self.id, window.id) self._add_window(window) def set_active_window_idx(self, idx): if idx != self.active_window_idx: self.active_window_idx = self.current_layout.set_active_window(self.windows, idx) self.relayout_borders() def set_active_window(self, window): try: idx = self.windows.index(window) except ValueError: return self.set_active_window_idx(idx) def get_nth_window(self, n): if self.windows: return self.current_layout.nth_window(self.windows, n, make_active=False) def nth_window(self, num=0): if self.windows: if num < 0: idx = self.previous_active_window_idx(-num) if idx is None: return self.active_window_idx = self.current_layout.set_active_window(self.windows, idx) else: self.active_window_idx = self.current_layout.nth_window(self.windows, num) self.relayout_borders() def _next_window(self, delta=1): if len(self.windows) > 1: self.active_window_idx = self.current_layout.next_window(self.windows, self.active_window_idx, delta) self.relayout_borders() def next_window(self): self._next_window() def previous_window(self): self._next_window(-1) prev_window = previous_window def neighboring_window(self, which): neighbors = self.current_layout.neighbors(self.windows, self.active_window_idx) candidates = neighbors.get(which) if candidates: self.active_window_idx = self.current_layout.set_active_window(self.windows, candidates[0]) self.relayout_borders() def move_window(self, delta=1): self.active_window_idx = self.current_layout.move_window(self.windows, self.active_window_idx, delta) self.relayout() def move_window_to_top(self): self.move_window(-self.active_window_idx) def move_window_forward(self): self.move_window() def move_window_backward(self): self.move_window(-1) def list_windows(self, active_window): for w in self: yield w.as_dict(is_focused=w is active_window) def matches(self, field, pat): if field == 'id': return pat.pattern == str(self.id) if field == 'title': return pat.search(self.name or self.title) is not None return False def __iter__(self): return iter(self.windows) def __len__(self): return len(self.windows) def __contains__(self, window): return window in self.windows def destroy(self): evict_cached_layouts(self.id) for w in self.windows: w.destroy() self.windows = deque() def __repr__(self): return 'Tab(title={}, id={})'.format(self.name or self.title, hex(id(self))) def make_active(self): tm = self.tab_manager_ref() if tm is not None: tm.set_active_tab(self) # }}} class TabManager: # {{{ def __init__(self, os_window_id, opts, args, startup_session=None): self.os_window_id = os_window_id self.last_active_tab_id = None self.opts, self.args = opts, args self.tab_bar_hidden = self.opts.tab_bar_style == 'hidden' self.tabs = [] self.active_tab_history = deque() self.tab_bar = TabBar(self.os_window_id, opts) 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): return self._active_tab_idx @active_tab_idx.setter def active_tab_idx(self, val): try: old_active_tab = self.tabs[self._active_tab_idx] except Exception: old_active_tab = None else: add_active_id_to_history(self.active_tab_history, old_active_tab.id) self._active_tab_idx = max(0, min(val, len(self.tabs) - 1)) try: new_active_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): if not self.tab_bar_hidden: self.tab_bar.screen.refresh_sprite_positions() @property def tab_bar_should_be_visible(self): return len(self.tabs) >= self.opts.tab_bar_min_tabs def _add_tab(self, tab): 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): 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): self.active_tab_idx = idx set_active_tab(self.os_window_id, idx) def tabbar_visibility_changed(self): if not self.tab_bar_hidden: self.tab_bar.layout() self.resize(only_tabs=True) def mark_tab_bar_dirty(self): 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): self.tab_bar.update(self.tab_bar_data) def update_dpi_based_sizes(self): for tab in self.tabs: tab.recalculate_sizes() def resize(self, only_tabs=False): 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): self._set_active_tab(idx) self.active_tab.relayout_borders() self.mark_tab_bar_dirty() def set_active_tab(self, tab): try: idx = self.tabs.index(tab) except Exception: return False self.set_active_tab_idx(idx) return True def next_tab(self, delta=1): if len(self.tabs) > 1: self.set_active_tab_idx((self.active_tab_idx + len(self.tabs) + delta) % len(self.tabs)) def goto_tab(self, tab_num): 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 __iter__(self): return iter(self.tabs) def __len__(self): return len(self.tabs) def list_tabs(self, active_tab, active_window): for tab in self: yield { 'id': tab.id, 'is_focused': tab is active_tab, 'title': tab.name or tab.title, 'layout': tab.current_layout.name, 'windows': list(tab.list_windows(active_window)), 'active_window_history': list(tab.active_window_history), } @property def active_tab(self): return self.tabs[self.active_tab_idx] if self.tabs else None @property def active_window(self): t = self.active_tab if t is not None: return t.active_window def tab_for_id(self, tab_id): for t in self.tabs: if t.id == tab_id: return t def move_tab(self, delta=1): 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=None, cwd_from=None, as_neighbor=False, empty_tab=False, location='last'): 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)) self._set_active_tab(idx) if as_neighbor: location = 'after' if location == 'neighbor': location = 'after' 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) self._set_active_tab(desired_idx) idx = desired_idx self.mark_tab_bar_dirty() return self.tabs[idx] def remove(self, tab): self._remove_tab(tab) next_active_tab = -1 while True: try: self.active_tab_history.remove(tab.id) except ValueError: break if self.opts.tab_switch_strategy == 'previous': while self.active_tab_history and next_active_tab < 0: tab_id = self.active_tab_history.pop() for idx, qtab in enumerate(self.tabs): if qtab.id == tab_id: next_active_tab = idx break elif self.opts.tab_switch_strategy == 'left': next_active_tab = max(0, self.active_tab_idx - 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) self.mark_tab_bar_dirty() tab.destroy() @property def tab_bar_data(self): at = self.active_tab ans = [] for t in self.tabs: title = (t.name or t.title or appname).strip() needs_attention = False for w in t: if w.needs_attention: needs_attention = True break ans.append(TabBarData(title, t is at, needs_attention)) return ans def activate_tab_at(self, x): i = self.tab_bar.tab_at(x) if i is not None: self.set_active_tab_idx(i) @property def blank_rects(self): return self.tab_bar.blank_rects if self.tab_bar_should_be_visible else () def destroy(self): for t in self: t.destroy() self.tab_bar.destroy() del self.tab_bar del self.tabs # }}}