diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 2bcd70e..0000000 --- a/.flake8 +++ /dev/null @@ -1,2 +0,0 @@ -[flake8] -max-line-length = 88 diff --git a/i3_quickterm/main.py b/i3_quickterm/main.py index b7282ce..1f43ae5 100755 --- a/i3_quickterm/main.py +++ b/i3_quickterm/main.py @@ -211,17 +211,20 @@ def command(self, payload: str, *kargs, **kwargs): class Quickterm: - def __init__(self, conf: Conf, shell: str): + def __init__(self, conf: Conf, shell: str, conn: Optional[i3ipc.Connection] = None): self.conf = conf self.shell = shell - self._conn = None + self._conn = conn self._ws = None self._ws_fetched = False + self._con = None + self._con_fetched = False + self._verbose = self.conf.get("_verbose", False) @property def conn(self) -> i3ipc.Connection: if self._conn is None: - if self.conf["_verbose"]: + if self._verbose: self._conn = VerboseConnection() else: self._conn = i3ipc.Connection() @@ -229,18 +232,31 @@ def conn(self) -> i3ipc.Connection: @property def ws(self) -> Optional[i3ipc.Con]: - if self._ws is None: + if not self._ws_fetched and self._ws is None: self._ws = get_current_workspace(self.conn) self._ws_fetched = True return self._ws @property - def select_mark(self) -> str: + def mark(self) -> str: if self.shell is None: raise RuntimeError("No shell defined") return MARK_QT.format(self.shell) - def con_on_workspace(self, mark: str) -> Optional[i3ipc.Con]: + @property + def con(self) -> Optional[i3ipc.Con]: + """Find container in complete tree""" + if not self._con_fetched and self._con is None: + node = self.conn.get_tree().find_marked(self.mark) + if len(node) == 0: + self._con = None + else: + self._con = node[0] + self._con_fetched = True + return self._con + + def con_in_workspace(self, mark: str) -> Optional[i3ipc.Con]: + """Find container in workspace""" if self.ws is None: return None c = self.ws.find_marked(mark) @@ -249,10 +265,12 @@ def con_on_workspace(self, mark: str) -> Optional[i3ipc.Con]: return c[0] def execvp(self, cmd): - if self.conf["_verbose"]: + if self._verbose: print(f"execvp: {cmd}") os.execvp(cmd[0], cmd) + """Operations""" + def launch_inplace(self): """Quickterm is called by itself @@ -260,33 +278,18 @@ def launch_inplace(self): process """ - self.conn.command(f"mark {self.select_mark}") + self.conn.command(f"mark {self.mark}") self.focus_on_current_ws() prog_cmd = expand_command(self.conf["shells"][self.shell]) self.execvp(prog_cmd) - def toggle(self): - """Toggle quickterm + def toggle_on_current_ws(self): + """If on another workspace: hide, otherwise show on current""" + move_to_scratchpad(self.conn, f"[con_id={self.con.id}]") - If it does not exist: create() - Else: - hide(); - if workspace was not current: - focus_on_current() - """ - qt_node = self.conn.get_tree().find_marked(self.select_mark) - - if len(qt_node) == 0: - self.execute_term() - return - - qt_node = qt_node[0] - - move_to_scratchpad(self.conn, f"[con_id={qt_node.id}]") - - if self.ws is not None and qt_node.workspace().name != self.ws.name: + if self.ws is not None and self.con.workspace().name != self.ws.name: self.focus_on_current_ws() def focus_on_current_ws(self): @@ -309,7 +312,7 @@ def focus_on_current_ws(self): posy = wy self.conn.command( - f"[con_mark={self.select_mark}] " + f"[con_mark={self.mark}] " f"move scratchpad, " f"scratchpad show, " f"resize set {width} px {height} px, " @@ -317,9 +320,10 @@ def focus_on_current_ws(self): ) def execute_term(self): + """Launch i3-quickterm in a new terminal""" term = TERMS.get(self.conf["term"], self.conf["term"]) qt_cmd = f"{sys.argv[0]} -i {self.shell}" - if self.conf["_verbose"]: + if self._verbose: qt_cmd += " -v" if "_config" in self.conf: qt_cmd += f" -c {self.conf['_config']}" @@ -333,6 +337,41 @@ def execute_term(self): self.execvp(term_cmd) +def run_qt(qt: Quickterm, in_place: bool = False): + """Main logic""" + shell = qt.shell + + if in_place: + if shell is None: + raise RuntimeError("shell should be provided when running in place") + + # we are launched by ourselves: start a shell + qt.launch_inplace() + return + + if shell is None: + c = qt.con_in_workspace(MARK_QT_PATTERN) + if c is not None: + # undefined shell and visible on workspace: hide + move_to_scratchpad(qt.conn, f"[con_id={c.id}]") + return + + # undefined shell and nothing on workspace: ask for shell selection + shell = select_shell(qt.conf) + if shell is None: + return + qt.shell = shell + + # show logic + # if it does not exist: create + # else: toggle on current workspace + if qt.con is None: + qt.execute_term() + return + + qt.toggle_on_current_ws() + + def main(): parser = argparse.ArgumentParser() parser.add_argument("-i", "--in-place", dest="in_place", action="store_true") @@ -365,31 +404,7 @@ def main(): qt = Quickterm(conf, args.shell) - shell = args.shell - - if args.in_place: - if shell is None: - raise RuntimeError("shell should be provided when running in place") - - # we are launched by ourselves: start a shell - qt.launch_inplace() - return 0 - - if shell is None: - c = qt.con_on_workspace(MARK_QT_PATTERN) - if c is not None: - # undefined shell and visible on workspace: hide - move_to_scratchpad(qt.conn, f"[con_id={c.id}]") - return 0 - - # undefined shell and nothing on workspace: ask for shell selection - shell = select_shell(conf) - if shell is None: - return 0 - qt.shell = shell - - # main toggle logic - qt.toggle() + run_qt(qt) return 0 diff --git a/requirements-dev.txt b/requirements-dev.txt index 82f941c..ab386b7 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,12 +1,15 @@ black==24.1.1 click==8.1.7 flake8==7.0.0 +iniconfig==2.0.0 mccabe==0.7.0 mypy-extensions==1.0.0 packaging==23.2 pathspec==0.12.1 platformdirs==4.2.0 +pluggy==1.4.0 pycodestyle==2.11.1 pyflakes==3.2.0 +pytest==8.0.0 tomli==2.0.1 typing_extensions==4.9.0 diff --git a/setup.cfg b/setup.cfg index 5489239..32aa83c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,3 +3,9 @@ license_files = LICENSE.txt [bdist_wheel] universal=0 + +[tool:pytest] +testpaths = tests + +[flake8] +max-line-length = 88 diff --git a/setup.py b/setup.py index e54e4ff..9ba3947 100644 --- a/setup.py +++ b/setup.py @@ -33,7 +33,7 @@ def find_version(*file_paths): long_description_content_type="text/markdown", url="https://github.com/lbonn/i3-quickterm", author="lbonn", - author_email="bonnans.l@gmail.com", + author_email="github@lbonnans.net", classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: End Users/Desktop", @@ -52,7 +52,7 @@ def find_version(*file_paths): python_requires=">=3.6", install_requires=["i3ipc>=2.0.1"], extras_require={ - "dev": ["black", "flake8"], + "dev": ["black", "flake8", "pytest"], }, entry_points={ "console_scripts": [ diff --git a/tests/test_i3quickterm.py b/tests/test_i3quickterm.py new file mode 100644 index 0000000..a6d6e66 --- /dev/null +++ b/tests/test_i3quickterm.py @@ -0,0 +1,182 @@ +from i3_quickterm.main import Quickterm, run_qt, DEFAULT_CONF + +import i3ipc + +import copy + +import pytest +import unittest.mock +from unittest.mock import call, ANY + + +@pytest.fixture +def execvp(): + with unittest.mock.patch("os.execvp") as mock_execvp: + yield mock_execvp + + +@pytest.fixture +def i3ipc_con(): + con = unittest.mock.Mock(i3ipc.Con) + con.find_marked.return_value = [con] + con.find_focused.return_value = con + con.id = "0" + ws = unittest.mock.MagicMock() + con.workspace.return_value = ws + ws.name = "ws" + ws.rect = i3ipc.Rect({"x": 0, "y": 0, "height": 0, "width": 0}) + return con + + +@pytest.fixture +def i3ipc_connection(i3ipc_con): + conn = unittest.mock.Mock(i3ipc.Connection) + conn.get_tree.return_value = i3ipc_con + return conn + + +@pytest.fixture +def conf(tmpdir): + c = copy.deepcopy(DEFAULT_CONF) + c.update({ + "menu": "/bin/true", + "shells": {"shell": "bash"}, + "verbose": True, + "history": f"{str(tmpdir)}/shells.order", + }) + return c + + +@pytest.fixture +def quickterm_mock(i3ipc_connection, conf): + qt = unittest.mock.Mock(Quickterm) + qt.shell = None + qt.con = None + qt.conf = conf + qt.conn = i3ipc_connection + + return qt + + +"""Test quickterm operations""" + + +def test_launch_inplace(i3ipc_connection, conf, execvp): + """In place: just call the shell""" + qt = Quickterm(conf, "shell", conn=i3ipc_connection) + + qt.launch_inplace() + + i3ipc_connection.command.assert_has_calls([ + call("mark quickterm_shell"), + call("[con_mark=quickterm_shell] move scratchpad, scratchpad show, " + "resize set 0 px 0 px, move absolute position 0px 0px"), + ]) + execvp.assert_called_once_with('bash', ['bash']) + + +def test_execute_term(i3ipc_connection, i3ipc_con, conf, execvp): + """Create term (when not found)""" + i3ipc_con.find_marked.return_value = [] + + qt = Quickterm(conf, "shell", conn=i3ipc_connection) + + qt.execute_term() + + execvp.assert_has_calls([call('urxvt', ANY)]) + + +def test_toggle_hide(i3ipc_connection, conf, execvp): + """Toggle with visible term: hide""" + qt = Quickterm(conf, "shell", conn=i3ipc_connection) + + qt.toggle_on_current_ws() + + i3ipc_connection.command.assert_called_once_with( + '[con_id=0] floating enable, move scratchpad') + assert execvp.call_count == 0 + + +def test_toggle_from_other_workspace(i3ipc_connection, i3ipc_con, conf, execvp): + """Toggle with visible term on another workspace: hide and show on current""" + qt = Quickterm(conf, "shell", conn=i3ipc_connection) + + k = 0 + + def new_workspace(): + nonlocal k + ws = unittest.mock.MagicMock() + ws.name = f"ws{k}" + ws.rect = i3ipc.Rect({"x": 0, "y": 0, "height": 0, "width": 0}) + k += 1 + return ws + i3ipc_con.workspace.side_effect = new_workspace + + qt.toggle_on_current_ws() + + i3ipc_connection.command.assert_has_calls([ + call('[con_id=0] floating enable, move scratchpad'), + call('[con_mark=quickterm_shell] move scratchpad, scratchpad show, ' + 'resize set 0 px 0 px, move absolute position 0px 0px'), + ]) + assert execvp.call_count == 0 + + +"""Test logic""" + + +def test_run_qt_inplace_no_shell(quickterm_mock): + with pytest.raises(RuntimeError): + run_qt(quickterm_mock, in_place=True) + + +def test_run_qt_inplace(quickterm_mock): + qt = quickterm_mock + qt.shell = "bash" + run_qt(qt, in_place=True) + qt.launch_inplace.assert_called_once() + + +def test_run_qt_noshell_hide(quickterm_mock, i3ipc_connection, i3ipc_con): + qt = quickterm_mock + qt.con_in_workspace.return_value = i3ipc_con + + run_qt(qt) + + i3ipc_connection.command.assert_called_once_with( + "[con_id=0] floating enable, move scratchpad" + ) + + +def test_run_qt_noshell_select_none(quickterm_mock): + qt = quickterm_mock + qt.con_in_workspace.return_value = None + + run_qt(qt) + + assert qt.shell == None + + +def test_run_qt_noshell_select_one(quickterm_mock): + qt = quickterm_mock + qt.con_in_workspace.return_value = None + qt.conf["menu"] = "echo shell" + + run_qt(qt) + + assert qt.shell == "shell" + + +def test_run_qt_execute_shell(quickterm_mock): + qt = quickterm_mock + qt.shell = "bash" + run_qt(qt) + qt.execute_term.assert_called_once() + + +def test_run_qt_toggle_on_current_ws(i3ipc_con, quickterm_mock): + qt = quickterm_mock + qt.shell = "bash" + qt.con = i3ipc_con + run_qt(qt) + qt.toggle_on_current_ws.assert_called_once()