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