def __init__(self,
                 identity: str,
                 name: str,
                 is_first: bool = False) -> "Window":
        """
        Sets up details about the window

        Parameters
        ----------
        identity : string
            A unique ID representing this window
        name : string
            The name of the window
        is_first : boolean
            Is this the first window that was created?
        """
        self.identity = identity
        self.name = name
        self.first = is_first
        self.panes = []
        self.base_width = 0
        self.base_height = 0
        self.panes_added = 0
        self.last_pane = None
        self.objects = ObjectTracker()
        self.objects.add_window(self)
Exemple #2
0
    def __init__(self,
                 identity: str,
                 window: "Window",
                 direction: str,
                 is_first: bool = False) -> "Pane":
        """
        Sets up details about the pane

        Parameters
        ----------
        identity : string
            A unique ID representing this pane
        window : Window
            The Window object this pane belongs to
        direction : string|None
            Which direction to split this pane - 'h' for horizontal, 'v' for vertical
        is_first : bool
            Is this the first pane that was created?
        """
        self.identity = identity
        self.window = window
        self.direction = direction
        self.is_first = is_first
        self.session_name = None
        self.command = ""
        self.split_from = None
        self.width = 0
        self.height = 0
        self.number = None
        self.parent_number = None
        self.start_dir = None
        self.objects = ObjectTracker()
        self.objects.add_pane(self)
    def __init__(self,
                 session_name: str,
                 starting_directory: str = "",
                 detach: bool = True) -> "TmuxBuilder":
        """
        Sets up details about the tmux builder

        Parameters
        ----------
        session_name : string
            The name of the session to create
        starting_directory : string
            The directory to base all windows/panes out of
        detach : bool
            Whether to detach this session after creatio
        """
        self.session_name = session_name
        self.objects = ObjectTracker()
        self.windows = []
        self.start_dir = starting_directory
        self.detach = bool(detach)

        self.__get_terminal_size()
Exemple #4
0
def test_adding_duplicate_ids():
    """
    Try adding two items with the same ID
    """
    tracker = ObjectTracker()
    tracker.add_pane(Pane("foo"))

    try:
        tracker.add_pane(Pane("foo"))
        assert False, "Expected ValueError exception, but one was not thrown"
    # pylint: disable=broad-except
    except Exception as error:
        check_exception(error, 'Object ID "foo" is already in use!',
                        ValueError)

    try:
        tracker.add_window(Window("foo"))
        assert False, "Expected ValueError exception, but one was not thrown"
    # pylint: disable=broad-except
    except Exception as error:
        check_exception(error, 'Object ID "foo" is already in use!',
                        ValueError)
Exemple #5
0
def test_adding_mismatched_types():
    """
    Try adding a pane as a window, or vice versa
    """
    tracker = ObjectTracker()

    try:
        tracker.add_window(Pane("foo"))
        assert False, "Expected TypeError exception, but one was not thrown"
    # pylint: disable=broad-except
    except Exception as error:
        check_exception(error, "Object provided is not a window!", TypeError)

    try:
        tracker.add_pane(Window("foo"))
        assert False, "Expected TypeError exception, but one was not thrown"
    # pylint: disable=broad-except
    except Exception as error:
        check_exception(error, "Object provided is not a pane!", TypeError)
Exemple #6
0
def test_adding_non_classes():
    """
    Try adding an integer as a window, etc.
    """
    tracker = ObjectTracker()

    try:
        tracker.add_pane(Integer(5))
        assert False, "Expected TypeError exception, but one was not thrown"
    # pylint: disable=broad-except
    except Exception as error:
        check_exception(error, "Object provided is not a pane!", TypeError)

    try:
        tracker.add_window(Integer(3))
        assert False, "Expected TypeError exception, but one was not thrown"
    # pylint: disable=broad-except
    except Exception as error:
        check_exception(error, "Object provided is not a window!", TypeError)
