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