diff --git a/README.md b/README.md index 7c19036..2d210e1 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ own command. Shown in the default theme - several dark themes ship with it.* ## Features -- **Tabs and split panes** - split any pane right or down, move panes between - tabs or out into their own. +- **Tabs and split panes** - split any pane right or down, starting in the + directory you were already in; move panes between tabs or out into their own. - **Any shell on the box** - PowerShell, Command Prompt, Git Bash or a specific WSL distro, discovered at runtime. - **Commands and Macros** - saved one-liners sent to the terminal you're in; diff --git a/src/qtxterm/assets/USAGE.md b/src/qtxterm/assets/USAGE.md index b145cd3..c266700 100644 --- a/src/qtxterm/assets/USAGE.md +++ b/src/qtxterm/assets/USAGE.md @@ -66,6 +66,13 @@ there are two pairs and either works: Everything that rearranges panes lives under that one **Pane** group. Splits nest, so you can build columns of rows. Drag the divider to resize. +A split pane starts **in the directory the pane it came from is in**, so +splitting to run something beside your build lands you where you already +were. That works for PowerShell, cmd and Git Bash, which qtxterm asks to +report their directory as it launches them - a shell it did not launch that +way (a WSL distro, or anything you started yourself inside a pane) has +nothing to report, and a split off it opens where a new tab would. + Browser panes split too, and a split gives you **another pane of the same kind** - splitting a browser gives a browser, splitting a terminal gives a terminal. In a browser pane the shortcuts are the only route: right-clicking @@ -492,6 +499,10 @@ zooming *is* editing the preference - which is why `Ctrl+0` returns to the default rather than to whatever the dialog last held, since otherwise it would have nothing to mean. +Ctrl+scrolling the mouse wheel does *not* resize the text. A stray scroll +over the buffer would otherwise scale the page's pixels without resizing the +grid the shell is drawing into, leaving the two disagreeing. + ## What else is remembered The window's size and position, and whether the Commands sidebar is showing, diff --git a/src/qtxterm/assets/shell_integration/qtxterm.ps1 b/src/qtxterm/assets/shell_integration/qtxterm.ps1 new file mode 100644 index 0000000..316e6f6 --- /dev/null +++ b/src/qtxterm/assets/shell_integration/qtxterm.ps1 @@ -0,0 +1,37 @@ +# Reports the working directory to qtxterm after every prompt, as OSC 7. +# +# Dot-sourced by qtxterm when it launches PowerShell, *after* the user's +# profile has run, so $function:prompt here is whatever the profile left - +# the original is called for its output and this only prefixes the escape +# sequence. PowerShell needs to be asked because, unlike cmd and bash, it +# does not update the process's own working directory when you `cd`, so +# there is nothing for the app to read from the outside. + +if (-not $global:__qtxtermPromptWrapped) { + $global:__qtxtermPromptWrapped = $true + $global:__qtxtermInnerPrompt = $function:prompt + + function global:prompt { + try { + # ProviderPath, not Path: inside a PSDrive or a registry + # location Path reads "HKLM:\...", and only ProviderPath is a + # directory anything else could start in. It is empty for a + # location with no filesystem path at all, hence the guard. + $qtxtermPath = (Get-Location).ProviderPath + if ($qtxtermPath) { + # .Replace, not -replace: the latter takes a regex, in which + # a lone backslash is a syntax error rather than a backslash. + $qtxtermUrl = [uri]::EscapeUriString($qtxtermPath.Replace('\', '/')) + # Empty host (file:///...) - the path is on this machine, and + # it keeps the sequence the same shape as the other shells'. + # BEL-terminated, which every terminal accepts and which + # avoids ending a PowerShell string on a backslash. + [Console]::Write("$([char]27)]7;file:///$qtxtermUrl$([char]7)") + } + } catch { + # A prompt that throws leaves the shell unusable; a missing cwd + # report only costs a split pane its starting directory. + } + & $global:__qtxtermInnerPrompt + } +} diff --git a/src/qtxterm/assets/terminal.js b/src/qtxterm/assets/terminal.js index 812ff80..ee10801 100644 --- a/src/qtxterm/assets/terminal.js +++ b/src/qtxterm/assets/terminal.js @@ -440,6 +440,15 @@ term.onData((data) => bridge.sendInput(data)); term.onTitleChange((title) => bridge.setTitle(title)); + + // OSC 7 - the shell saying which directory it is in, so a pane split + // off this one can start there. Only shells qtxterm has hooked (see + // shell_integration.py) send it; returning true marks it handled so + // xterm.js does not pass the payload on to be printed. + term.parser.registerOscHandler(7, (payload) => { + bridge.setCwd(payload); + return true; + }); term.onSelectionChange(() => bridge.setSelection(term.getSelection())); bridge.output.connect((data) => term.write(data)); diff --git a/src/qtxterm/pty_backend/base.py b/src/qtxterm/pty_backend/base.py index e32c331..f394724 100644 --- a/src/qtxterm/pty_backend/base.py +++ b/src/qtxterm/pty_backend/base.py @@ -65,8 +65,22 @@ def _emit_from_reader(self, signal, value) -> None: pass @abstractmethod - def start(self, command: list[str], cols: int, rows: int) -> None: - """Spawn `command` (argv: executable + args) attached to a new PTY.""" + def start( + self, + command: list[str], + cols: int, + rows: int, + cwd: str | None = None, + env: dict[str, str] | None = None, + ) -> None: + """Spawn `command` (argv: executable + args) attached to a new PTY. + + `cwd` is the directory the shell starts in - a split pane passes the + directory its sibling was in - and `env` replaces the inherited + environment wholesale, which is how the shell-integration hooks in + `shell_integration` reach the shell. Both default to this process's + own, which is what every caller wanted before either existed. + """ @abstractmethod def write(self, data: str) -> None: diff --git a/src/qtxterm/pty_backend/posix.py b/src/qtxterm/pty_backend/posix.py index bfe107c..f6e6bec 100644 --- a/src/qtxterm/pty_backend/posix.py +++ b/src/qtxterm/pty_backend/posix.py @@ -15,8 +15,17 @@ def __init__(self) -> None: self._process: PtyProcessUnicode | None = None self._reader_thread: threading.Thread | None = None - def start(self, command: list[str], cols: int, rows: int) -> None: - self._process = PtyProcessUnicode.spawn(command, dimensions=(rows, cols)) + def start( + self, + command: list[str], + cols: int, + rows: int, + cwd: str | None = None, + env: dict[str, str] | None = None, + ) -> None: + self._process = PtyProcessUnicode.spawn( + command, cwd=cwd, env=env, dimensions=(rows, cols) + ) self._reader_thread = threading.Thread(target=self._read_loop, daemon=True) self._reader_thread.start() diff --git a/src/qtxterm/pty_backend/win.py b/src/qtxterm/pty_backend/win.py index eca5f93..1e1c990 100644 --- a/src/qtxterm/pty_backend/win.py +++ b/src/qtxterm/pty_backend/win.py @@ -15,12 +15,21 @@ def __init__(self) -> None: self._process: PtyProcess | None = None self._reader_thread: threading.Thread | None = None - def start(self, command: list[str], cols: int, rows: int) -> None: + def start( + self, + command: list[str], + cols: int, + rows: int, + cwd: str | None = None, + env: dict[str, str] | None = None, + ) -> None: # Must be a real argv list, not a joined string: PtyProcess.spawn() # shlex-splits string argv on whitespace, which breaks paths like # "C:\Program Files\Git\bin\bash.exe" (splits into "C:\Program" + # the rest, and "C:\Program" isn't found on PATH). - self._process = PtyProcess.spawn(command, dimensions=(rows, cols)) + self._process = PtyProcess.spawn( + command, cwd=cwd, env=env, dimensions=(rows, cols) + ) self._reader_thread = threading.Thread(target=self._read_loop, daemon=True) self._reader_thread.start() diff --git a/src/qtxterm/shell_integration.py b/src/qtxterm/shell_integration.py new file mode 100644 index 0000000..0aab0ae --- /dev/null +++ b/src/qtxterm/shell_integration.py @@ -0,0 +1,100 @@ +"""Teach the shells we launch to report their working directory (OSC 7). + +A split pane opens where its sibling was, which means the app has to know +where that is. Asking the OS for the shell process's working directory only +works for some shells - PowerShell, the Windows default, never updates it on +`cd` - so instead each shell is asked to *say* where it is, using the same +OSC 7 sequence VS Code and Windows Terminal rely on: + + ESC ] 7 ; file:/// ESC \ + +Nothing here is required for a terminal to work. A shell we do not +recognise, or one whose prompt the user has since replaced, simply never +reports, and a pane split off it starts in the default directory. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from urllib.parse import unquote + +ASSETS_DIR = Path(__file__).parent / "assets" +POWERSHELL_HOOK = ASSETS_DIR / "shell_integration" / "qtxterm.ps1" + +_POWERSHELLS = frozenset({"powershell", "pwsh"}) +_BASHES = frozenset({"bash", "sh"}) + +# printf, not echo -e: echo's escape handling differs between bash and the +# sh some distros link it to. cygpath translates Git Bash's /c/Users/... into +# C:/Users/..., which is the only form Windows can start a process in; on +# Linux and macOS there is no cygpath and $PWD is already the right thing. +# +# The host is left empty (file:///...) rather than filled in with $HOSTNAME: +# an empty host means "this machine", which is the only case worth reporting, +# and it makes the sequence identical in shape whether the path that follows +# is /home/dev or C:/Users/dev. Stripping a leading slash before adding one +# back is what keeps both of those to exactly three slashes. +_BASH_PROMPT_COMMAND = ( + '__qtxterm_cwd=$(cygpath -m "$PWD" 2>/dev/null || printf %s "$PWD"); ' + 'printf "\x1b]7;file:///%s\x07" "${__qtxterm_cwd#/}"' +) + +_CMD_PROMPT = r"$E]7;file:///$P$E\$P$G" + + +def stem(command: str) -> str: + """Lowercased executable name without its extension, e.g. 'powershell'.""" + return Path(command).stem.lower() + + +def decorate(command: list[str]) -> tuple[list[str], dict[str, str] | None]: + """The argv and environment to launch `command` with, hooks included. + + Returns the command unchanged and `None` for the environment when the + shell is one we have no hook for - `None` rather than a copy of + os.environ so the backends keep their plain inherit-everything path. + """ + if not command: + return command, None + + name = stem(command[0]) + if name in _POWERSHELLS: + # -Command has to come last: PowerShell treats everything after it as + # the command. -NoExit keeps the session interactive afterwards. + quoted = str(POWERSHELL_HOOK).replace("'", "''") + return [*command, "-NoExit", "-Command", f". '{quoted}'"], None + if name in _BASHES: + # An environment variable rather than --rcfile, which would replace + # the user's own startup files instead of running alongside them. + return command, os.environ | {"PROMPT_COMMAND": _BASH_PROMPT_COMMAND} + if name == "cmd": + return command, os.environ | {"PROMPT": _CMD_PROMPT} + return command, None + + +def path_from_osc7(uri: str) -> str | None: + """The local directory an OSC 7 payload names, or None if it names none. + + Parsed here rather than with QUrl because the payloads are not all + well-formed URLs: cmd's PROMPT can only produce a native path, complete + with backslashes and unescaped spaces, and a Windows drive letter arrives + as the "/C:/Users/..." Qt would hand back as a UNC path. + """ + text = (uri or "").strip() + if not text: + return None + if text.startswith("file://"): + rest = text[len("file://") :] + # Everything up to the first separator is the hostname, which is + # deliberately ignored: a remote host's path is not ours to open. + slash = rest.find("/") + text = rest[slash:] if slash != -1 else "" + text = unquote(text).replace("\\", "/") + if not text: + return None + # "/C:/Users/dev" -> "C:/Users/dev". Only a drive letter, so a POSIX + # "/home/dev" keeps its root. + if len(text) > 2 and text[0] == "/" and text[2] == ":": + text = text[1:] + return text or None diff --git a/src/qtxterm/terminal_bridge.py b/src/qtxterm/terminal_bridge.py index c65ef29..b366516 100644 --- a/src/qtxterm/terminal_bridge.py +++ b/src/qtxterm/terminal_bridge.py @@ -21,6 +21,7 @@ class needing to know about PTYs at all. title_changed = Signal(str) selection_changed = Signal(str) link_activated = Signal(str) + cwd_changed = Signal(str) @Slot(str) def sendInput(self, data: str) -> None: @@ -51,6 +52,16 @@ def loaded(self) -> None: def setTitle(self, title: str) -> None: self.title_changed.emit(title) + @Slot(str) + def setCwd(self, uri: str) -> None: + """The shell reported its working directory (OSC 7). + + The payload is whatever the shell printed - on an SSH session, a + remote path that means nothing locally - so TerminalWidget checks it + against the filesystem before starting anything in it. + """ + self.cwd_changed.emit(uri) + @Slot(str) def setSelection(self, text: str) -> None: """Pushed by xterm.js on every selection change. diff --git a/src/qtxterm/terminal_tabs.py b/src/qtxterm/terminal_tabs.py index 9556569..8fd66f9 100644 --- a/src/qtxterm/terminal_tabs.py +++ b/src/qtxterm/terminal_tabs.py @@ -189,6 +189,7 @@ def _make_terminal( self, shell: str | list[str] | None = None, pty_session: PtySession | None = None, + cwd: str | None = None, ) -> TerminalWidget: appearance = ( self._appearance_store.current if self._appearance_store else Appearance() @@ -200,7 +201,7 @@ def _make_terminal( if shell is None: shell = self.preferred_shell() widget = TerminalWidget( - shell=shell, pty_session=pty_session, appearance=appearance + shell=shell, pty_session=pty_session, appearance=appearance, cwd=cwd ) # Deliberately not wired to the tab label. Shells set wildly # different OSC titles - Git Bash sends @@ -377,7 +378,13 @@ def _clone_kind_of( """ if isinstance(pane, BrowserWidget): return BrowserWidget() - return self._make_terminal(shell, pty_session) + # Splitting is "another one of these, here": the new pane starts in + # the directory the old one is in, the way a split does in tmux or + # Windows Terminal. Only shells that report their directory (see + # shell_integration) have one to inherit; the rest start where a new + # tab would. + cwd = pane.current_directory if isinstance(pane, TerminalWidget) else None + return self._make_terminal(shell, pty_session, cwd=cwd) def split_active( self, diff --git a/src/qtxterm/terminal_widget.py b/src/qtxterm/terminal_widget.py index c232689..d4667ff 100644 --- a/src/qtxterm/terminal_widget.py +++ b/src/qtxterm/terminal_widget.py @@ -3,7 +3,7 @@ import json from pathlib import Path -from PySide6.QtCore import QPoint, Qt, QUrl, QUrlQuery, Signal +from PySide6.QtCore import QEvent, QObject, QPoint, Qt, QUrl, QUrlQuery, Signal from PySide6.QtGui import QColor, QDesktopServices, QGuiApplication from PySide6.QtWebChannel import QWebChannel from PySide6.QtWebEngineWidgets import QWebEngineView @@ -12,6 +12,7 @@ from qtxterm.appearance import Appearance from qtxterm.pane import PANE_BORDER_WIDTH, PaneWidget from qtxterm.pty_backend import PtySession, create_pty_session, default_shell +from qtxterm.shell_integration import decorate, path_from_osc7 from qtxterm.terminal_bridge import TerminalBridge ASSETS_DIR = Path(__file__).parent / "assets" @@ -25,6 +26,42 @@ OPENABLE_URL_SCHEMES = frozenset({"http", "https"}) +class _TerminalView(QWebEngineView): + """A web view with Chromium's own zoom gestures taken out. + + Ctrl+wheel (and a trackpad pinch) zoom the *page* - every pixel of it, + the grid included - which is not what a terminal wants: the shell keeps + its old size while the glyphs change, and one stray scroll over a + scrollback buffer leaves the pane a size nothing in the app agrees on. + Font size is a preference, changed by the zoom shortcuts, which resize + the grid properly. + + Chromium receives input through a child widget the view creates after + construction, so the filter is installed on children as they appear + rather than on the view itself. + """ + + def event(self, event: QEvent) -> bool: + if event.type() == QEvent.Type.ChildAdded: + child = event.child() + if child.isWidgetType(): + child.installEventFilter(self) + return super().event(event) + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if ( + event.type() == QEvent.Type.Wheel + and event.modifiers() & Qt.KeyboardModifier.ControlModifier + ): + return True + if ( + event.type() == QEvent.Type.NativeGesture + and event.gestureType() == Qt.NativeGestureType.ZoomNativeGesture + ): + return True + return super().eventFilter(watched, event) + + def shell_short_name(shell: str) -> str: """Best-effort short label for a shell path, e.g. 'powershell.exe' -> 'powershell'.""" name = Path(shell).name @@ -52,6 +89,7 @@ def __init__( parent: QWidget | None = None, pty_session: PtySession | None = None, appearance: Appearance | None = None, + cwd: str | None = None, ) -> None: super().__init__(parent) if shell is None: @@ -63,8 +101,14 @@ def __init__( self._pty = pty_session or create_pty_session() self.is_pty_started = False self._selection = "" - - self._view = QWebEngineView(self) + # Where the shell says it is, kept fresh by OSC 7, and where it was + # asked to start. The second is the answer until the first arrives, + # so a pane split off a brand new pane - before its shell has drawn + # a single prompt - still lands in the right place. + self._reported_cwd: str | None = None + self._start_cwd = cwd + + self._view = _TerminalView(self) # Without this the view shows Chromium's own menu (Back, Reload, View # Source), which is meaningless for a terminal. self._view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) @@ -99,6 +143,7 @@ def __init__( self._bridge.title_changed.connect(self.title_changed.emit) self._bridge.selection_changed.connect(self._on_selection_changed) self._bridge.link_activated.connect(self.open_link) + self._bridge.cwd_changed.connect(self._on_cwd_changed) self._pty.output_ready.connect(self._bridge.output.emit) self._pty.exited.connect(self._bridge.exited.emit) self._pty.exited.connect(self.process_exited.emit) @@ -209,10 +254,30 @@ def _apply_size(self) -> None: ) def _on_terminal_ready(self, cols: int, rows: int) -> None: - self._pty.start(self._command, cols, rows) + command, env = decorate(self._command) + self._pty.start(command, cols, rows, cwd=self._start_cwd, env=env) self.is_pty_started = True self.pty_started.emit() + def _on_cwd_changed(self, uri: str) -> None: + path = path_from_osc7(uri) + # Checked against the filesystem, not taken on trust: the payload + # came out of the terminal, which on an SSH session means it came + # from the remote host, and a path that is not a directory here would + # only make the next split fail to start. + if path and Path(path).is_dir(): + self._reported_cwd = path + + @property + def current_directory(self) -> str | None: + """The directory a pane split off this one should start in. + + None when the shell has never reported one and this pane was not + given a starting directory either - a shell qtxterm has no hook for, + or one running something that has taken the prompt over. + """ + return self._reported_cwd or self._start_cwd + def _on_context_menu_requested(self, pos: QPoint) -> None: self.context_menu_requested.emit(self._view.mapToGlobal(pos)) diff --git a/tests/conftest.py b/tests/conftest.py index 929f9a5..3f53a20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,12 +9,21 @@ class FakePtySession(PtySession): def __init__(self) -> None: super().__init__() self.start_calls: list[tuple[list[str], int, int]] = [] + self.start_kwargs: list[dict] = [] self.write_calls: list[str] = [] self.resize_calls: list[tuple[int, int]] = [] self.closed = False - def start(self, command: list[str], cols: int, rows: int) -> None: + def start( + self, + command: list[str], + cols: int, + rows: int, + cwd: str | None = None, + env: dict[str, str] | None = None, + ) -> None: self.start_calls.append((command, cols, rows)) + self.start_kwargs.append({"cwd": cwd, "env": env}) def write(self, data: str) -> None: self.write_calls.append(data) diff --git a/tests/test_shell_integration.py b/tests/test_shell_integration.py new file mode 100644 index 0000000..a2177bf --- /dev/null +++ b/tests/test_shell_integration.py @@ -0,0 +1,67 @@ +"""Shell hooks that make a shell report its directory, and reading it back.""" + +from __future__ import annotations + +import os + +from qtxterm.shell_integration import POWERSHELL_HOOK, decorate, path_from_osc7 + + +def test_powershell_dot_sources_the_hook_after_the_users_profile() -> None: + command, env = decorate(["powershell.exe"]) + + assert command[0] == "powershell.exe" + # -Command has to be last: PowerShell reads everything after it as the + # command, so an argument added later would be swallowed by the hook. + assert command[-2] == "-Command" + assert str(POWERSHELL_HOOK) in command[-1] + assert "-NoExit" in command + assert env is None + + +def test_pwsh_is_hooked_too() -> None: + command, _env = decorate(["C:/Program Files/PowerShell/7/pwsh.exe"]) + + assert "-NoExit" in command + + +def test_bash_is_hooked_through_the_environment() -> None: + command, env = decorate(["/usr/bin/bash"]) + + assert command == ["/usr/bin/bash"] + assert "file:///" in env["PROMPT_COMMAND"] + # The rest of the environment has to survive, or the shell starts without + # a PATH. + assert env["PATH"] == os.environ["PATH"] + + +def test_cmd_is_hooked_through_its_prompt_string() -> None: + _command, env = decorate(["C:/Windows/System32/cmd.exe"]) + + assert env["PROMPT"].startswith("$E]7;file:///$P") + # cmd's own default prompt is kept on the end, so the hook doesn't + # visibly change how the shell looks. + assert env["PROMPT"].endswith("$P$G") + + +def test_an_unknown_shell_is_left_exactly_as_it_was() -> None: + assert decorate(["/bin/fish"]) == (["/bin/fish"], None) + assert decorate([]) == ([], None) + + +def test_osc7_payloads_become_local_paths() -> None: + # Windows, from the PowerShell and bash hooks: empty host, drive letter. + assert path_from_osc7("file:///C:/Users/dev") == "C:/Users/dev" + # A named host, which some shells send and which we ignore. + assert path_from_osc7("file://desktop/C:/Users/dev") == "C:/Users/dev" + # cmd can only produce a native path, backslashes and spaces included. + assert path_from_osc7("file:///C:\\Program Files") == "C:/Program Files" + assert path_from_osc7("file:///C:/a%20b") == "C:/a b" + # POSIX keeps its root - only a drive letter loses the leading slash. + assert path_from_osc7("file:///home/dev") == "/home/dev" + + +def test_payloads_that_name_no_directory() -> None: + assert path_from_osc7("") is None + assert path_from_osc7(" ") is None + assert path_from_osc7("file://desktop") is None diff --git a/tests/test_terminal_tabs.py b/tests/test_terminal_tabs.py index 0e92468..24221c9 100644 --- a/tests/test_terminal_tabs.py +++ b/tests/test_terminal_tabs.py @@ -1447,3 +1447,29 @@ def test_a_rebound_shortcut_still_does_its_job(qtbot, tmp_path: Path) -> None: _shortcut_for(tabs, "Ctrl+Alt+Shift+R").activated.emit() assert len(tabs._panes_in(tabs.currentWidget())) == 2 + + +def test_a_split_pane_starts_where_the_pane_it_came_from_is(qtbot, tmp_path) -> None: + tabs = make_tabs(qtbot) + first = tabs.new_tab(shell="/bin/fake-shell", pty_session=FakePtySession()) + first._bridge.setCwd(tmp_path.as_uri()) + + second = tabs.split_active(Qt.Orientation.Horizontal, pty_session=FakePtySession()) + + assert Path(second.current_directory) == tmp_path + second._bridge.ready(80, 24) + assert Path(second._pty.start_kwargs[0]["cwd"]) == tmp_path + + +def test_a_split_off_a_pane_with_no_reported_directory_starts_anywhere(qtbot) -> None: + """A shell qtxterm has no hook for never reports one, and a split off it + should open a normal terminal rather than fail.""" + tabs = make_tabs(qtbot) + tabs.new_tab(shell="/bin/fake-shell", pty_session=FakePtySession()) + + second = tabs.split_active(Qt.Orientation.Horizontal, pty_session=FakePtySession()) + + assert second.current_directory is None + second._pty.start_kwargs.clear() + second._bridge.ready(80, 24) + assert second._pty.start_kwargs[0]["cwd"] is None diff --git a/tests/test_terminal_widget.py b/tests/test_terminal_widget.py index a8e88f2..9762115 100644 --- a/tests/test_terminal_widget.py +++ b/tests/test_terminal_widget.py @@ -2,10 +2,11 @@ from __future__ import annotations +from pathlib import Path from urllib.parse import parse_qs -from PySide6.QtCore import QPoint, Qt -from PySide6.QtGui import QGuiApplication +from PySide6.QtCore import QPoint, QPointF, Qt +from PySide6.QtGui import QGuiApplication, QWheelEvent from conftest import FakePtySession @@ -416,3 +417,84 @@ def test_geometry_pushed_before_the_page_loads_is_replayed(qtbot) -> None: widget._bridge.loaded() assert any("applyBackgroundGeometry(7, 9, 500, 400)" in s for s in pushed) + + +def _wheel_event(modifiers: Qt.KeyboardModifier) -> QWheelEvent: + return QWheelEvent( + QPointF(10, 10), + QPointF(10, 10), + QPoint(0, 0), + QPoint(0, 120), + Qt.MouseButton.NoButton, + modifiers, + Qt.ScrollPhase.NoScrollPhase, + False, + ) + + +def test_ctrl_wheel_does_not_reach_the_page(qtbot) -> None: + """Chromium's own Ctrl+wheel zoom scales the pixels without resizing the + grid - font size belongs to the zoom shortcuts.""" + widget = TerminalWidget(pty_session=FakePtySession()) + qtbot.addWidget(widget) + + event = _wheel_event(Qt.KeyboardModifier.ControlModifier) + + assert widget._view.eventFilter(widget._view, event) is True + + +def test_plain_wheel_still_scrolls(qtbot) -> None: + widget = TerminalWidget(pty_session=FakePtySession()) + qtbot.addWidget(widget) + + event = _wheel_event(Qt.KeyboardModifier.NoModifier) + + assert widget._view.eventFilter(widget._view, event) is False + + +def test_reported_directory_is_where_a_split_would_start(qtbot, tmp_path) -> None: + widget = TerminalWidget(pty_session=FakePtySession()) + qtbot.addWidget(widget) + assert widget.current_directory is None + + widget._bridge.setCwd(tmp_path.as_uri()) + + assert Path(widget.current_directory) == tmp_path + + +def test_a_directory_that_is_not_one_here_is_ignored(qtbot) -> None: + """The payload comes out of the terminal, so on an SSH session it is a + remote path - starting a shell in it would only fail.""" + widget = TerminalWidget(pty_session=FakePtySession()) + qtbot.addWidget(widget) + + widget._bridge.setCwd("file:///nowhere/in/particular") + + assert widget.current_directory is None + + +def test_the_starting_directory_answers_until_the_shell_reports( + qtbot, tmp_path +) -> None: + """A pane split off a pane whose shell has not drawn a prompt yet still + has somewhere to go.""" + widget = TerminalWidget(pty_session=FakePtySession(), cwd=str(tmp_path)) + qtbot.addWidget(widget) + + assert widget.current_directory == str(tmp_path) + + +def test_the_pty_starts_in_the_given_directory_with_the_shell_hooked( + qtbot, tmp_path +) -> None: + fake_pty = FakePtySession() + widget = TerminalWidget( + shell="powershell.exe", pty_session=fake_pty, cwd=str(tmp_path) + ) + qtbot.addWidget(widget) + + widget._bridge.ready(80, 24) + + command, _cols, _rows = fake_pty.start_calls[0] + assert command[-2] == "-Command" + assert fake_pty.start_kwargs[0]["cwd"] == str(tmp_path)