class Window:
    """
    Represents a window in Tmux

    Attributes
    ----------
    identity : string
        A unique ID representing this window
    name : string
        The name of the window
    number : number
        The number of this window
    is_first : boolean
        Is this the first window that was created?
    panes : list
        The panes to create in this window
    session_name : string
        The name of the current session
    start_dir : string
        The starting directory for the window
    """

    COMPARABLE_PROPERTIES = ["name", "start_dir", "session_name", "first"]
    # Contains <window_number>: <window_name><window_marker> (N panes)
    #          [<window_width>x<window_height>] [layout <layout_id,...
    WINDOW_TRAIL_REGEX = r"([\*~#\!MZ\-]+)"
    LAYOUT_DETAILS = r"([a-fA-F0-9]+),([0-9x]+),([0-9]+),([0-9]+)"
    DIRECTION_MAP = {"[": SPLIT_VERTICAL, "{": SPLIT_HORIZONTAL}
    LAYOUT_PAIRS = {"[": "]", "{": "}"}
    PANE_LAYOUT = r"([0-9]+x[0-9]+,[0-9]+,[0-9]+)"

    number = None
    start_dir = None
    session_name = None
    layout_id = None
    layout_size = None
    offset_left = None
    offset_top = None

    def __init__(self,
                 identity: str,
                 name: str,
                 is_first: bool = False) -> "Window":
        """
        Sets up details about the window

        Parameters
        ----------
        identity : string
            A unique ID representing this window
        name : string
            The name of the window
        is_first : boolean
            Is this the first window that was created?
        """
        self.identity = identity
        self.name = name
        self.first = is_first
        self.panes = []
        self.base_width = 0
        self.base_height = 0
        self.panes_added = 0
        self.last_pane = None
        self.objects = ObjectTracker()
        self.objects.add_window(self)

    def set_number(self, num: int) -> "Window":
        """
        Set the window number

        Parameters
        ----------
        num : number
            The window number

        Returns
        -------
        This instance
        """
        self.number = num
        return self

    def set_session_name(self, name: str) -> "Window":
        """
        Sets the session name for the window

        Parameters
        ----------
        name : string
            The name of the current session

        Returns
        -------
        This instance
        """
        self.session_name = name
        return self

    def set_start_dir(self, directory: str) -> "Window":
        """
        Sets the starting directory for the window

        Parameters
        ----------
        directory : string
            The starting directory of the current session

        Returns
        -------
        This instance
        """
        self.start_dir = directory
        return self

    def get_pane_count(self) -> int:
        """
        Returns the number of panes in this window

        Returns
        -------
        number
            The number of panes
        """
        return len(self.panes)

    def add_pane(self, pane: Pane) -> "Window":
        """
        Adds a pane to this window

        Parameters
        ----------
        pane : Pane
            The pane to add to the window

        Returns
        -------
        This instance
        """
        self.panes.append(pane)
        if pane.split_from is not None:
            parent_pane_number = self.objects.get_pane_by_id(
                pane.split_from).number
        # Tmux numbers panes left-to-right & top-down, order dependent on initial split
        # Each time a pane is split, the number increases by one from where it was split
        # Thus, anything equal to or larger than the initial pane will increase by one
        # This emulates that logic in the cached panes here
        if pane.split_from is not None:
            for window_pane in self.panes:
                if window_pane.number >= parent_pane_number + 1:
                    window_pane.number = window_pane.number + 1
            pane.number = parent_pane_number + 1

        return self

    def get_pane(self, idx: int) -> Pane:
        """
        Returns a pane at a given index

        Parameters
        ----------
        idx : number
            The index of the pane to get

        Returns
        -------
        Pane
            The pane at the given index
        """
        return self.panes[idx]

    def is_first(self) -> bool:
        """
        Returns true if this is the first window

        Returns
        -------
        boolean
            True if this is the first window
        """
        return self.first

    def get_create_command(self) -> str:
        """
        Gets the creation command for the window

        Returns
        -------
        string
            The creation command
        """
        # First window will be created automatically, so just need to rename it
        command = ""
        if not self.is_first():
            command = "new-window -n {0} {1}".format(self.name, self.start_dir)
        else:
            command = "rename-window " + self.name

        return command

    def get_difference(self, window: "Window") -> dict:
        """
        Compare this instance to another window
        Checks properties, not direct reference

        Parameters
        ----------
        window : Window
            The window to compare against

        Returns
        -------
        dict
            A key-value comparison of this and the other window
        """
        return get_object_differences(self, window, self.COMPARABLE_PROPERTIES)

    def get_pane_differences(self, panes: list) -> dict:
        """
        Compare this instance's panes to other panes
        Checks properties, not direct reference

        Parameters
        ----------
        panes : list[Pane]
            The panes to compare against

        Returns
        -------
        dict
            A key-value comparison of this window's panes and the provided list
        """
        differences = []
        for i in range(len(self.panes)):
            differences.append(
                get_object_differences(self.panes[i], panes[i],
                                       panes[i].COMPARABLE_PROPERTIES))

        return differences

    def load_from_layout(self, layout: str) -> None:
        """
        Load details about this window from a layout

        Parameters
        ----------
        layout : string
            The layout string to load details from
            Will be in a format of
                <num>: <name><status> (<N> panes) [<width>x<height>] [layout XXXX,...]
            May be zoomed, so this argument could be incomplete
        """
        match = re.match(r"^([0-9]+): ", layout)
        win_layout_num = match.groups()[0]
        debug("window", "loadLayout",
              "Parsing layout {0}".format(win_layout_num))
        layout = layout[len(win_layout_num) + 2:]
        self.name = re.split(self.WINDOW_TRAIL_REGEX, layout)[0]
        layout = layout[len(self.name):]
        chars = re.match(self.WINDOW_TRAIL_REGEX, layout)
        if chars:
            layout = layout[len(chars.groups()[0]):]
        layout = layout.strip()

        match = re.match(r".*\[([0-9]+x[0-9]+)\]", layout)
        win_size = parse_size(match.groups()[0])
        self.base_width = int(win_size["width"])
        self.base_height = int(win_size["height"])

        # Make sure layout is not zoomed
        select_pane(self.session_name, win_layout_num, 0)
        # Pull only the layout details
        win_layout = get_tmux_details(self.session_name, win_layout_num, None,
                                      WINDOW_LAYOUT_VARIABLE)
        self.parse_layout(win_layout)

    def parse_layout(self, layout: str) -> None:
        """
        Parse a layout string into panes

        The layout string should be of the format:
          <pane_indicator>,<pane_number or further layout data>

        Parameters
        ----------
        layout : string
            The layout string to parse
            Will be in the form:
                <layout_id>,<win_size>,<offset_left>,<offset_top>,<pane_data>
        """
        match = re.match(self.LAYOUT_DETAILS, layout)
        (
            self.layout_id,
            self.layout_size,
            self.offset_left,
            self.offset_top,
        ) = match.groups()

        # Three commas separating fields
        pane_data = layout[len(self.layout_id) + len(self.layout_size) +
                           len(self.offset_left) + len(self.offset_top) + 3:]
        # This will recursively populate all panes
        self.add_pane_row(pane_data)

        for pane in self.panes:
            pane.set_command(
                get_pane_command(self.session_name, self.number, pane.number))
            pane.set_start_dir(
                get_pane_working_directory(self.session_name, self.number,
                                           pane.number))

    def add_simple_pane(self, win_id: str, parent_id: str = None) -> None:
        """
        Adds a pane to this window

        Parameters
        ----------
        win_id : str
            The window ID to prefix this pane with
        parent_id = None : str
            Optionally, the ID of this pane's parent
        """
        if parent_id:
            number = self.objects.get_pane_by_id(parent_id).number + 1
        else:
            number = 0

        debug("Window", "add_simple_pane", "Adding pane: " + str(number))
        # The layout is a single pane
        pane = Pane(win_id + str(number), self, None, True)
        self.panes_added += 1
        # All that's left after the comma is the pane number
        pane.set_number(number)
        self.panes.append(pane)

    # pylint: disable=bad-continuation
    # black auto-format disagrees
    def add_pane_row(self,
                     layout: str,
                     parent_id: str = None,
                     prevailing_split: str = None) -> None:
        """
        Add a row of panes, either horizontally or vertically

        Parameters
        ----------
        layout : string
            The layout for the row, should start with either a [ or {,
            depending on whether the split is vertical or horizontal
        parent_id : string
            The identity of the pane to base these off of
        prevailing_split: string
            The current split direction, for the first pane to be based off of

            The following data will be a comma-separated array of pane data
            See add_pane_by_layout
        """
        win_id = "win{0}pane".format(self.number)
        debug("Window", "add_pane_row", "Adding pane row")
        debug("Window", "add_pane_row", layout)
        if layout[0] == ",":
            self.add_simple_pane(win_id, parent_id)
        else:
            direction = self.DIRECTION_MAP[layout[0]]
            direction = SPLIT_HORIZONTAL if layout[0] == "{" else SPLIT_VERTICAL
            debug("Window", "add_pane_row",
                  "Layout split direction: " + direction)
            # Strip outermost direction indicators
            layout = layout[1:-1]
            debug("Window", "add_pane_row", "Resolved layout: " + layout)

            deferred_layouts = self.identify_deferred_layouts(
                layout, direction, parent_id, prevailing_split)

            for deferred_layout in deferred_layouts:
                debug("Window", "add_pane_row", "Processing deferred layout:")
                debug("Window", "add_pane_row",
                      "Layout: " + deferred_layout["layout"])
                debug(
                    "Window",
                    "add_pane_row",
                    "Parent: " + str(deferred_layout["parent"]) +
                    ", number: " + str(
                        self.objects.get_pane_by_id(
                            deferred_layout["parent"]).number),
                )
                debug(
                    "Window",
                    "add_pane_row",
                    "Split direction: " + deferred_layout["split"],
                )
                self.add_pane_row(
                    deferred_layout["layout"],
                    deferred_layout["parent"],
                    deferred_layout["split"],
                )

    # pylint: disable=bad-continuation
    # black auto-format disagrees
    def identify_deferred_layouts(
        self,
        layout: str,
        direction: str,
        parent_id: str = None,
        prevailing_split: str = None,
    ) -> list:
        """
        Identifies any layouts needing to be resolved later
        This algorithm requires breadth-first parsing, to have
        correct numbering/re-creation behaviors

        Parameters
        ----------
        layout : str
            The layout to analyze
        direction : str
            The direction of this layout
        parent_id = None : str
            The original parent this layout should be created for
        prevailing_split = None : str
            The direction of the layout prior to this one

        Returns
        -------
        list
            A list of layouts which need to be processed later
        """
        first_found = True
        deferred_layouts = []

        while True:
            match = re.match(self.PANE_LAYOUT, layout)
            if not match:
                break

            pane_layout = match.groups()[0]
            debug("Window", "identify_deferred", "Pane details:")
            debug("Window", "identify_deferred", pane_layout)
            layout = layout[len(pane_layout):]
            debug("Window", "identify_deferred", "Remaining layout:")
            debug("Window", "identify_deferred", layout)
            if layout[0] == ",":
                debug("Window", "identify_deferred", "Got pane number:")
                match = re.match(r",([0-9]+)", layout)
                pane_number = match.groups()[0]
                debug("Window", "identify_deferred", pane_number)
                match = re.match(r"(,[0-9]+,?)", layout)
                to_remove = match.groups()[0]
                layout = layout[len(to_remove):]
                debug("Window", "identify_deferred",
                      "Stripping number, leaves:")
                debug("Window", "identify_deferred", layout)
                debug(
                    "Window",
                    "identify_deferred",
                    "Adding pane with split direction: " +
                    (direction if not first_found or not prevailing_split else
                     prevailing_split),
                )
                self.add_pane_by_layout(
                    pane_layout,
                    pane_number,
                    direction if not first_found or not prevailing_split else
                    prevailing_split,
                    parent_id if parent_id else self.last_pane,
                )
                first_found = False

            else:
                debug("Window", "identify_deferred",
                      "Resolving layout recursively")
                # Find the end of the nested layout
                # Tracks the levels of layouts
                # e.g. for [ { [ ] } ] will have [, {, [
                nested_layouts = [layout[0]]
                length = 0
                for char in layout[1:]:
                    if char in self.LAYOUT_PAIRS.keys():
                        nested_layouts.append(char)
                    elif char == self.LAYOUT_PAIRS[nested_layouts[-1]]:
                        nested_layouts.pop()
                        # If this was the last one, then stop searching the string

                    length += 1
                    if len(nested_layouts) == 0:
                        break

                debug("Window", "identify_deferred", "Found deferred layout:")
                debug("Window", "identify_deferred",
                      "Layout: " + layout[:length + 1])
                debug(
                    "Window",
                    "identify_deferred",
                    "Parent: " + str(self.panes_added - 1),
                )
                debug("Window", "identify_deferred",
                      "Split direction: " + direction)

                pane_details = self.get_pane_details_from_layout(
                    layout[1:length])
                self.add_pane_by_layout(
                    pane_details["selfLayout"],
                    pane_details["number"],
                    direction,
                    parent_id,
                )

                deferred_layouts.append({
                    # Keep the first character of the layout - [ or {
                    "layout":
                    layout[0]
                    # Deferred layout is everything after the first pane through
                    # the end of the matched (nested) layout
                    # This will be added later, since
                    # we need to parse layouts breadth-first
                    + layout[len(pane_details["selfLayout"]) + 1:length + 1],
                    "parent":
                    self.last_pane,
                    # Deferred layouts will contain the next direction,
                    # not the current direction
                    "split":
                    self.DIRECTION_MAP[layout[0]],
                })

                # Strip the part that was nested out of the layout
                # and remove a left-leading comma, if there is one
                layout = layout[length + 1:].lstrip(",")
                debug("Window", "identify_deferred",
                      "Removing deferred layout leaves:")
                debug("Window", "identify_deferred", layout)

            # For deferred layouts where multiple panes will be added,
            # chain parent to this pane
            # Otherwise, you end up with 0 - 2 - 1 for pane numbering,
            # when you really should have 0 - 1 - 2
            # This is because the "add_pane" bit will
            # shift numbers 1 and greater up one, but that doesn't match
            # the order of creation/insertion that is expected
            if parent_id:
                parent_id = self.last_pane

        return deferred_layouts

    def add_pane_by_layout(self,
                           layout: str,
                           pane_number: int,
                           direction: str,
                           parent_identity: str = None) -> None:
        """
        Adds a new pane, using the layout to fill in details

        Parameters
        ----------
        layout : string
            The layout string for the pane
            Will be in the following format:
              <size>,<offset_left>,<offset_top>
        pane_number : number
            The number of the pane, from the layout
        direction : string
            The direction this pane should be split from the previous one
        parent_identity : string
            The identity of the parent pane
            Used to re-order panes, if needed
        """
        size = parse_size(layout.split(",")[0])
        pane_id = "win{0}pane{1}".format(self.number, self.panes_added)
        pane = (Pane(pane_id, self, direction,
                     self.panes_added == 0).set_session_name(
                         self.session_name).set_size(
                             100 * size["width"] / self.base_width,
                             100 * size["height"] / self.base_height,
                         ))

        parent = None
        try:
            parent = self.objects.get_pane_by_id(parent_identity)
            parent_number = parent.number
        except NameError:
            parent_number = None

        pane_number = (parent_number +
                       1 if parent_number is not None else self.panes_added)

        if parent_number is not None and self.panes_added > pane_number:
            debug(
                "Window",
                "add_pane",
                "Incrementing pane numbers above {0}".format(pane_number),
            )
            for pane_to_check in self.panes:
                if pane_to_check.number >= pane_number:
                    pane_to_check.set_number(pane_to_check.number + 1)

        pane.set_number(pane_number)

        if self.last_pane is not None and parent_number is None:
            pane.set_target(self.last_pane)
        elif parent:
            pane.set_target(parent.identity)

        self.last_pane = pane.identity
        self.panes_added += 1
        self.panes.append(pane)
        debug(
            "Window",
            "add_pane",
            "Adding pane number {0} for parent {1} with size {2}".format(
                pane_number, parent_number, size),
        )

    def get_pane_details_from_layout(self, layout: str) -> dict:
        """
        Get details about a pane layout

        Parameters
        ----------
        layout : str
            The layout string

        Returns
        -------
        dict
            containing details about the pane
        """
        match = re.match(self.PANE_LAYOUT, layout)
        if not match:
            debug(
                "Window",
                "pane_details",
                "Layout did not match pane format! ({0})".format(layout),
            )
            return {}

        details = match.groups()[0]
        size, offset_left, offset_top = details.split(",")
        pane_number = layout.split(",")[3]

        layout = details + ",{0},".format(pane_number)

        return {
            "size": size,
            "left": offset_left,
            "top": offset_top,
            "number": pane_number,
            "selfLayout": layout,
        }
