1#!/usr/local/bin/python3.8 2# vim:fileencoding=utf-8 3# License: GPL v3 Copyright: 2018, Kovid Goyal <kovid at kovidgoyal.net> 4 5from functools import lru_cache 6from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple 7 8from .config import build_ansi_color_table 9from .fast_data_types import ( 10 DECAWM, Screen, cell_size_for_window, get_options, pt_to_px, 11 set_tab_bar_render_data, viewport_for_window 12) 13from .layout.base import Rect 14from .rgb import Color, alpha_blend, color_as_sgr, color_from_int, to_color 15from .types import WindowGeometry, run_once 16from .typing import PowerlineStyle, EdgeLiteral 17from .utils import color_as_int, log_error 18from .window import calculate_gl_geometry 19 20 21class TabBarData(NamedTuple): 22 title: str 23 is_active: bool 24 needs_attention: bool 25 num_windows: int 26 num_window_groups: int 27 layout_name: str 28 has_activity_since_last_focus: bool 29 30 31class DrawData(NamedTuple): 32 leading_spaces: int 33 sep: str 34 trailing_spaces: int 35 bell_on_tab: bool 36 bell_fg: int 37 alpha: Sequence[float] 38 active_fg: Color 39 active_bg: Color 40 inactive_fg: Color 41 inactive_bg: Color 42 default_bg: Color 43 title_template: str 44 active_title_template: Optional[str] 45 tab_activity_symbol: Optional[str] 46 powerline_style: PowerlineStyle 47 tab_bar_edge: EdgeLiteral 48 49 50def as_rgb(x: int) -> int: 51 return (x << 8) | 2 52 53 54@lru_cache() 55def report_template_failure(template: str, e: str) -> None: 56 log_error('Invalid tab title template: "{}" with error: {}'.format(template, e)) 57 58 59@lru_cache() 60def compile_template(template: str) -> Any: 61 try: 62 return compile('f"""' + template + '"""', '<template>', 'eval') 63 except Exception as e: 64 report_template_failure(template, str(e)) 65 66 67class ColorFormatter: 68 69 def __init__(self, which: str): 70 self.which = which 71 72 def __getattr__(self, name: str) -> str: 73 q = name 74 if q == 'default': 75 ans = '9' 76 else: 77 if name.startswith('_'): 78 q = '#' + name[1:] 79 c = to_color(q) 80 if c is None: 81 raise AttributeError(f'{name} is not a valid color') 82 ans = '8' + color_as_sgr(c) 83 return f'\x1b[{self.which}{ans}m' 84 85 86class Formatter: 87 reset = '\x1b[0m' 88 fg = ColorFormatter('3') 89 bg = ColorFormatter('4') 90 bold = '\x1b[1m' 91 nobold = '\x1b[22m' 92 italic = '\x1b[3m' 93 noitalic = '\x1b[23m' 94 95 96@run_once 97def super_sub_maps() -> Tuple[dict, dict]: 98 import string 99 sup_table = str.maketrans( 100 string.ascii_lowercase + string.ascii_uppercase + string.digits + '+-=()', 101 'ᵃᵇᶜᵈᵉᶠᵍʰⁱʲᵏˡᵐⁿᵒᵖqʳˢᵗᵘᵛʷˣʸᶻ' 'ᴬᴮᶜᴰᴱᶠᴳᴴᴵᴶᴷᴸᴹᴺᴼᴾQᴿˢᵀᵁⱽᵂˣʸᶻ' '⁰¹²³⁴⁵⁶⁷⁸⁹' '⁺⁻⁼⁽⁾') 102 sub_table = str.maketrans( 103 string.ascii_lowercase + string.ascii_uppercase + string.digits + '+-=()', 104 'ₐbcdₑfgₕᵢⱼₖₗₘₙₒₚqᵣₛₜᵤᵥwₓyz' 'ₐbcdₑfgₕᵢⱼₖₗₘₙₒₚqᵣₛₜᵤᵥwₓyz' '₀₁₂₃₄₅₆₇₈₉' '₊₋₌₍₎') 105 return sup_table, sub_table 106 107 108class SupSub: 109 110 def __init__(self, data: dict, is_subscript: bool = False): 111 self.__data = data 112 self.__is_subscript = is_subscript 113 114 def __getattr__(self, name: str) -> str: 115 name = str(self.__data.get(name, name)) 116 table = super_sub_maps()[int(self.__is_subscript)] 117 return name.translate(table) 118 119 120def draw_title(draw_data: DrawData, screen: Screen, tab: TabBarData, index: int) -> None: 121 if tab.needs_attention and draw_data.bell_on_tab: 122 fg = screen.cursor.fg 123 screen.cursor.fg = draw_data.bell_fg 124 screen.draw(' ') 125 screen.cursor.fg = fg 126 if tab.has_activity_since_last_focus and draw_data.tab_activity_symbol: 127 fg = screen.cursor.fg 128 screen.cursor.fg = draw_data.bell_fg 129 screen.draw(draw_data.tab_activity_symbol) 130 screen.cursor.fg = fg 131 132 template = draw_data.title_template 133 if tab.is_active and draw_data.active_title_template is not None: 134 template = draw_data.active_title_template 135 try: 136 data = { 137 'index': index, 138 'layout_name': tab.layout_name, 139 'num_windows': tab.num_windows, 140 'num_window_groups': tab.num_window_groups, 141 'title': tab.title, 142 } 143 eval_locals = { 144 'index': index, 145 'layout_name': tab.layout_name, 146 'num_windows': tab.num_windows, 147 'num_window_groups': tab.num_window_groups, 148 'title': tab.title, 149 'fmt': Formatter, 150 'sup': SupSub(data), 151 'sub': SupSub(data, True), 152 } 153 title = eval(compile_template(template), {'__builtins__': {}}, eval_locals) 154 except Exception as e: 155 report_template_failure(template, str(e)) 156 title = tab.title 157 if '\x1b' in title: 158 import re 159 for x in re.split('(\x1b\\[[^m]*m)', title): 160 if x.startswith('\x1b') and x.endswith('m'): 161 screen.apply_sgr(x[2:-1]) 162 else: 163 screen.draw(x) 164 else: 165 screen.draw(title) 166 167 168def draw_tab_with_slant(draw_data: DrawData, screen: Screen, tab: TabBarData, before: int, max_title_length: int, index: int, is_last: bool) -> int: 169 left_sep, right_sep = ('', '') if draw_data.tab_bar_edge == 'top' else ('', '') 170 tab_bg = as_rgb(color_as_int(draw_data.active_bg if tab.is_active else draw_data.inactive_bg)) 171 slant_fg = as_rgb(color_as_int(draw_data.default_bg)) 172 173 def draw_sep(which: str) -> None: 174 screen.cursor.bg = tab_bg 175 screen.cursor.fg = slant_fg 176 screen.draw(which) 177 screen.cursor.bg = tab_bg 178 screen.cursor.fg = 0 179 180 max_title_length += 1 181 if max_title_length <= 1: 182 screen.draw('…') 183 elif max_title_length == 2: 184 screen.draw('…|') 185 elif max_title_length < 6: 186 draw_sep(left_sep) 187 screen.draw((' ' if max_title_length == 5 else '') + '…' + (' ' if max_title_length >= 4 else '')) 188 draw_sep(right_sep) 189 else: 190 draw_sep(left_sep) 191 screen.draw(' ') 192 draw_title(draw_data, screen, tab, index) 193 extra = screen.cursor.x - before - max_title_length 194 if extra >= 0: 195 screen.cursor.x -= extra + 3 196 screen.draw('…') 197 elif extra == -1: 198 screen.cursor.x -= 2 199 screen.draw('…') 200 screen.draw(' ') 201 draw_sep(right_sep) 202 203 return screen.cursor.x 204 205 206def draw_tab_with_separator(draw_data: DrawData, screen: Screen, tab: TabBarData, before: int, max_title_length: int, index: int, is_last: bool) -> int: 207 tab_bg = draw_data.active_bg if tab.is_active else draw_data.inactive_bg 208 screen.cursor.bg = as_rgb(color_as_int(tab_bg)) 209 if draw_data.leading_spaces: 210 screen.draw(' ' * draw_data.leading_spaces) 211 draw_title(draw_data, screen, tab, index) 212 trailing_spaces = min(max_title_length - 1, draw_data.trailing_spaces) 213 max_title_length -= trailing_spaces 214 extra = screen.cursor.x - before - max_title_length 215 if extra > 0: 216 screen.cursor.x -= extra + 1 217 screen.draw('…') 218 if trailing_spaces: 219 screen.draw(' ' * trailing_spaces) 220 end = screen.cursor.x 221 screen.cursor.bold = screen.cursor.italic = False 222 screen.cursor.fg = 0 223 if not is_last: 224 screen.cursor.bg = as_rgb(color_as_int(draw_data.inactive_bg)) 225 screen.draw(draw_data.sep) 226 screen.cursor.bg = 0 227 return end 228 229 230def draw_tab_with_fade(draw_data: DrawData, screen: Screen, tab: TabBarData, before: int, max_title_length: int, index: int, is_last: bool) -> int: 231 tab_bg = draw_data.active_bg if tab.is_active else draw_data.inactive_bg 232 fade_colors = [as_rgb(color_as_int(alpha_blend(tab_bg, draw_data.default_bg, alpha))) for alpha in draw_data.alpha] 233 for bg in fade_colors: 234 screen.cursor.bg = bg 235 screen.draw(' ') 236 screen.cursor.bg = as_rgb(color_as_int(tab_bg)) 237 draw_title(draw_data, screen, tab, index) 238 extra = screen.cursor.x - before - max_title_length 239 if extra > 0: 240 screen.cursor.x = before 241 draw_title(draw_data, screen, tab, index) 242 extra = screen.cursor.x - before - max_title_length 243 if extra > 0: 244 screen.cursor.x -= extra + 1 245 screen.draw('…') 246 for bg in reversed(fade_colors): 247 if extra >= 0: 248 break 249 extra += 1 250 screen.cursor.bg = bg 251 screen.draw(' ') 252 end = screen.cursor.x 253 screen.cursor.bg = as_rgb(color_as_int(draw_data.default_bg)) 254 screen.draw(' ') 255 return end 256 257 258powerline_symbols: Dict[PowerlineStyle, Tuple[str, str]] = { 259 'slanted': ('', '╱'), 260 'round': ('', '') 261} 262 263 264def draw_tab_with_powerline(draw_data: DrawData, screen: Screen, tab: TabBarData, before: int, max_title_length: int, index: int, is_last: bool) -> int: 265 tab_bg = as_rgb(color_as_int(draw_data.active_bg if tab.is_active else draw_data.inactive_bg)) 266 tab_fg = as_rgb(color_as_int(draw_data.active_fg if tab.is_active else draw_data.inactive_fg)) 267 inactive_bg = as_rgb(color_as_int(draw_data.inactive_bg)) 268 default_bg = as_rgb(color_as_int(draw_data.default_bg)) 269 270 separator_symbol, separator_alt_symbol = powerline_symbols.get(draw_data.powerline_style, ('', '')) 271 min_title_length = 1 + 2 272 273 if screen.cursor.x + min_title_length >= screen.columns: 274 screen.cursor.x -= 2 275 screen.cursor.bg = default_bg 276 screen.cursor.fg = inactive_bg 277 screen.draw(f'{separator_symbol} ') 278 return screen.cursor.x 279 280 start_draw = 2 281 if tab.is_active and screen.cursor.x >= 2: 282 screen.cursor.x -= 2 283 screen.cursor.fg = inactive_bg 284 screen.cursor.bg = tab_bg 285 screen.draw(f'{separator_symbol} ') 286 screen.cursor.fg = tab_fg 287 elif screen.cursor.x == 0: 288 screen.cursor.bg = tab_bg 289 screen.draw(' ') 290 start_draw = 1 291 292 screen.cursor.bg = tab_bg 293 if min_title_length >= max_title_length: 294 screen.draw('…') 295 else: 296 draw_title(draw_data, screen, tab, index) 297 extra = screen.cursor.x + start_draw - before - max_title_length 298 if extra > 0 and extra + 1 < screen.cursor.x: 299 screen.cursor.x -= extra + 1 300 screen.draw('…') 301 302 if tab.is_active or is_last: 303 screen.draw(' ') 304 screen.cursor.fg = tab_bg 305 if is_last: 306 screen.cursor.bg = default_bg 307 else: 308 screen.cursor.bg = inactive_bg 309 screen.draw(separator_symbol) 310 else: 311 prev_fg = screen.cursor.fg 312 if tab_bg == tab_fg: 313 screen.cursor.fg = default_bg 314 elif tab_bg != default_bg: 315 c1 = draw_data.inactive_bg.contrast(draw_data.default_bg) 316 c2 = draw_data.inactive_bg.contrast(draw_data.inactive_fg) 317 if c1 < c2: 318 screen.cursor.fg = default_bg 319 screen.draw(f' {separator_alt_symbol}') 320 screen.cursor.fg = prev_fg 321 322 end = screen.cursor.x 323 if end < screen.columns: 324 screen.draw(' ') 325 return end 326 327 328class TabBar: 329 330 def __init__(self, os_window_id: int): 331 self.os_window_id = os_window_id 332 self.num_tabs = 1 333 self.data_buffer_size = 0 334 self.blank_rects: Tuple[Rect, ...] = () 335 self.laid_out_once = False 336 self.apply_options() 337 338 def apply_options(self) -> None: 339 opts = get_options() 340 self.dirty = True 341 self.margin_width = pt_to_px(opts.tab_bar_margin_width, self.os_window_id) 342 self.cell_width, cell_height = cell_size_for_window(self.os_window_id) 343 if not hasattr(self, 'screen'): 344 self.screen = s = Screen(None, 1, 10, 0, self.cell_width, cell_height) 345 else: 346 s = self.screen 347 s.color_profile.update_ansi_color_table(build_ansi_color_table(opts)) 348 s.color_profile.set_configured_colors( 349 color_as_int(opts.inactive_tab_foreground), 350 color_as_int(opts.tab_bar_background or opts.background) 351 ) 352 sep = opts.tab_separator 353 self.trailing_spaces = self.leading_spaces = 0 354 while sep and sep[0] == ' ': 355 sep = sep[1:] 356 self.trailing_spaces += 1 357 while sep and sep[-1] == ' ': 358 self.leading_spaces += 1 359 sep = sep[:-1] 360 self.sep = sep 361 self.active_font_style = opts.active_tab_font_style 362 self.inactive_font_style = opts.inactive_tab_font_style 363 364 self.active_bg = as_rgb(color_as_int(opts.active_tab_background)) 365 self.active_fg = as_rgb(color_as_int(opts.active_tab_foreground)) 366 self.bell_fg = as_rgb(0xff0000) 367 self.draw_data = DrawData( 368 self.leading_spaces, self.sep, self.trailing_spaces, opts.bell_on_tab, self.bell_fg, 369 opts.tab_fade, opts.active_tab_foreground, opts.active_tab_background, 370 opts.inactive_tab_foreground, opts.inactive_tab_background, 371 opts.tab_bar_background or opts.background, opts.tab_title_template, 372 opts.active_tab_title_template, 373 opts.tab_activity_symbol, 374 opts.tab_powerline_style, 375 'top' if opts.tab_bar_edge == 1 else 'bottom' 376 ) 377 if opts.tab_bar_style == 'separator': 378 self.draw_func = draw_tab_with_separator 379 elif opts.tab_bar_style == 'powerline': 380 self.draw_func = draw_tab_with_powerline 381 elif opts.tab_bar_style == 'slant': 382 self.draw_func = draw_tab_with_slant 383 else: 384 self.draw_func = draw_tab_with_fade 385 386 def patch_colors(self, spec: Dict[str, Any]) -> None: 387 if 'active_tab_foreground' in spec: 388 self.active_fg = (spec['active_tab_foreground'] << 8) | 2 389 self.draw_data = self.draw_data._replace(active_fg=color_from_int(spec['active_tab_foreground'])) 390 if 'active_tab_background' in spec: 391 self.active_bg = (spec['active_tab_background'] << 8) | 2 392 self.draw_data = self.draw_data._replace(active_bg=color_from_int(spec['active_tab_background'])) 393 if 'inactive_tab_background' in spec: 394 self.draw_data = self.draw_data._replace(inactive_bg=color_from_int(spec['inactive_tab_background'])) 395 if 'tab_bar_background' in spec: 396 self.draw_data = self.draw_data._replace(default_bg=color_from_int(spec['tab_bar_background'])) 397 opts = get_options() 398 fg = spec.get('inactive_tab_foreground', color_as_int(opts.inactive_tab_foreground)) 399 bg = spec.get('tab_bar_background', False) 400 if bg is None: 401 bg = color_as_int(opts.background) 402 elif bg is False: 403 bg = color_as_int(opts.tab_bar_background or opts.background) 404 self.screen.color_profile.set_configured_colors(fg, bg) 405 406 def layout(self) -> None: 407 central, tab_bar, vw, vh, cell_width, cell_height = viewport_for_window(self.os_window_id) 408 if tab_bar.width < 2: 409 return 410 opts = get_options() 411 self.cell_width = cell_width 412 s = self.screen 413 viewport_width = max(4 * cell_width, tab_bar.width - 2 * self.margin_width) 414 ncells = viewport_width // cell_width 415 s.resize(1, ncells) 416 s.reset_mode(DECAWM) 417 self.laid_out_once = True 418 margin = (viewport_width - ncells * cell_width) // 2 + self.margin_width 419 self.window_geometry = g = WindowGeometry( 420 margin, tab_bar.top, viewport_width - margin, tab_bar.bottom, s.columns, s.lines) 421 blank_rects: List[Rect] = [] 422 if margin > 0: 423 blank_rects.append(Rect(0, g.top, g.left, g.bottom + 1)) 424 blank_rects.append(Rect(g.right - 1, g.top, viewport_width, g.bottom + 1)) 425 if opts.tab_bar_margin_height: 426 if opts.tab_bar_edge == 3: # bottom 427 if opts.tab_bar_margin_height.outer: 428 blank_rects.append(Rect(0, tab_bar.bottom + 1, vw, vh)) 429 if opts.tab_bar_margin_height.inner: 430 blank_rects.append(Rect(0, central.bottom + 1, vw, vh)) 431 else: # top 432 if opts.tab_bar_margin_height.outer: 433 blank_rects.append(Rect(0, 0, vw, tab_bar.top)) 434 if opts.tab_bar_margin_height.inner: 435 blank_rects.append(Rect(0, tab_bar.bottom + 1, vw, central.top)) 436 437 self.blank_rects = tuple(blank_rects) 438 self.screen_geometry = sg = calculate_gl_geometry(g, vw, vh, cell_width, cell_height) 439 set_tab_bar_render_data(self.os_window_id, sg.xstart, sg.ystart, sg.dx, sg.dy, self.screen) 440 441 def update(self, data: Sequence[TabBarData]) -> None: 442 if not self.laid_out_once: 443 return 444 s = self.screen 445 s.cursor.x = 0 446 s.erase_in_line(2, False) 447 max_title_length = max(1, (self.screen_geometry.xnum // max(1, len(data))) - 1) 448 cr = [] 449 last_tab = data[-1] if data else None 450 451 for i, t in enumerate(data): 452 s.cursor.bg = self.active_bg if t.is_active else 0 453 s.cursor.fg = self.active_fg if t.is_active else 0 454 s.cursor.bold, s.cursor.italic = self.active_font_style if t.is_active else self.inactive_font_style 455 before = s.cursor.x 456 end = self.draw_func(self.draw_data, s, t, before, max_title_length, i + 1, t is last_tab) 457 s.cursor.bg = s.cursor.fg = 0 458 cr.append((before, end)) 459 if s.cursor.x > s.columns - max_title_length and t is not last_tab: 460 s.cursor.x = s.columns - 2 461 s.cursor.bg = as_rgb(color_as_int(self.draw_data.default_bg)) 462 s.cursor.fg = self.bell_fg 463 s.draw(' …') 464 break 465 s.erase_in_line(0, False) # Ensure no long titles bleed after the last tab 466 self.cell_ranges = cr 467 468 def destroy(self) -> None: 469 self.screen.reset_callbacks() 470 del self.screen 471 472 def tab_at(self, x: int) -> Optional[int]: 473 x = (x - self.window_geometry.left) // self.cell_width 474 for i, (a, b) in enumerate(self.cell_ranges): 475 if a <= x <= b: 476 return i 477