""" Repository: https://github.com/linusvanelswijk/kitty-jumptab License: MIT """ from argparse import ArgumentParser, BooleanOptionalAction from dataclasses import dataclass from weakref import ReferenceType from kittens.tui.handler import result_handler from kitty.boss import Boss, Tab, TabManager, Window from typing import List LOCATION_BY_TITLE = "by-title" LOCATION_DEFAULT = LOCATION_BY_TITLE LOCATION_CHOICES = [LOCATION_BY_TITLE, "first", "last", "before", "after", "neighbor"] def jumptab_open( args: List[str], boss: Boss, ) -> None: @dataclass class Config: launch_args: List[str] # command followed by args or empty list location: str match: str keep_focus: bool title: str def parse_arguments(args: List[str]) -> Config: parser = ArgumentParser() parser.add_argument( "--location", default=LOCATION_DEFAULT, choices=LOCATION_CHOICES ) parser.add_argument("--match") parser.add_argument("--keep-focus", action=BooleanOptionalAction) parser.add_argument("title") jmp_args, launch_args = try_parse_known_args(parser, "jumptab open", args[1:]) if jmp_args.keep_focus: launch_args.append("--keep-focus") return Config( launch_args=launch_args, location=jmp_args.location, match=jmp_args.match or f'title:"^{jmp_args.title}$"', keep_focus=jmp_args.keep_focus, title=jmp_args.title, ) def create_tab(boss: Boss, config: Config): def preprocess_location(location: str) -> str: if location != LOCATION_BY_TITLE: return location if boss.active_tab_manager is None: return "last" next_idx = next_tab_idx_by_title(boss.active_tab_manager, config.title) if next_idx is None: return "last" with boss.suppress_focus_change_events(): boss.active_tab_manager.set_active_tab_idx(next_idx) return "before" config.location = preprocess_location(config.location) boss.launch( "--type=tab", f"--tab-title={config.title}", f"--location={config.location}", *config.launch_args, ) config = parse_arguments(args) existing_tab = find_tab(boss=boss, match=config.match) if existing_tab and existing_tab.active_window: if not config.keep_focus: boss.set_active_window(existing_tab.active_window, switch_os_window_if_needed=True) else: create_tab(boss=boss, config=config) def jumptab_send(args: List[str], boss: Boss) -> None: @dataclass class Config: location: str match: str keep_focus: bool title: str def parse_arguments(args: List[str]) -> Config: parser = ArgumentParser() parser.add_argument( "--location", default=LOCATION_DEFAULT, choices=LOCATION_CHOICES ) parser.add_argument("--match") parser.add_argument("--keep-focus", action=BooleanOptionalAction) parser.add_argument("title") parsed_args = try_parse_args(parser, "jumptab open", args[1:]) return Config( location=parsed_args.location, match=parsed_args.match or f'title:"^{parsed_args.title}$"', keep_focus=parsed_args.keep_focus, title=parsed_args.title, ) def current_idx(tab_manager: TabManager | None): if tab_manager is None: return None return tab_manager.active_tab_idx def current_left_empty(tab_manager: TabManager | None): return ( tab_manager is None or tab_manager.active_tab is None or len(tab_manager.active_tab.windows) <= 1 ) def desired_idx( tab_manager: TabManager | None, prior_idx: int | None, prior_left_empty: bool, config: Config, ) -> int | None: if tab_manager is None: return None last_idx = len(tab_manager.tabs) - 1 if config.location == LOCATION_BY_TITLE: return next_tab_idx_by_title(tab_manager, config.title) if config.location == "first": return 0 if config.location == "last": return last_idx if config.location == "before": return prior_idx or 0 if config.location == "after" or config.location == "neighbor": return (prior_idx + int(not prior_left_empty)) if prior_idx else last_idx raise RuntimeError( f'Logic Error: did not expect config.location="{config.location}" in desired_idx.' ) def detach_window(boss: Boss, window: Window, target_tab: Tab | None, keep_focus: bool) -> Tab: with boss.suppress_focus_change_events(): target_tab_str = f"id:{target_tab.id}" if target_tab else "new" origin_tab=boss.active_tab boss.remote_control( "detach-window", f"--match=id:{window.id}", f"--target-tab={target_tab_str}", ) target_tab = boss.active_tab if origin_tab: boss.set_active_tab(origin_tab) assert target_tab is not None # We just created a tab if it was None if target_tab.active_window and not keep_focus: boss.set_active_window(target_tab.active_window, switch_os_window_if_needed=True) return target_tab tab_config = parse_arguments(args) window = boss.active_window if window is None: return # Nothing to do prior_idx = current_idx(boss.active_tab_manager) prior_left_empty = current_left_empty(boss.active_tab_manager) existing_tab = find_tab(boss=boss, match=tab_config.match) destination_tab = detach_window(boss=boss, window=window, target_tab=existing_tab, keep_focus=tab_config.keep_focus) destination_tab_is_new = existing_tab is None if destination_tab_is_new: destination_tab.set_title(tab_config.title) index = desired_idx( destination_tab.tab_manager_ref(), prior_idx, prior_left_empty, tab_config ) if index is not None: move_active_tab_to_index(boss.active_tab_manager, index) def invalid_arguments_exception_message(subcommand: str): return f"Failed to parse command {subcommand}: invalid arguments." def try_parse_args(parser: ArgumentParser, command: str, args: List[str]): try: return parser.parse_args(args) except SystemExit: raise RuntimeError(invalid_arguments_exception_message(command)) def try_parse_known_args(parser: ArgumentParser, command: str, args: List[str]): try: return parser.parse_known_args(args) except SystemExit: raise RuntimeError(invalid_arguments_exception_message(command)) def find_tab(boss: Boss, match: str) -> Tab | None: return next(boss.match_tabs(match), None) def next_tab_idx_by_title(tab_manager: TabManager | None, title: str) -> int | None: if tab_manager is None: return None for i, t_i in enumerate(tab_manager.tabs): if t_i.name > title: return i return None def move_active_tab_to_index(tab_manager: TabManager | None, desired_idx: int): if tab_manager is None: return tab_manager.move_tab(desired_idx - tab_manager.active_tab_idx) @result_handler(no_ui=True) def handle_result( args: List[str], answer: str, target_window_id: int, boss: Boss, ) -> None: _ = (answer, target_window_id) # supress unused param warnings if len(args) < 2: raise RuntimeError("jumptab expects an a action as its first argument") action, action_args = args[1], args[1:] if action == "open": jumptab_open(action_args, boss) elif action == "send": jumptab_send(action_args, boss) else: raise RuntimeError(f"{action} is not a valid jumptab action") def main(_): pass