diff --git a/CHANGES b/CHANGES index 5bfbab5bf..50e7ce57a 100644 --- a/CHANGES +++ b/CHANGES @@ -15,6 +15,39 @@ $ pip install --user --upgrade --pre libtmux +### Breaking changes + +#### Improved new sessions (#532) + +- `Session.new_window()` to {meth}`Session.new_window()` + + - Learned `direction`, via {class}`~libtmux.constants.WindowDirection`). + +#### Improved window splitting (#532) + +- `Window.split_window()` to {meth}`Window.split()` + + - Deprecate `Window.split_window()` + +- `Pane.split_window()` to {meth}`Pane.split()` + + - Deprecate `Pane.split_window()` + - Learned `direction`, via {class}`~libtmux.constants.PaneDirection`). + + - Deprecate `vertical` and `horizontal` in favor of `direction`. + + - Learned `zoom` + +#### Tweak: Pane position (#532) + +It's now possible to retrieve the position of a pane in a window via a +`bool` helper:: + +- {attr}`Pane.at_left` +- {attr}`Pane.at_right` +- {attr}`Pane.at_bottom` +- {attr}`Pane.at_right` + ### Development - poetry: 1.7.1 -> 1.8.1 diff --git a/MIGRATION b/MIGRATION index aa35fc214..9d0b021ba 100644 --- a/MIGRATION +++ b/MIGRATION @@ -25,6 +25,13 @@ _Detailed migration steps for the next version will be posted here._ +## 0.33.0: Deprecations for splitting (2024-03-03) + +### Deprecations (#532) + +- `Window.split_window()` to {meth}`Window.split()` +- `Pane.split_window()` to {meth}`Pane.split()` + ## 0.31.0: Renaming and command cleanup (2024-02-17) ### Cleanups (#527) diff --git a/README.md b/README.md index 99b9674a8..eaca41335 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ Grab remaining tmux window: ```python >>> window = session.active_window ->>> window.split_window(attach=False) +>>> window.split(attach=False) Pane(%2 Window(@1 1:... Session($1 ...))) ``` @@ -196,14 +196,14 @@ Window(@1 1:libtmuxower, Session($1 ...)) Split window (create a new pane): ```python ->>> pane = window.split_window() ->>> pane = window.split_window(attach=False) +>>> pane = window.split() +>>> pane = window.split(attach=False) >>> pane.select() Pane(%3 Window(@1 1:..., Session($1 ...))) >>> window = session.new_window(attach=False, window_name="test") >>> window Window(@2 2:test, Session($1 ...)) ->>> pane = window.split_window(attach=False) +>>> pane = window.split(attach=False) >>> pane Pane(%5 Window(@2 2:test, Session($1 ...))) ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index db8e42cdb..1c17fa87c 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -149,7 +149,7 @@ Create a pane from a window: '%2' ``` -Raw output directly to a {class}`Pane` (in practice, you'd use {meth}`Window.split_window()`): +Raw output directly to a {class}`Pane` (in practice, you'd use {meth}`Window.split()`): ```python >>> Pane.from_pane_id(pane_id=window.cmd('split-window', '-P', '-F#{pane_id}').stdout[0], server=window.server) @@ -312,10 +312,10 @@ to grab our current window. `window` now has access to all of the objects inside of {class}`Window`. -Let's create a pane, {meth}`Window.split_window`: +Let's create a pane, {meth}`Window.split`: ```python ->>> window.split_window(attach=False) +>>> window.split(attach=False) Pane(%2 Window(@1 ...:..., Session($1 ...))) ``` @@ -341,7 +341,7 @@ You have two ways you can move your cursor to new sessions, windows and panes. For one, arguments such as `attach=False` can be omittted. ```python ->>> pane = window.split_window() +>>> pane = window.split() ``` This gives you the {class}`Pane` along with moving the cursor to a new window. You @@ -349,7 +349,7 @@ can also use the `.select_*` available on the object, in this case the pane has {meth}`Pane.select()`. ```python ->>> pane = window.split_window(attach=False) +>>> pane = window.split(attach=False) ``` ```python @@ -371,7 +371,7 @@ As long as you have the object, or are iterating through a list of them, you can ```python >>> window = session.new_window(attach=False, window_name="test") ->>> pane = window.split_window(attach=False) +>>> pane = window.split(attach=False) >>> pane.send_keys('echo hey', enter=False) ``` diff --git a/src/libtmux/constants.py b/src/libtmux/constants.py index d6d52c3e3..ea8bcab3b 100644 --- a/src/libtmux/constants.py +++ b/src/libtmux/constants.py @@ -19,3 +19,34 @@ class ResizeAdjustmentDirection(enum.Enum): ResizeAdjustmentDirection.Left: "-L", ResizeAdjustmentDirection.Right: "-R", } + + +class WindowDirection(enum.Enum): + """Used for *adjustment* in :meth:`Session.new_window()`.""" + + Before = "BEFORE" + After = "AFTER" + + +WINDOW_DIRECTION_FLAG_MAP: t.Dict[WindowDirection, str] = { + WindowDirection.Before: "-b", + WindowDirection.After: "-a", +} + + +class PaneDirection(enum.Enum): + """Used for *adjustment* in :meth:`Pane.split()`.""" + + Above = "ABOVE" + Below = "BELOW" # default with no args + Right = "RIGHT" + Left = "LEFT" + + +PANE_DIRECTION_FLAG_MAP: t.Dict[PaneDirection, t.List[str]] = { + # -v is assumed, but for explicitness it is passed + PaneDirection.Above: ["-v", "-b"], + PaneDirection.Below: ["-v"], + PaneDirection.Right: ["-h"], + PaneDirection.Left: ["-h", "-b"], +} diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 6b34e555f..e5d3ae32e 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -89,6 +89,10 @@ class Obj: next_session_id: t.Union[str, None] = None origin_flag: t.Union[str, None] = None pane_active: t.Union[str, None] = None # Not detected by script + pane_at_bottom: t.Union[str, None] = None + pane_at_left: t.Union[str, None] = None + pane_at_right: t.Union[str, None] = None + pane_at_top: t.Union[str, None] = None pane_bg: t.Union[str, None] = None pane_bottom: t.Union[str, None] = None pane_current_command: t.Union[str, None] = None @@ -110,7 +114,6 @@ class Obj: pane_top: t.Union[str, None] = None pane_tty: t.Union[str, None] = None pane_width: t.Union[str, None] = None - pid: t.Union[str, None] = None scroll_position: t.Union[str, None] = None scroll_region_lower: t.Union[str, None] = None diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 608ea9ffb..2ef250c5c 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -7,15 +7,19 @@ import dataclasses import logging +import pathlib import typing as t import warnings from typing import overload -from libtmux.common import has_gte_version, tmux_cmd +from libtmux.common import has_gte_version, has_lt_version, tmux_cmd from libtmux.constants import ( + PANE_DIRECTION_FLAG_MAP, RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, + PaneDirection, ResizeAdjustmentDirection, ) +from libtmux.formats import FORMAT_SEPARATOR from libtmux.neo import Obj, fetch_obj from . import exc @@ -308,7 +312,7 @@ def send_keys( Examples -------- - >>> pane = window.split_window(shell='sh') + >>> pane = window.split(shell='sh') >>> pane.capture_pane() ['$'] @@ -377,7 +381,7 @@ def kill( -------- Kill a pane: - >>> pane_1 = pane.split_window() + >>> pane_1 = pane.split() >>> pane_1 in window.panes True @@ -392,10 +396,10 @@ def kill( >>> pane.window.resize(height=100, width=100) Window(@1 1...) - >>> one_pane_to_rule_them_all = pane.split_window() + >>> one_pane_to_rule_them_all = pane.split() - >>> other_panes = pane.split_window( - ... ), pane.split_window() + >>> other_panes = pane.split( + ... ), pane.split() >>> all([p in window.panes for p in other_panes]) True @@ -436,7 +440,7 @@ def select(self) -> "Pane": Examples -------- >>> pane = window.active_pane - >>> new_pane = window.split_window() + >>> new_pane = window.split() >>> pane.refresh() >>> active_panes = [p for p in window.panes if p.pane_active == '1'] @@ -483,47 +487,162 @@ def select_pane(self) -> "Pane": raise exc.PaneNotFound(pane_id=self.pane_id) return pane - def split_window( + def split( self, - attach: bool = False, start_directory: t.Optional[str] = None, - vertical: bool = True, + attach: bool = False, + direction: t.Optional[PaneDirection] = None, + full_window_split: t.Optional[bool] = None, + zoom: t.Optional[bool] = None, shell: t.Optional[str] = None, size: t.Optional[t.Union[str, int]] = None, - percent: t.Optional[int] = None, # deprecated environment: t.Optional[t.Dict[str, str]] = None, - ) -> "Pane": # New Pane, not self - """Split window at pane and return newly created :class:`Pane`. + ) -> "Pane": + """Split window and return :class:`Pane`, by default beneath current pane. Parameters ---------- attach : bool, optional - Attach / select pane after creation. + make new window the current window after creating it, default + True. start_directory : str, optional - specifies the working directory in which the new pane is created. - vertical : bool, optional - split vertically - percent: int, optional - percentage to occupy with respect to current pane + specifies the working directory in which the new window is created. + direction : PaneDirection, optional + split in direction. If none is specified, assume down. + full_window_split: bool, optional + split across full window width or height, rather than active pane. + zoom: bool, optional + expand pane + shell : str, optional + execute a command on splitting the window. The pane will close + when the command exits. + + NOTE: When this command exits the pane will close. This feature + is useful for long-running processes where the closing of the + window upon completion is desired. + size: int, optional + Cell/row or percentage to occupy with respect to current window. environment: dict, optional Environmental variables for new pane. tmux 3.0+ only. Passthrough to ``-e``. - Notes - ----- - .. deprecated:: 0.28.0 + Examples + -------- + >>> (pane.at_left, pane.at_right, + ... pane.at_top, pane.at_bottom) + (True, True, + True, True) + + >>> new_pane = pane.split() + + >>> (new_pane.at_left, new_pane.at_right, + ... new_pane.at_top, new_pane.at_bottom) + (True, True, + False, True) + + >>> right_pane = pane.split(direction=PaneDirection.Right) + + >>> (right_pane.at_left, right_pane.at_right, + ... right_pane.at_top, right_pane.at_bottom) + (False, True, + True, False) + + >>> left_pane = pane.split(direction=PaneDirection.Left) + + >>> (left_pane.at_left, left_pane.at_right, + ... left_pane.at_top, left_pane.at_bottom) + (True, False, + True, False) + + >>> top_pane = pane.split(direction=PaneDirection.Above) + + >>> (top_pane.at_left, top_pane.at_right, + ... top_pane.at_top, top_pane.at_bottom) + (False, False, + True, False) + + >>> pane = session.new_window().active_pane + + >>> top_pane = pane.split(direction=PaneDirection.Above, full_window_split=True) - ``percent=25`` deprecated in favor of ``size="25%"``. + >>> (top_pane.at_left, top_pane.at_right, + ... top_pane.at_top, top_pane.at_bottom) + (True, True, + True, False) + + >>> bottom_pane = pane.split( + ... direction=PaneDirection.Below, + ... full_window_split=True) + + >>> (bottom_pane.at_left, bottom_pane.at_right, + ... bottom_pane.at_top, bottom_pane.at_bottom) + (True, True, + False, True) """ - return self.window.split_window( - target=self.pane_id, - attach=attach, - start_directory=start_directory, - vertical=vertical, - shell=shell, - size=size, - percent=percent, - environment=environment, - ) + tmux_formats = ["#{pane_id}" + FORMAT_SEPARATOR] + + tmux_args: t.Tuple[str, ...] = () + + if direction: + tmux_args += tuple(PANE_DIRECTION_FLAG_MAP[direction]) + else: + tmux_args += tuple(PANE_DIRECTION_FLAG_MAP[PaneDirection.Below]) + + if size is not None: + if has_lt_version("3.1"): + if isinstance(size, str) and size.endswith("%"): + tmux_args += (f'-p{str(size).rstrip("%")}',) + else: + warnings.warn( + 'Ignored size. Use percent in tmux < 3.1, e.g. "size=50%"', + stacklevel=2, + ) + else: + tmux_args += (f"-l{size}",) + + if full_window_split: + tmux_args += ("-f",) + + if zoom: + tmux_args += ("-Z",) + + tmux_args += ("-P", "-F%s" % "".join(tmux_formats)) # output + + if start_directory is not None: + # as of 2014-02-08 tmux 1.9-dev doesn't expand ~ in new-window -c. + start_path = pathlib.Path(start_directory).expanduser() + tmux_args += (f"-c{start_path}",) + + if not attach: + tmux_args += ("-d",) + + if environment: + if has_gte_version("3.0"): + for k, v in environment.items(): + tmux_args += (f"-e{k}={v}",) + else: + logger.warning( + "Environment flag ignored, tmux 3.0 or newer required.", + ) + + if shell: + tmux_args += (shell,) + + pane_cmd = self.cmd("split-window", *tmux_args) + + # tmux < 1.7. This is added in 1.7. + if pane_cmd.stderr: + if "pane too small" in pane_cmd.stderr: + raise exc.LibTmuxException(pane_cmd.stderr) + + raise exc.LibTmuxException( + pane_cmd.stderr, self.__dict__, self.window.panes + ) + + pane_output = pane_cmd.stdout[0] + + pane_formatters = dict(zip(["pane_id"], pane_output.split(FORMAT_SEPARATOR))) + + return self.from_pane_id(server=self.server, pane_id=pane_formatters["pane_id"]) """ Commands (helpers) @@ -633,9 +752,104 @@ def width(self) -> t.Optional[str]: """ return self.pane_width + @property + def at_top(self) -> bool: + """Typed, converted wrapper around :attr:`Pane.pane_at_top`. + + >>> pane.pane_at_top + '1' + + >>> pane.at_top + True + """ + return self.pane_at_top == "1" + + @property + def at_bottom(self) -> bool: + """Typed, converted wrapper around :attr:`Pane.pane_at_bottom`. + + >>> pane.pane_at_bottom + '1' + + >>> pane.at_bottom + True + """ + return self.pane_at_bottom == "1" + + @property + def at_left(self) -> bool: + """Typed, converted wrapper around :attr:`Pane.pane_at_left`. + + >>> pane.pane_at_left + '1' + + >>> pane.at_left + True + """ + return self.pane_at_left == "1" + + @property + def at_right(self) -> bool: + """Typed, converted wrapper around :attr:`Pane.pane_at_right`. + + >>> pane.pane_at_right + '1' + + >>> pane.at_right + True + """ + return self.pane_at_right == "1" + # # Legacy: Redundant stuff we want to remove # + def split_window( + self, + attach: bool = False, + start_directory: t.Optional[str] = None, + vertical: bool = True, + shell: t.Optional[str] = None, + size: t.Optional[t.Union[str, int]] = None, + percent: t.Optional[int] = None, # deprecated + environment: t.Optional[t.Dict[str, str]] = None, + ) -> "Pane": # New Pane, not self + """Split window at pane and return newly created :class:`Pane`. + + Parameters + ---------- + attach : bool, optional + Attach / select pane after creation. + start_directory : str, optional + specifies the working directory in which the new pane is created. + vertical : bool, optional + split vertically + percent: int, optional + percentage to occupy with respect to current pane + environment: dict, optional + Environmental variables for new pane. tmux 3.0+ only. Passthrough to ``-e``. + + Notes + ----- + .. deprecated:: 0.33 + + Deprecated in favor of :meth:`.split`. + """ + warnings.warn( + "Pane.split_window() is deprecated in favor of Pane.split()", + category=DeprecationWarning, + stacklevel=2, + ) + if size is None and percent is not None: + size = f'{str(percent).rstrip("%")}%' + return self.split( + attach=attach, + start_directory=start_directory, + direction=PaneDirection.Below if vertical else PaneDirection.Right, + shell=shell, + size=size, + environment=environment, + ) + def get(self, key: str, default: t.Optional[t.Any] = None) -> t.Any: """Return key-based lookup. Deprecated by attributes. diff --git a/src/libtmux/session.py b/src/libtmux/session.py index cd6fa21c9..a85ac8341 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -13,6 +13,7 @@ from libtmux._internal.query_list import QueryList from libtmux.common import tmux_cmd +from libtmux.constants import WINDOW_DIRECTION_FLAG_MAP, WindowDirection from libtmux.formats import FORMAT_SEPARATOR from libtmux.neo import Obj, fetch_obj, fetch_objs from libtmux.pane import Pane @@ -541,6 +542,8 @@ def new_window( window_index: str = "", window_shell: t.Optional[str] = None, environment: t.Optional[t.Dict[str, str]] = None, + direction: t.Optional[WindowDirection] = None, + target_window: t.Optional[str] = None, ) -> "Window": """Create new window, returns new :class:`Window`. @@ -566,10 +569,52 @@ def new_window( useful for long-running processes where the closing of the window upon completion is desired. + direction : WindowDirection, optional + Insert window before or after target window (tmux 3.2+). + + target_window : str, optional + Used by :meth:`Window.new_window` to specify the target window. + .. versionchanged:: 0.28.0 ``attach`` default changed from ``True`` to ``False``. + See Also + -------- + :meth:`Window.new_window()` + + Examples + -------- + .. :: + >>> import pytest + >>> from libtmux.common import has_lt_version + >>> if has_lt_version('3.2'): + ... pytest.skip('direction doctests require tmux 3.2 or newer') + >>> window_initial = session.new_window(window_name='Example') + >>> window_initial + Window(@... 2:Example, Session($1 libtmux_...)) + >>> window_initial.window_index + '2' + + >>> window_before = session.new_window( + ... window_name='Window before', direction=WindowDirection.Before) + >>> window_initial.refresh() + >>> window_before + Window(@... 1:Window before, Session($1 libtmux_...)) + >>> window_initial + Window(@... 3:Example, Session($1 libtmux_...)) + + >>> window_after = session.new_window( + ... window_name='Window after', direction=WindowDirection.After) + >>> window_initial.refresh() + >>> window_after.refresh() + >>> window_after + Window(@... 3:Window after, Session($1 libtmux_...)) + >>> window_initial + Window(@... 4:Example, Session($1 libtmux_...)) + >>> window_before + Window(@... 1:Window before, Session($1 libtmux_...)) + Returns ------- :class:`Window` @@ -590,11 +635,6 @@ def new_window( if window_name is not None and isinstance(window_name, str): window_args += ("-n", window_name) - window_args += ( - # empty string for window_index will use the first one available - f"-t{self.session_id}:{window_index}", - ) - if environment: if has_gte_version("3.0"): for k, v in environment.items(): @@ -604,6 +644,22 @@ def new_window( "Environment flag ignored, requires tmux 3.0 or newer.", ) + if direction is not None: + if has_gte_version("3.2"): + window_args += (WINDOW_DIRECTION_FLAG_MAP[direction],) + else: + logger.warning( + "Direction flag ignored, requires tmux 3.1 or newer.", + ) + + if target_window: + if has_gte_version("3.2"): + window_args += (f"-t{target_window}",) + else: + logger.warning( + "Window target ignored, requires tmux 3.1 or newer.", + ) + if window_shell: window_args += (window_shell,) diff --git a/src/libtmux/test.py b/src/libtmux/test.py index 6a1294c01..6413b7b0b 100644 --- a/src/libtmux/test.py +++ b/src/libtmux/test.py @@ -271,7 +271,7 @@ def temp_window( >>> with temp_window(session) as window: - ... window.split_window() + ... window.split() Pane(%4 Window(@3 2:libtmux_..., Session($1 libtmux_...))) """ if "window_name" not in kwargs: diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 895fe874f..5d6047660 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -7,7 +7,6 @@ import dataclasses import logging -import pathlib import shlex import typing as t import warnings @@ -16,14 +15,15 @@ from libtmux.common import has_gte_version, tmux_cmd from libtmux.constants import ( RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, + PaneDirection, ResizeAdjustmentDirection, + WindowDirection, ) from libtmux.neo import Obj, fetch_obj, fetch_objs from libtmux.pane import Pane from . import exc -from .common import PaneDict, WindowOptionDict, handle_option_error, has_lt_version -from .formats import FORMAT_SEPARATOR +from .common import PaneDict, WindowOptionDict, handle_option_error if t.TYPE_CHECKING: from .server import Server @@ -197,20 +197,19 @@ def select_pane(self, target_pane: t.Union[str, int]) -> t.Optional["Pane"]: return self.active_pane - def split_window( + def split( self, target: t.Optional[t.Union[int, str]] = None, start_directory: t.Optional[str] = None, attach: bool = False, - vertical: bool = True, + direction: t.Optional[PaneDirection] = None, + full_window_split: t.Optional[bool] = None, + zoom: t.Optional[bool] = None, shell: t.Optional[str] = None, size: t.Optional[t.Union[str, int]] = None, - percent: t.Optional[int] = None, # deprecated environment: t.Optional[t.Dict[str, str]] = None, ) -> "Pane": - """Split window and return the created :class:`Pane`. - - Used for splitting window and holding in a python object. + """Split window on active pane and return the created :class:`Pane`. Parameters ---------- @@ -219,10 +218,12 @@ def split_window( True. start_directory : str, optional specifies the working directory in which the new window is created. - target : str - ``target_pane`` to split. - vertical : str - split vertically + direction : PaneDirection, optional + split in direction. If none is specified, assume down. + full_window_split: bool, optional + split across full window width or height, rather than active pane. + zoom: bool, optional + expand pane shell : str, optional execute a command on splitting the window. The pane will close when the command exits. @@ -232,108 +233,20 @@ def split_window( window upon completion is desired. size: int, optional Cell/row or percentage to occupy with respect to current window. - percent: int, optional - Deprecated in favor of size. Percentage to occupy with respect to current - window. environment: dict, optional Environmental variables for new pane. tmux 3.0+ only. Passthrough to ``-e``. - - Notes - ----- - :term:`tmux(1)` will move window to the new pane if the - ``split-window`` target is off screen. tmux handles the ``-d`` the - same way as ``new-window`` and ``attach`` in - :class:`Session.new_window`. - - By default, this will make the window the pane is created in - active. To remain on the same window and split the pane in another - target window, pass in ``attach=False``. - - .. versionchanged:: 0.28.0 - - ``attach`` default changed from ``True`` to ``False``. - - .. deprecated:: 0.28.0 - - ``percent=25`` deprecated in favor of ``size="25%"``. """ - tmux_formats = ["#{pane_id}" + FORMAT_SEPARATOR] - - tmux_args: t.Tuple[str, ...] = () - - if target is not None: - tmux_args += ("-t%s" % target,) - else: - if len(self.panes): - tmux_args += ( - f"-t{self.session_id}:{self.window_id}.{self.panes[0].pane_index}", - ) - else: - tmux_args += (f"-t{self.session_id}:{self.window_id}",) - - if vertical: - tmux_args += ("-v",) - else: - tmux_args += ("-h",) - - if size is not None: - if has_lt_version("3.1"): - if isinstance(size, str) and size.endswith("%"): - tmux_args += (f'-p{str(size).rstrip("%")}',) - else: - warnings.warn( - 'Ignored size. Use percent in tmux < 3.1, e.g. "size=50%"', - stacklevel=2, - ) - else: - tmux_args += (f"-l{size}",) - - if percent is not None: - # Deprecated in 3.1 in favor of -l - warnings.warn( - f'Deprecated in favor of size="{str(percent).rstrip("%")}%" ' - + ' ("-l" flag) in tmux 3.1+.', - category=DeprecationWarning, - stacklevel=2, - ) - tmux_args += (f"-p{percent}",) - - tmux_args += ("-P", "-F%s" % "".join(tmux_formats)) # output - - if start_directory is not None: - # as of 2014-02-08 tmux 1.9-dev doesn't expand ~ in new-window -c. - start_path = pathlib.Path(start_directory).expanduser() - tmux_args += (f"-c{start_path}",) - - if not attach: - tmux_args += ("-d",) - - if environment: - if has_gte_version("3.0"): - for k, v in environment.items(): - tmux_args += (f"-e{k}={v}",) - else: - logger.warning( - "Environment flag ignored, tmux 3.0 or newer required.", - ) - - if shell: - tmux_args += (shell,) - - pane_cmd = self.cmd("split-window", *tmux_args) - - # tmux < 1.7. This is added in 1.7. - if pane_cmd.stderr: - if "pane too small" in pane_cmd.stderr: - raise exc.LibTmuxException(pane_cmd.stderr) - - raise exc.LibTmuxException(pane_cmd.stderr, self.__dict__, self.panes) - - pane_output = pane_cmd.stdout[0] - - pane_formatters = dict(zip(["pane_id"], pane_output.split(FORMAT_SEPARATOR))) - - return Pane.from_pane_id(server=self.server, pane_id=pane_formatters["pane_id"]) + active_pane = self.active_pane or self.panes[0] + return active_pane.split( + start_directory=start_directory, + attach=attach, + direction=direction, + full_window_split=full_window_split, + zoom=zoom, + shell=shell, + size=size, + environment=environment, + ) def resize( self, @@ -705,6 +618,65 @@ def move_window( return self + def new_window( + self, + window_name: t.Optional[str] = None, + start_directory: None = None, + attach: bool = False, + window_index: str = "", + window_shell: t.Optional[str] = None, + environment: t.Optional[t.Dict[str, str]] = None, + direction: t.Optional[WindowDirection] = None, + ) -> "Window": + """Create new window respective of current window's position. + + See Also + -------- + :meth:`Session.new_window()` + + Examples + -------- + .. :: + >>> import pytest + >>> from libtmux.common import has_lt_version + >>> if has_lt_version('3.2'): + ... pytest.skip('This doctest requires tmux 3.2 or newer') + >>> window_initial = session.new_window(window_name='Example') + >>> window_initial + Window(@... 2:Example, Session($1 libtmux_...)) + >>> window_initial.window_index + '2' + + >>> window_before = window_initial.new_window( + ... window_name='Window before', direction=WindowDirection.Before) + >>> window_initial.refresh() + >>> window_before + Window(@... 2:Window before, Session($1 libtmux_...)) + >>> window_initial + Window(@... 3:Example, Session($1 libtmux_...)) + + >>> window_after = window_initial.new_window( + ... window_name='Window after', direction=WindowDirection.After) + >>> window_initial.refresh() + >>> window_after.refresh() + >>> window_after + Window(@... 4:Window after, Session($1 libtmux_...)) + >>> window_initial + Window(@... 3:Example, Session($1 libtmux_...)) + >>> window_before + Window(@... 2:Window before, Session($1 libtmux_...)) + """ + return self.session.new_window( + window_name=window_name, + start_directory=start_directory, + attach=attach, + window_index=window_index, + window_shell=window_shell, + environment=environment, + direction=direction, + target_window=self.window_id, + ) + # # Climbers # @@ -833,6 +805,60 @@ def width(self) -> t.Optional[str]: # # Legacy: Redundant stuff we want to remove # + def split_window( + self, + target: t.Optional[t.Union[int, str]] = None, + start_directory: t.Optional[str] = None, + attach: bool = False, + vertical: bool = True, + shell: t.Optional[str] = None, + size: t.Optional[t.Union[str, int]] = None, + percent: t.Optional[int] = None, # deprecated + environment: t.Optional[t.Dict[str, str]] = None, + ) -> "Pane": + """Split window and return the created :class:`Pane`. + + Notes + ----- + .. deprecated:: 0.33.0 + + Deprecated in favor of :meth:`.split()`. + + .. versionchanged:: 0.28.0 + + ``attach`` default changed from ``True`` to ``False``. + + .. deprecated:: 0.28.0 + + ``percent=25`` deprecated in favor of ``size="25%"``. + """ + warnings.warn( + "Window.split_window() is deprecated in favor of Window.split()", + category=DeprecationWarning, + stacklevel=2, + ) + + if percent is not None: + # Deprecated in 3.1 in favor of -l + warnings.warn( + f'Deprecated in favor of size="{str(percent).rstrip("%")}%" ' + + ' ("-l" flag) in tmux 3.1+.', + category=DeprecationWarning, + stacklevel=2, + ) + if size is None: + size = f"{str(percent).rstrip('%')}%" + + return self.split( + target=target, + start_directory=start_directory, + attach=attach, + direction=PaneDirection.Below if vertical else PaneDirection.Right, + shell=shell, + size=size, + environment=environment, + ) + @property def attached_pane(self) -> t.Optional["Pane"]: """Return attached :class:`Pane`. diff --git a/tests/legacy_api/test_window.py b/tests/legacy_api/test_window.py index db662fd78..811065953 100644 --- a/tests/legacy_api/test_window.py +++ b/tests/legacy_api/test_window.py @@ -8,7 +8,7 @@ import pytest from libtmux import exc -from libtmux.common import has_gte_version, has_lt_version +from libtmux.common import has_gte_version, has_lt_version, has_version from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session @@ -158,6 +158,58 @@ def test_split_window_horizontal(session: Session) -> None: assert float(window.panes[0].width) <= ((float(window.width) + 1) / 2) +@pytest.mark.filterwarnings("ignore:.*deprecated in favor of Window.split()") +@pytest.mark.filterwarnings("ignore:.*vertical is not required to pass with direction.") +def test_split_percentage( + session: Session, +) -> None: + """Test deprecated percent param.""" + window = session.new_window(window_name="split window size") + window.resize(height=100, width=100) + window_height_before = ( + int(window.window_height) if isinstance(window.window_height, str) else 0 + ) + if has_version("3.4"): + pytest.skip( + "tmux 3.4 has a split-window bug." + + " See https://github.com/tmux/tmux/pull/3840." + ) + with pytest.warns(match="Deprecated in favor of size.*"): + pane = window.split_window(percent=10) + assert pane.pane_height == str(int(window_height_before * 0.1)) + + +def test_split_window_size(session: Session) -> None: + """Window.split_window() respects size.""" + window = session.new_window(window_name="split_window window size") + window.resize(height=100, width=100) + + if has_gte_version("3.1"): + pane = window.split_window(size=10) + assert pane.pane_height == "10" + + pane = window.split_window(vertical=False, size=10) + assert pane.pane_width == "10" + + pane = window.split_window(size="10%") + assert pane.pane_height == "8" + + pane = window.split_window(vertical=False, size="10%") + assert pane.pane_width == "8" + else: + window_height_before = ( + int(window.window_height) if isinstance(window.window_height, str) else 0 + ) + window_width_before = ( + int(window.window_width) if isinstance(window.window_width, str) else 0 + ) + pane = window.split_window(size="10%") + assert pane.pane_height == str(int(window_height_before * 0.1)) + + pane = window.split_window(vertical=False, size="10%") + assert pane.pane_width == str(int(window_width_before * 0.1)) + + @pytest.mark.parametrize( "window_name_before,window_name_after", [("test", "ha ha ha fjewlkjflwef"), ("test", "hello \\ wazzup 0")], diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py index 6069aada3..9d164a3ef 100644 --- a/tests/test_dataclasses.py +++ b/tests/test_dataclasses.py @@ -41,8 +41,8 @@ def test_pane( assert __session is not None __window = __session.active_window - __window.split_window() - __pane = __window.split_window() + __window.split() + __pane = __window.split() __window.select_layout("main-vertical") assert __pane is not None @@ -125,12 +125,12 @@ def test_pane( # # Window-level - new_pane = window.split_window() + new_pane = window.split() assert new_pane.pane_id != pane.pane_id assert new_pane.window_id == window.window_id # Pane-level - new_pane_2 = new_pane.split_window() + new_pane_2 = new_pane.split() assert new_pane_2.pane_id != new_pane.pane_id assert new_pane_2.window_id == new_pane.window_id diff --git a/tests/test_pane.py b/tests/test_pane.py index 36137ae77..428fda2e3 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -5,8 +5,8 @@ import pytest -from libtmux.common import has_gte_version, has_lt_version -from libtmux.constants import ResizeAdjustmentDirection +from libtmux.common import has_gte_version, has_lt_version, has_lte_version +from libtmux.constants import PaneDirection, ResizeAdjustmentDirection from libtmux.session import Session logger = logging.getLogger(__name__) @@ -28,7 +28,7 @@ def test_send_keys(session: Session) -> None: def test_set_height(session: Session) -> None: """Verify Pane.set_height().""" window = session.new_window(window_name="test_set_height") - window.split_window() + window.split() pane1 = window.active_pane assert pane1 is not None pane1_height = pane1.pane_height @@ -42,7 +42,7 @@ def test_set_height(session: Session) -> None: def test_set_width(session: Session) -> None: """Verify Pane.set_width().""" window = session.new_window(window_name="test_set_width") - window.split_window() + window.split() window.select_layout("main-vertical") pane1 = window.active_pane @@ -133,6 +133,36 @@ def test_capture_pane_end(session: Session) -> None: assert pane_contents == '$ printf "%s"\n$' +@pytest.mark.skipif( + has_lte_version("3.1"), + reason="3.2 has the -Z flag on split-window", +) +def test_pane_split_window_zoom( + session: Session, +) -> None: + """Verify splitting window with zoom.""" + window_without_zoom = session.new_window(window_name="split_without_zoom") + initial_pane_without_zoom = window_without_zoom.active_pane + assert initial_pane_without_zoom is not None + window_with_zoom = session.new_window(window_name="split_with_zoom") + initial_pane_with_zoom = window_with_zoom.active_pane + assert initial_pane_with_zoom is not None + pane_without_zoom = initial_pane_without_zoom.split( + zoom=False, + ) + pane_with_zoom = initial_pane_with_zoom.split( + zoom=True, + ) + + assert pane_without_zoom.width == pane_without_zoom.window_width + assert pane_without_zoom.height is not None + assert pane_without_zoom.window_height is not None + assert pane_without_zoom.height < pane_without_zoom.window_height + + assert pane_with_zoom.width == pane_with_zoom.window_width + assert pane_with_zoom.height == pane_with_zoom.window_height + + @pytest.mark.skipif( has_lt_version("2.9"), reason="resize-window only exists in tmux 2.9+", @@ -144,8 +174,8 @@ def test_resize_pane( session.cmd("detach-client", "-s") window = session.active_window - pane = window.split_window(attach=False) - window.split_window(vertical=True, attach=False) + pane = window.split(attach=False) + window.split(direction=PaneDirection.Above, attach=False) assert pane is not None @@ -223,3 +253,49 @@ def test_resize_pane( ) pane_height_expanded = int(pane.pane_height) assert pane_height_before < pane_height_expanded + + +def test_split_pane_size(session: Session) -> None: + """Pane.split().""" + window = session.new_window(window_name="split window size") + window.resize(height=100, width=100) + pane = window.active_pane + assert pane is not None + + if has_gte_version("3.1"): + short_pane = pane.split(size=10) + assert short_pane.pane_height == "10" + + assert short_pane.at_left + assert short_pane.at_right + assert not short_pane.at_top + assert short_pane.at_bottom + + narrow_pane = pane.split(direction=PaneDirection.Right, size=10) + assert narrow_pane.pane_width == "10" + + assert not narrow_pane.at_left + assert narrow_pane.at_right + assert narrow_pane.at_top + assert not narrow_pane.at_bottom + + new_pane = pane.split(size="10%") + assert new_pane.pane_height == "8" + + new_pane = short_pane.split(direction=PaneDirection.Right, size="10%") + assert new_pane.pane_width == "10" + + assert not new_pane.at_left + assert new_pane.at_right + else: + window_height_before = ( + int(window.window_height) if isinstance(window.window_height, str) else 0 + ) + window_width_before = ( + int(window.window_width) if isinstance(window.window_width, str) else 0 + ) + new_pane = pane.split(size="10%") + assert new_pane.pane_height == str(int(window_height_before * 0.1)) + + new_pane = new_pane.split(direction=PaneDirection.Right, size="10%") + assert new_pane.pane_width == str(int(window_width_before * 0.1)) diff --git a/tests/test_session.py b/tests/test_session.py index 9f6b310e6..aa131702e 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -8,6 +8,7 @@ from libtmux import exc from libtmux.common import has_gte_version, has_lt_version +from libtmux.constants import WindowDirection from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session @@ -327,3 +328,53 @@ def test_new_window_with_environment_logs_warning_for_old_tmux( assert any( "Environment flag ignored" in record.msg for record in caplog.records ), "Warning missing" + + +@pytest.mark.skipif( + has_lt_version("3.2"), + reason="Only 3.2+ has the -a and -b flag on new-window", +) +def test_session_new_window_with_direction( + session: Session, +) -> None: + """Verify new window with direction.""" + window = session.active_window + window.refresh() + + window_initial = session.new_window(window_name="Example") + assert window_initial.window_index == "2" + + window_before = session.new_window( + window_name="Window before", direction=WindowDirection.Before + ) + window_initial.refresh() + assert window_before.window_index == "1" + assert window_initial.window_index == "3" + + window_after = session.new_window( + window_name="Window after", direction=WindowDirection.After + ) + window_initial.refresh() + window_after.refresh() + assert window_after.window_index == "3" + assert window_initial.window_index == "4" + assert window_before.window_index == "1" + + +@pytest.mark.skipif( + has_gte_version("3.1"), + reason="Only 3.1 has the -a and -b flag on new-window", +) +def test_session_new_window_with_direction_logs_warning_for_old_tmux( + session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + """Verify new window with direction create a warning if tmux is too old.""" + session.new_window( + window_name="session_window_with_direction", + direction=WindowDirection.After, + ) + + assert any( + "Direction flag ignored" in record.msg for record in caplog.records + ), "Warning missing" diff --git a/tests/test_tmuxobject.py b/tests/test_tmuxobject.py index b5a2d4728..585f7244f 100644 --- a/tests/test_tmuxobject.py +++ b/tests/test_tmuxobject.py @@ -99,7 +99,7 @@ def test_find_where_multiple_infos(server: Server, session: Session) -> None: def test_where(server: Server, session: Session) -> None: """Test self.where() returns matching objects.""" window = session.active_window - window.split_window() # create second pane + window.split() # create second pane for session in server.sessions: session_id = session.session_id @@ -156,7 +156,7 @@ def test_filter(server: Server) -> None: sess = server.new_session("test") window = sess.active_window - window.split_window() # create second pane + window.split() # create second pane for session in server.sessions: session_id = session.session_id diff --git a/tests/test_window.py b/tests/test_window.py index f43efcf57..80091a4b2 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -9,8 +9,12 @@ from libtmux import exc from libtmux._internal.query_list import ObjectDoesNotExist -from libtmux.common import has_gte_version, has_lt_version, has_version -from libtmux.constants import ResizeAdjustmentDirection +from libtmux.common import has_gte_version, has_lt_version, has_lte_version +from libtmux.constants import ( + PaneDirection, + ResizeAdjustmentDirection, + WindowDirection, +) from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session @@ -72,7 +76,7 @@ def test_fresh_window_data(session: Session) -> None: window = session.active_window assert isinstance(window, Window) assert len(session.active_window.panes) == 1 - window.split_window() + window.split() active_window = session.active_window assert active_window is not None @@ -104,12 +108,12 @@ def test_newest_pane_data(session: Session) -> None: window = session.new_window(window_name="test", attach=True) assert isinstance(window, Window) assert len(window.panes) == 1 - window.split_window(attach=True) + window.split(attach=True) assert len(window.panes) == 2 - # note: the below used to accept -h, removing because split_window now + # note: the below used to accept -h, removing because split now # has attach as its only argument now - window.split_window(attach=True) + window.split(attach=True) assert len(window.panes) == 3 @@ -119,11 +123,11 @@ def test_active_pane(session: Session) -> None: assert isinstance(window.active_pane, Pane) -def test_split_window(session: Session) -> None: - """Window.split_window() splits window, returns new Pane, vertical.""" +def test_split(session: Session) -> None: + """Window.split() splits window, returns new Pane, vertical.""" window_name = "test split window" window = session.new_window(window_name=window_name, attach=True) - pane = window.split_window() + pane = window.split() assert len(window.panes) == 2 assert isinstance(pane, Pane) @@ -134,12 +138,12 @@ def test_split_window(session: Session) -> None: assert float(first_pane.pane_height) <= ((float(window.window_width) + 1) / 2) -def test_split_window_shell(session: Session) -> None: - """Window.split_window() splits window, returns new Pane, vertical.""" +def test_split_shell(session: Session) -> None: + """Window.split() splits window, returns new Pane, vertical.""" window_name = "test split window" cmd = "sleep 1m" window = session.new_window(window_name=window_name, attach=True) - pane = window.split_window(shell=cmd) + pane = window.split(shell=cmd) assert len(window.panes) == 2 assert isinstance(pane, Pane) @@ -156,11 +160,11 @@ def test_split_window_shell(session: Session) -> None: assert pane.pane_start_command == cmd -def test_split_window_horizontal(session: Session) -> None: - """Window.split_window() splits window, returns new Pane, horizontal.""" +def test_split_horizontal(session: Session) -> None: + """Window.split() splits window, returns new Pane, horizontal.""" window_name = "test split window" window = session.new_window(window_name=window_name, attach=True) - pane = window.split_window(vertical=False) + pane = window.split(direction=PaneDirection.Right) assert len(window.panes) == 2 assert isinstance(pane, Pane) @@ -172,39 +176,22 @@ def test_split_window_horizontal(session: Session) -> None: assert float(first_pane.pane_width) <= ((float(window.window_width) + 1) / 2) -def test_split_percentage(session: Session) -> None: - """Test deprecated percent param.""" - window = session.new_window(window_name="split window size") - window.resize(height=100, width=100) - window_height_before = ( - int(window.window_height) if isinstance(window.window_height, str) else 0 - ) - if has_version("3.4"): - pytest.skip( - "tmux 3.4 has a split-window bug." - + " See https://github.com/tmux/tmux/pull/3840." - ) - with pytest.warns(match="Deprecated in favor of size.*"): - pane = window.split_window(percent=10) - assert pane.pane_height == str(int(window_height_before * 0.1)) - - -def test_split_window_size(session: Session) -> None: - """Window.split_window() respects size.""" +def test_split_size(session: Session) -> None: + """Window.split() respects size.""" window = session.new_window(window_name="split window size") window.resize(height=100, width=100) if has_gte_version("3.1"): - pane = window.split_window(size=10) + pane = window.split(size=10) assert pane.pane_height == "10" - pane = window.split_window(vertical=False, size=10) + pane = window.split(direction=PaneDirection.Right, size=10) assert pane.pane_width == "10" - pane = window.split_window(size="10%") + pane = window.split(size="10%") assert pane.pane_height == "8" - pane = window.split_window(vertical=False, size="10%") + pane = window.split(direction=PaneDirection.Right, size="10%") assert pane.pane_width == "8" else: window_height_before = ( @@ -213,10 +200,10 @@ def test_split_window_size(session: Session) -> None: window_width_before = ( int(window.window_width) if isinstance(window.window_width, str) else 0 ) - pane = window.split_window(size="10%") + pane = window.split(size="10%") assert pane.pane_height == str(int(window_height_before * 0.1)) - pane = window.split_window(vertical=False, size="10%") + pane = window.split(direction=PaneDirection.Right, size="10%") assert pane.pane_width == str(int(window_width_before * 0.1)) @@ -405,7 +392,7 @@ def test_empty_window_name(session: Session) -> None: {"ENV_VAR_1": "pane_1", "ENV_VAR_2": "pane_2"}, ], ) -def test_split_window_with_environment( +def test_split_with_environment( session: Session, environment: t.Dict[str, str], ) -> None: @@ -413,8 +400,8 @@ def test_split_window_with_environment( env = shutil.which("env") assert env is not None, "Cannot find usable `env` in Path." - window = session.new_window(window_name="split_window_with_environment") - pane = window.split_window( + window = session.new_window(window_name="split_with_environment") + pane = window.split( shell=f"{env} PS1='$ ' sh", environment=environment, ) @@ -426,11 +413,37 @@ def test_split_window_with_environment( assert pane.capture_pane()[-2] == v +@pytest.mark.skipif( + has_lte_version("3.1"), + reason="3.2 has the -Z flag on split-window", +) +def test_split_window_zoom( + session: Session, +) -> None: + """Verify splitting window with zoom.""" + window_without_zoom = session.new_window(window_name="split_without_zoom") + window_with_zoom = session.new_window(window_name="split_with_zoom") + pane_without_zoom = window_without_zoom.split( + zoom=False, + ) + pane_with_zoom = window_with_zoom.split( + zoom=True, + ) + + assert pane_without_zoom.width == pane_without_zoom.window_width + assert pane_without_zoom.height is not None + assert pane_without_zoom.window_height is not None + assert pane_without_zoom.height < pane_without_zoom.window_height + + assert pane_with_zoom.width == pane_with_zoom.window_width + assert pane_with_zoom.height == pane_with_zoom.window_height + + @pytest.mark.skipif( has_gte_version("3.0"), reason="3.0 has the -e flag on split-window", ) -def test_split_window_with_environment_logs_warning_for_old_tmux( +def test_split_with_environment_logs_warning_for_old_tmux( session: Session, caplog: pytest.LogCaptureFixture, ) -> None: @@ -438,8 +451,8 @@ def test_split_window_with_environment_logs_warning_for_old_tmux( env = shutil.which("env") assert env is not None, "Cannot find usable `env` in Path." - window = session.new_window(window_name="split_window_with_environment") - window.split_window( + window = session.new_window(window_name="split_with_environment") + window.split( shell=f"{env} PS1='$ ' sh", environment={"ENV_VAR": "pane"}, ) @@ -527,3 +540,60 @@ def test_resize( ) window_height_expanded = int(window.window_height) assert window_height_before < window_height_expanded + + +@pytest.mark.skipif( + has_lt_version("3.2"), + reason="Only 3.2+ has the -a and -b flag on new-window", +) +def test_new_window_with_direction( + session: Session, +) -> None: + """Verify new window with direction.""" + window = session.active_window + window.refresh() + + window_initial = session.new_window(window_name="Example") + assert window_initial.window_index == "2" + + window_before = window_initial.new_window( + window_name="Window before", direction=WindowDirection.Before + ) + window_initial.refresh() + assert window_before.window_index == "2" + assert window_initial.window_index == "3" + + window_after = window_initial.new_window( + window_name="Window after", direction=WindowDirection.After + ) + window_initial.refresh() + window_after.refresh() + assert window_after.window_index == "4" + assert window_initial.window_index == "3" + assert window_before.window_index == "2" + + +@pytest.mark.skipif( + has_gte_version("3.2"), + reason="Only 3.2+ has the -a and -b flag on new-window", +) +def test_new_window_with_direction_logs_warning_for_old_tmux( + session: Session, + caplog: pytest.LogCaptureFixture, +) -> None: + """Verify new window with direction create a warning if tmux is too old.""" + window = session.active_window + window.refresh() + + window.new_window( + window_name="window_with_direction", + direction=WindowDirection.After, + ) + + assert any( + "Window target ignored" in record.msg for record in caplog.records + ), "Warning missing" + + assert any( + "Direction flag ignored" in record.msg for record in caplog.records + ), "Warning missing"