def setup():
    """
    Reset the object tracker windows and panes
    """
    ObjectTracker().reset()
def setup():
    """
    Reset the object tracker
    """
    ObjectTracker().reset()
Exemple #10
0
def test_adding_and_retrieving():
    """
    Try adding two items with the same ID
    """
    tracker = ObjectTracker()

    pane1 = Pane("pane1")
    tracker.add_pane(pane1)
    pane2 = Pane("pane2")
    tracker.add_pane(pane2)
    window1 = Window("window1")
    tracker.add_window(window1)
    window2 = Window("window2")
    tracker.add_window(window2)

    ids = ["pane1", "pane2", "window1", "window2"]
    for expected in ids:
        if "pane" in expected:
            actual = tracker.get_pane_by_id(expected).identity
        else:
            actual = tracker.get_window_by_id(expected).identity
        assert actual == expected, "Retrieved {0} does not match!".format(
            expected)
Exemple #11
0
def test_getting_nonexistent_items():
    """
    Try getting items which do not exist
    """
    tracker = ObjectTracker()

    try:
        tracker.get_window_by_id("foo")
        assert False, "Expected NameError exception, but one was not thrown"
    # pylint: disable=broad-except
    except Exception as error:
        check_exception(error, "No such object: foo!", NameError)

    try:
        tracker.get_pane_by_id("foo")
        assert False, "Expected NameError exception, but one was not thrown"
    # pylint: disable=broad-except
    except Exception as error:
        check_exception(error, "No such object: foo!", NameError)

    tracker.add_pane(Pane("foo"))
    try:
        tracker.get_window_by_id("foo")
        assert False, "Expected TypeError exception, but one was not thrown"
    # pylint: disable=broad-except
    except Exception as error:
        check_exception(error, "Identity provided is not a window!", TypeError)

    tracker.add_window(Window("foo2"))
    try:
        tracker.get_pane_by_id("foo2")
        assert False, "Expected TypeError exception, but one was not thrown"
    # pylint: disable=broad-except
    except Exception as error:
        check_exception(error, "Identity provided is not a pane!", TypeError)
