kitty/kitty/tab_bar.py

659 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net>
import os
from functools import lru_cache, partial, wraps
from string import Formatter as StringFormatter
from typing import (
Any, Callable, Dict, List, NamedTuple, Optional, Sequence, Tuple, Union,
)
from .borders import Border, BorderColor
from .config import build_ansi_color_table
from .constants import config_dir
from .fast_data_types import (
DECAWM, Color, Region, Screen, cell_size_for_window, get_boss, get_options,
pt_to_px, set_tab_bar_render_data, viewport_for_window,
)
from .rgb import alpha_blend, color_as_sgr, color_from_int, to_color
from .types import WindowGeometry, run_once
from .typing import EdgeLiteral, PowerlineStyle
from .utils import color_as_int, log_error, sgr_sanitizer_pat
class TabBarData(NamedTuple):
title: str
is_active: bool
needs_attention: bool
tab_id: int
num_windows: int
num_window_groups: int
layout_name: str
has_activity_since_last_focus: bool
active_fg: Optional[int]
active_bg: Optional[int]
inactive_fg: Optional[int]
inactive_bg: Optional[int]
class DrawData(NamedTuple):
leading_spaces: int
sep: str
trailing_spaces: int
bell_on_tab: str
alpha: Sequence[float]
active_fg: Color
active_bg: Color
inactive_fg: Color
inactive_bg: Color
default_bg: Color
title_template: str
active_title_template: Optional[str]
tab_activity_symbol: str
powerline_style: PowerlineStyle
tab_bar_edge: EdgeLiteral
def tab_fg(self, tab: TabBarData) -> int:
if tab.is_active:
if tab.active_fg is not None:
return tab.active_fg
return int(self.active_fg)
if tab.inactive_fg is not None:
return tab.inactive_fg
return int(self.inactive_fg)
def tab_bg(self, tab: TabBarData) -> int:
if tab.is_active:
if tab.active_bg is not None:
return tab.active_bg
return int(self.active_bg)
if tab.inactive_bg is not None:
return tab.inactive_bg
return int(self.inactive_bg)
def as_rgb(x: int) -> int:
return (x << 8) | 2
@lru_cache()
def report_template_failure(template: str, e: str) -> None:
log_error(f'Invalid tab title template: "{template}" with error: {e}')
@lru_cache()
def compile_template(template: str) -> Any:
try:
return compile('f"""' + template + '"""', '<template>', 'eval')
except Exception as e:
report_template_failure(template, str(e))
class ColorFormatter:
draw_data: DrawData
tab_data: TabBarData
def __init__(self, which: str):
self.which = which
def __getattr__(self, name: str) -> str:
q = name
if q == 'default':
ans = '9'
elif q == 'tab':
col = color_from_int((self.draw_data.tab_bg if self.which == '4' else self.draw_data.tab_fg)(self.tab_data))
ans = f'8{color_as_sgr(col)}'
else:
if name.startswith('_'):
q = f'#{name[1:]}'
c = to_color(q)
if c is None:
raise AttributeError(f'{name} is not a valid color')
ans = f'8{color_as_sgr(c)}'
return f'\x1b[{self.which}{ans}m'
class Formatter:
reset = '\x1b[0m'
fg = ColorFormatter('3')
bg = ColorFormatter('4')
bold = '\x1b[1m'
nobold = '\x1b[22m'
italic = '\x1b[3m'
noitalic = '\x1b[23m'
@run_once
def super_sub_maps() -> Tuple[Dict[int, Union[None, int]], Dict[int, Union[None, int]]]:
import string
sup_table = str.maketrans(
string.ascii_lowercase + string.ascii_uppercase + string.digits + '+-=()',
'ᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖqʳˢᵗᵘᵛʷˣʸᶻ' 'ᴬᴮᶜᴰᴱᶠᴳᴴᴵᴶᴷᴸᴹᴺᴼᴾQᴿˢᵀᵁⱽᵂˣʸᶻ' '⁰¹²³⁴⁵⁶⁷⁸⁹' '⁺⁻⁼⁽⁾')
sub_table = str.maketrans(
string.ascii_lowercase + string.ascii_uppercase + string.digits + '+-=()',
'ₐbcdₑfgₕᵢⱼₖₗₘₙₒₚqᵣₛₜᵤᵥwₓyz' 'ₐbcdₑfgₕᵢⱼₖₗₘₙₒₚqᵣₛₜᵤᵥwₓyz' '₀₁₂₃₄₅₆₇₈₉' '₊₋₌₍₎')
return sup_table, sub_table
class SupSub:
def __init__(self, data: Dict[str, Any], is_subscript: bool = False):
self.__data = data
self.__is_subscript = is_subscript
def __getattr__(self, name: str) -> str:
name = str(self.__data.get(name, name))
table = super_sub_maps()[int(self.__is_subscript)]
return name.translate(table)
class ExtraData:
prev_tab: Optional[TabBarData] = None
next_tab: Optional[TabBarData] = None
for_layout: bool = False
def draw_attributed_string(title: str, screen: Screen) -> None:
if '\x1b' in title:
for x in sgr_sanitizer_pat(for_splitting=True).split(title):
if x.startswith('\x1b') and x.endswith('m'):
screen.apply_sgr(x[2:-1])
else:
screen.draw(x)
else:
screen.draw(title)
@lru_cache(maxsize=16)
def template_has_field(template: str, field: str) -> bool:
q = StringFormatter()
for (literal_text, field_name, format_spec, conversion) in q.parse(template):
if field_name and field in field_name.split():
return True
return False
class TabAccessor:
def __init__(self, tab_id: int):
self.tab_id = tab_id
@property
def active_wd(self) -> str:
tab = get_boss().tab_for_id(self.tab_id)
return (tab.get_cwd_of_active_window() if tab else '') or ''
safe_builtins = {
'max': max, 'min': min, 'str': str, 'repr': repr, 'abs': abs, 'len': len,
}
def draw_title(draw_data: DrawData, screen: Screen, tab: TabBarData, index: int, max_title_length: int = 0) -> None:
ta = TabAccessor(tab.tab_id)
data = {
'index': index,
'layout_name': tab.layout_name,
'num_windows': tab.num_windows,
'num_window_groups': tab.num_window_groups,
'title': tab.title,
'tab': ta,
}
ColorFormatter.draw_data = draw_data
ColorFormatter.tab_data = tab
eval_locals = {
'index': index,
'layout_name': tab.layout_name,
'num_windows': tab.num_windows,
'num_window_groups': tab.num_window_groups,
'title': tab.title,
'tab': ta,
'fmt': Formatter,
'sup': SupSub(data),
'sub': SupSub(data, True),
'bell_symbol': draw_data.bell_on_tab if tab.needs_attention else '',
'activity_symbol': draw_data.tab_activity_symbol if tab.has_activity_since_last_focus else '',
'max_title_length': max_title_length,
}
template = draw_data.title_template
if tab.is_active and draw_data.active_title_template is not None:
template = draw_data.active_title_template
prefix = ''
if eval_locals['bell_symbol'] and not template_has_field(template, 'bell_symbol'):
prefix = '{bell_symbol}'
if eval_locals['activity_symbol'] and not template_has_field(template, 'activity_symbol'):
prefix += '{activity_symbol}'
if prefix:
template = '{fmt.fg.red}' + prefix + '{fmt.fg.tab}' + template
try:
title = eval(compile_template(template), {'__builtins__': safe_builtins}, eval_locals)
except Exception as e:
report_template_failure(template, str(e))
title = tab.title
draw_attributed_string(title, screen)
DrawTabFunc = Callable[[DrawData, Screen, TabBarData, int, int, int, bool, ExtraData], int]
def draw_tab_with_slant(
draw_data: DrawData, screen: Screen, tab: TabBarData,
before: int, max_title_length: int, index: int, is_last: bool,
extra_data: ExtraData
) -> int:
orig_fg = screen.cursor.fg
left_sep, right_sep = ('', '') if draw_data.tab_bar_edge == 'top' else ('', '')
tab_bg = screen.cursor.bg
slant_fg = as_rgb(color_as_int(draw_data.default_bg))
def draw_sep(which: str) -> None:
screen.cursor.bg = tab_bg
screen.cursor.fg = slant_fg
screen.draw(which)
screen.cursor.bg = tab_bg
screen.cursor.fg = orig_fg
max_title_length += 1
if max_title_length <= 1:
screen.draw('')
elif max_title_length == 2:
screen.draw('…|')
elif max_title_length < 6:
draw_sep(left_sep)
screen.draw((' ' if max_title_length == 5 else '') + '' + (' ' if max_title_length >= 4 else ''))
draw_sep(right_sep)
else:
draw_sep(left_sep)
screen.draw(' ')
draw_title(draw_data, screen, tab, index, max_title_length)
extra = screen.cursor.x - before - max_title_length
if extra >= 0:
screen.cursor.x -= extra + 3
screen.draw('')
elif extra == -1:
screen.cursor.x -= 2
screen.draw('')
screen.draw(' ')
draw_sep(right_sep)
return screen.cursor.x
def draw_tab_with_separator(
draw_data: DrawData, screen: Screen, tab: TabBarData,
before: int, max_title_length: int, index: int, is_last: bool,
extra_data: ExtraData
) -> int:
if draw_data.leading_spaces:
screen.draw(' ' * draw_data.leading_spaces)
draw_title(draw_data, screen, tab, index, max_title_length)
trailing_spaces = min(max_title_length - 1, draw_data.trailing_spaces)
max_title_length -= trailing_spaces
extra = screen.cursor.x - before - max_title_length
if extra > 0:
screen.cursor.x -= extra + 1
screen.draw('')
if trailing_spaces:
screen.draw(' ' * trailing_spaces)
end = screen.cursor.x
screen.cursor.bold = screen.cursor.italic = False
screen.cursor.fg = 0
if not is_last:
screen.cursor.bg = as_rgb(color_as_int(draw_data.inactive_bg))
screen.draw(draw_data.sep)
screen.cursor.bg = 0
return end
def draw_tab_with_fade(
draw_data: DrawData, screen: Screen, tab: TabBarData,
before: int, max_title_length: int, index: int, is_last: bool,
extra_data: ExtraData
) -> int:
orig_bg = screen.cursor.bg
tab_bg = color_from_int(orig_bg >> 8)
fade_colors = [as_rgb(color_as_int(alpha_blend(tab_bg, draw_data.default_bg, alpha))) for alpha in draw_data.alpha]
for bg in fade_colors:
screen.cursor.bg = bg
screen.draw(' ')
screen.cursor.bg = orig_bg
draw_title(draw_data, screen, tab, index, max(0, max_title_length - 8))
extra = screen.cursor.x - before - max_title_length
if extra > 0:
screen.cursor.x = before
draw_title(draw_data, screen, tab, index, max(0, max_title_length - 4))
extra = screen.cursor.x - before - max_title_length
if extra > 0:
screen.cursor.x -= extra + 1
screen.draw('')
for bg in reversed(fade_colors):
if extra >= 0:
break
extra += 1
screen.cursor.bg = bg
screen.draw(' ')
end = screen.cursor.x
screen.cursor.bg = as_rgb(color_as_int(draw_data.default_bg))
screen.draw(' ')
return end
powerline_symbols: Dict[PowerlineStyle, Tuple[str, str]] = {
'slanted': ('', ''),
'round': ('', '')
}
def draw_tab_with_powerline(
draw_data: DrawData, screen: Screen, tab: TabBarData,
before: int, max_title_length: int, index: int, is_last: bool,
extra_data: ExtraData
) -> int:
tab_bg = screen.cursor.bg
tab_fg = screen.cursor.fg
default_bg = as_rgb(int(draw_data.default_bg))
if extra_data.next_tab:
next_tab_bg = as_rgb(draw_data.tab_bg(extra_data.next_tab))
needs_soft_separator = next_tab_bg == tab_bg
else:
next_tab_bg = default_bg
needs_soft_separator = False
separator_symbol, soft_separator_symbol = powerline_symbols.get(draw_data.powerline_style, ('', ''))
min_title_length = 1 + 2
start_draw = 2
if screen.cursor.x == 0:
screen.cursor.bg = tab_bg
screen.draw(' ')
start_draw = 1
screen.cursor.bg = tab_bg
if min_title_length >= max_title_length:
screen.draw('')
else:
draw_title(draw_data, screen, tab, index, max_title_length)
extra = screen.cursor.x + start_draw - before - max_title_length
if extra > 0 and extra + 1 < screen.cursor.x:
screen.cursor.x -= extra + 1
screen.draw('')
if not needs_soft_separator:
screen.draw(' ')
screen.cursor.fg = tab_bg
screen.cursor.bg = next_tab_bg
screen.draw(separator_symbol)
else:
prev_fg = screen.cursor.fg
if tab_bg == tab_fg:
screen.cursor.fg = default_bg
elif tab_bg != default_bg:
c1 = draw_data.inactive_bg.contrast(draw_data.default_bg)
c2 = draw_data.inactive_bg.contrast(draw_data.inactive_fg)
if c1 < c2:
screen.cursor.fg = default_bg
screen.draw(f' {soft_separator_symbol}')
screen.cursor.fg = prev_fg
end = screen.cursor.x
if end < screen.columns:
screen.draw(' ')
return end
@run_once
def load_custom_draw_tab() -> DrawTabFunc:
import runpy
import traceback
try:
m = runpy.run_path(os.path.join(config_dir, 'tab_bar.py'))
func: DrawTabFunc = m['draw_tab']
except Exception as e:
traceback.print_exc()
log_error(f'Failed to load custom draw_tab function with error: {e}')
return draw_tab_with_fade
@wraps(func)
def draw_tab(
draw_data: DrawData, screen: Screen, tab: TabBarData,
before: int, max_title_length: int, index: int, is_last: bool,
extra_data: ExtraData
) -> int:
try:
return func(draw_data, screen, tab, before, max_title_length, index, is_last, extra_data)
except Exception as e:
log_error(f'Custom draw tab function failed with error: {e}')
return draw_tab_with_fade(draw_data, screen, tab, before, max_title_length, index, is_last, extra_data)
return draw_tab
class TabBar:
def __init__(self, os_window_id: int):
self.os_window_id = os_window_id
self.num_tabs = 1
self.data_buffer_size = 0
self.blank_rects: Tuple[Border, ...] = ()
self.cell_ranges: List[Tuple[int, int]] = []
self.laid_out_once = False
self.apply_options()
def apply_options(self) -> None:
opts = get_options()
self.dirty = True
self.margin_width = pt_to_px(opts.tab_bar_margin_width, self.os_window_id)
self.cell_width, cell_height = cell_size_for_window(self.os_window_id)
if not hasattr(self, 'screen'):
self.screen = s = Screen(None, 1, 10, 0, self.cell_width, cell_height)
else:
s = self.screen
s.color_profile.update_ansi_color_table(build_ansi_color_table(opts))
s.color_profile.set_configured_colors(
color_as_int(opts.inactive_tab_foreground),
color_as_int(opts.tab_bar_background or opts.background)
)
sep = opts.tab_separator
self.trailing_spaces = self.leading_spaces = 0
while sep and sep[0] == ' ':
sep = sep[1:]
self.trailing_spaces += 1
while sep and sep[-1] == ' ':
self.leading_spaces += 1
sep = sep[:-1]
self.sep = sep
self.active_font_style = opts.active_tab_font_style
self.inactive_font_style = opts.inactive_tab_font_style
self.active_bg = as_rgb(color_as_int(opts.active_tab_background))
self.active_fg = as_rgb(color_as_int(opts.active_tab_foreground))
self.draw_data = DrawData(
self.leading_spaces, self.sep, self.trailing_spaces, opts.bell_on_tab,
opts.tab_fade, opts.active_tab_foreground, opts.active_tab_background,
opts.inactive_tab_foreground, opts.inactive_tab_background,
opts.tab_bar_background or opts.background, opts.tab_title_template,
opts.active_tab_title_template,
opts.tab_activity_symbol,
opts.tab_powerline_style,
'top' if opts.tab_bar_edge == 1 else 'bottom'
)
ts = opts.tab_bar_style
if ts == 'separator':
self.draw_func: DrawTabFunc = draw_tab_with_separator
elif ts == 'powerline':
self.draw_func = draw_tab_with_powerline
elif ts == 'slant':
self.draw_func = draw_tab_with_slant
elif ts == 'custom':
self.draw_func = load_custom_draw_tab()
else:
self.draw_func = draw_tab_with_fade
if opts.tab_bar_align == 'center':
self.align: Callable[[], None] = partial(self.align_with_factor, 2)
elif opts.tab_bar_align == 'right':
self.align = self.align_with_factor
else:
self.align = lambda: None
def patch_colors(self, spec: Dict[str, Optional[int]]) -> None:
opts = get_options()
atf = spec.get('active_tab_foreground')
if isinstance(atf, int):
self.active_fg = (atf << 8) | 2
self.draw_data = self.draw_data._replace(active_fg=color_from_int(atf))
atb = spec.get('active_tab_background')
if isinstance(atb, int):
self.active_bg = (atb << 8) | 2
self.draw_data = self.draw_data._replace(active_bg=color_from_int(atb))
itb = spec.get('inactive_tab_background')
if isinstance(itb, int):
self.draw_data = self.draw_data._replace(inactive_bg=color_from_int(itb))
if 'tab_bar_background' in spec:
val = spec['tab_bar_background']
if val is None:
val = color_as_int(opts.background)
self.draw_data = self.draw_data._replace(default_bg=color_from_int(val))
elif not opts.tab_bar_background:
self.draw_data = self.draw_data._replace(default_bg=opts.background)
bg = spec.get('tab_bar_background', False)
if bg is None:
bg = color_as_int(opts.background)
elif bg is False:
bg = color_as_int(opts.tab_bar_background or opts.background)
fg = spec.get('inactive_tab_foreground')
if fg is None:
fg = color_as_int(opts.inactive_tab_foreground)
else:
ifg = color_from_int(fg)
if ifg is not None:
self.draw_data = self.draw_data._replace(inactive_fg=ifg)
self.screen.color_profile.set_configured_colors(fg, bg)
def update_blank_rects(self, central: Region, tab_bar: Region, vw: int, vh: int) -> None:
opts = get_options()
blank_rects: List[Border] = []
bg = BorderColor.tab_bar_margin_color if opts.tab_bar_margin_color is not None else BorderColor.default_bg
if opts.tab_bar_margin_height:
if opts.tab_bar_edge == 3: # bottom
if opts.tab_bar_margin_height.outer:
blank_rects.append(Border(0, tab_bar.bottom + 1, vw, vh, bg))
if opts.tab_bar_margin_height.inner:
blank_rects.append(Border(0, central.bottom + 1, vw, vh, bg))
else: # top
if opts.tab_bar_margin_height.outer:
blank_rects.append(Border(0, 0, vw, tab_bar.top, bg))
if opts.tab_bar_margin_height.inner:
blank_rects.append(Border(0, tab_bar.bottom + 1, vw, central.top, bg))
g = self.window_geometry
if g.left > 0:
blank_rects.append(Border(0, g.top, g.left, g.bottom + 1, bg))
if g.right - 1 < vw:
blank_rects.append(Border(g.right - 1, g.top, vw, g.bottom + 1, bg))
self.blank_rects = tuple(blank_rects)
def layout(self) -> None:
central, tab_bar, vw, vh, cell_width, cell_height = viewport_for_window(self.os_window_id)
if tab_bar.width < 2:
return
self.cell_width = cell_width
s = self.screen
viewport_width = max(4 * cell_width, tab_bar.width - 2 * self.margin_width)
ncells = viewport_width // cell_width
s.resize(1, ncells)
s.reset_mode(DECAWM)
self.laid_out_once = True
margin = (viewport_width - ncells * cell_width) // 2 + self.margin_width
self.window_geometry = g = WindowGeometry(
margin, tab_bar.top, viewport_width - margin, tab_bar.bottom, s.columns, s.lines)
self.update_blank_rects(central, tab_bar, vw, vh)
set_tab_bar_render_data(self.os_window_id, self.screen, *g[:4])
def update(self, data: Sequence[TabBarData]) -> None:
if not self.laid_out_once:
return
s = self.screen
last_tab = data[-1] if data else None
ed = ExtraData()
def draw_tab(i: int, tab: TabBarData, cell_ranges: List[Tuple[int, int]], max_tab_length: int, check_overflow: bool = True) -> None:
ed.prev_tab = data[i - 1] if i > 0 else None
ed.next_tab = data[i + 1] if i + 1 < len(data) else None
s.cursor.bg = as_rgb(self.draw_data.tab_bg(t))
s.cursor.fg = as_rgb(self.draw_data.tab_fg(t))
s.cursor.bold, s.cursor.italic = self.active_font_style if t.is_active else self.inactive_font_style
before = s.cursor.x
end = self.draw_func(self.draw_data, s, t, before, max_tab_length, i + 1, t is last_tab, ed)
s.cursor.bg = s.cursor.fg = 0
cell_ranges.append((before, end))
if check_overflow and s.cursor.x > s.columns - max_tab_length and t is not last_tab:
s.cursor.x = s.columns - 2
s.cursor.bg = as_rgb(color_as_int(self.draw_data.default_bg))
s.cursor.fg = as_rgb(0xff0000)
s.draw('')
raise StopIteration()
unconstrained_tab_length = max(1, s.columns - 2)
ideal_tab_lengths = [i for i in range(len(data))]
default_max_tab_length = max(1, (s.columns // max(1, len(data))) - 1)
max_tab_lengths = [default_max_tab_length for _ in range(len(data))]
active_idx = 0
extra = 0
ed.for_layout = True
for i, t in enumerate(data):
s.cursor.x = 0
draw_tab(i, t, [], unconstrained_tab_length, check_overflow=False)
ideal_tab_lengths[i] = tl = max(1, s.cursor.x)
if t.is_active:
active_idx = i
if tl < default_max_tab_length:
max_tab_lengths[i] = tl
extra += default_max_tab_length - tl
if extra > 0:
if ideal_tab_lengths[active_idx] > max_tab_lengths[active_idx]:
d = min(extra, ideal_tab_lengths[active_idx] - max_tab_lengths[active_idx])
max_tab_lengths[active_idx] += d
extra -= d
if extra > 0:
over_achievers = tuple(i for i in range(len(data)) if ideal_tab_lengths[i] > max_tab_lengths[i])
if over_achievers:
amt_per_over_achiever = extra // len(over_achievers)
if amt_per_over_achiever > 0:
for i in over_achievers:
max_tab_lengths[i] += amt_per_over_achiever
s.cursor.x = 0
s.erase_in_line(2, False)
cr: List[Tuple[int, int]] = []
ed.for_layout = False
for i, t in enumerate(data):
try:
draw_tab(i, t, cr, max_tab_lengths[i])
except StopIteration:
break
self.cell_ranges = cr
s.erase_in_line(0, False) # Ensure no long titles bleed after the last tab
self.align()
def align_with_factor(self, factor: int = 1) -> None:
if not self.cell_ranges:
return
end = self.cell_ranges[-1][1]
if end < self.screen.columns - 1:
shift = (self.screen.columns - end) // factor
self.screen.cursor.x = 0
self.screen.insert_characters(shift)
self.cell_ranges = [(s + shift, e + shift) for (s, e) in self.cell_ranges]
def destroy(self) -> None:
self.screen.reset_callbacks()
del self.screen
def tab_at(self, x: int) -> Optional[int]:
if self.laid_out_once:
x = (x - self.window_geometry.left) // self.cell_width
for i, (a, b) in enumerate(self.cell_ranges):
if a <= x <= b:
return i
return None