From aa7d18ca355d3bb27fd2470bb4de0d096333fc16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Lureau?= Date: Wed, 9 Sep 2026 18:59:25 +0200 Subject: [PATCH] add fullscreen toggle and fix shell integration resources --- .github/workflows/ci.yml | 14 ++++ CONTRIBUTING.md | 9 +++ README.md | 2 +- build.zig | 4 +- src/command_palette.zig | 1 + src/ghostty_bridge.zig | 12 ++++ src/keybinds.zig | 3 + src/pane.zig | 4 +- src/shortcuts_overlay.zig | 1 + src/window.zig | 31 ++++++++ tests/test_fullscreen.py | 114 ++++++++++++++++++++++++++++++ tests/test_ghostty_integration.py | 20 +++++- tests/test_keyboard_remaps.py | 3 +- tests/test_shell_integration.py | 86 ++++++++++++++++++++++ tests/window_probe.c | 62 ++++++++++++++++ 15 files changed, 357 insertions(+), 9 deletions(-) create mode 100644 tests/test_fullscreen.py create mode 100644 tests/test_shell_integration.py create mode 100644 tests/window_probe.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d2840f..e07b2a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: - name: Install system dependencies run: | + # Chrome is not a build dependency; its index can block apt updates. + sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/apt/sources.list.d/google-chrome.sources sudo apt-get update sudo apt-get install -y \ libgtk-4-dev \ @@ -35,6 +37,8 @@ jobs: libonig-dev \ xdotool \ x11-xkb-utils \ + openbox \ + zsh \ xvfb - name: Build @@ -59,6 +63,16 @@ jobs: env: SEANCE_TEST_BINARY: ${{ github.workspace }}/zig-out/bin/seance + - name: Fullscreen tests + run: python3 -m unittest discover -s tests -p 'test_fullscreen.py' -v + env: + SEANCE_TEST_BINARY: ${{ github.workspace }}/zig-out/bin/seance + + - name: Shell integration tests + run: xvfb-run -a python3 -m unittest discover -s tests -p 'test_shell_integration.py' -v + env: + SEANCE_TEST_BINARY: ${{ github.workspace }}/zig-out/bin/seance + - name: IME tests run: python3 -m unittest discover -s tests -p 'test_ime.py' -v env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80bad50..1e7446a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,6 +33,15 @@ Set `SEANCE_TEST_CODEX` to the real Codex binary (outside Séance's wrapper dire to also check hook loading and approval persistence using its local app-server. This check uses a temporary Codex home and makes no model calls. +Fullscreen tests require Openbox, Xvfb, xdotool, setxkbmap, a C compiler, and +libadwaita headers. They use a separate X server and window manager. Shell +integration tests require zsh and check user startup files and both sets of hooks: + +```bash +SEANCE_TEST_BINARY="$PWD/zig-out/bin/seance" python3 -m unittest discover -s tests -p 'test_fullscreen.py' -v +SEANCE_TEST_BINARY="$PWD/zig-out/bin/seance" xvfb-run -a python3 -m unittest discover -s tests -p 'test_shell_integration.py' -v +``` + Physical keyboard remap tests require `Xvfb`, `xdotool`, and `setxkbmap`. They create a separate X server and never modify your desktop's keyboard layout: diff --git a/README.md b/README.md index 6d4e543..f3eebf2 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ A bundled [skill file](skills/seance-skill.md) provides AI agents with a complet ### And also -Workspaces, session persistence across restarts, tabs within columns, a command palette, focus-follows-mouse, and no telemetry. +Workspaces, session persistence across restarts, tabs within columns, a command palette, focus-follows-mouse, and no telemetry. Press **F11** to toggle fullscreen, or choose **Toggle Fullscreen** in the command palette. The header bar hides in fullscreen and returns when you exit. ## Installation diff --git a/build.zig b/build.zig index 6c139f4..1da77a8 100644 --- a/build.zig +++ b/build.zig @@ -205,14 +205,14 @@ pub fn build(b: *std.Build) void { b.installDirectory(.{ .source_dir = b.path("resources/shell-integration"), .install_dir = .prefix, - .install_subdir = "share/shell-integration", + .install_subdir = "share/seance/shell-integration", }); // Install wrapper scripts (claude wrapper, etc.) b.installDirectory(.{ .source_dir = b.path("resources/bin"), .install_dir = .prefix, - .install_subdir = "share/bin", + .install_subdir = "share/seance/bin", }); // Install bundled icons (from GNOME Icon Library) into hicolor theme diff --git a/src/command_palette.zig b/src/command_palette.zig index 31ed8d0..fbbc3a5 100644 --- a/src/command_palette.zig +++ b/src/command_palette.zig @@ -118,6 +118,7 @@ const static_commands = [_]Command{ .{ .label = "Jump to Unread", .action = .jump_to_unread, .keywords = "jump unread notification" }, .{ .label = "Open Settings", .action = .open_settings, .keywords = "settings preferences config options" }, .{ .label = "Reload Configuration", .action = .reload_config, .keywords = "config reload refresh settings" }, + .{ .label = "Toggle Fullscreen", .action = .toggle_fullscreen, .keywords = "fullscreen toggle window maximize f11" }, .{ .label = "Quit Seance", .action = .quit_app, .keywords = "quit exit close application app shutdown" }, // Workspace commands diff --git a/src/ghostty_bridge.zig b/src/ghostty_bridge.zig index 9c2ce3c..fa691e4 100644 --- a/src/ghostty_bridge.zig +++ b/src/ghostty_bridge.zig @@ -211,6 +211,18 @@ fn createWrapperResourcesDir() bool { } } + // Copy .zshenv rather than symlinking it: zsh's :A modifier resolves + // symlinks when this bootstrap locates its sibling ghostty-integration. + // It must find our wrapper so both Ghostty and Seance hooks are loaded. + { + var zbuf: [std.fs.max_path_bytes]u8 = undefined; + const source = std.fmt.bufPrint(&zbuf, "{s}/shell-integration/zsh/.zshenv", .{real}) catch return false; + std.Io.Dir.cwd().copyFile(source, wd, "shell-integration/zsh/.zshenv", io.get(), .{}) catch |e| { + std.log.warn("ghostty_bridge: failed to copy .zshenv: {}", .{e}); + return false; + }; + } + // Write wrapper shell integration scripts writeShellWrapper(wd, "shell-integration/bash/ghostty.bash", real, "/shell-integration/bash/ghostty.bash", "/bash-integration.sh") catch return false; writeShellWrapper(wd, "shell-integration/zsh/ghostty-integration", real, "/shell-integration/zsh/ghostty-integration", "/zsh-integration.sh") catch return false; diff --git a/src/keybinds.zig b/src/keybinds.zig index 8ed2096..3fb98ee 100644 --- a/src/keybinds.zig +++ b/src/keybinds.zig @@ -73,6 +73,7 @@ pub const Action = enum(u8) { // Window new_window, + toggle_fullscreen, quit_app, // Command palette @@ -260,6 +261,7 @@ fn initDefaults() void { // Window set(.new_window, .{ .key = c.GDK_KEY_N, .ctrl = true, .shift = true }); + set(.toggle_fullscreen, .{ .key = c.GDK_KEY_F11 }); set(.quit_app, .{ .key = c.GDK_KEY_Q, .ctrl = true, .shift = true }); // Command palette @@ -547,6 +549,7 @@ pub fn executeAction(action: Action, state: *Window.WindowState) c.gboolean { .new_window => { _ = state.window_manager.newWindow(); }, + .toggle_fullscreen => state.toggleFullscreen(), .quit_app => state.quitApp(), // Command palette diff --git a/src/pane.zig b/src/pane.zig index ecafee3..5258f40 100644 --- a/src/pane.zig +++ b/src/pane.zig @@ -707,10 +707,10 @@ fn initSurface(pane: *Pane, width: u32, height: u32) void { if (io.executablePath(&exe_buf)) |exe_path| { if (std.fs.path.dirname(exe_path)) |exe_dir| { if (std.fs.path.dirname(exe_dir)) |prefix| { - if (std.fmt.bufPrintZ(&int_env_val_buf, "{s}/share/shell-integration", .{prefix})) |iv| { + if (std.fmt.bufPrintZ(&int_env_val_buf, "{s}/share/seance/shell-integration", .{prefix})) |iv| { int_val = iv; } else |_| {} - if (std.fmt.bufPrintZ(&bin_env_val_buf, "{s}/share/bin", .{prefix})) |bv| { + if (std.fmt.bufPrintZ(&bin_env_val_buf, "{s}/share/seance/bin", .{prefix})) |bv| { bin_val = bv; } else |_| {} } diff --git a/src/shortcuts_overlay.zig b/src/shortcuts_overlay.zig index b98f185..203f354 100644 --- a/src/shortcuts_overlay.zig +++ b/src/shortcuts_overlay.zig @@ -121,6 +121,7 @@ const groups = [_]Group{ .title = "General", .rows = &.{ .{ .single = .{ .action = .new_window, .label = "New Window" } }, + .{ .single = .{ .action = .toggle_fullscreen, .label = "Fullscreen" } }, .{ .single = .{ .action = .open_command_palette, .label = "Command Palette" } }, .{ .single = .{ .action = .open_folder, .label = "Open Folder" } }, .{ .single = .{ .action = .open_settings, .label = "Settings" } }, diff --git a/src/window.zig b/src/window.zig index bffde2a..0f35917 100644 --- a/src/window.zig +++ b/src/window.zig @@ -805,6 +805,15 @@ pub const WindowState = struct { self.closeWorkspace(self.active_workspace); } + pub fn toggleFullscreen(self: *WindowState) void { + const win: *c.GtkWindow = @ptrCast(self.gtk_window); + if (c.gtk_window_is_fullscreen(win) != 0) { + c.gtk_window_unfullscreen(win); + } else { + c.gtk_window_fullscreen(win); + } + } + /// Quit the entire application. Routes through g_application_quit so /// onShutdown saves all currently-open windows (with scrollback). If /// `confirm_close_window` is enabled and there's interesting state to @@ -1197,11 +1206,18 @@ pub const WindowState = struct { c.adw_application_window_set_content(@ptrCast(window), toolbar_view); self.toolbar_view = @ptrCast(toolbar_view); + self.syncFullscreenChrome(); // Push any existing unread count into the fresh badge. self.sidebar.refresh(); } + fn syncFullscreenChrome(self: *WindowState) void { + const tv = self.toolbar_view orelse return; + const is_fullscreen = c.gtk_window_is_fullscreen(@ptrCast(self.gtk_window)); + c.adw_toolbar_view_set_reveal_top_bars(@ptrCast(tv), if (is_fullscreen != 0) 0 else 1); + } + /// Build the SSD chrome: the banner_box becomes the window's direct /// content, the sidebar optionally grows a footer with action buttons, and we /// ask the windowing system to draw decorations. @@ -1584,6 +1600,16 @@ pub fn create(wm: *WindowManager) !*WindowState { 0, ); + // Hide CSD title bar when entering fullscreen + _ = c.g_signal_connect_data( + @as(c.gpointer, @ptrCast(window)), + "notify::fullscreened", + @as(c.GCallback, @ptrCast(&onFullscreenChanged)), + @ptrCast(state), + null, + 0, + ); + // Load theme-aware CSS loadThemeCss(); @@ -1727,6 +1753,11 @@ fn onWindowFocusChanged(_: *c.GObject, _: ?*anyopaque, data: c.gpointer) callcon } } +fn onFullscreenChanged(_: *c.GObject, _: ?*anyopaque, data: c.gpointer) callconv(.c) void { + const state: *WindowState = @ptrCast(@alignCast(data)); + state.syncFullscreenChrome(); +} + fn onWorkspaceSelect(index: usize) void { if (window_manager) |wm| if (wm.active_window) |state| state.selectWorkspace(index); } diff --git a/tests/test_fullscreen.py b/tests/test_fullscreen.py new file mode 100644 index 0000000..c98865a --- /dev/null +++ b/tests/test_fullscreen.py @@ -0,0 +1,114 @@ +"""Fullscreen through physical keys, GTK and Openbox on isolated X servers.""" +import json +from pathlib import Path +import shlex +import shutil +import subprocess +import tempfile + +import test_keyboard_remaps as keyboard + + +class FullscreenTests(keyboard.PhysicalKeyboardTestCase): + config_extra = '\n[window]\ndecoration-mode = "csd"\n' + + @classmethod + def setUpClass(cls): + cls.fixture_dir = tempfile.TemporaryDirectory(prefix="seance-fullscreen-") + cls.addClassCleanup(cls.fixture_dir.cleanup) + cls.module = str(Path(cls.fixture_dir.name) / "window-probe.so") + flags = subprocess.check_output(["pkg-config", "--cflags", "--libs", "libadwaita-1"], text=True) + subprocess.run(["cc", "-shared", "-fPIC", "-Wall", "-Wextra", "-Werror", + str(Path(__file__).with_name("window_probe.c")), "-o", cls.module, + *shlex.split(flags)], check=True) + + def setUp(self): + if not shutil.which("openbox"): + self.skipTest("fullscreen tests require openbox") + self.state_file = Path(self.fixture_dir.name) / "windows.json" + self.state_file.write_text("{}") + self.env_extra = {"LD_PRELOAD": self.module, + "SEANCE_TEST_WINDOW_STATE": str(self.state_file), + "XDG_CURRENT_DESKTOP": ""} + super().setUp() + # No desktop configuration or WM keybindings may intercept our keys. + config = self.app.root / "openbox.xml" + config.write_text('') + wm_env = self.app.env.copy() + wm_env.pop("LD_PRELOAD", None) + wm = subprocess.Popen(["openbox", "--sm-disable", "--config-file", str(config)], + env=wm_env, stdout=subprocess.PIPE, stderr=self.app.log) + self.addCleanup(self.stop_server, wm) + self.window = self.run_x("xdotool", "search", "--onlyvisible", "--pid", + str(self.app.process.pid)).strip().splitlines()[0] + self.app.wait(lambda: subprocess.run( + ["xdotool", "windowactivate", self.window], env=self.app.env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10).returncode == 0, + "window manager startup") + self.run_x("xdotool", "windowactivate", "--sync", self.window) + self.app.wait(lambda: self.state().get("header_height", 0) > 0, "initial CSD header") + + def state(self, window=None): + return json.loads(self.state_file.read_text()).get(window or self.window, {}) + + def expect_state(self, fullscreen, header): + self.app.wait(lambda: self.state().get("fullscreen") == int(fullscreen) and + (self.state().get("header_height", -2) == header if header <= 0 else + self.state().get("header_height", 0) > 0), + f"fullscreen={fullscreen}, header={header}; last state={self.state()}") + + def palette_toggle(self): + self.run_x("xdotool", "key", "ctrl+shift+p") + self.app.wait(lambda: self.state().get("search_focused"), "command palette focus") + self.run_x("xdotool", "type", "Toggle Fullscreen") + # GtkSearchEntry debounces search-changed; Enter must use the new results. + self.app.wait(lambda: self.state().get("fullscreen_selected"), "fullscreen search result") + self.run_x("xdotool", "key", "Return") + + def test_f11_and_palette_restore_header_and_terminal_input(self): + self.capture() + self.run_x("xdotool", "key", "F11") + self.expect_state(True, 0) + self.run_x("xdotool", "key", "a") + self.palette_toggle() + self.expect_state(False, 1) + self.palette_toggle() + self.expect_state(True, 0) + self.run_x("xdotool", "key", "F11") + self.expect_state(False, 1) + self.run_x("xdotool", "key", "b") + self.app.wait(lambda: self.received().endswith(b"b"), "typing after fullscreen") + self.assertEqual(self.received(), b"ab", "fullscreen shortcuts must not reach the terminal") + + def test_decoration_reload_keeps_fullscreen_header_hidden(self): + self.run_x("xdotool", "key", "F11") + self.expect_state(True, 0) + config = self.app.root / "config" / "seance" / "config.toml" + original = config.read_text() + config.write_text(original.replace('decoration-mode = "csd"', 'decoration-mode = "ssd"')) + self.run_x("xdotool", "key", "ctrl+shift+comma") + self.expect_state(True, -1) + self.run_x("xdotool", "key", "F11") + self.expect_state(False, -1) + self.run_x("xdotool", "key", "F11") + self.expect_state(True, -1) + config.write_text(original) + self.run_x("xdotool", "key", "ctrl+shift+comma") + self.expect_state(True, 0) + self.run_x("xdotool", "key", "F11") + self.expect_state(False, 1) + + def test_fullscreen_binding_can_be_reassigned(self): + config = self.app.root / "config" / "seance" / "config.toml" + with config.open("a") as output: + output.write('\n[keybinds]\ntoggle-fullscreen = "F12"\n') + self.run_x("xdotool", "key", "ctrl+shift+comma") + self.capture() + self.run_x("xdotool", "key", "F11", "a") + self.app.wait(lambda: self.received().endswith(b"a"), "unbound F11 delivered to terminal") + self.assertEqual(self.received(), b"\x1b[23~a") + self.expect_state(False, 1) + self.run_x("xdotool", "key", "F12") + self.expect_state(True, 0) + self.run_x("xdotool", "key", "F12") + self.expect_state(False, 1) diff --git a/tests/test_ghostty_integration.py b/tests/test_ghostty_integration.py index 96a1cf3..aeed620 100644 --- a/tests/test_ghostty_integration.py +++ b/tests/test_ghostty_integration.py @@ -10,8 +10,10 @@ @unittest.skipUnless(os.environ.get("SEANCE_TEST_BINARY"), "set SEANCE_TEST_BINARY for GUI validation") -class GhosttyIntegrationTests(unittest.TestCase): +class GhosttyTestCase(unittest.TestCase): config_extra = "" + env_extra = {} + shell_command = "/bin/bash --noprofile --norc" def setUp(self): self.tmp = tempfile.TemporaryDirectory(prefix="seance-ghostty-") @@ -27,6 +29,7 @@ def setUp(self): self.env[key] = str(path) self.env.update(GDK_BACKEND="x11", DBUS_SESSION_BUS_ADDRESS="disabled:", LIBGL_ALWAYS_SOFTWARE="1", NO_AT_BRIDGE="1", SHELL="/bin/bash") + self.env.update(self.env_extra) self.env.pop("SEANCE_DISABLE_SESSION_RESTORE", None) config = self.root / "config" / "seance" config.mkdir() @@ -35,13 +38,17 @@ def setUp(self): '\n[behavior]\nconfirm-close-window = false\n' + self.config_extra) ghostty = self.root / "config" / "ghostty" ghostty.mkdir() - (ghostty / "config").write_text("command = /bin/bash --noprofile --norc\n") + (ghostty / "config").write_text("command = " + self.shell_command + "\n") self.log = (self.root / "stderr.log").open("w+") self.addCleanup(self.log.close) self.process = None self.addCleanup(self.stop) + self.prepare() self.start() + def prepare(self): + """Set up shell files or fixtures before the first surface starts.""" + def start(self): self.process = subprocess.Popen([self.binary], env=self.env, cwd=self.root, stdout=subprocess.DEVNULL, stderr=self.log) @@ -79,6 +86,7 @@ def wait(self, check, description): try: if check(): return + last = None except (OSError, ValueError, AssertionError) as exc: last = exc if self.process.poll() is not None: @@ -86,7 +94,11 @@ def wait(self, check, description): time.sleep(0.1) self.log.flush() self.log.seek(0) - self.fail(f"{description}: {last}\n{self.log.read()[-8000:]}") + try: + screen = self.call("surface.read_screen")["text"] + except (OSError, ValueError, AssertionError): + screen = "" + self.fail(f"{description}: {last}\n{self.log.read()[-8000:]}\nTerminal:\n{screen}") def print_marker(self, marker, surface=None): params = {"text": "printf '\\033[31mSEANCE_%s\\033[0m\\n' " + marker + "\n"} @@ -97,6 +109,8 @@ def print_marker(self, marker, surface=None): "surface.read_screen", {"surface_id": surface} if surface else None)["text"], "terminal output") + +class GhosttyIntegrationTests(GhosttyTestCase): def test_indexed_scrollback_survives_restart_and_cli_reads_it(self): self.wait(lambda: self.call("surface.read_screen").get("text"), "shell startup") self.print_marker("SAVED_COLOR_42") diff --git a/tests/test_keyboard_remaps.py b/tests/test_keyboard_remaps.py index ee4c4fd..cfe52b6 100644 --- a/tests/test_keyboard_remaps.py +++ b/tests/test_keyboard_remaps.py @@ -26,8 +26,9 @@ def setUp(self): self.assertTrue(select.select([server.stdout], [], [], 10)[0], "Xvfb startup timed out") display = ":" + server.stdout.readline().decode().strip() self.assertNotEqual(display, ":", "Xvfb failed to start") - self.app = integration.GhosttyIntegrationTests() + self.app = integration.GhosttyTestCase() self.app.config_extra = getattr(self, "config_extra", "") + self.app.env_extra = getattr(self, "env_extra", {}) self.addCleanup(self.app.doCleanups) with patch.dict(os.environ, {"DISPLAY": display}): self.app.setUp() diff --git a/tests/test_shell_integration.py b/tests/test_shell_integration.py new file mode 100644 index 0000000..19f34d8 --- /dev/null +++ b/tests/test_shell_integration.py @@ -0,0 +1,86 @@ +"""Installed shell resources and real zsh startup; run under Xvfb.""" +from pathlib import Path +import shlex +import shutil +import unittest + +import test_ghostty_integration as integration + + +@unittest.skipUnless(shutil.which("zsh"), "shell integration tests require zsh") +class ZshIntegrationTests(integration.GhosttyTestCase): + custom_zdotdir = False + shell_command = "zsh -l" + + def prepare(self): + self.env.pop("ZDOTDIR", None) + self.env.pop("GHOSTTY_RESOURCES_DIR", None) + # Keep startup errors readable if zsh exits before reaching its prompt. + with (self.root / "config" / "ghostty" / "config").open("a") as config: + config.write("wait-after-command = true\n") + self.dotdir = self.root / "zsh config" if self.custom_zdotdir else self.root / "home" + self.dotdir.mkdir(exist_ok=True) + if self.custom_zdotdir: + self.env["ZDOTDIR"] = str(self.dotdir) + (self.dotdir / ".zshenv").write_text( + # Ubuntu's global zshrc otherwise runs compinit and can prompt about + # permissions on the runner's completion directories. This fixture + # checks startup and prompt hooks, so it does not need completions. + "skip_global_compinit=1\n" + "typeset -gi USER_ZSHENV_COUNT=$((USER_ZSHENV_COUNT + 1))\n") + (self.dotdir / ".zprofile").write_text("typeset -gi USER_ZPROFILE_COUNT=$((USER_ZPROFILE_COUNT + 1))\n") + (self.dotdir / ".zshrc").write_text("typeset -gi USER_ZSHRC_COUNT=$((USER_ZSHRC_COUNT + 1))\nPROMPT='READY> '\n") + + def test_user_config_and_both_integrations_load(self): + self.wait(lambda: "READY>" in self.call("surface.read_screen")["text"], "user zsh prompt") + output = self.root / "shell-state" + script = self.root / "check.zsh" + script.write_text( + "print -r -- $USER_ZSHENV_COUNT $USER_ZPROFILE_COUNT $USER_ZSHRC_COUNT\n" + "print -r -- ${ZDOTDIR-$HOME}\n" + "print -r -- $+functions[_ghostty_precmd] $+functions[_seance_precmd]\n" + "print -r -- ${precmd_functions[(I)_ghostty_precmd]} ${precmd_functions[(I)_seance_precmd]}\n" + "print -r -- $SEANCE_SHELL_INTEGRATION_DIR\n" + "print -r -- $SEANCE_BIN_DIR\n" + "whence -p claude\n" + ) + self.call("surface.send_text", {"text": "source " + shlex.quote(str(script)) + + " > " + shlex.quote(str(output)) + "\n"}) + self.wait(lambda: output.exists() and len(output.read_text().splitlines()) == 7, + "shell integration state") + lines = output.read_text().splitlines() + self.assertEqual(lines[0], "1 1 1", "each user startup file must run exactly once") + self.assertEqual(lines[1], str(self.dotdir)) + self.assertEqual(lines[2], "1 1", "Ghostty and Seance functions must both be installed") + self.assertTrue(all(int(index) > 0 for index in lines[3].split()), + "both integrations must register their prompt hooks") + resources = Path(self.binary).parent.parent / "share" / "seance" + self.assertEqual(lines[4], str(resources / "shell-integration")) + self.assertTrue((Path(lines[4]) / "zsh-integration.sh").is_file()) + self.assertEqual(lines[5], str(resources / "bin")) + self.assertEqual(lines[6], str(resources / "bin" / "claude")) + + +class CustomZdotdirTests(ZshIntegrationTests): + custom_zdotdir = True + + +class BashIntegrationTests(integration.GhosttyTestCase): + shell_command = "/bin/bash" + + def prepare(self): + self.env.pop("GHOSTTY_RESOURCES_DIR", None) + (self.root / "home" / ".bashrc").write_text("PS1='BASH_READY> '\n") + + def test_namespaced_resources_load_and_wrapper_is_on_path(self): + self.wait(lambda: "BASH_READY>" in self.call("surface.read_screen")["text"], "user bash prompt") + output = self.root / "bash-state" + self.call("surface.send_text", {"text": + "{ type -t _seance_prompt_command; printf '%s\\n' \"$SEANCE_SHELL_INTEGRATION_DIR\"; " + "command -v claude; } > " + shlex.quote(str(output)) + "\n"}) + self.wait(lambda: output.exists() and len(output.read_text().splitlines()) == 3, + "bash integration state") + resources = Path(self.binary).parent.parent / "share" / "seance" + self.assertEqual(output.read_text().splitlines(), [ + "function", str(resources / "shell-integration"), str(resources / "bin" / "claude")]) + self.assertTrue((resources / "shell-integration" / "bash-integration.sh").is_file()) diff --git a/tests/window_probe.c b/tests/window_probe.c new file mode 100644 index 0000000..d63f667 --- /dev/null +++ b/tests/window_probe.c @@ -0,0 +1,62 @@ +/* Observe GTK's actual fullscreen state and allocated header height. + * Preloaded only into the test app; leaves input and window management intact. + */ +#define GDK_DISABLE_DEPRECATION_WARNINGS +#include +#include + +static gboolean fullscreen_label(GtkWidget *widget) { + if (GTK_IS_LABEL(widget) && + g_strcmp0(gtk_label_get_text(GTK_LABEL(widget)), "Toggle Fullscreen") == 0) return TRUE; + for (GtkWidget *child = gtk_widget_get_first_child(widget); child; + child = gtk_widget_get_next_sibling(child)) { + if (fullscreen_label(child)) return TRUE; + } + return FALSE; +} + +static gboolean fullscreen_selected(GtkWidget *widget) { + if (!gtk_widget_get_mapped(widget)) return FALSE; + if (GTK_IS_LIST_BOX(widget)) { + GtkListBoxRow *row = gtk_list_box_get_selected_row(GTK_LIST_BOX(widget)); + if (row && fullscreen_label(GTK_WIDGET(row))) return TRUE; + } + for (GtkWidget *child = gtk_widget_get_first_child(widget); child; + child = gtk_widget_get_next_sibling(child)) { + if (fullscreen_selected(child)) return TRUE; + } + return FALSE; +} + +static gboolean snapshot(gpointer data) { + (void)data; + GListModel *windows = gtk_window_get_toplevels(); + GString *json = g_string_new("{"); + const char *separator = ""; + for (guint i = 0; i < g_list_model_get_n_items(windows); ++i) { + GtkWindow *window = g_list_model_get_item(windows, i); + GdkSurface *surface = gtk_native_get_surface(GTK_NATIVE(window)); + if (ADW_IS_APPLICATION_WINDOW(window) && GDK_IS_X11_SURFACE(surface)) { + GtkWidget *content = adw_application_window_get_content(ADW_APPLICATION_WINDOW(window)); + int height = ADW_IS_TOOLBAR_VIEW(content) + ? adw_toolbar_view_get_top_bar_height(ADW_TOOLBAR_VIEW(content)) : -1; + GtkWidget *focus = gtk_window_get_focus(window); + gboolean search_focused = focus && gtk_widget_get_ancestor(focus, GTK_TYPE_SEARCH_ENTRY); + g_string_append_printf(json, "%s\"%lu\":{\"fullscreen\":%d,\"header_height\":%d," + "\"search_focused\":%d,\"fullscreen_selected\":%d}", + separator, gdk_x11_surface_get_xid(surface), + gtk_window_is_fullscreen(window), height, + search_focused, fullscreen_selected(GTK_WIDGET(window))); + separator = ","; + } + g_object_unref(window); + } + g_string_append(json, "}"); + g_file_set_contents(g_getenv("SEANCE_TEST_WINDOW_STATE"), json->str, json->len, NULL); + g_string_free(json, TRUE); + return G_SOURCE_CONTINUE; +} + +__attribute__((constructor)) static void init(void) { + if (g_getenv("SEANCE_TEST_WINDOW_STATE")) g_timeout_add(50, snapshot, NULL); +}