Exemple #12
0
def set_session_parameters(
    session: str, session_start_directory: str, windows: list
) -> str:
    """
    Sets session data based off of parameters

    Parameters
    ----------
    session: string
        The name of the session
    session_start_directory : string
        The path to the session's starting directory
    windows : list
        A list of window information
        Each needs:
          - identity
          - number
          - title
          - postfix (appended to title in window list)
          - dir
          - layout
          - panes (list of objects)
            - identity
            - number
            - dir
            - command [optional]
            - parent [optional]

    Returns
    -------
    string
        The window list to analyze
    """
    inject_session_data(SESSION_INITIAL_DIR_KEY, session_start_directory)
    layout = ""

    for window in windows:
        win_object = (
            FakeWindow(window["identity"])
            .set_session_name(session)
            .set_name(window["title"])
            .set_number(window["number"])
            .set_layout(window["layout"])
            .set_directory(window.get("dir", session_start_directory))
        )

        win_object.inject()
        ObjectTracker().add_window(win_object)

        for pane in window["panes"]:
            pane_object = (
                FakePane(pane["identity"])
                .set_session_name(session)
                .set_number(pane["number"])
                .set_window(win_object)
                .set_directory(
                    pane.get("dir", window.get("dir", session_start_directory))
                )
                .set_command(pane.get("command", ""))
            )

            if "parent" in pane:
                pane_object.set_split_from(pane["parent"])

            pane_object.inject()
            ObjectTracker().add_pane(pane_object)

        layout += "{0}: {1}{2} [200x60] ({3} panes) [{4}] @{0}\n".format(
            window["number"],
            window["title"],
            window["postfix"],
            len(window["panes"]),
            window["layout"],
        )

    inject_session_data(WINDOW_LIST_KEY, layout)
    return layout
