kitty/kitty/tabs.py
David Martin 34de072a10 Add option to disable switching to the previous tab when closing the current one.
When setting tab_bar_switch_to_previous_when_closing_current_tab kitty
will now switch to the left tab instead of the previously active one
when the currently active tab is closed. This makes the closing of tabs
a bit more predictable.

Note that we are not touching the handling of the active_tab_history at
all. I was considering it, but we want to keep track of it in any case
to keep the 'switch to previous tab' shortcut working.
2019-04-03 19:27:04 +11:00

609 lines
22 KiB
Python

#!/usr/bin/env python
# vim:fileencoding=utf-8
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
import weakref
from collections import deque, namedtuple
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, 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 .session import resolved_shell
from .tab_bar import TabBar, TabBarData
from .utils import log_error
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):
try:
items.remove(item_id)
except ValueError:
pass
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):
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.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.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, pt_to_px(self.opts.window_border_width, self.os_window_id), self.padding_width)
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
if session_tab is None:
self.cwd = self.args.directory
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:
self.cwd = session_tab.cwd or self.args.directory
l0 = session_tab.layout
self._set_current_layout(l0)
self.startup(session_tab)
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(visible_windows, w, self.current_layout,
tm.blank_rects, 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.borders.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 launch_child(self, use_shell=False, cmd=None, stdin=None, cwd_from=None, cwd=None, env=None):
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)
ans.fork()
return ans
def new_window(self, use_shell=True, cmd=None, stdin=None, override_title=None, cwd_from=None, cwd=None, overlay_for=None, env=None):
child = self.launch_child(use_shell=use_shell, cmd=cmd, stdin=stdin, cwd_from=cwd_from, cwd=cwd, env=env)
window = Window(self, child, self.opts, self.args, override_title=override_title)
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.active_window_idx = self.current_layout.add_window(self.windows, window, self.active_window_idx)
self.relayout_borders()
return window
def new_special_window(self, special_window):
return self.new_window(False, *special_window)
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):
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)
remove_window(self.os_window_id, self.id, window.id)
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 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)))
# }}}
class TabManager: # {{{
def __init__(self, os_window_id, opts, args, startup_session):
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
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 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
self.set_active_tab_idx(idx)
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) and 0 <= tab_num:
self.set_active_tab_idx(tab_num)
elif tab_num < 0:
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)),
}
@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):
nidx = self.active_tab_idx + 1
idx = len(self.tabs)
self._add_tab(Tab(self, special_window=special_window, cwd_from=cwd_from))
self._set_active_tab(idx)
if len(self.tabs) > 2 and as_neighbor and idx != nidx:
for i in range(idx, nidx, -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(nidx)
idx = nidx
self.mark_tab_bar_dirty()
return self.tabs[idx]
def remove(self, tab):
self._remove_tab(tab)
next_active_tab = -1
while self.active_tab_history and next_active_tab < 0:
tab_id = self.active_tab_history.pop()
if tab_id == tab.id:
continue
for idx, qtab in enumerate(self.tabs):
if qtab.id == tab_id:
next_active_tab = idx
break
if next_active_tab < 0:
next_active_tab = max(0, min(self.active_tab_idx, len(self.tabs) - 1))
if not self.opts.tab_bar_switch_to_previous_when_closing_current_tab:
# When we do not switch to the previously active tab, we set the next left tab
# as active. Unless there are no further tabs on the left, in which case
# we select the new leftmost tab.
if self.active_tab_idx > 0:
next_active_tab = self.active_tab_idx - 1
else:
next_active_tab = 0
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
# }}}