1#!/usr/local/bin/python3.8
2# vim:fileencoding=utf-8
3# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
4
5import os
6import stat
7import weakref
8from collections import deque
9from contextlib import suppress
10from functools import partial
11from operator import attrgetter
12from typing import (
13    Any, Deque, Dict, Generator, Iterator, List, NamedTuple, Optional, Pattern,
14    Sequence, Tuple, Union, cast
15)
16
17from .borders import Borders
18from .child import Child
19from .cli_stub import CLIOptions
20from .constants import appname, kitty_exe
21from .fast_data_types import (
22    add_tab, attach_window, detach_window, get_boss, get_options,
23    mark_tab_bar_dirty, next_window_id, remove_tab, remove_window, ring_bell,
24    set_active_tab, set_active_window, swap_tabs, sync_os_window_title
25)
26from .layout.base import Layout, Rect
27from .layout.interface import create_layout_object_for, evict_cached_layouts
28from .tab_bar import TabBar, TabBarData
29from .types import ac
30from .typing import EdgeLiteral, SessionTab, SessionType, TypedDict
31from .utils import log_error, platform_window_id, resolved_shell
32from .window import Watchers, Window, WindowDict
33from .window_list import WindowList
34
35
36class TabDict(TypedDict):
37    id: int
38    is_focused: bool
39    title: str
40    layout: str
41    layout_state: Dict[str, Any]
42    windows: List[WindowDict]
43    active_window_history: List[int]
44
45
46class SpecialWindowInstance(NamedTuple):
47    cmd: Optional[List[str]]
48    stdin: Optional[bytes]
49    override_title: Optional[str]
50    cwd_from: Optional[int]
51    cwd: Optional[str]
52    overlay_for: Optional[int]
53    env: Optional[Dict[str, str]]
54    watchers: Optional[Watchers]
55
56
57def SpecialWindow(
58    cmd: Optional[List[str]],
59    stdin: Optional[bytes] = None,
60    override_title: Optional[str] = None,
61    cwd_from: Optional[int] = None,
62    cwd: Optional[str] = None,
63    overlay_for: Optional[int] = None,
64    env: Optional[Dict[str, str]] = None,
65    watchers: Optional[Watchers] = None
66) -> SpecialWindowInstance:
67    return SpecialWindowInstance(cmd, stdin, override_title, cwd_from, cwd, overlay_for, env, watchers)
68
69
70def add_active_id_to_history(items: Deque[int], item_id: int, maxlen: int = 64) -> None:
71    with suppress(ValueError):
72        items.remove(item_id)
73    items.append(item_id)
74    if len(items) > maxlen:
75        items.popleft()
76
77
78class Tab:  # {{{
79
80    def __init__(
81        self,
82        tab_manager: 'TabManager',
83        session_tab: Optional['SessionTab'] = None,
84        special_window: Optional[SpecialWindowInstance] = None,
85        cwd_from: Optional[int] = None,
86        no_initial_window: bool = False
87    ):
88        self.tab_manager_ref = weakref.ref(tab_manager)
89        self.os_window_id: int = tab_manager.os_window_id
90        self.id: int = add_tab(self.os_window_id)
91        if not self.id:
92            raise Exception('No OS window with id {} found, or tab counter has wrapped'.format(self.os_window_id))
93        self.args = tab_manager.args
94        self.name = getattr(session_tab, 'name', '')
95        self.enabled_layouts = [x.lower() for x in getattr(session_tab, 'enabled_layouts', None) or get_options().enabled_layouts]
96        self.borders = Borders(self.os_window_id, self.id)
97        self.windows = WindowList(self)
98        for i, which in enumerate('first second third fourth fifth sixth seventh eighth ninth tenth'.split()):
99            setattr(self, which + '_window', partial(self.nth_window, num=i))
100        self._last_used_layout: Optional[str] = None
101        self._current_layout_name: Optional[str] = None
102        self.cwd = self.args.directory
103        if no_initial_window:
104            self._set_current_layout(self.enabled_layouts[0])
105        elif session_tab is None:
106            sl = self.enabled_layouts[0]
107            self._set_current_layout(sl)
108            if special_window is None:
109                self.new_window(cwd_from=cwd_from)
110            else:
111                self.new_special_window(special_window)
112        else:
113            if session_tab.cwd:
114                self.cwd = session_tab.cwd
115            l0 = session_tab.layout
116            self._set_current_layout(l0)
117            self.startup(session_tab)
118
119    def apply_options(self) -> None:
120        for window in self:
121            window.apply_options()
122        self.enabled_layouts = [x.lower() for x in get_options().enabled_layouts] or ['tall']
123        if self.current_layout.name not in self.enabled_layouts:
124            self._set_current_layout(self.enabled_layouts[0])
125        self.relayout()
126
127    def take_over_from(self, other_tab: 'Tab') -> None:
128        self.name, self.cwd = other_tab.name, other_tab.cwd
129        self.enabled_layouts = list(other_tab.enabled_layouts)
130        if other_tab._current_layout_name:
131            self._set_current_layout(other_tab._current_layout_name)
132        self._last_used_layout = other_tab._last_used_layout
133        for window in other_tab.windows:
134            detach_window(other_tab.os_window_id, other_tab.id, window.id)
135        self.windows = other_tab.windows
136        self.windows.change_tab(self)
137        other_tab.windows = WindowList(other_tab)
138        for window in self.windows:
139            window.change_tab(self)
140            attach_window(self.os_window_id, self.id, window.id)
141        self.relayout()
142
143    def _set_current_layout(self, layout_name: str) -> None:
144        self._last_used_layout = self._current_layout_name
145        self.current_layout = self.create_layout_object(layout_name)
146        self._current_layout_name = layout_name
147        self.mark_tab_bar_dirty()
148
149    def startup(self, session_tab: 'SessionTab') -> None:
150        for cmd in session_tab.windows:
151            if isinstance(cmd, SpecialWindowInstance):
152                self.new_special_window(cmd)
153            else:
154                from .launch import launch
155                launch(get_boss(), cmd.opts, cmd.args, target_tab=self, force_target_tab=True)
156        self.windows.set_active_window_group_for(self.windows.all_windows[session_tab.active_window_idx])
157
158    def serialize_state(self) -> Dict[str, Any]:
159        return {
160            'version': 1,
161            'id': self.id,
162            'window_list': self.windows.serialize_state(),
163            'current_layout': self._current_layout_name,
164            'last_used_layout': self._last_used_layout,
165            'name': self.name,
166        }
167
168    def active_window_changed(self) -> None:
169        w = self.active_window
170        set_active_window(self.os_window_id, self.id, 0 if w is None else w.id)
171        self.mark_tab_bar_dirty()
172        self.relayout_borders()
173        self.current_layout.update_visibility(self.windows)
174
175    def mark_tab_bar_dirty(self) -> None:
176        tm = self.tab_manager_ref()
177        if tm is not None:
178            tm.mark_tab_bar_dirty()
179
180    @property
181    def active_window(self) -> Optional[Window]:
182        return self.windows.active_window
183
184    @property
185    def active_window_for_cwd(self) -> Optional[Window]:
186        return self.windows.active_group_base
187
188    @property
189    def title(self) -> str:
190        return cast(str, getattr(self.active_window, 'title', appname))
191
192    def set_title(self, title: str) -> None:
193        self.name = title or ''
194        self.mark_tab_bar_dirty()
195
196    def title_changed(self, window: Window) -> None:
197        if window is self.active_window:
198            tm = self.tab_manager_ref()
199            if tm is not None:
200                tm.title_changed(self)
201
202    def on_bell(self, window: Window) -> None:
203        self.mark_tab_bar_dirty()
204
205    def relayout(self) -> None:
206        if self.windows:
207            self.current_layout(self.windows)
208        self.relayout_borders()
209
210    def relayout_borders(self) -> None:
211        tm = self.tab_manager_ref()
212        if tm is not None:
213            w = self.active_window
214            ly = self.current_layout
215            self.borders(
216                all_windows=self.windows,
217                current_layout=ly, extra_blank_rects=tm.blank_rects,
218                draw_window_borders=(ly.needs_window_borders and self.windows.num_visble_groups > 1) or ly.must_draw_borders
219            )
220            if w is not None:
221                w.change_titlebar_color()
222
223    def create_layout_object(self, name: str) -> Layout:
224        return create_layout_object_for(name, self.os_window_id, self.id)
225
226    @ac('lay', 'Go to the next enabled layout')
227    def next_layout(self) -> None:
228        if len(self.enabled_layouts) > 1:
229            for i, layout_name in enumerate(self.enabled_layouts):
230                if layout_name == self.current_layout.full_name:
231                    idx = i
232                    break
233            else:
234                idx = -1
235            nl = self.enabled_layouts[(idx + 1) % len(self.enabled_layouts)]
236            self._set_current_layout(nl)
237            self.relayout()
238
239    @ac('lay', 'Go to the previously used layout')
240    def last_used_layout(self) -> None:
241        if len(self.enabled_layouts) > 1 and self._last_used_layout and self._last_used_layout != self._current_layout_name:
242            self._set_current_layout(self._last_used_layout)
243            self.relayout()
244
245    @ac('lay', '''
246        Switch to the named layout
247
248        For example::
249
250            map f1 goto_layout tall
251        ''')
252    def goto_layout(self, layout_name: str, raise_exception: bool = False) -> None:
253        layout_name = layout_name.lower()
254        if layout_name not in self.enabled_layouts:
255            if raise_exception:
256                raise ValueError(layout_name)
257            log_error('Unknown or disabled layout: {}'.format(layout_name))
258            return
259        self._set_current_layout(layout_name)
260        self.relayout()
261
262    @ac('lay', '''
263        Toggle the named layout
264
265        Switches to the named layout if another layout is current, otherwise
266        switches to the last used layout. Useful to "zoom" a window temporarily
267        by switching to the stack layout. For example::
268
269            map f1 toggle_layout stack
270        ''')
271    def toggle_layout(self, layout_name: str) -> None:
272        if self._current_layout_name == layout_name:
273            self.last_used_layout()
274        else:
275            self.goto_layout(layout_name)
276
277    def resize_window_by(self, window_id: int, increment: float, is_horizontal: bool) -> Optional[str]:
278        increment_as_percent = self.current_layout.bias_increment_for_cell(is_horizontal) * increment
279        if self.current_layout.modify_size_of_window(self.windows, window_id, increment_as_percent, is_horizontal):
280            self.relayout()
281            return None
282        return 'Could not resize'
283
284    @ac('win', '''
285        Resize the active window by the specified amount
286
287        See :ref:`window_resizing` for details.
288        ''')
289    def resize_window(self, quality: str, increment: int) -> None:
290        if increment < 1:
291            raise ValueError(increment)
292        is_horizontal = quality in ('wider', 'narrower')
293        increment *= 1 if quality in ('wider', 'taller') else -1
294        w = self.active_window
295        if w is not None and self.resize_window_by(
296                w.id, increment, is_horizontal) is not None:
297            if get_options().enable_audio_bell:
298                ring_bell()
299
300    @ac('win', 'Reset window sizes undoing any dynamic resizing of windows')
301    def reset_window_sizes(self) -> None:
302        if self.current_layout.remove_all_biases():
303            self.relayout()
304
305    @ac('lay', 'Perform a layout specific action. See :doc:`layouts` for details')
306    def layout_action(self, action_name: str, args: Sequence[str]) -> None:
307        ret = self.current_layout.layout_action(action_name, args, self.windows)
308        if ret is None:
309            ring_bell()
310            return
311        self.relayout()
312
313    def launch_child(
314        self,
315        use_shell: bool = False,
316        cmd: Optional[List[str]] = None,
317        stdin: Optional[bytes] = None,
318        cwd_from: Optional[int] = None,
319        cwd: Optional[str] = None,
320        env: Optional[Dict[str, str]] = None,
321        allow_remote_control: bool = False
322    ) -> Child:
323        check_for_suitability = True
324        if cmd is None:
325            if use_shell:
326                cmd = resolved_shell(get_options())
327                check_for_suitability = False
328            else:
329                if self.args.args:
330                    cmd = list(self.args.args)
331                else:
332                    cmd = resolved_shell(get_options())
333                    check_for_suitability = False
334                cmd = self.args.args or resolved_shell(get_options())
335        if check_for_suitability:
336            old_exe = cmd[0]
337            if not os.path.isabs(old_exe):
338                import shutil
339                actual_exe = shutil.which(old_exe)
340                old_exe = actual_exe if actual_exe else os.path.abspath(old_exe)
341            try:
342                is_executable = os.access(old_exe, os.X_OK)
343            except OSError:
344                pass
345            else:
346                try:
347                    st = os.stat(old_exe)
348                except OSError:
349                    pass
350                else:
351                    if stat.S_ISDIR(st.st_mode):
352                        cwd = old_exe
353                        cmd = resolved_shell(get_options())
354                    elif not is_executable:
355                        import shlex
356                        with suppress(OSError):
357                            with open(old_exe) as f:
358                                cmd = [kitty_exe(), '+hold']
359                                if f.read(2) == '#!':
360                                    line = f.read(4096).splitlines()[0]
361                                    cmd += shlex.split(line) + [old_exe]
362                                else:
363                                    cmd += [resolved_shell(get_options())[0], cmd[0]]
364        fenv: Dict[str, str] = {}
365        if env:
366            fenv.update(env)
367        fenv['KITTY_WINDOW_ID'] = str(next_window_id())
368        pwid = platform_window_id(self.os_window_id)
369        if pwid is not None:
370            fenv['WINDOWID'] = str(pwid)
371        ans = Child(cmd, cwd or self.cwd, stdin, fenv, cwd_from, allow_remote_control=allow_remote_control)
372        ans.fork()
373        return ans
374
375    def _add_window(self, window: Window, location: Optional[str] = None, overlay_for: Optional[int] = None) -> None:
376        self.current_layout.add_window(self.windows, window, location, overlay_for)
377        self.mark_tab_bar_dirty()
378        self.relayout()
379
380    def new_window(
381        self,
382        use_shell: bool = True,
383        cmd: Optional[List[str]] = None,
384        stdin: Optional[bytes] = None,
385        override_title: Optional[str] = None,
386        cwd_from: Optional[int] = None,
387        cwd: Optional[str] = None,
388        overlay_for: Optional[int] = None,
389        env: Optional[Dict[str, str]] = None,
390        location: Optional[str] = None,
391        copy_colors_from: Optional[Window] = None,
392        allow_remote_control: bool = False,
393        marker: Optional[str] = None,
394        watchers: Optional[Watchers] = None
395    ) -> Window:
396        child = self.launch_child(
397            use_shell=use_shell, cmd=cmd, stdin=stdin, cwd_from=cwd_from, cwd=cwd, env=env, allow_remote_control=allow_remote_control)
398        window = Window(
399            self, child, self.args, override_title=override_title,
400            copy_colors_from=copy_colors_from, watchers=watchers
401        )
402        # Must add child before laying out so that resize_pty succeeds
403        get_boss().add_child(window)
404        self._add_window(window, location=location, overlay_for=overlay_for)
405        if marker:
406            try:
407                window.set_marker(marker)
408            except Exception:
409                import traceback
410                traceback.print_exc()
411        return window
412
413    def new_special_window(
414            self,
415            special_window: SpecialWindowInstance,
416            location: Optional[str] = None,
417            copy_colors_from: Optional[Window] = None,
418            allow_remote_control: bool = False,
419    ) -> Window:
420        return self.new_window(
421            use_shell=False, cmd=special_window.cmd, stdin=special_window.stdin,
422            override_title=special_window.override_title,
423            cwd_from=special_window.cwd_from, cwd=special_window.cwd, overlay_for=special_window.overlay_for,
424            env=special_window.env, location=location, copy_colors_from=copy_colors_from,
425            allow_remote_control=allow_remote_control, watchers=special_window.watchers
426        )
427
428    @ac('win', 'Close the currently active window')
429    def close_window(self) -> None:
430        w = self.active_window
431        if w is not None:
432            self.remove_window(w)
433
434    @ac('win', 'Close all windows in the tab other than the currently active window')
435    def close_other_windows_in_tab(self) -> None:
436        if len(self.windows) > 1:
437            active_window = self.active_window
438            for window in tuple(self.windows):
439                if window is not active_window:
440                    self.remove_window(window)
441
442    def remove_window(self, window: Window, destroy: bool = True) -> None:
443        self.windows.remove_window(window)
444        if destroy:
445            remove_window(self.os_window_id, self.id, window.id)
446        else:
447            detach_window(self.os_window_id, self.id, window.id)
448        self.mark_tab_bar_dirty()
449        self.relayout()
450        active_window = self.active_window
451        if active_window:
452            self.title_changed(active_window)
453
454    def detach_window(self, window: Window) -> Tuple[Window, ...]:
455        windows = list(self.windows.windows_in_group_of(window))
456        windows.sort(key=attrgetter('id'))  # since ids increase in order of creation
457        for w in reversed(windows):
458            self.remove_window(w, destroy=False)
459        return tuple(windows)
460
461    def attach_window(self, window: Window) -> None:
462        window.change_tab(self)
463        attach_window(self.os_window_id, self.id, window.id)
464        self._add_window(window)
465
466    def set_active_window(self, x: Union[Window, int]) -> None:
467        self.windows.set_active_window_group_for(x)
468
469    def get_nth_window(self, n: int) -> Optional[Window]:
470        if self.windows:
471            return self.current_layout.nth_window(self.windows, n)
472
473    @ac('win', '''
474        Focus the nth window if positive or the previously active windows if negative
475
476        For example, to ficus the previously active window::
477
478            map ctrl+p nth_window -1
479        ''')
480    def nth_window(self, num: int = 0) -> None:
481        if self.windows:
482            if num < 0:
483                self.windows.make_previous_group_active(-num)
484            else:
485                self.current_layout.activate_nth_window(self.windows, num)
486            self.relayout_borders()
487
488    def _next_window(self, delta: int = 1) -> None:
489        if len(self.windows) > 1:
490            self.current_layout.next_window(self.windows, delta)
491            self.relayout_borders()
492
493    @ac('win', 'Focus the next window in the current tab')
494    def next_window(self) -> None:
495        self._next_window()
496
497    @ac('win', 'Focus the previous window in the current tab')
498    def previous_window(self) -> None:
499        self._next_window(-1)
500
501    prev_window = previous_window
502
503    def most_recent_group(self, groups: Sequence[int]) -> Optional[int]:
504        groups_set = frozenset(groups)
505
506        for window_id in reversed(self.windows.active_window_history):
507            group = self.windows.group_for_window(window_id)
508            if group and group.id in groups_set:
509                return group.id
510
511        if groups:
512            return groups[0]
513
514    def nth_active_window_id(self, n: int = 0) -> int:
515        if n <= 0:
516            return self.active_window.id if self.active_window else 0
517        ids = tuple(reversed(self.windows.active_window_history))
518        return ids[min(n - 1, len(ids) - 1)] if ids else 0
519
520    def neighboring_group_id(self, which: EdgeLiteral) -> Optional[int]:
521        neighbors = self.current_layout.neighbors(self.windows)
522        candidates = neighbors.get(which)
523        if candidates:
524            return self.most_recent_group(candidates)
525
526    @ac('win', '''
527        Focus the neighboring window in the current tab
528
529        For example::
530
531            map ctrl+left neighboring_window left
532            map ctrl+down neighboring_window bottom
533        ''')
534    def neighboring_window(self, which: EdgeLiteral) -> None:
535        neighbor = self.neighboring_group_id(which)
536        if neighbor:
537            self.windows.set_active_group(neighbor)
538
539    @ac('win', '''
540        Move the window in the specified direction
541
542        For example::
543
544            map ctrl+left move_window left
545            map ctrl+down move_window bottom
546        ''')
547    def move_window(self, delta: Union[EdgeLiteral, int] = 1) -> None:
548        if isinstance(delta, int):
549            if self.current_layout.move_window(self.windows, delta):
550                self.relayout()
551        elif isinstance(delta, str):
552            neighbor = self.neighboring_group_id(delta)
553            if neighbor:
554                if self.current_layout.move_window_to_group(self.windows, neighbor):
555                    self.relayout()
556
557    @ac('win', 'Move active window to the top (make it the first window)')
558    def move_window_to_top(self) -> None:
559        n = self.windows.active_group_idx
560        if n > 0:
561            self.move_window(-n)
562
563    @ac('win', 'Move active window forward (swap it with the next window)')
564    def move_window_forward(self) -> None:
565        self.move_window()
566
567    @ac('win', 'Move active window backward (swap it with the previous window)')
568    def move_window_backward(self) -> None:
569        self.move_window(-1)
570
571    def list_windows(self, active_window: Optional[Window], self_window: Optional[Window] = None) -> Generator[WindowDict, None, None]:
572        for w in self:
573            yield w.as_dict(is_focused=w is active_window, is_self=w is self_window)
574
575    def matches(self, field: str, pat: Pattern) -> bool:
576        if field == 'id':
577            return bool(pat.pattern == str(self.id))
578        if field == 'title':
579            return pat.search(self.name or self.title) is not None
580        return False
581
582    def __iter__(self) -> Iterator[Window]:
583        return iter(self.windows)
584
585    def __len__(self) -> int:
586        return len(self.windows)
587
588    @property
589    def num_window_groups(self) -> int:
590        return self.windows.num_groups
591
592    def __contains__(self, window: Window) -> bool:
593        return window in self.windows
594
595    def destroy(self) -> None:
596        evict_cached_layouts(self.id)
597        for w in self.windows:
598            w.destroy()
599        self.windows = WindowList(self)
600
601    def __repr__(self) -> str:
602        return 'Tab(title={}, id={})'.format(self.name or self.title, hex(id(self)))
603
604    def make_active(self) -> None:
605        tm = self.tab_manager_ref()
606        if tm is not None:
607            tm.set_active_tab(self)
608# }}}
609
610
611class TabManager:  # {{{
612
613    def __init__(self, os_window_id: int, args: CLIOptions, wm_class: str, wm_name: str, startup_session: Optional[SessionType] = None):
614        self.os_window_id = os_window_id
615        self.wm_class = wm_class
616        self.wm_name = wm_name
617        self.last_active_tab_id = None
618        self.args = args
619        self.tab_bar_hidden = get_options().tab_bar_style == 'hidden'
620        self.tabs: List[Tab] = []
621        self.active_tab_history: Deque[int] = deque()
622        self.tab_bar = TabBar(self.os_window_id)
623        self._active_tab_idx = 0
624
625        if startup_session is not None:
626            for t in startup_session.tabs:
627                self._add_tab(Tab(self, session_tab=t))
628            self._set_active_tab(max(0, min(startup_session.active_tab_idx, len(self.tabs) - 1)))
629
630    @property
631    def active_tab_idx(self) -> int:
632        return self._active_tab_idx
633
634    @active_tab_idx.setter
635    def active_tab_idx(self, val: int) -> None:
636        new_active_tab_idx = max(0, min(val, len(self.tabs) - 1))
637        if new_active_tab_idx == self._active_tab_idx:
638            return
639        try:
640            old_active_tab: Optional[Tab] = self.tabs[self._active_tab_idx]
641        except Exception:
642            old_active_tab = None
643        else:
644            assert old_active_tab is not None
645            add_active_id_to_history(self.active_tab_history, old_active_tab.id)
646        self._active_tab_idx = new_active_tab_idx
647        try:
648            new_active_tab: Optional[Tab] = self.tabs[self._active_tab_idx]
649        except Exception:
650            new_active_tab = None
651        if old_active_tab is not new_active_tab:
652            if old_active_tab is not None:
653                w = old_active_tab.active_window
654                if w is not None:
655                    w.focus_changed(False)
656            if new_active_tab is not None:
657                w = new_active_tab.active_window
658                if w is not None:
659                    w.focus_changed(True)
660
661    def refresh_sprite_positions(self) -> None:
662        if not self.tab_bar_hidden:
663            self.tab_bar.screen.refresh_sprite_positions()
664
665    @property
666    def tab_bar_should_be_visible(self) -> bool:
667        return len(self.tabs) >= get_options().tab_bar_min_tabs
668
669    def _add_tab(self, tab: Tab) -> None:
670        visible_before = self.tab_bar_should_be_visible
671        self.tabs.append(tab)
672        if not visible_before and self.tab_bar_should_be_visible:
673            self.tabbar_visibility_changed()
674
675    def _remove_tab(self, tab: Tab) -> None:
676        visible_before = self.tab_bar_should_be_visible
677        remove_tab(self.os_window_id, tab.id)
678        self.tabs.remove(tab)
679        if visible_before and not self.tab_bar_should_be_visible:
680            self.tabbar_visibility_changed()
681
682    def _set_active_tab(self, idx: int) -> None:
683        self.active_tab_idx = idx
684        set_active_tab(self.os_window_id, idx)
685
686    def tabbar_visibility_changed(self) -> None:
687        if not self.tab_bar_hidden:
688            self.tab_bar.layout()
689            self.resize(only_tabs=True)
690
691    def mark_tab_bar_dirty(self) -> None:
692        if self.tab_bar_should_be_visible and not self.tab_bar_hidden:
693            mark_tab_bar_dirty(self.os_window_id)
694
695    def update_tab_bar_data(self) -> None:
696        self.tab_bar.update(self.tab_bar_data)
697
698    def title_changed(self, tab: Tab) -> None:
699        self.mark_tab_bar_dirty()
700        if tab is self.active_tab:
701            sync_os_window_title(self.os_window_id)
702
703    def resize(self, only_tabs: bool = False) -> None:
704        if not only_tabs:
705            if not self.tab_bar_hidden:
706                self.tab_bar.layout()
707                self.mark_tab_bar_dirty()
708        for tab in self.tabs:
709            tab.relayout()
710
711    def set_active_tab_idx(self, idx: int) -> None:
712        self._set_active_tab(idx)
713        tab = self.active_tab
714        if tab is not None:
715            tab.relayout_borders()
716        self.mark_tab_bar_dirty()
717
718    def set_active_tab(self, tab: Tab) -> bool:
719        try:
720            idx = self.tabs.index(tab)
721        except Exception:
722            return False
723        self.set_active_tab_idx(idx)
724        return True
725
726    def next_tab(self, delta: int = 1) -> None:
727        if len(self.tabs) > 1:
728            self.set_active_tab_idx((self.active_tab_idx + len(self.tabs) + delta) % len(self.tabs))
729
730    def tab_at_location(self, loc: str) -> Optional[Tab]:
731        if loc == 'prev':
732            if self.active_tab_history:
733                old_active_tab_id = self.active_tab_history[-1]
734                for idx, tab in enumerate(self.tabs):
735                    if tab.id == old_active_tab_id:
736                        return tab
737        elif loc in ('left', 'right'):
738            delta = -1 if loc == 'left' else 1
739            idx = (len(self.tabs) + self.active_tab_idx + delta) % len(self.tabs)
740            return self.tabs[idx]
741
742    def goto_tab(self, tab_num: int) -> None:
743        if tab_num >= len(self.tabs):
744            tab_num = max(0, len(self.tabs) - 1)
745        if tab_num >= 0:
746            self.set_active_tab_idx(tab_num)
747        else:
748            try:
749                old_active_tab_id = self.active_tab_history[tab_num]
750            except IndexError:
751                return
752            for idx, tab in enumerate(self.tabs):
753                if tab.id == old_active_tab_id:
754                    self.set_active_tab_idx(idx)
755                    break
756
757    def nth_active_tab(self, n: int = 0) -> Optional[Tab]:
758        if n <= 0:
759            return self.active_tab
760        tab_ids = tuple(reversed(self.active_tab_history))
761        return self.tab_for_id(tab_ids[min(n - 1, len(tab_ids) - 1)]) if tab_ids else None
762
763    def __iter__(self) -> Iterator[Tab]:
764        return iter(self.tabs)
765
766    def __len__(self) -> int:
767        return len(self.tabs)
768
769    def list_tabs(self, active_tab: Optional[Tab], active_window: Optional[Window], self_window: Optional[Window] = None) -> Generator[TabDict, None, None]:
770        for tab in self:
771            yield {
772                'id': tab.id,
773                'is_focused': tab is active_tab,
774                'title': tab.name or tab.title,
775                'layout': str(tab.current_layout.name),
776                'layout_state': tab.current_layout.layout_state(),
777                'windows': list(tab.list_windows(active_window, self_window)),
778                'active_window_history': list(tab.windows.active_window_history),
779            }
780
781    def serialize_state(self) -> Dict[str, Any]:
782        return {
783            'version': 1,
784            'id': self.os_window_id,
785            'tabs': [tab.serialize_state() for tab in self],
786            'active_tab_idx': self.active_tab_idx,
787        }
788
789    @property
790    def active_tab(self) -> Optional[Tab]:
791        try:
792            return self.tabs[self.active_tab_idx] if self.tabs else None
793        except Exception:
794            return None
795
796    @property
797    def active_window(self) -> Optional[Window]:
798        t = self.active_tab
799        if t is not None:
800            return t.active_window
801
802    @property
803    def number_of_windows(self) -> int:
804        count = 0
805        for tab in self:
806            for window in tab:
807                count += 1
808        return count
809
810    def tab_for_id(self, tab_id: int) -> Optional[Tab]:
811        for t in self.tabs:
812            if t.id == tab_id:
813                return t
814
815    def move_tab(self, delta: int = 1) -> None:
816        if len(self.tabs) > 1:
817            idx = self.active_tab_idx
818            nidx = (idx + len(self.tabs) + delta) % len(self.tabs)
819            step = 1 if idx < nidx else -1
820            for i in range(idx, nidx, step):
821                self.tabs[i], self.tabs[i + step] = self.tabs[i + step], self.tabs[i]
822                swap_tabs(self.os_window_id, i, i + step)
823            self._set_active_tab(nidx)
824            self.mark_tab_bar_dirty()
825
826    def new_tab(
827        self,
828        special_window: Optional[SpecialWindowInstance] = None,
829        cwd_from: Optional[int] = None,
830        as_neighbor: bool = False,
831        empty_tab: bool = False,
832        location: str = 'last'
833    ) -> Tab:
834        idx = len(self.tabs)
835        orig_active_tab_idx = self.active_tab_idx
836        self._add_tab(Tab(self, no_initial_window=True) if empty_tab else Tab(self, special_window=special_window, cwd_from=cwd_from))
837        self._set_active_tab(idx)
838        if as_neighbor:
839            location = 'after'
840        if location == 'neighbor':
841            location = 'after'
842        if len(self.tabs) > 1 and location != 'last':
843            if location == 'first':
844                desired_idx = 0
845            else:
846                desired_idx = orig_active_tab_idx + (0 if location == 'before' else 1)
847            if idx != desired_idx:
848                for i in range(idx, desired_idx, -1):
849                    self.tabs[i], self.tabs[i-1] = self.tabs[i-1], self.tabs[i]
850                    swap_tabs(self.os_window_id, i, i-1)
851                self._set_active_tab(desired_idx)
852                idx = desired_idx
853        self.mark_tab_bar_dirty()
854        return self.tabs[idx]
855
856    def remove(self, tab: Tab) -> None:
857        active_tab_before_removal = self.active_tab
858        self._remove_tab(tab)
859        active_tab = self.active_tab
860        active_tab_needs_to_change = (active_tab is None and (active_tab_before_removal is None or active_tab_before_removal is tab)) or active_tab is tab
861        while True:
862            try:
863                self.active_tab_history.remove(tab.id)
864            except ValueError:
865                break
866
867        if active_tab_needs_to_change:
868            next_active_tab = -1
869            if get_options().tab_switch_strategy == 'previous':
870                while self.active_tab_history and next_active_tab < 0:
871                    tab_id = self.active_tab_history.pop()
872                    for idx, qtab in enumerate(self.tabs):
873                        if qtab.id == tab_id:
874                            next_active_tab = idx
875                            break
876            elif get_options().tab_switch_strategy == 'left':
877                next_active_tab = max(0, self.active_tab_idx - 1)
878            elif get_options().tab_switch_strategy == 'right':
879                next_active_tab = min(self.active_tab_idx, len(self.tabs) - 1)
880
881            if next_active_tab < 0:
882                next_active_tab = max(0, min(self.active_tab_idx, len(self.tabs) - 1))
883
884            self._set_active_tab(next_active_tab)
885        elif active_tab_before_removal is not None:
886            try:
887                idx = self.tabs.index(active_tab_before_removal)
888            except Exception:
889                pass
890            else:
891                self._active_tab_idx = idx
892        self.mark_tab_bar_dirty()
893        tab.destroy()
894
895    @property
896    def tab_bar_data(self) -> List[TabBarData]:
897        at = self.active_tab
898        ans = []
899        for t in self.tabs:
900            title = (t.name or t.title or appname).strip()
901            needs_attention = False
902            has_activity_since_last_focus = False
903            for w in t:
904                if w.needs_attention:
905                    needs_attention = True
906                if w.has_activity_since_last_focus:
907                    has_activity_since_last_focus = True
908            ans.append(TabBarData(
909                title, t is at, needs_attention,
910                len(t), t.num_window_groups, t.current_layout.name or '',
911                has_activity_since_last_focus
912            ))
913        return ans
914
915    def activate_tab_at(self, x: int, is_double: bool = False) -> None:
916        i = self.tab_bar.tab_at(x)
917        if i is None:
918            if is_double:
919                self.new_tab()
920        else:
921            self.set_active_tab_idx(i)
922
923    @property
924    def blank_rects(self) -> Tuple[Rect, ...]:
925        return self.tab_bar.blank_rects if self.tab_bar_should_be_visible else ()
926
927    def destroy(self) -> None:
928        for t in self:
929            t.destroy()
930        self.tab_bar.destroy()
931        del self.tab_bar
932        del self.tabs
933
934    def apply_options(self) -> None:
935        for tab in self:
936            tab.apply_options()
937        self.tab_bar_hidden = get_options().tab_bar_style == 'hidden'
938        self.tab_bar.apply_options()
939        self.update_tab_bar_data()
940        self.mark_tab_bar_dirty()
941        self.tab_bar.layout()
942# }}}
943