Exemple #13
0
class Pane:
    """
    Represents a pane in a Tmux window

    Attributes
    ----------
    identity : string
        A unique ID representing this pane
    window : string
        The unique ID for the window this pane belongs to
    direction : string|None
        Which direction to split this pane - 'h' for horizontal, 'v' for vertical
        Can be None ONLY if this is the first pane
    is_first : bool
        Is this the first pane created in the window?
    number : int
        The number of this window
    session_name : string
        The name of the current session
    start_dir : string
        The starting directory for the pane
    split_from : string
        The ID of the pane to split this one from
    width : int
        The width of the pane, in lines
    height : int
        The height of the pane, in lines
    command : string
        The command to run in this pane
    """

    COMPARABLE_PROPERTIES = [
        "width",
        "height",
        "split_from",
        "direction",
        "start_dir",
        "command",
    ]

    # pylint: disable=bad-continuation
    # black auto-format disagrees
    def __init__(self,
                 identity: str,
                 window: "Window",
                 direction: str,
                 is_first: bool = False) -> "Pane":
        """
        Sets up details about the pane

        Parameters
        ----------
        identity : string
            A unique ID representing this pane
        window : Window
            The Window object this pane belongs to
        direction : string|None
            Which direction to split this pane - 'h' for horizontal, 'v' for vertical
        is_first : bool
            Is this the first pane that was created?
        """
        self.identity = identity
        self.window = window
        self.direction = direction
        self.is_first = is_first
        self.session_name = None
        self.command = ""
        self.split_from = None
        self.width = 0
        self.height = 0
        self.number = None
        self.parent_number = None
        self.start_dir = None
        self.objects = ObjectTracker()
        self.objects.add_pane(self)

    def set_number(self, num: int) -> "Pane":
        """
        Set the pane number

        Parameters
        ----------
        num : int
            The pane number

        Returns
        -------
        This instance
        """
        self.number = num
        return self

    def set_session_name(self, name: str) -> "Pane":
        """
        Sets the session name for the pane

        Parameters
        ----------
        name : string
            The name of the current session

        Returns
        -------
        This instance
        """
        self.session_name = name
        return self

    def set_start_dir(self, directory: str) -> "Pane":
        """
        Sets the starting directory for the pane

        Parameters
        ----------
        directory : string
            The starting directory of the current session

        Returns
        -------
        This instance
        """
        self.start_dir = directory
        return self

    def set_target(self, target: str) -> "Pane":
        """
        Set the target to split this pane from

        Parameters
        ----------
        target : string
            The identity of the pane to split this pane from

        Returns
        -------
        This instance
        """
        self.split_from = target
        self.parent_number = self.objects.get_pane_by_id(target).number
        return self

    def set_size(self, width: int = 0, height: int = 0) -> "Pane":
        """
        Set the size of this pane

        Parameters
        ----------
        width : int|0
            The line width of the pane
        height : int|0
            The line height of the pane

        Returns
        -------
        This instance
        """
        if height:
            self.height = height
        if width:
            self.width = width

        return self

    def set_command(self, command: str, needs_enter: bool = True) -> "Pane":
        """
        Set the command to run in this pane

        Parameters
        ----------
        command : string
            The command to run in this pane
        needs_enter : bool
            Whether the command needs an "enter" keystroke at the end of it

        Returns
        -------
        This instance
        """
        self.command = escape_command(command, needs_enter)
        return self

    def set_parent_directly(self, parent_number: int) -> "Pane":
        """
        Set the number of the parent directly, rather than by ID
        Needed for scripting a Tmux session, since panes need to be created twice

        Parameters
        ----------
        parent_number : number
            The number of the parent of this pane

        Returns
        -------
        self
            This instance
        """
        self.parent_number = parent_number
        return self

    def get_target(self, target_self: bool = False) -> str:
        """
        Return the target for this pane, if any

        Parameters
        ----------
        target_self : bool
            Whether the target should be myself

        Returns
        -------
        string|None
            The tmux-formatted target for this pane
            None if no target set to split from
        """
        target = ""
        if target_self:
            target = get_pane_syntax(self.session_name, self.window.number,
                                     self.number)
        elif self.parent_number:
            target = get_pane_syntax(self.session_name, self.window.number,
                                     self.parent_number)
        elif self.split_from is not None:
            parent_pane = self.objects.get_pane_by_id(self.split_from)
            target = get_pane_syntax(self.session_name, self.window.number,
                                     parent_pane.number)
        else:
            target = None

        return target

    def get_split_command(self) -> str:
        """
        Creates the split command for this pane

        Returns
        -------
        string
            The command used to split this pane
        """
        split_command = ""
        # First pane does not need a command
        if self.is_first:
            pass
        else:
            split_command = "split-window "
            target = self.get_target()
            target = "-t " + target if target else ""
            if self.start_dir and target:
                split_command += "-{0} {2} {1}".format(self.direction,
                                                       self.start_dir, target)
            elif self.start_dir:
                split_command += "-{0} {1}".format(self.direction,
                                                   self.start_dir)
            elif target:
                split_command += "-{0} {1}".format(self.direction, target)
            else:
                split_command += "-{0}".format(self.direction)

        return split_command

    def get_run_command(self) -> str:
        """
        Returns the tmux-formatted run command for this pane

        Returns
        -------
        string
            The tmux-formatted run command
        """
        if self.command and len(self.command) > 0:
            return "send-keys {0}".format(self.command)

        return ""

    def get_size_command(self) -> str:
        """
        Returns the command to set the pane size

        Returns
        -------
        string
            The command to set this pane's size
        """
        target = self.get_target(True)

        width = ""
        if self.width:
            width = "-x {0}".format(self.width)

        height = ""
        if self.height:
            height = "-y {0}".format(self.height)

        if width or height:
            return "resize-pane -t {0} {1} {2}".format(target, width, height)

        return ""

    def get_difference(self, pane: "Pane") -> dict:
        """
        Compare this instance to another pane
        Checks properties, not direct reference

        Parameters
        ----------
        pane : Pane
            The pane to compare against

        Returns
        -------
        dict
            A key-value comparison of the two panes, containing this versus the argument
        """
        return get_object_differences(self, pane, self.COMPARABLE_PROPERTIES)

    def to_string(self) -> str:
        """
        Makes a string representation of this class
        """
        return ("identity : {0}"
                "  number : {1}"
                "  parent : {2}"
                "    size : {3}"
                " command : {4}").format(
                    self.identity,
                    self.number,
                    self.split_from,
                    "{0}x{1}".format(self.width, self.height),
                    self.command,
                )
