"""WizardKit: tmux Functions""" # vim: sts=2 sw=2 ts=2 import logging import pathlib from typing import Any from wk.exe import run_program from wk.std import PLATFORM # STATIC_VARIABLES LOG = logging.getLogger(__name__) # Functions def capture_pane(pane_id: str | None = None) -> str: """Capture text from current or target pane, returns str.""" cmd = ['tmux', 'capture-pane', '-p'] if pane_id: cmd.extend(['-t', pane_id]) # Capture and return proc = run_program(cmd, check=False) return proc.stdout.strip() def clear_pane(pane_id: str | None = None) -> None: """Clear pane buffer for current or target pane.""" commands = [ ['tmux', 'send-keys', '-R'], ['tmux', 'clear-history'], ] if pane_id: commands = [[*cmd, '-t', pane_id] for cmd in commands] # Clear pane for cmd in commands: run_program(cmd, check=False) def fix_layout( layout: dict[str, dict[str, Any]], clear_on_resize: bool = False, forced: bool = False, ) -> None: """Fix pane sizes based on layout. NOTE: The magic +/- 1 values are for the split rows/columns. """ resize_kwargs = [] # Bail early if not (forced or layout_needs_fixed(layout)): # Layout should be fine return # Clear current pane if needed if clear_on_resize: clear_pane() # Remove closed panes for data in layout.values(): data['Panes'] = [pane for pane in data['Panes'] if poll_pane(pane)] # Calculate constraints avail_horizontal, avail_vertical = get_window_size() avail_vertical -= layout['Current'].get('height', 0) for group in ('Title', 'Info'): if not layout[group]['Panes']: continue avail_vertical -= layout[group].get('height', 0) + 1 num_workers = len(layout['Workers']['Panes']) avail_vertical -= num_workers * (layout['Workers'].get('height', 0) + 1) avail_horizontal -= layout['Progress']['width'] + 1 # Fix heights for group, data in layout.items(): if not data['Panes'] or group in ('Started', 'Progress'): continue resize_kwargs.append( {'pane_id': data['Panes'][0], 'height': data.get('height', avail_vertical)} ) if group == 'Workers' and len(data['Panes']) > 1: for pane_id in data['Panes'][1:]: resize_kwargs.append( {'pane_id': pane_id, 'height': data.get('height', avail_vertical)} ) # Fix widths resize_kwargs.append( {'pane_id': layout['Progress']['Panes'][0], 'width': layout['Progress']['width']} ) resize_kwargs.append( {'pane_id': layout['Started']['Panes'][0], 'height': layout['Started']['height']} ) for group, data in layout.items(): num_panes = len(data['Panes']) if num_panes < 2 or group not in ('Title', 'Info'): continue pane_width, remainder = divmod(avail_horizontal - (num_panes-1), num_panes) for pane_id in data['Panes']: new_width = pane_width if remainder > 0: new_width += 1 remainder -= 1 resize_kwargs.append({'pane_id': pane_id, 'width': new_width}) # Resize panes for kwargs in resize_kwargs: try: resize_pane(**kwargs) except RuntimeError: # Assuming pane was closed just before resizing pass def get_pane_size(pane_id: str | None = None) -> tuple[int, int]: """Get current or target pane size, returns tuple.""" cmd = ['tmux', 'display', '-p'] if pane_id: cmd.extend(['-t', pane_id]) cmd.append('#{pane_width} #{pane_height}') # Get resolution proc = run_program(cmd, check=False) try: width, height = proc.stdout.strip().split() except ValueError: # Assuming this is a race condition as it usually happens inside the # background fix layout loop return 0, 0 width = int(width) height = int(height) # Done return (width, height) def get_window_size() -> tuple[int, int]: """Get current window size, returns tuple.""" cmd = ['tmux', 'display', '-p', '#{window_width} #{window_height}'] # Get resolution proc = run_program(cmd, check=False) width, height = proc.stdout.strip().split() width = int(width) height = int(height) # Done return (width, height) def kill_all_panes(pane_id: str | None = None) -> None: """Kill all panes except for the current or target pane.""" cmd = ['tmux', 'kill-pane', '-a'] if pane_id: cmd.extend(['-t', pane_id]) # Kill run_program(cmd, check=False) def kill_pane(*pane_ids: str) -> None: """Kill pane(s) by id.""" cmd = ['tmux', 'kill-pane', '-t'] # Iterate over all passed pane IDs for pane_id in pane_ids: run_program(cmd+[pane_id], check=False) def layout_needs_fixed(layout: dict[str, dict[str, Any]]) -> bool: """Check if layout needs fixed, returns bool.""" needs_fixed = False # Check panes for data in layout.values(): if 'height' in data: needs_fixed = needs_fixed or any( get_pane_size(pane)[1] != data['height'] for pane in data['Panes'] ) if 'width' in data: needs_fixed = needs_fixed or any( get_pane_size(pane)[0] != data['width'] for pane in data['Panes'] ) # Done return needs_fixed def poll_pane(pane_id: str) -> bool: """Check if pane exists, returns bool.""" cmd = ['tmux', 'list-panes', '-F', '#D'] # Get list of panes proc = run_program(cmd, check=False) existant_panes = proc.stdout.splitlines() # Check if pane exists return pane_id in existant_panes def prep_action( cmd: str | None = None, working_dir: pathlib.Path | str | None = None, text: str | None = None, watch_file: pathlib.Path | str | None = None, watch_cmd: str = 'cat', ) -> list[str]: """Prep action to perform during a tmux call, returns list. This will prep for running a basic command, displaying text on screen, or monitoring a file. The last option uses cat by default but can be overridden by using the watch_cmd. """ action_cmd = [] if working_dir: action_cmd.extend(['-c', working_dir]) if cmd: # Basic command action_cmd.append(cmd) elif text: # Display text echo_cmd = ['echo'] if PLATFORM == 'Linux': echo_cmd.append('-e') action_cmd.extend([ 'watch', '--color', '--exec', '--no-title', '--interval', '1', ]) action_cmd.extend(echo_cmd) action_cmd.append(text) elif watch_file: # Monitor file prep_file(watch_file) if watch_cmd == 'cat': action_cmd.extend([ 'watch', '--color', '--no-title', '--interval', '1', 'cat', ]) elif watch_cmd == 'tail': action_cmd.extend(['tail', '-f']) action_cmd.append(watch_file) else: LOG.error('No action specified') raise RuntimeError('No action specified') # Done return action_cmd def prep_file(path: pathlib.Path | str) -> None: """Check if file exists and create empty file if not.""" path = pathlib.Path(path).resolve() try: path.touch(exist_ok=False) except FileExistsError: # Leave existing files alone pass def resize_pane( pane_id: str | None = None, width: int | None = None, height: int | None = None, ) -> None: """Resize current or target pane. NOTE: kwargs is only here to make calling this function easier by dropping any extra kwargs passed. """ cmd = ['tmux', 'resize-pane'] # Safety checks if not (width or height): LOG.error('Neither width nor height specified') raise RuntimeError('Neither width nor height specified') # Finish building cmd if pane_id: cmd.extend(['-t', pane_id]) if width: cmd.extend(['-x', str(width)]) if height: cmd.extend(['-y', str(height)]) # Resize run_program(cmd, check=False) def respawn_pane(pane_id: str, **action) -> None: """Respawn pane with action.""" cmd = ['tmux', 'respawn-pane', '-k', '-t', pane_id] cmd.extend(prep_action(**action)) # Respawn run_program(cmd, check=False) def split_window( lines: int | None = None, percent: int | None = None, behind: bool = False, vertical: bool = False, target_id: str | None = None, **action) -> str: """Split tmux window, run action, and return pane_id as str.""" cmd = ['tmux', 'split-window', '-d', '-PF', '#D'] # Safety checks if not (lines or percent): LOG.error('Neither lines nor percent specified') raise RuntimeError('Neither lines nor percent specified') # New pane placement if behind: cmd.append('-b') if vertical: cmd.append('-v') else: cmd.append('-h') if target_id: cmd.extend(['-t', target_id]) # New pane size if lines: cmd.extend(['-l', str(lines)]) elif percent: cmd.extend(['-p', str(percent)]) # New pane action cmd.extend(prep_action(**action)) # Run and return pane_id proc = run_program(cmd, check=False) return proc.stdout.strip() def zoom_pane(pane_id: str | None = None) -> None: """Toggle zoom status for current or target pane.""" cmd = ['tmux', 'resize-pane', '-Z'] if pane_id: cmd.extend(['-t', pane_id]) # Toggle run_program(cmd, check=False) if __name__ == '__main__': print("This file is not meant to be called directly.")