class TmuxBuilder:
    """
    The class to build a Tmux session

    this was designed to chain methods, such as:
      TmuxBuilder('A session') \\ \n
          .add_window('main') \\ \n
          .add_pane('test') \\ \n
          .run_command('echo "foo"') \\ \\n
          ...

    Attributes
    ----------
    windows : list[Window]
        A list of the windows to create
    session_name : string
        The name of the session
    start_dir : string
        A directory to base all windows/panes from
    current_window : int
        The index of the currently-active window
    current_pane : int
        The index of the currently-active pane
    detach : bool
        Whether to detach the session after creation
    objects_by_id : dictionary
        A list of windows/panes indexed by ID, for target reference
    """

    size_warning_printed = False

    session_name = ""
    start_dir = ""
    current_window = -1
    current_pane = 0
    terminal_width = None
    terminal_height = None
    detach = True

    # pylint: disable=bad-continuation
    # black auto-format disagrees
    def __init__(self,
                 session_name: str,
                 starting_directory: str = "",
                 detach: bool = True) -> "TmuxBuilder":
        """
        Sets up details about the tmux builder

        Parameters
        ----------
        session_name : string
            The name of the session to create
        starting_directory : string
            The directory to base all windows/panes out of
        detach : bool
            Whether to detach this session after creatio
        """
        self.session_name = session_name
        self.objects = ObjectTracker()
        self.windows = []
        self.start_dir = starting_directory
        self.detach = bool(detach)

        self.__get_terminal_size()

    def __get_terminal_size(self) -> None:
        """
        Gets the terminal size using the tput command
        NOTE: ASSUMES CURRENT SIZE IS THE SAME SIZE AS THE INTENDED SESSION
        """
        if self.terminal_width and self.terminal_height:
            return

        self.terminal_height = int(
            check_output(["tput", "lines"]).decode("utf-8").strip())
        self.terminal_width = int(
            check_output(["tput", "cols"]).decode("utf-8").strip())

    # pylint: disable=bad-continuation
    # black auto-format disagrees
    def set_terminal_size(self, terminal_width: int,
                          terminal_height: int) -> "TmuxBuilder":
        """
        Sets the terminal height, rather than using tput
        This is used for testing

        Parameters
        ----------
        terminal_width : int
            The width of the terminal
        terminal_height : int
            The height of the terminal

        Returns
        -------
        This instance
        """
        self.terminal_width = terminal_width
        self.terminal_height = terminal_height

        return self

    def __coalesce_window(self, window: str = None) -> Window:
        """
        Determines which window to target
        If none provided, defaults to the current window

        Parameters
        ----------
        window : string|None
            The window ID to target

        Returns
        -------
        Window
            The window object
        """
        return (self.objects.get_window_by_id(window)
                if window else self.windows[self.current_window])

    def __coalesce_pane(self, pane: str = None) -> Window:
        """
        Determines which pane to target
        If none provided, defaults to the current pane

        Parameters
        ----------
        pane : string|None
            The pane ID to target

        Returns
        -------
        Window
            The pane object to target
        """
        if pane == 0:
            return pane
        return (self.objects.get_pane_by_id(pane) if pane else
                self.windows[self.current_window].get_pane(self.current_pane))

    def add_window(
        self,
        window_id: str,
        pane_id: str,
        window_name: str,
        working_directory: str = None,
    ) -> "TmuxBuilder":
        """
        Adds a new window

        Parameters
        ----------
        window_id : string
            A unique ID for the new window
        pane_id : string
            A unique ID for the new pane in the window
        window_name : string
            The name of the new window
        working_directory : string|None
            The working directory for the first pane

        Returns
        -------
        This instance
        """
        window_num = len(self.windows)
        window = (Window(
            window_id, window_name,
            window_num == 0).set_number(window_num).set_session_name(
                self.session_name).set_start_dir(
                    format_working_directory(working_directory
                                             or self.start_dir)))
        self.windows.append(window)
        self.current_pane = 0
        self.current_window = len(self.windows) - 1
        return self.add_pane(pane_id, None, window_id)

    # pylint: disable=too-many-arguments
    def add_pane(
        self,
        identity: str,
        split_dir: str,
        window: str = None,
        split_from: str = None,
        working_directory: str = None,
    ) -> "TmuxBuilder":
        """
        Adds a new pane to target or current window

        Parameters
        ----------
        identity : string
            A unique ID for this pane
        split_dir : string
            The direction to split the window, either 'h' (horizontal) or 'v' (vertical)
        window : string|None
            The window ID to create the pane in, defaults to the current window
        split_from : string|None
            The pane ID to split this one from
        working_directory : string|None
            The working directory to set for this pane

        Returns
        -------
        This instance
        """
        if split_dir not in [None, "h", "v"]:
            raise ValueError(
                'Invalid split direction specified: must be "v" or "h"!')

        if split_from:
            target = self.objects.get_pane_by_id(split_from)
            window = target.window
        else:
            window = self.__coalesce_window(window)
            target = None

        panes = window.get_pane_count()

        start_dir = None
        if working_directory:
            start_dir = format_working_directory(working_directory)
        elif window.start_dir:
            start_dir = window.start_dir
        else:
            start_dir = format_working_directory(self.start_dir)

        pane = (Pane(identity, window, split_dir,
                     panes == 0).set_number(panes).set_session_name(
                         self.session_name).set_start_dir(start_dir))

        if split_from:
            pane.set_target(split_from)

        window.add_pane(pane)

        # The index of the current pane, not the number
        self.current_pane = window.get_pane_count() - 1
        return self

    def __print_size_warning(self) -> None:
        """
        Prints a warning about the size of the terminal
        """
        if not self.size_warning_printed:
            print("NOTE: USING CURRENT TERMINAL SIZE AS TEMPLATE FOR SESSION",
                  file=stderr)
            self.size_warning_printed = True

    def set_pane_height(self, height: int, pane: str = None) -> "TmuxBuilder":
        """
        Sets the height of the given pane, percent of available
        Can target a specific pane, or default to current

        Parameters
        ----------
        height : int
            The percentage of the screen this pane should be high
        pane : string|None
            The target pane ID

        Returns
        -------
        This instance
        """
        self.__print_size_warning()
        return self.__set_pane_size(0, height, pane)

    def set_pane_width(self, width: int, pane: str = None) -> "TmuxBuilder":
        """
        Sets the width of the given pane, percent of available
        Can target a specific pane, or default to current

        Parameters
        ----------
        width : int
            The percentage of the screen this pane should be wide
        pane : string|None
            The target pane ID

        Returns
        -------
        This instance
        """
        self.__print_size_warning()
        return self.__set_pane_size(width, 0, pane)

    def __set_pane_size(self,
                        width: int = 0,
                        height: int = 0,
                        pane: str = None) -> "TmuxBuilder":
        """
        Set the actual pane size, for a given target pane
        Defaults to current window/pane

        Parameters
        ----------
        width : int
            The width to set the pane to
        height : int
            The height to set the pane to
        pane : string|None
            The pane ID to target

        Returns
        -------
        This instance
        """
        pane = self.__coalesce_pane(pane)

        line_width = floor(self.terminal_width * (width / 100))
        line_height = floor(self.terminal_height * (height / 100))

        pane.set_size(line_width, line_height)
        return self

    def set_pane_parent(self, parent_number: int, pane_id: str = None):
        """
        Sets the parent number of the pane

        Parameters
        ----------
        parent_number : number
            The number of the pane's parent
        pane_id = None : string
            Optionally, the ID to set the parent for
            Defaults to the most recently-added pane
        """
        # If pane is passed in, use that, otherwise get the current pane
        pane = (self.objects.get_pane_by_id(pane_id)
                if pane_id else self.__coalesce_pane())
        pane.set_parent_directly(parent_number)

    def run_command(self, command: str, window: str = None, pane: str = None):
        """
        Sets the command to run for a given window/pane
        Defaults to current window/pane

        Parameters
        ----------
        command : string
            The command to run in the pane
        window : string|None
            The window ID to run the command in
        pane : string|None
            The pane ID to run the command in

        Returns
        -------
        This instance
        """
        window = self.__coalesce_window(window)
        if not pane:
            pane = window.get_pane(self.current_pane)
        else:
            pane = self.__coalesce_pane(pane)

        pane.set_command(command)
        return self

    def debug(self) -> None:
        """
        Prints the built tmux command
        """
        print(self.build())

    def build(self) -> str:
        """
        Builds the actual bash command that will create the tmux session

        Parameters
        ----------

        Returns
        -------
        string
            The bash command to execute
        """
        if len(self.windows) == 0:
            raise RuntimeError("No windows provided!")

        commands = [
            "tmux new-session {1} -s {0}".format(self.session_name,
                                                 "-d" if self.detach else "")
        ]

        # Create windows, panes, and commands

        for window in self.windows:
            commands.append(window.get_create_command())

            for pane in window.panes:
                # Only split window for a new pane if not the first pane in the window
                split_command = pane.get_split_command()
                if split_command:
                    commands.append(split_command)

                run_cmd = pane.get_run_command()
                if run_cmd:
                    commands.append(run_cmd)

        # Set the panel dimensions later, since can't set dimensions if related panes aren't created
        # e.g., can't set a vertical height if the second pane doesn't exist yet
        for window in self.windows:
            for pane in window.panes:
                size_command = pane.get_size_command()
                if size_command:
                    commands.append(size_command)

        commands = " \\; \\\n".join(commands)
        return 'pushd "{0}"\n'.format(
            self.start_dir) + commands + "\n" + "popd"

    def run(self) -> None:
        """
        Execute the commands to build a session
        """
        cmd = self.build()
        system(cmd)