diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 90ce82989..876137517 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 1342c4db6..4b46a83c2 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -83,7 +83,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 82645c102..d9fe4e5a8 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -161,7 +161,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml index 6fc7e0fb4..c625ea043 100644 --- a/.github/workflows/snap.yml +++ b/.github/workflows/snap.yml @@ -38,7 +38,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4f253f249..ee517b899 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,7 +77,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -120,7 +120,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -153,7 +153,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -187,7 +187,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -231,7 +231,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -267,7 +267,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -296,7 +296,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -329,7 +329,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -375,7 +375,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -604,7 +604,7 @@ jobs: echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -646,7 +646,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -694,7 +694,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -729,7 +729,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -793,7 +793,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -822,7 +822,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -852,7 +852,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -881,7 +881,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -908,7 +908,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -935,7 +935,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -962,7 +962,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -994,7 +994,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -1021,7 +1021,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -1056,7 +1056,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix @@ -1085,7 +1085,7 @@ jobs: uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10 - name: Configure Namespace powered Buildx - uses: namespacelabs/nscloud-setup-buildx-action@a7e525416136ee2842da3c800e7067b72a27200e # v0.0.21 + uses: namespacelabs/nscloud-setup-buildx-action@f5814dcf37a16cce0624d5bec2ab879654294aa0 # v0.0.22 - name: Download Source Tarball Artifacts uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 @@ -1118,7 +1118,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix diff --git a/.github/workflows/update-colorschemes.yml b/.github/workflows/update-colorschemes.yml index 9395f19e1..33c55bb42 100644 --- a/.github/workflows/update-colorschemes.yml +++ b/.github/workflows/update-colorschemes.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21 + uses: namespacelabs/nscloud-cache-action@a4cc4697b9de3b562bec5a856e6f3c31f94506e2 # v1.3.0 with: path: | /nix diff --git a/build.zig.zon b/build.zig.zon index d5c06259a..05e440fcc 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -116,8 +116,8 @@ // Other .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz", - .hash = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260126-150817-02c580d.tgz", + .hash = "N-V-__8AAM5RAwB5jHC1P1uqrabiz_ieQvfrIYNzs4eAY__c", .lazy = true, }, }, diff --git a/build.zig.zon.json b/build.zig.zon.json index b12216bd9..debfb1689 100644 --- a/build.zig.zon.json +++ b/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7": { + "N-V-__8AAM5RAwB5jHC1P1uqrabiz_ieQvfrIYNzs4eAY__c": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz", - "hash": "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260126-150817-02c580d.tgz", + "hash": "sha256-jWbZAS2GSIH8kGdafJua81OMJY8djEQUhvTtmnT90oI=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/build.zig.zon.nix b/build.zig.zon.nix index 430619e74..ce8f5b056 100644 --- a/build.zig.zon.nix +++ b/build.zig.zon.nix @@ -171,11 +171,11 @@ in }; } { - name = "N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7"; + name = "N-V-__8AAM5RAwB5jHC1P1uqrabiz_ieQvfrIYNzs4eAY__c"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz"; - hash = "sha256-NIqF12KqXhIrP+LyBtg6WtkHxNUdWOyziAdq8S45RrU="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260126-150817-02c580d.tgz"; + hash = "sha256-jWbZAS2GSIH8kGdafJua81OMJY8djEQUhvTtmnT90oI="; }; } { diff --git a/build.zig.zon.txt b/build.zig.zon.txt index 72597a650..264c49232 100644 --- a/build.zig.zon.txt +++ b/build.zig.zon.txt @@ -6,7 +6,7 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260126-150817-02c580d.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz diff --git a/flake.nix b/flake.nix index c96004a09..945dcd946 100644 --- a/flake.nix +++ b/flake.nix @@ -101,7 +101,7 @@ formatter = forAllPlatforms (pkgs: pkgs.alejandra); apps = forBuildablePlatforms (pkgs: let - runVM = module: desc: let + runVM = module: let vm = import ./nix/vm/create.nix { inherit (pkgs.stdenv.hostPlatform) system; inherit module nixpkgs; diff --git a/flatpak/zig-packages.json b/flatpak/zig-packages.json index 3e2b1e26d..df810aaa8 100644 --- a/flatpak/zig-packages.json +++ b/flatpak/zig-packages.json @@ -67,9 +67,9 @@ }, { "type": "archive", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260112-150707-28c8f5b.tgz", - "dest": "vendor/p/N-V-__8AAIdIAwDt5PxH-cwCxEcTfw4jBV8sR6fZ_XLh-cR7", - "sha256": "348a85d762aa5e122b3fe2f206d83a5ad907c4d51d58ecb388076af12e3946b5" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260126-150817-02c580d.tgz", + "dest": "vendor/p/N-V-__8AAM5RAwB5jHC1P1uqrabiz_ieQvfrIYNzs4eAY__c", + "sha256": "8d66d9012d864881fc90675a7c9b9af3538c258f1d8c441486f4ed9a74fdd282" }, { "type": "archive", diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 59389c5c0..183dca544 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -1335,7 +1335,7 @@ extension Ghostty { mode: ghostty_action_inspector_e) { switch (target.tag) { case GHOSTTY_TARGET_APP: - Ghostty.logger.warning("toggle split zoom does nothing with an app target") + Ghostty.logger.warning("toggle inspector does nothing with an app target") return case GHOSTTY_TARGET_SURFACE: diff --git a/macos/Sources/Ghostty/Ghostty.Inspector.swift b/macos/Sources/Ghostty/Ghostty.Inspector.swift new file mode 100644 index 000000000..79567bc4a --- /dev/null +++ b/macos/Sources/Ghostty/Ghostty.Inspector.swift @@ -0,0 +1,100 @@ +import GhosttyKit +import Metal + +extension Ghostty { + /// Represents the inspector for a surface within Ghostty. + /// + /// Wraps a `ghostty_inspector_t` + final class Inspector: Sendable { + private let inspector: ghostty_inspector_t + + /// Read the underlying C value for this inspector. This is unsafe because the value will be + /// freed when the Inspector class is deinitialized. + var unsafeCValue: ghostty_inspector_t { + inspector + } + + /// Initialize from the C structure. + init(cInspector: ghostty_inspector_t) { + self.inspector = cInspector + } + + /// Set the focus state of the inspector. + @MainActor + func setFocus(_ focused: Bool) { + ghostty_inspector_set_focus(inspector, focused) + } + + /// Set the content scale of the inspector. + @MainActor + func setContentScale(x: Double, y: Double) { + ghostty_inspector_set_content_scale(inspector, x, y) + } + + /// Set the size of the inspector. + @MainActor + func setSize(width: UInt32, height: UInt32) { + ghostty_inspector_set_size(inspector, width, height) + } + + /// Send a mouse button event to the inspector. + @MainActor + func mouseButton( + _ state: ghostty_input_mouse_state_e, + button: ghostty_input_mouse_button_e, + mods: ghostty_input_mods_e + ) { + ghostty_inspector_mouse_button(inspector, state, button, mods) + } + + /// Send a mouse position event to the inspector. + @MainActor + func mousePos(x: Double, y: Double) { + ghostty_inspector_mouse_pos(inspector, x, y) + } + + /// Send a mouse scroll event to the inspector. + @MainActor + func mouseScroll(x: Double, y: Double, mods: ghostty_input_scroll_mods_t) { + ghostty_inspector_mouse_scroll(inspector, x, y, mods) + } + + /// Send a key event to the inspector. + @MainActor + func key( + _ action: ghostty_input_action_e, + key: ghostty_input_key_e, + mods: ghostty_input_mods_e + ) { + ghostty_inspector_key(inspector, action, key, mods) + } + + /// Send text to the inspector. + @MainActor + func text(_ text: String) { + text.withCString { ptr in + ghostty_inspector_text(inspector, ptr) + } + } + + /// Initialize Metal rendering for the inspector. + @MainActor + func metalInit(device: MTLDevice) -> Bool { + let devicePtr = Unmanaged.passRetained(device).toOpaque() + return ghostty_inspector_metal_init(inspector, devicePtr) + } + + /// Render the inspector using Metal. + @MainActor + func metalRender( + commandBuffer: MTLCommandBuffer, + descriptor: MTLRenderPassDescriptor + ) { + ghostty_inspector_metal_render( + inspector, + Unmanaged.passRetained(commandBuffer).toOpaque(), + Unmanaged.passRetained(descriptor).toOpaque() + ) + } + } +} diff --git a/macos/Sources/Ghostty/Surface View/InspectorView.swift b/macos/Sources/Ghostty/Surface View/InspectorView.swift index e8eaf3a80..03be794e9 100644 --- a/macos/Sources/Ghostty/Surface View/InspectorView.swift +++ b/macos/Sources/Ghostty/Surface View/InspectorView.swift @@ -98,7 +98,7 @@ extension Ghostty { didSet { surfaceViewDidChange() } } - private var inspector: ghostty_inspector_t? { + private var inspector: Ghostty.Inspector? { guard let surfaceView = self.surfaceView else { return nil } return surfaceView.inspector } @@ -120,9 +120,9 @@ extension Ghostty { self.commandQueue = commandQueue super.init(frame: frame, device: device) - // This makes it so renders only happen when we request - self.enableSetNeedsDisplay = true - self.isPaused = true + // Use timed updates mode. This is required for the inspector. + self.isPaused = false + self.preferredFramesPerSecond = 30 // After initializing the parent we can set our own properties self.device = MTLCreateSystemDefaultDevice() @@ -130,6 +130,13 @@ extension Ghostty { // Setup our tracking areas for mouse events updateTrackingAreas() + + // Observe occlusion state to pause rendering when not visible + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidChangeOcclusionState), + name: NSWindow.didChangeOcclusionStateNotification, + object: nil) } required init(coder: NSCoder) { @@ -141,28 +148,19 @@ extension Ghostty { NotificationCenter.default.removeObserver(self) } + @objc private func windowDidChangeOcclusionState(_ notification: NSNotification) { + guard let window = notification.object as? NSWindow, + window == self.window else { return } + // Pause rendering when our window isn't visible. + isPaused = !window.occlusionState.contains(.visible) + } + // MARK: Internal Inspector Funcs private func surfaceViewDidChange() { - let center = NotificationCenter.default - center.removeObserver(self) - - guard let surfaceView = self.surfaceView else { return } guard let inspector = self.inspector else { return } guard let device = self.device else { return } - let devicePtr = Unmanaged.passRetained(device).toOpaque() - ghostty_inspector_metal_init(inspector, devicePtr) - - // Register an observer for render requests - center.addObserver( - self, - selector: #selector(didRequestRender), - name: Ghostty.Notification.inspectorNeedsDisplay, - object: surfaceView) - } - - @objc private func didRequestRender(notification: SwiftUI.Notification) { - self.needsDisplay = true + _ = inspector.metalInit(device: device) } private func updateSize() { @@ -172,10 +170,10 @@ extension Ghostty { let fbFrame = self.convertToBacking(self.frame) let xScale = fbFrame.size.width / self.frame.size.width let yScale = fbFrame.size.height / self.frame.size.height - ghostty_inspector_set_content_scale(inspector, xScale, yScale) + inspector.setContentScale(x: xScale, y: yScale) // When our scale factor changes, so does our fb size so we send that too - ghostty_inspector_set_size(inspector, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height)) + inspector.setSize(width: UInt32(fbFrame.size.width), height: UInt32(fbFrame.size.height)) } // MARK: NSView @@ -184,7 +182,7 @@ extension Ghostty { let result = super.becomeFirstResponder() if (result) { if let inspector = self.inspector { - ghostty_inspector_set_focus(inspector, true) + inspector.setFocus(true) } } return result @@ -194,7 +192,7 @@ extension Ghostty { let result = super.resignFirstResponder() if (result) { if let inspector = self.inspector { - ghostty_inspector_set_focus(inspector, false) + inspector.setFocus(false) } } return result @@ -229,25 +227,25 @@ extension Ghostty { override func mouseDown(with event: NSEvent) { guard let inspector = self.inspector else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) + inspector.mouseButton(GHOSTTY_MOUSE_PRESS, button: GHOSTTY_MOUSE_LEFT, mods: mods) } override func mouseUp(with event: NSEvent) { guard let inspector = self.inspector else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + inspector.mouseButton(GHOSTTY_MOUSE_RELEASE, button: GHOSTTY_MOUSE_LEFT, mods: mods) } override func rightMouseDown(with event: NSEvent) { guard let inspector = self.inspector else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) + inspector.mouseButton(GHOSTTY_MOUSE_PRESS, button: GHOSTTY_MOUSE_RIGHT, mods: mods) } override func rightMouseUp(with event: NSEvent) { guard let inspector = self.inspector else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_inspector_mouse_button(inspector, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods) + inspector.mouseButton(GHOSTTY_MOUSE_RELEASE, button: GHOSTTY_MOUSE_RIGHT, mods: mods) } override func mouseMoved(with event: NSEvent) { @@ -255,7 +253,7 @@ extension Ghostty { // Convert window position to view position. Note (0, 0) is bottom left. let pos = self.convert(event.locationInWindow, from: nil) - ghostty_inspector_mouse_pos(inspector, pos.x, frame.height - pos.y) + inspector.mousePos(x: pos.x, y: frame.height - pos.y) } @@ -297,7 +295,7 @@ extension Ghostty { // Pack our momentum value into the mods bitmask mods |= Int32(momentum.rawValue) << 1 - ghostty_inspector_mouse_scroll(inspector, x, y, mods) + inspector.mouseScroll(x: x, y: y, mods: mods) } override func keyDown(with event: NSEvent) { @@ -336,7 +334,7 @@ extension Ghostty { guard let inspector = self.inspector else { return } guard let key = Ghostty.Input.Key(keyCode: event.keyCode) else { return } let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_inspector_key(inspector, action, key.cKey, mods) + inspector.key(action, key: key.cKey, mods: mods) } // MARK: NSTextInputClient @@ -406,9 +404,7 @@ extension Ghostty { let len = chars.utf8CString.count if (len == 0) { return } - chars.withCString { ptr in - ghostty_inspector_text(inspector, ptr) - } + inspector.text(chars) } override func doCommand(by selector: Selector) { @@ -435,11 +431,7 @@ extension Ghostty { updateSize() // Render - ghostty_inspector_metal_render( - inspector, - Unmanaged.passRetained(commandBuffer).toOpaque(), - Unmanaged.passRetained(descriptor).toOpaque() - ) + inspector.metalRender(commandBuffer: commandBuffer, descriptor: descriptor) guard let drawable = self.currentDrawable else { return } commandBuffer.present(drawable) diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 0ddfe57b8..c856b0163 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -173,10 +173,11 @@ extension Ghostty { } // Returns the inspector instance for this surface, or nil if the - // surface has been closed. - var inspector: ghostty_inspector_t? { + // surface has been closed or no inspector is active. + var inspector: Ghostty.Inspector? { guard let surface = self.surface else { return nil } - return ghostty_surface_inspector(surface) + guard let cInspector = ghostty_surface_inspector(surface) else { return nil } + return Ghostty.Inspector(cInspector: cInspector) } // True if the inspector should be visible diff --git a/pkg/dcimgui/build.zig b/pkg/dcimgui/build.zig index 95c4af303..ae907dac0 100644 --- a/pkg/dcimgui/build.zig +++ b/pkg/dcimgui/build.zig @@ -49,6 +49,7 @@ pub fn build(b: *std.Build) !void { var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); try flags.appendSlice(b.allocator, &.{ + "-DIMGUI_HAS_DOCK=1", "-DIMGUI_USE_WCHAR32=1", "-DIMGUI_DISABLE_OBSOLETE_FUNCTIONS=1", }); diff --git a/pkg/dcimgui/main.zig b/pkg/dcimgui/main.zig index e709158f5..59bfca4f2 100644 --- a/pkg/dcimgui/main.zig +++ b/pkg/dcimgui/main.zig @@ -5,6 +5,8 @@ pub const c = @cImport({ // during import time to get the right types. Without this // you get stack size mismatches on some structs. @cDefine("IMGUI_USE_WCHAR32", "1"); + + @cDefine("IMGUI_HAS_DOCK", "1"); @cInclude("dcimgui.h"); }); @@ -25,11 +27,39 @@ pub extern fn ImGui_ImplOSX_Init(*anyopaque) callconv(.c) bool; pub extern fn ImGui_ImplOSX_Shutdown() callconv(.c) void; pub extern fn ImGui_ImplOSX_NewFrame(*anyopaque) callconv(.c) void; -// Internal API functions from dcimgui_internal.h +// Internal API types and functions from dcimgui_internal.h // We declare these manually because the internal header contains bitfields // that Zig's cImport cannot translate. +pub const ImGuiDockNodeFlagsPrivate = struct { + pub const DockSpace: c.ImGuiDockNodeFlags = 1 << 10; + pub const CentralNode: c.ImGuiDockNodeFlags = 1 << 11; + pub const NoTabBar: c.ImGuiDockNodeFlags = 1 << 12; + pub const HiddenTabBar: c.ImGuiDockNodeFlags = 1 << 13; + pub const NoWindowMenuButton: c.ImGuiDockNodeFlags = 1 << 14; + pub const NoCloseButton: c.ImGuiDockNodeFlags = 1 << 15; + pub const NoResizeX: c.ImGuiDockNodeFlags = 1 << 16; + pub const NoResizeY: c.ImGuiDockNodeFlags = 1 << 17; + pub const DockedWindowsInFocusRoute: c.ImGuiDockNodeFlags = 1 << 18; + pub const NoDockingSplitOther: c.ImGuiDockNodeFlags = 1 << 19; + pub const NoDockingOverMe: c.ImGuiDockNodeFlags = 1 << 20; + pub const NoDockingOverOther: c.ImGuiDockNodeFlags = 1 << 21; + pub const NoDockingOverEmpty: c.ImGuiDockNodeFlags = 1 << 22; +}; pub extern fn ImGui_DockBuilderDockWindow(window_name: [*:0]const u8, node_id: c.ImGuiID) callconv(.c) void; +pub extern fn ImGui_DockBuilderGetNode(node_id: c.ImGuiID) callconv(.c) ?*anyopaque; +pub extern fn ImGui_DockBuilderGetCentralNode(node_id: c.ImGuiID) callconv(.c) ?*anyopaque; +pub extern fn ImGui_DockBuilderAddNode() callconv(.c) c.ImGuiID; +pub extern fn ImGui_DockBuilderAddNodeEx(node_id: c.ImGuiID, flags: c.ImGuiDockNodeFlags) callconv(.c) c.ImGuiID; +pub extern fn ImGui_DockBuilderRemoveNode(node_id: c.ImGuiID) callconv(.c) void; +pub extern fn ImGui_DockBuilderRemoveNodeDockedWindows(node_id: c.ImGuiID) callconv(.c) void; +pub extern fn ImGui_DockBuilderRemoveNodeDockedWindowsEx(node_id: c.ImGuiID, clear_settings_refs: bool) callconv(.c) void; +pub extern fn ImGui_DockBuilderRemoveNodeChildNodes(node_id: c.ImGuiID) callconv(.c) void; +pub extern fn ImGui_DockBuilderSetNodePos(node_id: c.ImGuiID, pos: c.ImVec2) callconv(.c) void; +pub extern fn ImGui_DockBuilderSetNodeSize(node_id: c.ImGuiID, size: c.ImVec2) callconv(.c) void; pub extern fn ImGui_DockBuilderSplitNode(node_id: c.ImGuiID, split_dir: c.ImGuiDir, size_ratio_for_node_at_dir: f32, out_id_at_dir: *c.ImGuiID, out_id_at_opposite_dir: *c.ImGuiID) callconv(.c) c.ImGuiID; +pub extern fn ImGui_DockBuilderCopyDockSpace(src_dockspace_id: c.ImGuiID, dst_dockspace_id: c.ImGuiID, in_window_remap_pairs: *c.ImVector_const_charPtr) callconv(.c) void; +pub extern fn ImGui_DockBuilderCopyNode(src_node_id: c.ImGuiID, dst_node_id: c.ImGuiID, out_node_remap_pairs: *c.ImVector_ImGuiID) callconv(.c) void; +pub extern fn ImGui_DockBuilderCopyWindowSettings(src_name: [*:0]const u8, dst_name: [*:0]const u8) callconv(.c) void; pub extern fn ImGui_DockBuilderFinish(node_id: c.ImGuiID) callconv(.c) void; // Extension functions from ext.cpp diff --git a/src/App.zig b/src/App.zig index 00be56f49..33c8318db 100644 --- a/src/App.zig +++ b/src/App.zig @@ -237,14 +237,19 @@ pub fn needsConfirmQuit(self: *const App) bool { /// Drain the mailbox. fn drainMailbox(self: *App, rt_app: *apprt.App) !void { while (self.mailbox.pop()) |message| { - log.debug("mailbox message={s}", .{@tagName(message)}); + if (comptime std.log.logEnabled(.debug, .app)) { + switch (message) { + // these tend to be way too verbose for normal debugging + .redraw_surface => {}, + else => log.debug("mailbox message={t}", .{message}), + } + } switch (message) { .open_config => try self.performAction(rt_app, .open_config), .new_window => |msg| try self.newWindow(rt_app, msg), .close => |surface| self.closeSurface(surface), .surface_message => |msg| try self.surfaceMessage(msg.surface, msg.message), .redraw_surface => |surface| try self.redrawSurface(rt_app, surface), - .redraw_inspector => |surface| self.redrawInspector(rt_app, surface), // If we're quitting, then we set the quit flag and stop // draining the mailbox immediately. This lets us defer @@ -283,11 +288,6 @@ fn redrawSurface( ); } -fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) void { - if (!self.hasRtSurface(surface)) return; - rt_app.redrawInspector(surface); -} - /// Create a new window pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void { const target: apprt.Target = target: { @@ -559,10 +559,6 @@ pub const Message = union(enum) { /// message if it needs to. redraw_surface: *apprt.Surface, - /// Redraw the inspector. This is called whenever some non-OS event - /// causes the inspector to need to be redrawn. - redraw_inspector: *apprt.Surface, - const NewWindow = struct { /// The parent surface parent: ?*Surface = null, diff --git a/src/Surface.zig b/src/Surface.zig index 0bf3aa008..44385fdae 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -803,7 +803,7 @@ pub fn deinit(self: *Surface) void { self.io.deinit(); if (self.inspector) |v| { - v.deinit(); + v.deinit(self.alloc); self.alloc.destroy(v); } @@ -879,8 +879,10 @@ pub fn activateInspector(self: *Surface) !void { // Setup the inspector const ptr = try self.alloc.create(inspectorpkg.Inspector); errdefer self.alloc.destroy(ptr); - ptr.* = try inspectorpkg.Inspector.init(self); + ptr.* = try inspectorpkg.Inspector.init(self.alloc); + errdefer ptr.deinit(self.alloc); self.inspector = ptr; + errdefer self.inspector = null; // Put the inspector onto the render state { @@ -912,7 +914,7 @@ pub fn deactivateInspector(self: *Surface) void { self.queueIo(.{ .inspector = false }, .unlocked); // Deinit the inspector - insp.deinit(); + insp.deinit(self.alloc); self.alloc.destroy(insp); self.inspector = null; } @@ -2618,7 +2620,7 @@ pub fn keyCallback( defer crash.sentry.thread_state = null; // Setup our inspector event if we have an inspector. - var insp_ev: ?inspectorpkg.key.Event = if (self.inspector != null) ev: { + var insp_ev: ?inspectorpkg.KeyEvent = if (self.inspector != null) ev: { var copy = event; copy.utf8 = ""; if (event.utf8.len > 0) copy.utf8 = try self.alloc.dupe(u8, event.utf8); @@ -2635,7 +2637,7 @@ pub fn keyCallback( break :ev; }; - if (insp.recordKeyEvent(ev)) { + if (insp.recordKeyEvent(self.alloc, ev)) { self.queueRender() catch {}; } else |err| { log.warn("error adding key event to inspector err={}", .{err}); @@ -2798,7 +2800,7 @@ pub fn keyCallback( fn maybeHandleBinding( self: *Surface, event: input.KeyEvent, - insp_ev: ?*inspectorpkg.key.Event, + insp_ev: ?*inspectorpkg.KeyEvent, ) !?InputEffect { switch (event.action) { // Release events never trigger a binding but we need to check if @@ -3131,7 +3133,7 @@ fn endKeySequence( fn encodeKey( self: *Surface, event: input.KeyEvent, - insp_ev: ?*inspectorpkg.key.Event, + insp_ev: ?*inspectorpkg.KeyEvent, ) !?termio.Message.WriteReq { const write_req: termio.Message.WriteReq = req: { // Build our encoding options, which requires the lock. @@ -3872,36 +3874,8 @@ pub fn mouseButtonCallback( // log.debug("mouse action={} button={} mods={}", .{ action, button, mods }); // If we have an inspector, we always queue a render - if (self.inspector) |insp| { + if (self.inspector != null) { defer self.queueRender() catch {}; - - self.renderer_state.mutex.lock(); - defer self.renderer_state.mutex.unlock(); - - // If the inspector is requesting a cell, then we intercept - // left mouse clicks and send them to the inspector. - if (insp.cell == .requested and - button == .left and - action == .press) - { - const pos = try self.rt_surface.getCursorPos(); - const point = self.posToViewport(pos.x, pos.y); - const screen: *terminal.Screen = self.renderer_state.terminal.screens.active; - const p = screen.pages.pin(.{ .viewport = point }) orelse { - log.warn("failed to get pin for clicked point", .{}); - return false; - }; - - insp.cell.select( - self.alloc, - p, - point.x, - point.y, - ) catch |err| { - log.warn("error selecting cell for inspector err={}", .{err}); - }; - return false; - } } // Always record our latest mouse state @@ -4321,7 +4295,7 @@ fn clickMoveCursor(self: *Surface, to: terminal.Pin) !void { // This flag is only set if we've seen at least one semantic prompt // OSC sequence. If we've never seen that sequence, we can't possibly // move the cursor so we can fast path out of here. - if (!t.flags.shell_redraws_prompt) return; + if (!t.screens.active.flags.semantic_content) return; // Get our path const from = t.screens.active.cursor.page_pin.*; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 2fbae8fdf..dcf8a6357 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -1061,7 +1061,7 @@ pub const Inspector = struct { render: { const surface = &self.surface.core_surface; const inspector = surface.inspector orelse break :render; - inspector.render(); + inspector.render(surface); } // Render diff --git a/src/apprt/gtk/class/imgui_widget.zig b/src/apprt/gtk/class/imgui_widget.zig index 8ad75f5d0..01b3f3e5c 100644 --- a/src/apprt/gtk/class/imgui_widget.zig +++ b/src/apprt/gtk/class/imgui_widget.zig @@ -63,6 +63,12 @@ pub const ImguiWidget = extern struct { /// Our previous instant used to calculate delta time for animations. instant: ?std.time.Instant = null, + /// Tick callback ID for timed updates. + tick_callback_id: c_uint = 0, + + /// Last render time for throttling to 30 FPS. + last_render_time: ?std.time.Instant = null, + pub var offset: c_int = 0; }; @@ -231,11 +237,26 @@ pub const ImguiWidget = extern struct { // Call the virtual method to setup the UI. self.setup(); + + // Add a tick callback to drive timed updates via the frame clock. + priv.tick_callback_id = self.as(gtk.Widget).addTickCallback( + tickCallback, + null, + null, + ); } /// Handle a request to unrealize the GLArea fn glAreaUnrealize(_: *gtk.GLArea, self: *ImguiWidget) callconv(.c) void { - assert(self.private().ig_context != null); + const priv = self.private(); + assert(priv.ig_context != null); + + // Remove the tick callback if it was registered. + if (priv.tick_callback_id != 0) { + self.as(gtk.Widget).removeTickCallback(priv.tick_callback_id); + priv.tick_callback_id = 0; + } + self.setCurrentContext() catch return; cimgui.ImGui_ImplOpenGL3_Shutdown(); } @@ -265,6 +286,10 @@ pub const ImguiWidget = extern struct { fn glAreaRender(_: *gtk.GLArea, _: *gdk.GLContext, self: *Self) callconv(.c) c_int { self.setCurrentContext() catch return @intFromBool(false); + // Update last render time for tick callback throttling. + const priv = self.private(); + priv.last_render_time = std.time.Instant.now() catch null; + // Setup our frame. We render twice because some ImGui behaviors // take multiple renders to process. I don't know how to make this // more efficient. @@ -411,6 +436,34 @@ pub const ImguiWidget = extern struct { cimgui.c.ImGuiIO_AddInputCharactersUTF8(io, bytes); } + /// Tick callback for timed updates. This drives periodic redraws. + /// Redraws are limited to 30 FPS max since our imgui widgets don't + /// usually need higher frame rates than that. + fn tickCallback( + widget: *gtk.Widget, + _: *gdk.FrameClock, + _: ?*anyopaque, + ) callconv(.c) c_int { + const self: *Self = gobject.ext.cast(Self, widget) orelse return 0; + const priv = self.private(); + + const now = std.time.Instant.now() catch { + self.queueRender(); + return 1; + }; + + // Throttle to 30 FPS (~33ms between frames) + const frame_time_ns: u64 = std.time.ns_per_s / 30; + const should_render = if (priv.last_render_time) |last| + now.since(last) >= frame_time_ns + else + true; + + if (should_render) self.queueRender(); + + return 1; // Continue the tick callback + } + //--------------------------------------------------------------- // Default virtual method handlers diff --git a/src/apprt/gtk/class/inspector_widget.zig b/src/apprt/gtk/class/inspector_widget.zig index 046cd2174..80ac7fc3e 100644 --- a/src/apprt/gtk/class/inspector_widget.zig +++ b/src/apprt/gtk/class/inspector_widget.zig @@ -89,7 +89,7 @@ pub const InspectorWidget = extern struct { const surface = priv.surface orelse return; const core_surface = surface.core() orelse return; const inspector = core_surface.inspector orelse return; - inspector.render(); + inspector.render(core_surface); } //--------------------------------------------------------------- diff --git a/src/inspector/AGENTS.md b/src/inspector/AGENTS.md new file mode 100644 index 000000000..dafc81e0b --- /dev/null +++ b/src/inspector/AGENTS.md @@ -0,0 +1,12 @@ +# Inspector Subsystem + +The inspector is a feature of Ghostty that works similar to a +browser's developer tools. It allows the user to inspect and modify the +terminal state. + +- See the full C API by finding `dcimgui.h` in the `.zig-cache` folder + in the root: `find . -type f -name dcimgui.h`. Use the newest version. +- See full examples of how to use every widget by loading this file: + +- On macOS, run builds with `-Demit-macos-app=false` to verify API usage. +- There are no unit tests in this package. diff --git a/src/inspector/Inspector.zig b/src/inspector/Inspector.zig index 6ffb43d43..22d763f73 100644 --- a/src/inspector/Inspector.zig +++ b/src/inspector/Inspector.zig @@ -10,119 +10,16 @@ const builtin = @import("builtin"); const cimgui = @import("dcimgui"); const Surface = @import("../Surface.zig"); const font = @import("../font/main.zig"); -const input = @import("../input.zig"); -const renderer = @import("../renderer.zig"); const terminal = @import("../terminal/main.zig"); const inspector = @import("main.zig"); -const units = @import("units.zig"); - -/// The window names. These are used with docking so we need to have access. -const window_cell = "Cell"; -const window_modes = "Modes"; -const window_keyboard = "Keyboard"; -const window_termio = "Terminal IO"; -const window_screen = "Screen"; -const window_size = "Surface Info"; -const window_imgui_demo = "Dear ImGui Demo"; - -/// The surface that we're inspecting. -surface: *Surface, - -/// This is used to track whether we're rendering for the first time. This -/// is used to set up the initial window positions. -first_render: bool = true, +const widgets = @import("widgets.zig"); /// Mouse state that we track in addition to normal mouse states that /// Ghostty always knows about. -mouse: struct { - /// Last hovered x/y - last_xpos: f64 = 0, - last_ypos: f64 = 0, +mouse: widgets.surface.Mouse = .{}, - // Last hovered screen point - last_point: ?terminal.Pin = null, -} = .{}, - -/// A selected cell. -cell: CellInspect = .{ .idle = {} }, - -/// The list of keyboard events -key_events: inspector.key.EventRing, - -/// The VT stream -vt_events: inspector.termio.VTEventRing, -vt_stream: inspector.termio.Stream, - -/// The currently selected event sequence number for keyboard navigation -selected_event_seq: ?u32 = null, - -/// Flag indicating whether we need to scroll to the selected item -need_scroll_to_selected: bool = false, - -/// Flag indicating whether the selection was made by keyboard -is_keyboard_selection: bool = false, - -/// Enum representing keyboard navigation actions -const KeyAction = enum { - down, - none, - up, -}; - -const CellInspect = union(enum) { - /// Idle, no cell inspection is requested - idle: void, - - /// Requested, a cell is being picked. - requested: void, - - /// The cell has been picked and set to this. This is a copy so that - /// if the cell contents change we still have the original cell. - selected: Selected, - - const Selected = struct { - alloc: Allocator, - row: usize, - col: usize, - cell: inspector.Cell, - }; - - pub fn deinit(self: *CellInspect) void { - switch (self.*) { - .idle, .requested => {}, - .selected => |*v| v.cell.deinit(v.alloc), - } - } - - pub fn request(self: *CellInspect) void { - switch (self.*) { - .idle => self.* = .requested, - .selected => |*v| { - v.cell.deinit(v.alloc); - self.* = .requested; - }, - .requested => {}, - } - } - - pub fn select( - self: *CellInspect, - alloc: Allocator, - pin: terminal.Pin, - x: usize, - y: usize, - ) !void { - assert(self.* == .requested); - const cell = try inspector.Cell.init(alloc, pin); - errdefer cell.deinit(alloc); - self.* = .{ .selected = .{ - .alloc = alloc, - .row = y, - .col = x, - .cell = cell, - } }; - } -}; +// ImGui state +gui: widgets.surface.Inspector, /// Setup the ImGui state. This requires an ImGui context to be set. pub fn setup() void { @@ -158,56 +55,42 @@ pub fn setup() void { } } -pub fn init(surface: *Surface) !Inspector { - var key_buf = try inspector.key.EventRing.init(surface.alloc, 2); - errdefer key_buf.deinit(surface.alloc); - - var vt_events = try inspector.termio.VTEventRing.init(surface.alloc, 2); - errdefer vt_events.deinit(surface.alloc); - - var vt_handler = inspector.termio.VTHandler.init(surface); - errdefer vt_handler.deinit(); - - return .{ - .surface = surface, - .key_events = key_buf, - .vt_events = vt_events, - .vt_stream = .initAlloc(surface.alloc, vt_handler), - }; +pub fn init(alloc: Allocator) !Inspector { + var gui: widgets.surface.Inspector = try .init(alloc); + errdefer gui.deinit(alloc); + return .{ .gui = gui }; } -pub fn deinit(self: *Inspector) void { - self.cell.deinit(); +pub fn deinit(self: *Inspector, alloc: Allocator) void { + self.gui.deinit(alloc); +} - { - var it = self.key_events.iterator(.forward); - while (it.next()) |v| v.deinit(self.surface.alloc); - self.key_events.deinit(self.surface.alloc); - } - - { - var it = self.vt_events.iterator(.forward); - while (it.next()) |v| v.deinit(self.surface.alloc); - self.vt_events.deinit(self.surface.alloc); - - self.vt_stream.deinit(); - } +/// Returns the renderer info panel. This is a convenience function +/// to access and find this state to read and modify. +pub fn rendererInfo(self: *Inspector) *widgets.renderer.Info { + return &self.gui.renderer_info; } /// Record a keyboard event. -pub fn recordKeyEvent(self: *Inspector, ev: inspector.key.Event) !void { +pub fn recordKeyEvent( + self: *Inspector, + alloc: Allocator, + ev: inspector.KeyEvent, +) Allocator.Error!void { const max_capacity = 50; - self.key_events.append(ev) catch |err| switch (err) { - error.OutOfMemory => if (self.key_events.capacity() < max_capacity) { + + const events: *widgets.key.EventRing = &self.gui.key_stream.events; + events.append(ev) catch |err| switch (err) { + error.OutOfMemory => if (events.capacity() < max_capacity) { // We're out of memory, but we can allocate to our capacity. - const new_capacity = @min(self.key_events.capacity() * 2, max_capacity); - try self.key_events.resize(self.surface.alloc, new_capacity); - try self.key_events.append(ev); + const new_capacity = @min(events.capacity() * 2, max_capacity); + try events.resize(alloc, new_capacity); + try events.append(ev); } else { - var it = self.key_events.iterator(.forward); - if (it.next()) |old_ev| old_ev.deinit(self.surface.alloc); - self.key_events.deleteOldest(1); - try self.key_events.append(ev); + var it = events.iterator(.forward); + if (it.next()) |old_ev| old_ev.deinit(alloc); + events.deleteOldest(1); + try events.append(ev); }, else => return err, @@ -215,1078 +98,34 @@ pub fn recordKeyEvent(self: *Inspector, ev: inspector.key.Event) !void { } /// Record data read from the pty. -pub fn recordPtyRead(self: *Inspector, data: []const u8) !void { - try self.vt_stream.nextSlice(data); +pub fn recordPtyRead( + self: *Inspector, + alloc: Allocator, + t: *terminal.Terminal, + data: []const u8, +) !void { + try self.gui.vt_stream.recordPtyRead( + alloc, + t, + data, + ); } /// Render the frame. -pub fn render(self: *Inspector) void { - const dock_id = cimgui.c.ImGui_DockSpaceOverViewport(); - - // Render all of our data. We hold the mutex for this duration. This is - // expensive but this is an initial implementation until it doesn't work - // anymore. - { - self.surface.renderer_state.mutex.lock(); - defer self.surface.renderer_state.mutex.unlock(); - self.renderScreenWindow(); - self.renderModesWindow(); - self.renderKeyboardWindow(); - self.renderTermioWindow(); - self.renderCellWindow(); - self.renderSizeWindow(); - } - - // In debug we show the ImGui demo window so we can easily view available - // widgets and such. - if (builtin.mode == .Debug) { - var show: bool = true; - cimgui.c.ImGui_ShowDemoWindow(&show); - } - - // On first render we set up the layout. We can actually do this at - // the end of the frame, allowing the individual rendering to also - // observe the first render flag. - if (self.first_render) { - self.first_render = false; - self.setupLayout(dock_id); - } -} - -fn setupLayout(self: *Inspector, dock_id_main: cimgui.c.ImGuiID) void { - _ = self; - - // Our initial focus - cimgui.c.ImGui_SetWindowFocusStr(window_screen); - - // Setup our initial layout. - const dock_id: struct { - left: cimgui.c.ImGuiID, - right: cimgui.c.ImGuiID, - } = dock_id: { - var dock_id_left: cimgui.c.ImGuiID = undefined; - var dock_id_right: cimgui.c.ImGuiID = undefined; - _ = cimgui.ImGui_DockBuilderSplitNode( - dock_id_main, - cimgui.c.ImGuiDir_Left, - 0.7, - &dock_id_left, - &dock_id_right, - ); - - break :dock_id .{ - .left = dock_id_left, - .right = dock_id_right, - }; - }; - - cimgui.ImGui_DockBuilderDockWindow(window_cell, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_modes, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_screen, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id.left); - cimgui.ImGui_DockBuilderDockWindow(window_size, dock_id.right); - cimgui.ImGui_DockBuilderFinish(dock_id_main); -} - -fn renderScreenWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_screen, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - const t = self.surface.renderer_state.terminal; - const screen: *terminal.Screen = t.screens.active; - - { - _ = cimgui.c.ImGui_BeginTable( - "table_screen", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Active Screen"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(t.screens.active_key).ptr); - } - } - } - - if (cimgui.c.ImGui_CollapsingHeader( - "Cursor", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - { - _ = cimgui.c.ImGui_BeginTable( - "table_cursor", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - inspector.cursor.renderInTable( - self.surface.renderer_state.terminal, - &screen.cursor, - ); - } // table - - cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); - } // cursor - - if (cimgui.c.ImGui_CollapsingHeader( - "Keyboard", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - { - _ = cimgui.c.ImGui_BeginTable( - "table_keyboard", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - const kitty_flags = screen.kitty_keyboard.current(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Mode"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - const mode = if (kitty_flags.int() != 0) "kitty" else "legacy"; - cimgui.c.ImGui_Text("%s", mode.ptr); - } - } - - if (kitty_flags.int() != 0) { - const Flags = @TypeOf(kitty_flags); - inline for (@typeInfo(Flags).@"struct".fields) |field| { - { - const value = @field(kitty_flags, field.name); - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - const name = std.fmt.comptimePrint("{s}", .{field.name}); - cimgui.c.ImGui_Text("%s", name.ptr); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%s", - if (value) "true".ptr else "false".ptr, - ); - } - } - } - } else { - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Xterm modify keys"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%s", - if (t.flags.modify_other_keys_2) "true".ptr else "false".ptr, - ); - } - } - } // keyboard mode info - } // table - } // keyboard - - if (cimgui.c.ImGui_CollapsingHeader( - "Kitty Graphics", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) kitty_gfx: { - if (!screen.kitty_images.enabled()) { - cimgui.c.ImGui_TextDisabled("(Kitty graphics are disabled)"); - break :kitty_gfx; - } - - { - _ = cimgui.c.ImGui_BeginTable( - "##kitty_graphics", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - const kitty_images = &screen.kitty_images; - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Memory Usage"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_bytes, units.toKibiBytes(kitty_images.total_bytes)); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Memory Limit"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_limit, units.toKibiBytes(kitty_images.total_limit)); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Image Count"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", kitty_images.images.count()); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Placement Count"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", kitty_images.placements.count()); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Image Loading"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", if (kitty_images.loading != null) "true".ptr else "false".ptr); - } - } - } // table - } // kitty graphics - - if (cimgui.c.ImGui_CollapsingHeader( - "Internal Terminal State", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - const pages = &screen.pages; - - { - _ = cimgui.c.ImGui_BeginTable( - "##terminal_state", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Memory Usage"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.page_size, units.toKibiBytes(pages.page_size)); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Memory Limit"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.maxSize(), units.toKibiBytes(pages.maxSize())); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Viewport Location"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); - } - } - } // table - // - if (cimgui.c.ImGui_CollapsingHeader( - "Active Page", - cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, - )) { - inspector.page.render(&pages.pages.last.?.data); - } - } // terminal state -} - -/// The modes window shows the currently active terminal modes and allows -/// users to toggle them on and off. -fn renderModesWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_modes, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - _ = cimgui.c.ImGui_BeginTable( - "table_modes", - 3, - cimgui.c.ImGuiTableFlags_SizingFixedFit | - cimgui.c.ImGuiTableFlags_RowBg, +pub fn render( + self: *Inspector, + surface: *Surface, +) void { + // Draw the UI + self.gui.draw( + surface, + self.mouse, ); - defer cimgui.c.ImGui_EndTable(); - { - cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_NoResize); - cimgui.c.ImGui_TableSetupColumn("Number", cimgui.c.ImGuiTableColumnFlags_PreferSortAscending); - cimgui.c.ImGui_TableSetupColumn("Name", cimgui.c.ImGuiTableColumnFlags_WidthStretch); - cimgui.c.ImGui_TableHeadersRow(); - } - - const t = self.surface.renderer_state.terminal; - inline for (@typeInfo(terminal.Mode).@"enum".fields) |field| { - @setEvalBranchQuota(6000); - const tag: terminal.modes.ModeTag = @bitCast(@as(terminal.modes.ModeTag.Backing, field.value)); - - cimgui.c.ImGui_TableNextRow(); - cimgui.c.ImGui_PushIDInt(@intCast(field.value)); - defer cimgui.c.ImGui_PopID(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - var value: bool = t.modes.get(@field(terminal.Mode, field.name)); - _ = cimgui.c.ImGui_Checkbox("##checkbox", &value); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%s%d", - if (tag.ansi) "" else "?", - @as(u32, @intCast(tag.value)), - ); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - const name = std.fmt.comptimePrint("{s}", .{field.name}); - cimgui.c.ImGui_Text("%s", name.ptr); - } - } -} - -fn renderSizeWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_size, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - cimgui.c.ImGui_SeparatorText("Dimensions"); - - { - _ = cimgui.c.ImGui_BeginTable( - "table_size", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - // Screen Size - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Screen Size"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%dpx x %dpx", - self.surface.size.screen.width, - self.surface.size.screen.height, - ); - } - } - - // Grid Size - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Grid Size"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - const grid_size = self.surface.size.grid(); - cimgui.c.ImGui_Text( - "%dc x %dr", - grid_size.columns, - grid_size.rows, - ); - } - } - - // Cell Size - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Cell Size"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%dpx x %dpx", - self.surface.size.cell.width, - self.surface.size.cell.height, - ); - } - } - - // Padding - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Window Padding"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "T=%d B=%d L=%d R=%d px", - self.surface.size.padding.top, - self.surface.size.padding.bottom, - self.surface.size.padding.left, - self.surface.size.padding.right, - ); - } - } - } - - cimgui.c.ImGui_SeparatorText("Font"); - - { - _ = cimgui.c.ImGui_BeginTable( - "table_font", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Size (Points)"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%.2f pt", - self.surface.font_size.points, - ); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Size (Pixels)"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "%.2f px", - self.surface.font_size.pixels(), - ); - } - } - } - - cimgui.c.ImGui_SeparatorText("Mouse"); - - { - _ = cimgui.c.ImGui_BeginTable( - "table_mouse", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - const mouse = &self.surface.mouse; - const t = self.surface.renderer_state.terminal; - - { - const hover_point: terminal.point.Coordinate = pt: { - const p = self.mouse.last_point orelse break :pt .{}; - const pt = t.screens.active.pages.pointFromPin( - .active, - p, - ) orelse break :pt .{}; - break :pt pt.coord(); - }; - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Hover Grid"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "row=%d, col=%d", - hover_point.y, - hover_point.x, - ); - } - } - - { - const coord: renderer.Coordinate.Terminal = (renderer.Coordinate{ - .surface = .{ - .x = self.mouse.last_xpos, - .y = self.mouse.last_ypos, - }, - }).convert(.terminal, self.surface.size).terminal; - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Hover Point"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "(%dpx, %dpx)", - @as(i64, @intFromFloat(coord.x)), - @as(i64, @intFromFloat(coord.y)), - ); - } - } - - const any_click = for (mouse.click_state) |state| { - if (state == .press) break true; - } else false; - - click: { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Click State"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - if (!any_click) { - cimgui.c.ImGui_Text("none"); - break :click; - } - - for (mouse.click_state, 0..) |state, i| { - if (state != .press) continue; - const button: input.MouseButton = @enumFromInt(i); - cimgui.c.ImGui_SameLine(); - cimgui.c.ImGui_Text("%s", (switch (button) { - .unknown => "?", - .left => "L", - .middle => "M", - .right => "R", - .four => "{4}", - .five => "{5}", - .six => "{6}", - .seven => "{7}", - .eight => "{8}", - .nine => "{9}", - .ten => "{10}", - .eleven => "{11}", - }).ptr); - } - } - } - - { - const left_click_point: terminal.point.Coordinate = pt: { - const p = mouse.left_click_pin orelse break :pt .{}; - const pt = t.screens.active.pages.pointFromPin( - .active, - p.*, - ) orelse break :pt .{}; - break :pt pt.coord(); - }; - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Click Grid"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "row=%d, col=%d", - left_click_point.y, - left_click_point.x, - ); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Click Point"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "(%dpx, %dpx)", - @as(u32, @intFromFloat(mouse.left_click_xpos)), - @as(u32, @intFromFloat(mouse.left_click_ypos)), - ); - } - } - } -} - -fn renderCellWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_cell, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - // Our popup for the picker - const popup_picker = "Cell Picker"; - - if (cimgui.c.ImGui_Button("Picker")) { - // Request a cell - self.cell.request(); - - cimgui.c.ImGui_OpenPopup( - popup_picker, - cimgui.c.ImGuiPopupFlags_None, - ); - } - - if (cimgui.c.ImGui_BeginPopupModal( - popup_picker, - null, - cimgui.c.ImGuiWindowFlags_AlwaysAutoResize, - )) popup: { - defer cimgui.c.ImGui_EndPopup(); - - // Once we select a cell, close this popup. - if (self.cell == .selected) { - cimgui.c.ImGui_CloseCurrentPopup(); - break :popup; - } - - cimgui.c.ImGui_Text( - "Click on a cell in the terminal to inspect it.\n" ++ - "The click will be intercepted by the picker, \n" ++ - "so it won't be sent to the terminal.", - ); - cimgui.c.ImGui_Separator(); - - if (cimgui.c.ImGui_Button("Cancel")) { - cimgui.c.ImGui_CloseCurrentPopup(); - } - } // cell pick popup - - cimgui.c.ImGui_Separator(); - - if (self.cell != .selected) { - cimgui.c.ImGui_Text("No cell selected."); - return; - } - - const selected = self.cell.selected; - selected.cell.renderTable( - self.surface.renderer_state.terminal, - selected.col, - selected.row, - ); -} - -fn renderKeyboardWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_keyboard, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - list: { - if (self.key_events.empty()) { - cimgui.c.ImGui_Text("No recorded key events. Press a key with the " ++ - "terminal focused to record it."); - break :list; - } - - if (cimgui.c.ImGui_Button("Clear")) { - var it = self.key_events.iterator(.forward); - while (it.next()) |v| v.deinit(self.surface.alloc); - self.key_events.clear(); - self.vt_stream.handler.current_seq = 1; - } - - cimgui.c.ImGui_Separator(); - - _ = cimgui.c.ImGui_BeginTable( - "table_key_events", - 1, - //cimgui.c.ImGuiTableFlags_ScrollY | - cimgui.c.ImGuiTableFlags_RowBg | - cimgui.c.ImGuiTableFlags_Borders, - ); - defer cimgui.c.ImGui_EndTable(); - - var it = self.key_events.iterator(.reverse); - while (it.next()) |ev| { - // Need to push an ID so that our selectable is unique. - cimgui.c.ImGui_PushIDPtr(ev); - defer cimgui.c.ImGui_PopID(); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - - var buf: [1024]u8 = undefined; - const label = ev.label(&buf) catch "Key Event"; - _ = cimgui.c.ImGui_SelectableBoolPtr( - label.ptr, - &ev.imgui_state.selected, - cimgui.c.ImGuiSelectableFlags_None, - ); - - if (!ev.imgui_state.selected) continue; - ev.render(); - } - } // table -} - -/// Helper function to check keyboard state and determine navigation action. -fn getKeyAction(self: *Inspector) KeyAction { - _ = self; - const keys = .{ - .{ .key = cimgui.c.ImGuiKey_J, .action = KeyAction.down }, - .{ .key = cimgui.c.ImGuiKey_DownArrow, .action = KeyAction.down }, - .{ .key = cimgui.c.ImGuiKey_K, .action = KeyAction.up }, - .{ .key = cimgui.c.ImGuiKey_UpArrow, .action = KeyAction.up }, - }; - - inline for (keys) |k| { - if (cimgui.c.ImGui_IsKeyPressed(k.key)) { - return k.action; - } - } - return .none; -} - -fn renderTermioWindow(self: *Inspector) void { - // Start our window. If we're collapsed we do nothing. - defer cimgui.c.ImGui_End(); - if (!cimgui.c.ImGui_Begin( - window_termio, - null, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) return; - - const popup_filter = "Filter"; - - list: { - const pause_play: [:0]const u8 = if (self.vt_stream.handler.active) - "Pause##pause_play" - else - "Resume##pause_play"; - if (cimgui.c.ImGui_Button(pause_play.ptr)) { - self.vt_stream.handler.active = !self.vt_stream.handler.active; - } - - cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); - if (cimgui.c.ImGui_Button("Filter")) { - cimgui.c.ImGui_OpenPopup( - popup_filter, - cimgui.c.ImGuiPopupFlags_None, - ); - } - - if (!self.vt_events.empty()) { - cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); - if (cimgui.c.ImGui_Button("Clear")) { - var it = self.vt_events.iterator(.forward); - while (it.next()) |v| v.deinit(self.surface.alloc); - self.vt_events.clear(); - - // We also reset the sequence number. - self.vt_stream.handler.current_seq = 1; - } - } - - cimgui.c.ImGui_Separator(); - - if (self.vt_events.empty()) { - cimgui.c.ImGui_Text("Waiting for events..."); - break :list; - } - - _ = cimgui.c.ImGui_BeginTable( - "table_vt_events", - 3, - cimgui.c.ImGuiTableFlags_RowBg | - cimgui.c.ImGuiTableFlags_Borders, - ); - defer cimgui.c.ImGui_EndTable(); - - cimgui.c.ImGui_TableSetupColumn( - "Seq", - cimgui.c.ImGuiTableColumnFlags_WidthFixed, - ); - cimgui.c.ImGui_TableSetupColumn( - "Kind", - cimgui.c.ImGuiTableColumnFlags_WidthFixed, - ); - cimgui.c.ImGui_TableSetupColumn( - "Description", - cimgui.c.ImGuiTableColumnFlags_WidthStretch, - ); - - // Handle keyboard navigation when window is focused - if (cimgui.c.ImGui_IsWindowFocused(cimgui.c.ImGuiFocusedFlags_RootAndChildWindows)) { - const key_pressed = self.getKeyAction(); - - switch (key_pressed) { - .none => {}, - .up, .down => { - // If no event is selected, select the first/last event based on direction - if (self.selected_event_seq == null) { - if (!self.vt_events.empty()) { - var it = self.vt_events.iterator(if (key_pressed == .up) .forward else .reverse); - if (it.next()) |ev| { - self.selected_event_seq = @as(u32, @intCast(ev.seq)); - } - } - } else { - // Find next/previous event based on current selection - var it = self.vt_events.iterator(.reverse); - switch (key_pressed) { - .down => { - var found = false; - while (it.next()) |ev| { - if (found) { - self.selected_event_seq = @as(u32, @intCast(ev.seq)); - break; - } - if (ev.seq == self.selected_event_seq.?) { - found = true; - } - } - }, - .up => { - var prev_ev: ?*const inspector.termio.VTEvent = null; - while (it.next()) |ev| { - if (ev.seq == self.selected_event_seq.?) { - if (prev_ev) |prev| { - self.selected_event_seq = @as(u32, @intCast(prev.seq)); - break; - } - } - prev_ev = ev; - } - }, - .none => unreachable, - } - } - - // Mark that we need to scroll to the newly selected item - self.need_scroll_to_selected = true; - self.is_keyboard_selection = true; - }, - } - } - - var it = self.vt_events.iterator(.reverse); - while (it.next()) |ev| { - // Need to push an ID so that our selectable is unique. - cimgui.c.ImGui_PushIDPtr(ev); - defer cimgui.c.ImGui_PopID(); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableNextColumn(); - - // Store the previous selection state to detect changes - const was_selected = ev.imgui_selected; - - // Update selection state based on keyboard navigation - if (self.selected_event_seq) |seq| { - ev.imgui_selected = (@as(u32, @intCast(ev.seq)) == seq); - } - - // Handle selectable widget - if (cimgui.c.ImGui_SelectableBoolPtr( - "##select", - &ev.imgui_selected, - cimgui.c.ImGuiSelectableFlags_SpanAllColumns, - )) { - // If selection state changed, update keyboard navigation state - if (ev.imgui_selected != was_selected) { - self.selected_event_seq = if (ev.imgui_selected) - @as(u32, @intCast(ev.seq)) - else - null; - self.is_keyboard_selection = false; - } - } - - cimgui.c.ImGui_SameLine(); - cimgui.c.ImGui_Text("%d", ev.seq); - _ = cimgui.c.ImGui_TableNextColumn(); - cimgui.c.ImGui_Text("%s", @tagName(ev.kind).ptr); - _ = cimgui.c.ImGui_TableNextColumn(); - cimgui.c.ImGui_Text("%s", ev.str.ptr); - - // If the event is selected, we render info about it. For now - // we put this in the last column because that's the widest and - // imgui has no way to make a column span. - if (ev.imgui_selected) { - { - _ = cimgui.c.ImGui_BeginTable( - "details", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - inspector.cursor.renderInTable( - self.surface.renderer_state.terminal, - &ev.cursor, - ); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Scroll Region"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text( - "T=%d B=%d L=%d R=%d", - ev.scrolling_region.top, - ev.scrolling_region.bottom, - ev.scrolling_region.left, - ev.scrolling_region.right, - ); - } - } - - var md_it = ev.metadata.iterator(); - while (md_it.next()) |entry| { - var buf: [256]u8 = undefined; - const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch - ""; - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableNextColumn(); - cimgui.c.ImGui_Text("%s", key.ptr); - _ = cimgui.c.ImGui_TableNextColumn(); - cimgui.c.ImGui_Text("%s", entry.value_ptr.ptr); - } - } - - // If this is the selected event and scrolling is needed, scroll to it - if (self.need_scroll_to_selected and self.is_keyboard_selection) { - cimgui.c.ImGui_SetScrollHereY(0.5); - self.need_scroll_to_selected = false; - } - } - } - } // table - - if (cimgui.c.ImGui_BeginPopupModal( - popup_filter, - null, - cimgui.c.ImGuiWindowFlags_AlwaysAutoResize, - )) { - defer cimgui.c.ImGui_EndPopup(); - - cimgui.c.ImGui_Text("Changed filter settings will only affect future events."); - - cimgui.c.ImGui_Separator(); - - { - _ = cimgui.c.ImGui_BeginTable( - "table_filter_kind", - 3, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - inline for (@typeInfo(terminal.Parser.Action.Tag).@"enum".fields) |field| { - const tag = @field(terminal.Parser.Action.Tag, field.name); - if (tag == .apc_put or tag == .dcs_put) continue; - - _ = cimgui.c.ImGui_TableNextColumn(); - var value = !self.vt_stream.handler.filter_exclude.contains(tag); - if (cimgui.c.ImGui_Checkbox(@tagName(tag).ptr, &value)) { - if (value) { - self.vt_stream.handler.filter_exclude.remove(tag); - } else { - self.vt_stream.handler.filter_exclude.insert(tag); - } - } - } - } // Filter kind table - - cimgui.c.ImGui_Separator(); - - cimgui.c.ImGui_Text( - "Filter by string. Empty displays all, \"abc\" finds lines\n" ++ - "containing \"abc\", \"abc,xyz\" finds lines containing \"abc\"\n" ++ - "or \"xyz\", \"-abc\" excludes lines containing \"abc\".", - ); - _ = cimgui.c.ImGuiTextFilter_Draw( - &self.vt_stream.handler.filter_text, - "##filter_text", - 0, - ); - - cimgui.c.ImGui_Separator(); - if (cimgui.c.ImGui_Button("Close")) { - cimgui.c.ImGui_CloseCurrentPopup(); - } - } // filter popup + // We always trigger a rebuild of the surface when the inspector + // is focused because modifying the inspector can change the terminal + // state. This is KIND OF expensive (wasted CPU if nothing was done) + // but the inspector is a development tool and it expressly costs + // more resources while open so its okay. + surface.renderer_thread.wakeup.notify() catch {}; } diff --git a/src/inspector/cell.zig b/src/inspector/cell.zig deleted file mode 100644 index 540e044fd..000000000 --- a/src/inspector/cell.zig +++ /dev/null @@ -1,223 +0,0 @@ -const std = @import("std"); -const assert = @import("../quirks.zig").inlineAssert; -const Allocator = std.mem.Allocator; -const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); - -/// A cell being inspected. This duplicates much of the data in -/// the terminal data structure because we want the inspector to -/// not have a reference to the terminal state or to grab any -/// locks. -pub const Cell = struct { - /// The main codepoint for this cell. - codepoint: u21, - - /// Codepoints for this cell to produce a single grapheme cluster. - /// This is only non-empty if the cell is part of a multi-codepoint - /// grapheme cluster. This does NOT include the primary codepoint. - cps: []const u21, - - /// The style of this cell. - style: terminal.Style, - - /// Wide state of the terminal cell - wide: terminal.Cell.Wide, - - pub fn init( - alloc: Allocator, - pin: terminal.Pin, - ) !Cell { - const cell = pin.rowAndCell().cell; - const style = pin.style(cell); - const cps: []const u21 = if (cell.hasGrapheme()) cps: { - const src = pin.grapheme(cell).?; - assert(src.len > 0); - break :cps try alloc.dupe(u21, src); - } else &.{}; - errdefer if (cps.len > 0) alloc.free(cps); - - return .{ - .codepoint = cell.codepoint(), - .cps = cps, - .style = style, - .wide = cell.wide, - }; - } - - pub fn deinit(self: *Cell, alloc: Allocator) void { - if (self.cps.len > 0) alloc.free(self.cps); - } - - pub fn renderTable( - self: *const Cell, - t: *const terminal.Terminal, - x: usize, - y: usize, - ) void { - // We have a selected cell, show information about it. - _ = cimgui.c.ImGui_BeginTable( - "table_cursor", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Grid Position"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("row=%d col=%d", y, x); - } - } - - // NOTE: we don't currently write the character itself because - // we haven't hooked up imgui to our font system. That's hard! We - // can/should instead hook up our renderer to imgui and just render - // the single glyph in an image view so it looks _identical_ to the - // terminal. - codepoint: { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Codepoints"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - if (cimgui.c.ImGui_BeginListBox("##codepoints", .{ .x = 0, .y = 0 })) { - defer cimgui.c.ImGui_EndListBox(); - - if (self.codepoint == 0) { - _ = cimgui.c.ImGui_SelectableEx("(empty)", false, 0, .{}); - break :codepoint; - } - - // Primary codepoint - var buf: [256]u8 = undefined; - { - const key = std.fmt.bufPrintZ(&buf, "U+{X}", .{self.codepoint}) catch - ""; - _ = cimgui.c.ImGui_SelectableEx(key.ptr, false, 0, .{}); - } - - // All extras - for (self.cps) |cp| { - const key = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch - ""; - _ = cimgui.c.ImGui_SelectableEx(key.ptr, false, 0, .{}); - } - } - } - } - - // Character width property - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Width Property"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text(@tagName(self.wide)); - - // If we have a color then we show the color - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Foreground Color"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (self.style.fg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = t.colors.palette.current[idx]; - cimgui.c.ImGui_Text("Palette %d", idx); - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_fg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - - .rgb => |rgb| { - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_fg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Background Color"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (self.style.bg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = t.colors.palette.current[idx]; - cimgui.c.ImGui_Text("Palette %d", idx); - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_bg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - - .rgb => |rgb| { - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_bg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - // Boolean styles - const styles = .{ - "bold", "italic", "faint", "blink", - "inverse", "invisible", "strikethrough", - }; - inline for (styles) |style| style: { - if (!@field(self.style.flags, style)) break :style; - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text(style.ptr); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("true"); - } - } - - cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); - } -}; diff --git a/src/inspector/cursor.zig b/src/inspector/cursor.zig deleted file mode 100644 index 4f8bfb2e0..000000000 --- a/src/inspector/cursor.zig +++ /dev/null @@ -1,142 +0,0 @@ -const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); - -/// Render cursor information with a table already open. -pub fn renderInTable( - t: *const terminal.Terminal, - cursor: *const terminal.Screen.Cursor, -) void { - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Position (x, y)"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("(%d, %d)", cursor.x, cursor.y); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Style"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(cursor.cursor_style).ptr); - } - } - - if (cursor.pending_wrap) { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Pending Wrap"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", if (cursor.pending_wrap) "true".ptr else "false".ptr); - } - } - - // If we have a color then we show the color - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Foreground Color"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (cursor.style.fg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = t.colors.palette.current[idx]; - cimgui.c.ImGui_Text("Palette %d", idx); - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_fg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - - .rgb => |rgb| { - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_fg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Background Color"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - switch (cursor.style.bg_color) { - .none => cimgui.c.ImGui_Text("default"), - .palette => |idx| { - const rgb = t.colors.palette.current[idx]; - cimgui.c.ImGui_Text("Palette %d", idx); - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_bg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - - .rgb => |rgb| { - var color: [3]f32 = .{ - @as(f32, @floatFromInt(rgb.r)) / 255, - @as(f32, @floatFromInt(rgb.g)) / 255, - @as(f32, @floatFromInt(rgb.b)) / 255, - }; - _ = cimgui.c.ImGui_ColorEdit3( - "color_bg", - &color, - cimgui.c.ImGuiColorEditFlags_DisplayHex | - cimgui.c.ImGuiColorEditFlags_NoPicker | - cimgui.c.ImGuiColorEditFlags_NoLabel, - ); - }, - } - - // Boolean styles - const styles = .{ - "bold", "italic", "faint", "blink", - "inverse", "invisible", "strikethrough", - }; - inline for (styles) |style| style: { - if (!@field(cursor.style.flags, style)) break :style; - - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text(style.ptr); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("true"); - } - } -} diff --git a/src/inspector/key.zig b/src/inspector/key.zig deleted file mode 100644 index 12d91a107..000000000 --- a/src/inspector/key.zig +++ /dev/null @@ -1,240 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const input = @import("../input.zig"); -const CircBuf = @import("../datastruct/main.zig").CircBuf; -const cimgui = @import("dcimgui"); - -/// Circular buffer of key events. -pub const EventRing = CircBuf(Event, undefined); - -/// Represents a recorded keyboard event. -pub const Event = struct { - /// The input event. - event: input.KeyEvent, - - /// The binding that was triggered as a result of this event. - /// Multiple bindings are possible if they are chained. - binding: []const input.Binding.Action = &.{}, - - /// The data sent to the pty as a result of this keyboard event. - /// This is allocated using the inspector allocator. - pty: []const u8 = "", - - /// State for the inspector GUI. Do not set this unless you're the inspector. - imgui_state: struct { - selected: bool = false, - } = .{}, - - pub fn init(alloc: Allocator, event: input.KeyEvent) !Event { - var copy = event; - copy.utf8 = ""; - if (event.utf8.len > 0) copy.utf8 = try alloc.dupe(u8, event.utf8); - return .{ .event = copy }; - } - - pub fn deinit(self: *const Event, alloc: Allocator) void { - alloc.free(self.binding); - if (self.event.utf8.len > 0) alloc.free(self.event.utf8); - if (self.pty.len > 0) alloc.free(self.pty); - } - - /// Returns a label that can be used for this event. This is null-terminated - /// so it can be easily used with C APIs. - pub fn label(self: *const Event, buf: []u8) ![:0]const u8 { - var buf_stream = std.io.fixedBufferStream(buf); - const writer = buf_stream.writer(); - - switch (self.event.action) { - .press => try writer.writeAll("Press: "), - .release => try writer.writeAll("Release: "), - .repeat => try writer.writeAll("Repeat: "), - } - - if (self.event.mods.shift) try writer.writeAll("Shift+"); - if (self.event.mods.ctrl) try writer.writeAll("Ctrl+"); - if (self.event.mods.alt) try writer.writeAll("Alt+"); - if (self.event.mods.super) try writer.writeAll("Super+"); - - // Write our key. If we have an invalid key we attempt to write - // the utf8 associated with it if we have it to handle non-ascii. - try writer.writeAll(switch (self.event.key) { - .unidentified => if (self.event.utf8.len > 0) self.event.utf8 else @tagName(self.event.key), - else => @tagName(self.event.key), - }); - - // Deadkey - if (self.event.composing) try writer.writeAll(" (composing)"); - - // Null-terminator - try writer.writeByte(0); - return buf[0..(buf_stream.getWritten().len - 1) :0]; - } - - /// Render this event in the inspector GUI. - pub fn render(self: *const Event) void { - _ = cimgui.c.ImGui_BeginTable( - "##event", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - if (self.binding.len > 0) { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Triggered Binding"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - - const height: f32 = height: { - const item_count: f32 = @floatFromInt(@min(self.binding.len, 5)); - const padding = cimgui.c.ImGui_GetStyle().*.FramePadding.y * 2; - break :height cimgui.c.ImGui_GetTextLineHeightWithSpacing() * item_count + padding; - }; - if (cimgui.c.ImGui_BeginListBox("##bindings", .{ .x = 0, .y = height })) { - defer cimgui.c.ImGui_EndListBox(); - for (self.binding) |action| { - _ = cimgui.c.ImGui_SelectableEx( - @tagName(action).ptr, - false, - cimgui.c.ImGuiSelectableFlags_None, - .{ .x = 0, .y = 0 }, - ); - } - } - } - - pty: { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Encoding to Pty"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - if (self.pty.len == 0) { - cimgui.c.ImGui_TextDisabled("(no data)"); - break :pty; - } - - self.renderPty() catch { - cimgui.c.ImGui_TextDisabled("(error rendering pty data)"); - break :pty; - }; - } - - { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Action"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(self.event.action).ptr); - } - { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Key"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%s", @tagName(self.event.key).ptr); - } - if (!self.event.mods.empty()) { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Mods"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - if (self.event.mods.shift) cimgui.c.ImGui_Text("shift "); - if (self.event.mods.ctrl) cimgui.c.ImGui_Text("ctrl "); - if (self.event.mods.alt) cimgui.c.ImGui_Text("alt "); - if (self.event.mods.super) cimgui.c.ImGui_Text("super "); - } - if (self.event.composing) { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Composing"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("true"); - } - utf8: { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("UTF-8"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - if (self.event.utf8.len == 0) { - cimgui.c.ImGui_TextDisabled("(empty)"); - break :utf8; - } - - self.renderUtf8(self.event.utf8) catch { - cimgui.c.ImGui_TextDisabled("(error rendering utf-8)"); - break :utf8; - }; - } - } - - fn renderUtf8(self: *const Event, utf8: []const u8) !void { - _ = self; - - // Format the codepoint sequence - var buf: [1024]u8 = undefined; - var buf_stream = std.io.fixedBufferStream(&buf); - const writer = buf_stream.writer(); - if (std.unicode.Utf8View.init(utf8)) |view| { - var it = view.iterator(); - while (it.nextCodepoint()) |cp| { - try writer.print("U+{X} ", .{cp}); - } - } else |_| { - try writer.writeAll("(invalid utf-8)"); - } - try writer.writeByte(0); - - // Render as a textbox - _ = cimgui.c.ImGui_InputText( - "##utf8", - &buf, - buf_stream.getWritten().len - 1, - cimgui.c.ImGuiInputTextFlags_ReadOnly, - ); - } - - fn renderPty(self: *const Event) !void { - // Format the codepoint sequence - var buf: [1024]u8 = undefined; - var buf_stream = std.io.fixedBufferStream(&buf); - const writer = buf_stream.writer(); - - for (self.pty) |byte| { - // Print ESC special because its so common - if (byte == 0x1B) { - try writer.writeAll("ESC "); - continue; - } - - // Print ASCII as-is - if (byte > 0x20 and byte < 0x7F) { - try writer.writeByte(byte); - continue; - } - - // Everything else as a hex byte - try writer.print("0x{X} ", .{byte}); - } - - try writer.writeByte(0); - - // Render as a textbox - _ = cimgui.c.ImGui_InputText( - "##pty", - &buf, - buf_stream.getWritten().len - 1, - cimgui.c.ImGuiInputTextFlags_ReadOnly, - ); - } -}; - -test "event string" { - const testing = std.testing; - const alloc = testing.allocator; - - var event = try Event.init(alloc, .{ .key = .key_a }); - defer event.deinit(alloc); - - var buf: [1024]u8 = undefined; - try testing.expectEqualStrings("Press: key_a", try event.label(&buf)); -} diff --git a/src/inspector/main.zig b/src/inspector/main.zig index ee871f200..ae185f479 100644 --- a/src/inspector/main.zig +++ b/src/inspector/main.zig @@ -1,13 +1,8 @@ -const std = @import("std"); -pub const cell = @import("cell.zig"); -pub const cursor = @import("cursor.zig"); -pub const key = @import("key.zig"); -pub const page = @import("page.zig"); -pub const termio = @import("termio.zig"); - -pub const Cell = cell.Cell; +pub const widgets = @import("widgets.zig"); pub const Inspector = @import("Inspector.zig"); +pub const KeyEvent = widgets.key.Event; + test { @import("std").testing.refAllDecls(@This()); } diff --git a/src/inspector/page.zig b/src/inspector/page.zig deleted file mode 100644 index fd9d3bfb4..000000000 --- a/src/inspector/page.zig +++ /dev/null @@ -1,163 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); -const units = @import("units.zig"); - -pub fn render(page: *const terminal.Page) void { - cimgui.c.ImGui_PushIDPtr(page); - defer cimgui.c.ImGui_PopID(); - - _ = cimgui.c.ImGui_BeginTable( - "##page_state", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Memory Size"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d bytes (%d KiB)", page.memory.len, units.toKibiBytes(page.memory.len)); - cimgui.c.ImGui_Text("%d VM pages", page.memory.len / std.heap.page_size_min); - } - } - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Unique Styles"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", page.styles.count()); - } - } - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Grapheme Entries"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", page.graphemeCount()); - } - } - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Capacity"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - _ = cimgui.c.ImGui_BeginTable( - "##capacity", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - const cap = page.capacity; - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Columns"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.cols))); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Rows"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.rows))); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Unique Styles"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", @as(u32, @intCast(cap.styles))); - } - } - - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Grapheme Bytes"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", cap.grapheme_bytes); - } - } - } - } - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Size"); - } - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - _ = cimgui.c.ImGui_BeginTable( - "##size", - 2, - cimgui.c.ImGuiTableFlags_None, - ); - defer cimgui.c.ImGui_EndTable(); - - const size = page.size; - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Columns"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", @as(u32, @intCast(size.cols))); - } - } - { - cimgui.c.ImGui_TableNextRow(); - { - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Rows"); - } - - { - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - cimgui.c.ImGui_Text("%d", @as(u32, @intCast(size.rows))); - } - } - } - } // size table -} diff --git a/src/inspector/termio.zig b/src/inspector/termio.zig deleted file mode 100644 index 934bb6e2d..000000000 --- a/src/inspector/termio.zig +++ /dev/null @@ -1,398 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; -const cimgui = @import("dcimgui"); -const terminal = @import("../terminal/main.zig"); -const CircBuf = @import("../datastruct/main.zig").CircBuf; -const Surface = @import("../Surface.zig"); - -/// The stream handler for our inspector. -pub const Stream = terminal.Stream(VTHandler); - -/// VT event circular buffer. -pub const VTEventRing = CircBuf(VTEvent, undefined); - -/// VT event -pub const VTEvent = struct { - /// Sequence number, just monotonically increasing. - seq: usize = 1, - - /// Kind of event, for filtering - kind: Kind, - - /// The formatted string of the event. This is allocated. We format the - /// event for now because there is so much data to copy if we wanted to - /// store the raw event. - str: [:0]const u8, - - /// Various metadata at the time of the event (before processing). - cursor: terminal.Screen.Cursor, - scrolling_region: terminal.Terminal.ScrollingRegion, - metadata: Metadata.Unmanaged = .{}, - - /// imgui selection state - imgui_selected: bool = false, - - const Kind = enum { print, execute, csi, esc, osc, dcs, apc }; - const Metadata = std.StringHashMap([:0]const u8); - - /// Initialize the event information for the given parser action. - pub fn init( - alloc: Allocator, - surface: *Surface, - action: terminal.Parser.Action, - ) !VTEvent { - var md = Metadata.init(alloc); - errdefer md.deinit(); - var buf: std.Io.Writer.Allocating = .init(alloc); - defer buf.deinit(); - try encodeAction(alloc, &buf.writer, &md, action); - const str = try buf.toOwnedSliceSentinel(0); - errdefer alloc.free(str); - - const kind: Kind = switch (action) { - .print => .print, - .execute => .execute, - .csi_dispatch => .csi, - .esc_dispatch => .esc, - .osc_dispatch => .osc, - .dcs_hook, .dcs_put, .dcs_unhook => .dcs, - .apc_start, .apc_put, .apc_end => .apc, - }; - - const t = surface.renderer_state.terminal; - - return .{ - .kind = kind, - .str = str, - .cursor = t.screens.active.cursor, - .scrolling_region = t.scrolling_region, - .metadata = md.unmanaged, - }; - } - - pub fn deinit(self: *VTEvent, alloc: Allocator) void { - { - var it = self.metadata.valueIterator(); - while (it.next()) |v| alloc.free(v.*); - self.metadata.deinit(alloc); - } - - alloc.free(self.str); - } - - /// Returns true if the event passes the given filter. - pub fn passFilter( - self: *const VTEvent, - filter: *const cimgui.c.ImGuiTextFilter, - ) bool { - // Check our main string - if (cimgui.c.ImGuiTextFilter_PassFilter( - filter, - self.str.ptr, - null, - )) return true; - - // We also check all metadata keys and values - var it = self.metadata.iterator(); - while (it.next()) |entry| { - var buf: [256]u8 = undefined; - const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch continue; - if (cimgui.c.ImGuiTextFilter_PassFilter( - filter, - key.ptr, - null, - )) return true; - if (cimgui.c.ImGuiTextFilter_PassFilter( - filter, - entry.value_ptr.ptr, - null, - )) return true; - } - - return false; - } - - /// Encode a parser action as a string that we show in the logs. - fn encodeAction( - alloc: Allocator, - writer: *std.Io.Writer, - md: *Metadata, - action: terminal.Parser.Action, - ) !void { - switch (action) { - .print => try encodePrint(writer, action), - .execute => try encodeExecute(writer, action), - .csi_dispatch => |v| try encodeCSI(writer, v), - .esc_dispatch => |v| try encodeEsc(writer, v), - .osc_dispatch => |v| try encodeOSC(alloc, writer, md, v), - else => try writer.print("{f}", .{action}), - } - } - - fn encodePrint(writer: *std.Io.Writer, action: terminal.Parser.Action) !void { - const ch = action.print; - try writer.print("'{u}' (U+{X})", .{ ch, ch }); - } - - fn encodeExecute(writer: *std.Io.Writer, action: terminal.Parser.Action) !void { - const ch = action.execute; - switch (ch) { - 0x00 => try writer.writeAll("NUL"), - 0x01 => try writer.writeAll("SOH"), - 0x02 => try writer.writeAll("STX"), - 0x03 => try writer.writeAll("ETX"), - 0x04 => try writer.writeAll("EOT"), - 0x05 => try writer.writeAll("ENQ"), - 0x06 => try writer.writeAll("ACK"), - 0x07 => try writer.writeAll("BEL"), - 0x08 => try writer.writeAll("BS"), - 0x09 => try writer.writeAll("HT"), - 0x0A => try writer.writeAll("LF"), - 0x0B => try writer.writeAll("VT"), - 0x0C => try writer.writeAll("FF"), - 0x0D => try writer.writeAll("CR"), - 0x0E => try writer.writeAll("SO"), - 0x0F => try writer.writeAll("SI"), - else => try writer.writeAll("?"), - } - try writer.print(" (0x{X})", .{ch}); - } - - fn encodeCSI(writer: *std.Io.Writer, csi: terminal.Parser.Action.CSI) !void { - for (csi.intermediates) |v| try writer.print("{c} ", .{v}); - for (csi.params, 0..) |v, i| { - if (i != 0) try writer.writeByte(';'); - try writer.print("{d}", .{v}); - } - if (csi.intermediates.len > 0 or csi.params.len > 0) try writer.writeByte(' '); - try writer.writeByte(csi.final); - } - - fn encodeEsc(writer: *std.Io.Writer, esc: terminal.Parser.Action.ESC) !void { - for (esc.intermediates) |v| try writer.print("{c} ", .{v}); - try writer.writeByte(esc.final); - } - - fn encodeOSC( - alloc: Allocator, - writer: *std.Io.Writer, - md: *Metadata, - osc: terminal.osc.Command, - ) !void { - // The description is just the tag - try writer.print("{s} ", .{@tagName(osc)}); - - // Add additional fields to metadata - switch (osc) { - inline else => |v, tag| if (tag == osc) { - try encodeMetadata(alloc, md, v); - }, - } - } - - fn encodeMetadata( - alloc: Allocator, - md: *Metadata, - v: anytype, - ) !void { - switch (@TypeOf(v)) { - void => {}, - []const u8, - [:0]const u8, - => try md.put("data", try alloc.dupeZ(u8, v)), - else => |T| switch (@typeInfo(T)) { - .@"struct" => |info| inline for (info.fields) |field| { - try encodeMetadataSingle( - alloc, - md, - field.name, - @field(v, field.name), - ); - }, - - .@"union" => |info| { - const Tag = info.tag_type orelse @compileError("Unions must have a tag"); - const tag_name = @tagName(@as(Tag, v)); - inline for (info.fields) |field| { - if (std.mem.eql(u8, field.name, tag_name)) { - if (field.type == void) { - break try md.put("data", tag_name); - } else { - break try encodeMetadataSingle(alloc, md, tag_name, @field(v, field.name)); - } - } - } - }, - - else => { - @compileLog(T); - @compileError("unsupported type, see log"); - }, - }, - } - } - - fn encodeMetadataSingle( - alloc: Allocator, - md: *Metadata, - key: []const u8, - value: anytype, - ) !void { - const Value = @TypeOf(value); - const info = @typeInfo(Value); - switch (info) { - .optional => if (value) |unwrapped| { - try encodeMetadataSingle(alloc, md, key, unwrapped); - } else { - try md.put(key, try alloc.dupeZ(u8, "(unset)")); - }, - - .bool => try md.put( - key, - try alloc.dupeZ(u8, if (value) "true" else "false"), - ), - - .@"enum" => try md.put( - key, - try alloc.dupeZ(u8, @tagName(value)), - ), - - .@"union" => |u| { - const Tag = u.tag_type orelse @compileError("Unions must have a tag"); - const tag_name = @tagName(@as(Tag, value)); - inline for (u.fields) |field| { - if (std.mem.eql(u8, field.name, tag_name)) { - const s = if (field.type == void) - try alloc.dupeZ(u8, tag_name) - else if (field.type == [:0]const u8 or field.type == []const u8) - try std.fmt.allocPrintSentinel(alloc, "{s}={s}", .{ - tag_name, - @field(value, field.name), - }, 0) - else - try std.fmt.allocPrintSentinel(alloc, "{s}={}", .{ - tag_name, - @field(value, field.name), - }, 0); - - try md.put(key, s); - } - } - }, - - .@"struct" => try md.put( - key, - try alloc.dupeZ(u8, @typeName(Value)), - ), - - else => switch (Value) { - []const u8, - [:0]const u8, - => try md.put(key, try alloc.dupeZ(u8, value)), - - else => |T| switch (@typeInfo(T)) { - .int => try md.put( - key, - try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0), - ), - else => { - @compileLog(T); - @compileError("unsupported type, see log"); - }, - }, - }, - } - } -}; - -/// Our VT stream handler. -pub const VTHandler = struct { - /// The surface that the inspector is attached to. We use this instead - /// of the inspector because this is pointer-stable. - surface: *Surface, - - /// True if the handler is currently recording. - active: bool = true, - - /// Current sequence number - current_seq: usize = 1, - - /// Exclude certain actions by tag. - filter_exclude: ActionTagSet = .initMany(&.{.print}), - filter_text: cimgui.c.ImGuiTextFilter = .{}, - - const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag); - - pub fn init(surface: *Surface) VTHandler { - return .{ - .surface = surface, - }; - } - - pub fn deinit(self: *VTHandler) void { - _ = self; - } - - pub fn vt( - self: *VTHandler, - comptime action: Stream.Action.Tag, - value: Stream.Action.Value(action), - ) !void { - _ = self; - _ = value; - } - - /// This is called with every single terminal action. - pub fn handleManually(self: *VTHandler, action: terminal.Parser.Action) !bool { - const insp = self.surface.inspector orelse return false; - - // We always increment the sequence number, even if we're paused or - // filter out the event. This helps show the user that there is a gap - // between events and roughly how large that gap was. - defer self.current_seq +%= 1; - - // If we're pausing, then we ignore all events. - if (!self.active) return true; - - // We ignore certain action types that are too noisy. - switch (action) { - .dcs_put, .apc_put => return true, - else => {}, - } - - // If we requested a specific type to be ignored, ignore it. - // We return true because we did "handle" it by ignoring it. - if (self.filter_exclude.contains(std.meta.activeTag(action))) return true; - - // Build our event - const alloc = self.surface.alloc; - var ev = try VTEvent.init(alloc, self.surface, action); - ev.seq = self.current_seq; - errdefer ev.deinit(alloc); - - // Check if the event passes the filter - if (!ev.passFilter(&self.filter_text)) { - ev.deinit(alloc); - return true; - } - - const max_capacity = 100; - insp.vt_events.append(ev) catch |err| switch (err) { - error.OutOfMemory => if (insp.vt_events.capacity() < max_capacity) { - // We're out of memory, but we can allocate to our capacity. - const new_capacity = @min(insp.vt_events.capacity() * 2, max_capacity); - try insp.vt_events.resize(insp.surface.alloc, new_capacity); - try insp.vt_events.append(ev); - } else { - var it = insp.vt_events.iterator(.forward); - if (it.next()) |old_ev| old_ev.deinit(insp.surface.alloc); - insp.vt_events.deleteOldest(1); - try insp.vt_events.append(ev); - }, - - else => return err, - }; - - return true; - } -}; diff --git a/src/inspector/widgets.zig b/src/inspector/widgets.zig new file mode 100644 index 000000000..dd8ebc002 --- /dev/null +++ b/src/inspector/widgets.zig @@ -0,0 +1,227 @@ +const cimgui = @import("dcimgui"); + +pub const page = @import("widgets/page.zig"); +pub const pagelist = @import("widgets/pagelist.zig"); +pub const key = @import("widgets/key.zig"); +pub const renderer = @import("widgets/renderer.zig"); +pub const screen = @import("widgets/screen.zig"); +pub const style = @import("widgets/style.zig"); +pub const surface = @import("widgets/surface.zig"); +pub const terminal = @import("widgets/terminal.zig"); +pub const termio = @import("widgets/termio.zig"); + +/// Draws a "(?)" disabled text marker that shows some help text +/// on hover. +pub fn helpMarker(text: [:0]const u8) void { + cimgui.c.ImGui_TextDisabled("(?)"); + if (!cimgui.c.ImGui_BeginItemTooltip()) return; + defer cimgui.c.ImGui_EndTooltip(); + + cimgui.c.ImGui_PushTextWrapPos(cimgui.c.ImGui_GetFontSize() * 35.0); + defer cimgui.c.ImGui_PopTextWrapPos(); + + cimgui.c.ImGui_TextUnformatted(text.ptr); +} + +/// DetachableHeader allows rendering a collapsing header that can be +/// detached into its own window. +pub const DetachableHeader = struct { + /// Set whether the window is detached. + detached: bool = false, + + /// If true, detaching will move the item into a docking position + /// to the right. + dock: bool = true, + + // Internal state do not touch. + window_first: bool = true, + + pub fn windowEnd(self: *DetachableHeader) void { + _ = self; + + // If we started the window, we need to end it. + cimgui.c.ImGui_End(); + } + + /// Returns null if there is no window created (not detached). + /// Otherwise returns whether the window is open. + pub fn window( + self: *DetachableHeader, + label: [:0]const u8, + ) ?bool { + // If we're not detached, we don't create a window. + if (!self.detached) { + self.window_first = true; + return null; + } + + // If this is our first time showing the window then we need to + // setup docking. We only do this on the first time because we + // don't want to reset a user's docking behavior later. + if (self.window_first) dock: { + self.window_first = false; + if (!self.dock) break :dock; + const dock_id = cimgui.c.ImGui_GetWindowDockID(); + if (dock_id == 0) break :dock; + var dock_id_right: cimgui.c.ImGuiID = 0; + var dock_id_left: cimgui.c.ImGuiID = 0; + _ = cimgui.ImGui_DockBuilderSplitNode( + dock_id, + cimgui.c.ImGuiDir_Right, + 0.4, + &dock_id_right, + &dock_id_left, + ); + cimgui.ImGui_DockBuilderDockWindow(label, dock_id_right); + cimgui.ImGui_DockBuilderFinish(dock_id); + } + + return cimgui.c.ImGui_Begin( + label, + &self.detached, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + } + + pub fn header( + self: *DetachableHeader, + label: [:0]const u8, + ) bool { + // If we're detached, create a separate window. + if (self.detached) return false; + + // Make sure all headers have a unique ID in the stack. We only + // need to do this for the header side because creating a window + // automatically creates an ID. + cimgui.c.ImGui_PushID(label); + defer cimgui.c.ImGui_PopID(); + + // Create the collapsing header with the pop out button overlaid. + cimgui.c.ImGui_SetNextItemAllowOverlap(); + const is_open = cimgui.c.ImGui_CollapsingHeader( + label, + cimgui.c.ImGuiTreeNodeFlags_None, + ); + + // Place pop-out button inside the header bar + const header_max = cimgui.c.ImGui_GetItemRectMax(); + const header_min = cimgui.c.ImGui_GetItemRectMin(); + const frame_height = cimgui.c.ImGui_GetFrameHeight(); + const button_size = frame_height - 4; + const padding = 4; + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_SetCursorScreenPos(.{ + .x = header_max.x - button_size - padding, + .y = header_min.y + 2, + }); + { + cimgui.c.ImGui_PushStyleVarImVec2( + cimgui.c.ImGuiStyleVar_FramePadding, + .{ .x = 0, .y = 0 }, + ); + defer cimgui.c.ImGui_PopStyleVar(); + if (cimgui.c.ImGui_ButtonEx( + ">>##detach", + .{ .x = button_size, .y = button_size }, + )) { + self.detached = true; + } + } + + if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { + cimgui.c.ImGui_SetTooltip("Detach into separate window"); + } + + return is_open; + } +}; + +pub const DetachableHeaderState = struct { + show_window: bool = false, + + /// Internal state. Don't touch. + first_show: bool = false, +}; + +/// Render a collapsing header that can be detached into its own window. +/// When detached, renders as a separate window with a close button. +/// When attached, renders as a collapsing header with a pop-out button. +pub fn detachableHeader( + label: [:0]const u8, + state: *DetachableHeaderState, + ctx: anytype, + comptime contentFn: fn (@TypeOf(ctx)) void, +) void { + cimgui.c.ImGui_PushID(label); + defer cimgui.c.ImGui_PopID(); + + if (state.show_window) { + // On first show, dock this window to the right of the parent window's dock. + // We only do this once so the user can freely reposition the window afterward + // without it snapping back to the right on every frame. + if (!state.first_show) { + state.first_show = true; + const current_dock_id = cimgui.c.ImGui_GetWindowDockID(); + if (current_dock_id != 0) { + var dock_id_right: cimgui.c.ImGuiID = 0; + var dock_id_left: cimgui.c.ImGuiID = 0; + _ = cimgui.ImGui_DockBuilderSplitNode( + current_dock_id, + cimgui.c.ImGuiDir_Right, + 0.3, + &dock_id_right, + &dock_id_left, + ); + cimgui.ImGui_DockBuilderDockWindow(label, dock_id_right); + cimgui.ImGui_DockBuilderFinish(current_dock_id); + } + } + + defer cimgui.c.ImGui_End(); + if (cimgui.c.ImGui_Begin( + label, + &state.show_window, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) contentFn(ctx); + return; + } + + // Reset first_show when window is closed so next open docks again + state.first_show = false; + + cimgui.c.ImGui_SetNextItemAllowOverlap(); + const is_open = cimgui.c.ImGui_CollapsingHeader( + label, + cimgui.c.ImGuiTreeNodeFlags_None, + ); + + // Place pop-out button inside the header bar + const header_max = cimgui.c.ImGui_GetItemRectMax(); + const header_min = cimgui.c.ImGui_GetItemRectMin(); + const frame_height = cimgui.c.ImGui_GetFrameHeight(); + const button_size = frame_height - 4; + const padding = 4; + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_SetCursorScreenPos(.{ + .x = header_max.x - button_size - padding, + .y = header_min.y + 2, + }); + cimgui.c.ImGui_PushStyleVarImVec2( + cimgui.c.ImGuiStyleVar_FramePadding, + .{ .x = 0, .y = 0 }, + ); + if (cimgui.c.ImGui_ButtonEx( + ">>##detach", + .{ .x = button_size, .y = button_size }, + )) { + state.show_window = true; + } + cimgui.c.ImGui_PopStyleVar(); + if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { + cimgui.c.ImGui_SetTooltip("Pop out into separate window"); + } + + if (is_open) contentFn(ctx); +} diff --git a/src/inspector/widgets/key.zig b/src/inspector/widgets/key.zig new file mode 100644 index 000000000..7d7188a6b --- /dev/null +++ b/src/inspector/widgets/key.zig @@ -0,0 +1,535 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const input = @import("../../input.zig"); +const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const cimgui = @import("dcimgui"); + +/// Circular buffer of key events. +pub const EventRing = CircBuf(Event, undefined); + +/// Represents a recorded keyboard event. +pub const Event = struct { + /// The input event. + event: input.KeyEvent, + + /// The binding that was triggered as a result of this event. + /// Multiple bindings are possible if they are chained. + binding: []const input.Binding.Action = &.{}, + + /// The data sent to the pty as a result of this keyboard event. + /// This is allocated using the inspector allocator. + pty: []const u8 = "", + + /// State for the inspector GUI. Do not set this unless you're the inspector. + imgui_state: struct { + selected: bool = false, + } = .{}, + + pub fn init(alloc: Allocator, ev: input.KeyEvent) !Event { + var copy = ev; + copy.utf8 = ""; + if (ev.utf8.len > 0) copy.utf8 = try alloc.dupe(u8, ev.utf8); + return .{ .event = copy }; + } + + pub fn deinit(self: *const Event, alloc: Allocator) void { + alloc.free(self.binding); + if (self.event.utf8.len > 0) alloc.free(self.event.utf8); + if (self.pty.len > 0) alloc.free(self.pty); + } + + /// Returns a label that can be used for this event. This is null-terminated + /// so it can be easily used with C APIs. + pub fn label(self: *const Event, buf: []u8) ![:0]const u8 { + var buf_stream = std.io.fixedBufferStream(buf); + const writer = buf_stream.writer(); + + switch (self.event.action) { + .press => try writer.writeAll("Press: "), + .release => try writer.writeAll("Release: "), + .repeat => try writer.writeAll("Repeat: "), + } + + if (self.event.mods.shift) try writer.writeAll("Shift+"); + if (self.event.mods.ctrl) try writer.writeAll("Ctrl+"); + if (self.event.mods.alt) try writer.writeAll("Alt+"); + if (self.event.mods.super) try writer.writeAll("Super+"); + + // Write our key. If we have an invalid key we attempt to write + // the utf8 associated with it if we have it to handle non-ascii. + try writer.writeAll(switch (self.event.key) { + .unidentified => if (self.event.utf8.len > 0) self.event.utf8 else @tagName(self.event.key), + else => @tagName(self.event.key), + }); + + // Deadkey + if (self.event.composing) try writer.writeAll(" (composing)"); + + // Null-terminator + try writer.writeByte(0); + return buf[0..(buf_stream.getWritten().len - 1) :0]; + } + + /// Render this event in the inspector GUI. + pub fn render(self: *const Event) void { + _ = cimgui.c.ImGui_BeginTable( + "##event", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + if (self.binding.len > 0) { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Triggered Binding"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + + const height: f32 = height: { + const item_count: f32 = @floatFromInt(@min(self.binding.len, 5)); + const padding = cimgui.c.ImGui_GetStyle().*.FramePadding.y * 2; + break :height cimgui.c.ImGui_GetTextLineHeightWithSpacing() * item_count + padding; + }; + if (cimgui.c.ImGui_BeginListBox("##bindings", .{ .x = 0, .y = height })) { + defer cimgui.c.ImGui_EndListBox(); + for (self.binding) |action| { + _ = cimgui.c.ImGui_SelectableEx( + @tagName(action).ptr, + false, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + ); + } + } + } + + pty: { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Encoding to Pty"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (self.pty.len == 0) { + cimgui.c.ImGui_TextDisabled("(no data)"); + break :pty; + } + + self.renderPty() catch { + cimgui.c.ImGui_TextDisabled("(error rendering pty data)"); + break :pty; + }; + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Action"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(self.event.action).ptr); + } + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Key"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(self.event.key).ptr); + } + if (!self.event.mods.empty()) { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Mods"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (self.event.mods.shift) cimgui.c.ImGui_Text("shift "); + if (self.event.mods.ctrl) cimgui.c.ImGui_Text("ctrl "); + if (self.event.mods.alt) cimgui.c.ImGui_Text("alt "); + if (self.event.mods.super) cimgui.c.ImGui_Text("super "); + } + if (self.event.composing) { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Composing"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("true"); + } + utf8: { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("UTF-8"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (self.event.utf8.len == 0) { + cimgui.c.ImGui_TextDisabled("(empty)"); + break :utf8; + } + + self.renderUtf8(self.event.utf8) catch { + cimgui.c.ImGui_TextDisabled("(error rendering utf-8)"); + break :utf8; + }; + } + } + + fn renderUtf8(self: *const Event, utf8: []const u8) !void { + _ = self; + + // Format the codepoint sequence + var buf: [1024]u8 = undefined; + var buf_stream = std.io.fixedBufferStream(&buf); + const writer = buf_stream.writer(); + if (std.unicode.Utf8View.init(utf8)) |view| { + var it = view.iterator(); + while (it.nextCodepoint()) |cp| { + try writer.print("U+{X} ", .{cp}); + } + } else |_| { + try writer.writeAll("(invalid utf-8)"); + } + try writer.writeByte(0); + + // Render as a textbox + _ = cimgui.c.ImGui_InputText( + "##utf8", + &buf, + buf_stream.getWritten().len - 1, + cimgui.c.ImGuiInputTextFlags_ReadOnly, + ); + } + + fn renderPty(self: *const Event) !void { + // Format the codepoint sequence + var buf: [1024]u8 = undefined; + var buf_stream = std.io.fixedBufferStream(&buf); + const writer = buf_stream.writer(); + + for (self.pty) |byte| { + // Print ESC special because its so common + if (byte == 0x1B) { + try writer.writeAll("ESC "); + continue; + } + + // Print ASCII as-is + if (byte > 0x20 and byte < 0x7F) { + try writer.writeByte(byte); + continue; + } + + // Everything else as a hex byte + try writer.print("0x{X} ", .{byte}); + } + + try writer.writeByte(0); + + // Render as a textbox + _ = cimgui.c.ImGui_InputText( + "##pty", + &buf, + buf_stream.getWritten().len - 1, + cimgui.c.ImGuiInputTextFlags_ReadOnly, + ); + } +}; + +fn modsTooltip( + mods: *const input.Mods, + buf: []u8, +) ![:0]const u8 { + var stream = std.io.fixedBufferStream(buf); + const writer = stream.writer(); + var first = true; + if (mods.shift) { + try writer.writeAll("Shift"); + first = false; + } + if (mods.ctrl) { + if (!first) try writer.writeAll("+"); + try writer.writeAll("Ctrl"); + first = false; + } + if (mods.alt) { + if (!first) try writer.writeAll("+"); + try writer.writeAll("Alt"); + first = false; + } + if (mods.super) { + if (!first) try writer.writeAll("+"); + try writer.writeAll("Super"); + } + try writer.writeByte(0); + const written = stream.getWritten(); + return written[0 .. written.len - 1 :0]; +} + +/// Keyboard event stream inspector widget. +pub const Stream = struct { + events: EventRing, + + pub fn init(alloc: Allocator) !Stream { + var events: EventRing = try .init(alloc, 2); + errdefer events.deinit(alloc); + return .{ .events = events }; + } + + pub fn deinit(self: *Stream, alloc: Allocator) void { + var it = self.events.iterator(.forward); + while (it.next()) |v| v.deinit(alloc); + self.events.deinit(alloc); + } + + pub fn draw( + self: *Stream, + open: bool, + alloc: Allocator, + ) void { + if (!open) return; + + if (self.events.empty()) { + cimgui.c.ImGui_Text("No recorded key events. Press a key with the " ++ + "terminal focused to record it."); + return; + } + + if (cimgui.c.ImGui_Button("Clear")) { + var it = self.events.iterator(.forward); + while (it.next()) |v| v.deinit(alloc); + self.events.clear(); + } + + cimgui.c.ImGui_Separator(); + + const table_flags = cimgui.c.ImGuiTableFlags_Borders | + cimgui.c.ImGuiTableFlags_Resizable | + cimgui.c.ImGuiTableFlags_ScrollY | + cimgui.c.ImGuiTableFlags_SizingFixedFit; + + if (!cimgui.c.ImGui_BeginTable("table_key_events", 6, table_flags)) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupScrollFreeze(0, 1); + cimgui.c.ImGui_TableSetupColumnEx("Action", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 80, 0); + cimgui.c.ImGui_TableSetupColumnEx("Key", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 160, 0); + cimgui.c.ImGui_TableSetupColumnEx("Mods", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 150, 0); + cimgui.c.ImGui_TableSetupColumnEx("UTF-8", cimgui.c.ImGuiTableColumnFlags_WidthFixed, 80, 0); + cimgui.c.ImGui_TableSetupColumnEx("PTY Encoding", cimgui.c.ImGuiTableColumnFlags_WidthStretch, 0, 0); + cimgui.c.ImGui_TableSetupColumnEx("Binding", cimgui.c.ImGuiTableColumnFlags_WidthStretch, 0, 0); + cimgui.c.ImGui_TableHeadersRow(); + + var it = self.events.iterator(.reverse); + while (it.next()) |ev| { + cimgui.c.ImGui_PushIDPtr(ev); + defer cimgui.c.ImGui_PopID(); + + cimgui.c.ImGui_TableNextRow(); + const row_min_y = cimgui.c.ImGui_GetCursorScreenPos().y; + + // Set row background color based on action + cimgui.c.ImGui_TableSetBgColor(cimgui.c.ImGuiTableBgTarget_RowBg0, actionColor(ev.event.action), -1); + + // Action column with colored text + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + const action_text_color: cimgui.c.ImVec4 = switch (ev.event.action) { + .press => .{ .x = 0.4, .y = 1.0, .z = 0.4, .w = 1.0 }, // Green + .release => .{ .x = 0.6, .y = 0.6, .z = 1.0, .w = 1.0 }, // Blue + .repeat => .{ .x = 1.0, .y = 1.0, .z = 0.4, .w = 1.0 }, // Yellow + }; + cimgui.c.ImGui_TextColored(action_text_color, "%s", @tagName(ev.event.action).ptr); + + // Key column with consistent key coloring + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + const key_name = switch (ev.event.key) { + .unidentified => if (ev.event.utf8.len > 0) ev.event.utf8 else @tagName(ev.event.key), + else => @tagName(ev.event.key), + }; + const key_rgba = keyColor(ev.event.key); + const key_color: cimgui.c.ImVec4 = .{ + .x = @as(f32, @floatFromInt(key_rgba & 0xFF)) / 255.0, + .y = @as(f32, @floatFromInt((key_rgba >> 8) & 0xFF)) / 255.0, + .z = @as(f32, @floatFromInt((key_rgba >> 16) & 0xFF)) / 255.0, + .w = 1.0, + }; + cimgui.c.ImGui_TextColored(key_color, "%s", key_name.ptr); + + // Composing indicator + if (ev.event.composing) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.6, .z = 0.0, .w = 1.0 }, "*"); + if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None)) { + cimgui.c.ImGui_SetTooltip("Composing (dead key)"); + } + } + + // Mods + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + mods: { + if (ev.event.mods.empty()) { + cimgui.c.ImGui_TextDisabled("-"); + break :mods; + } + + var any_hovered = false; + if (ev.event.mods.shift) { + _ = cimgui.c.ImGui_SmallButton("S"); + any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None); + cimgui.c.ImGui_SameLine(); + } + if (ev.event.mods.ctrl) { + _ = cimgui.c.ImGui_SmallButton("C"); + any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None); + cimgui.c.ImGui_SameLine(); + } + if (ev.event.mods.alt) { + _ = cimgui.c.ImGui_SmallButton("A"); + any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None); + cimgui.c.ImGui_SameLine(); + } + if (ev.event.mods.super) { + _ = cimgui.c.ImGui_SmallButton("M"); + any_hovered = any_hovered or cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_None); + cimgui.c.ImGui_SameLine(); + } + cimgui.c.ImGui_NewLine(); + + if (any_hovered) tooltip: { + var tooltip_buf: [64]u8 = undefined; + const tooltip = modsTooltip( + &ev.event.mods, + &tooltip_buf, + ) catch break :tooltip; + cimgui.c.ImGui_SetTooltip("%s", tooltip.ptr); + } + } + + // UTF-8 + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + if (ev.event.utf8.len == 0) { + cimgui.c.ImGui_TextDisabled("-"); + } else { + var utf8_buf: [128]u8 = undefined; + var utf8_stream = std.io.fixedBufferStream(&utf8_buf); + const utf8_writer = utf8_stream.writer(); + if (std.unicode.Utf8View.init(ev.event.utf8)) |view| { + var utf8_it = view.iterator(); + while (utf8_it.nextCodepoint()) |cp| { + utf8_writer.print("U+{X} ", .{cp}) catch break; + } + } else |_| { + utf8_writer.writeAll("?") catch {}; + } + utf8_writer.writeByte(0) catch {}; + cimgui.c.ImGui_Text("%s", &utf8_buf); + } + + // PTY + _ = cimgui.c.ImGui_TableSetColumnIndex(4); + if (ev.pty.len == 0) { + cimgui.c.ImGui_TextDisabled("-"); + } else { + var pty_buf: [256]u8 = undefined; + var pty_stream = std.io.fixedBufferStream(&pty_buf); + const pty_writer = pty_stream.writer(); + for (ev.pty) |byte| { + if (byte == 0x1B) { + pty_writer.writeAll("ESC ") catch break; + } else if (byte > 0x20 and byte < 0x7F) { + pty_writer.writeByte(byte) catch break; + } else { + pty_writer.print("0x{X} ", .{byte}) catch break; + } + } + pty_writer.writeByte(0) catch {}; + cimgui.c.ImGui_Text("%s", &pty_buf); + } + + // Binding + _ = cimgui.c.ImGui_TableSetColumnIndex(5); + if (ev.binding.len == 0) { + cimgui.c.ImGui_TextDisabled("-"); + } else { + var binding_buf: [256]u8 = undefined; + var binding_stream = std.io.fixedBufferStream(&binding_buf); + const binding_writer = binding_stream.writer(); + for (ev.binding, 0..) |action, i| { + if (i > 0) binding_writer.writeAll(", ") catch break; + binding_writer.writeAll(@tagName(action)) catch break; + } + binding_writer.writeByte(0) catch {}; + cimgui.c.ImGui_Text("%s", &binding_buf); + } + + // Row hover highlight + const row_max_y = cimgui.c.ImGui_GetCursorScreenPos().y; + const mouse_pos = cimgui.c.ImGui_GetMousePos(); + if (mouse_pos.y >= row_min_y and mouse_pos.y < row_max_y) { + cimgui.c.ImGui_TableSetBgColor(cimgui.c.ImGuiTableBgTarget_RowBg1, 0x1AFFFFFF, -1); + } + } + } +}; + +/// Returns row background color for an action (ABGR format for ImGui) +fn actionColor(action: input.Action) u32 { + return switch (action) { + .press => 0x1A4A6F4A, // Muted sage green + .release => 0x1A6A5A5A, // Muted slate gray + .repeat => 0x1A4A5A6F, // Muted warm brown + }; +} + +/// Generate a consistent color for a key based on its enum value. +/// Uses HSV color space with fixed saturation and value for pleasing colors. +fn keyColor(key: input.Key) u32 { + const key_int: u32 = @intCast(@intFromEnum(key)); + const hue: f32 = @as(f32, @floatFromInt(key_int *% 47)) / 256.0; + return hsvToRgba(hue, 0.5, 0.9, 1.0); +} + +/// Convert HSV (hue 0-1, saturation 0-1, value 0-1) to RGBA u32. +fn hsvToRgba(h: f32, s: f32, v: f32, a: f32) u32 { + var r: f32 = undefined; + var g: f32 = undefined; + var b: f32 = undefined; + + const i: u32 = @intFromFloat(h * 6.0); + const f = h * 6.0 - @as(f32, @floatFromInt(i)); + const p = v * (1.0 - s); + const q = v * (1.0 - f * s); + const t = v * (1.0 - (1.0 - f) * s); + + switch (i % 6) { + 0 => { + r = v; + g = t; + b = p; + }, + 1 => { + r = q; + g = v; + b = p; + }, + 2 => { + r = p; + g = v; + b = t; + }, + 3 => { + r = p; + g = q; + b = v; + }, + 4 => { + r = t; + g = p; + b = v; + }, + else => { + r = v; + g = p; + b = q; + }, + } + + const ri: u32 = @intFromFloat(r * 255.0); + const gi: u32 = @intFromFloat(g * 255.0); + const bi: u32 = @intFromFloat(b * 255.0); + const ai: u32 = @intFromFloat(a * 255.0); + + return (ai << 24) | (bi << 16) | (gi << 8) | ri; +} diff --git a/src/inspector/widgets/page.zig b/src/inspector/widgets/page.zig new file mode 100644 index 000000000..844abc355 --- /dev/null +++ b/src/inspector/widgets/page.zig @@ -0,0 +1,428 @@ +const std = @import("std"); +const cimgui = @import("dcimgui"); +const terminal = @import("../../terminal/main.zig"); +const units = @import("../units.zig"); +const widgets = @import("../widgets.zig"); + +const PageList = terminal.PageList; +const Page = terminal.Page; + +pub fn inspector(page: *const terminal.Page) void { + cimgui.c.ImGui_SeparatorText("Managed Memory"); + managedMemory(page); + + cimgui.c.ImGui_SeparatorText("Styles"); + stylesList(page); + + cimgui.c.ImGui_SeparatorText("Hyperlinks"); + hyperlinksList(page); + + cimgui.c.ImGui_SeparatorText("Rows"); + rowsTable(page); +} + +/// Draw a tree node header with metadata about this page. Returns if +/// the tree node is open or not. If it is open you must close it with +/// TreePop. +pub fn treeNode(state: struct { + /// The page + page: *const terminal.Page, + /// The index of the page in a page list, used for headers. + index: usize, + /// The range of rows this page covers, inclusive. + row_range: [2]usize, + /// Whether this page is the active or viewport node. + active: bool, + viewport: bool, +}) bool { + // Setup our node. + const open = open: { + var label_buf: [160]u8 = undefined; + const label = std.fmt.bufPrintZ( + &label_buf, + "Page {d}", + .{state.index}, + ) catch "Page"; + + const flags = cimgui.c.ImGuiTreeNodeFlags_AllowOverlap | + cimgui.c.ImGuiTreeNodeFlags_SpanFullWidth | + cimgui.c.ImGuiTreeNodeFlags_FramePadding; + break :open cimgui.c.ImGui_TreeNodeEx(label.ptr, flags); + }; + + // Move our cursor into the tree header so we can add extra info. + const header_min = cimgui.c.ImGui_GetItemRectMin(); + const header_max = cimgui.c.ImGui_GetItemRectMax(); + const header_height = header_max.y - header_min.y; + const text_line = cimgui.c.ImGui_GetTextLineHeight(); + const y_center = header_min.y + (header_height - text_line) * 0.5; + cimgui.c.ImGui_SetCursorScreenPos(.{ .x = header_min.x + 170, .y = y_center }); + + // Metadata + cimgui.c.ImGui_TextDisabled( + "%dc x %dr", + state.page.size.cols, + state.page.size.rows, + ); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("rows %d..%d", state.row_range[0], state.row_range[1]); + + // Labels + if (state.active) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "active"); + } + if (state.viewport) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport"); + } + if (state.page.isDirty()) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + } + + return open; +} + +pub fn managedMemory(page: *const Page) void { + if (cimgui.c.ImGui_BeginTable( + "##overview", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) { + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Size"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker( + "Memory allocated for this page. Note the backing memory " ++ + "may be a larger allocation from which this page " ++ + "uses a portion.", + ); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text( + "%d KiB", + units.toKibiBytes(page.memory.len), + ); + } + + if (cimgui.c.ImGui_BeginTable( + "##managed", + 4, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) { + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn("Resource", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Used", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Capacity", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableHeadersRow(); + + const size = page.size; + const cap = page.capacity; + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Columns"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Number of columns in the terminal grid."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", size.cols); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", cap.cols); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Rows"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Number of rows in this page."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", size.rows); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", cap.rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Styles"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Unique text styles (colors, attributes) currently in use."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", page.styles.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", page.styles.layout.cap); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Graphemes"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Extended grapheme clusters for multi-codepoint characters."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", page.graphemeCount()); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", page.graphemeCapacity()); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Strings (bytes)"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("String storage for hyperlink URIs and other text data."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", page.string_alloc.usedBytes(page.memory)); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", page.string_alloc.capacityBytes()); + + const hyperlink_map = page.hyperlink_map.map(page.memory); + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hyperlink Map"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Maps cell positions to hyperlink IDs."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", hyperlink_map.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", hyperlink_map.capacity()); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hyperlink IDs"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Unique hyperlink definitions (URI + optional ID)."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", page.hyperlink_set.count()); + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + cimgui.c.ImGui_Text("%d", page.hyperlink_set.layout.cap); + } +} + +fn rowsTable(page: *const terminal.Page) void { + const visible_rows: usize = @min(page.size.rows, 12); + const row_height: f32 = cimgui.c.ImGui_GetTextLineHeightWithSpacing(); + const child_height: f32 = row_height * (@as(f32, @floatFromInt(visible_rows)) + 2.0); + + // Child window so scrolling is separate. + // This defer first is not a bug, EndChild always needs to be called. + defer cimgui.c.ImGui_EndChild(); + if (!cimgui.c.ImGui_BeginChild( + "##page_rows", + .{ .x = 0.0, .y = child_height }, + cimgui.c.ImGuiChildFlags_Borders, + cimgui.c.ImGuiWindowFlags_None, + )) return; + + if (!cimgui.c.ImGui_BeginTable( + "##page_rows_table", + 10, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupScrollFreeze(0, 1); + cimgui.c.ImGui_TableSetupColumn("Row", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Text", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Dirty", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Wrap", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Cont", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Styled", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Grapheme", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Link", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Prompt", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Kitty", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableHeadersRow(); + + const rows = page.rows.ptr(page.memory)[0..page.size.rows]; + for (rows, 0..) |*row, row_index| { + var text_cells: usize = 0; + const cells = page.getCells(row); + for (cells) |cell| { + if (cell.hasText()) { + text_cells += 1; + } + } + + cimgui.c.ImGui_TableNextRow(); + + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%d", row_index); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (text_cells == 0) { + cimgui.c.ImGui_TextDisabled("0"); + } else { + cimgui.c.ImGui_Text("%d", text_cells); + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + flagCell(row.dirty); + + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + flagCell(row.wrap); + + _ = cimgui.c.ImGui_TableSetColumnIndex(4); + flagCell(row.wrap_continuation); + + _ = cimgui.c.ImGui_TableSetColumnIndex(5); + flagCell(row.styled); + + _ = cimgui.c.ImGui_TableSetColumnIndex(6); + flagCell(row.grapheme); + + _ = cimgui.c.ImGui_TableSetColumnIndex(7); + flagCell(row.hyperlink); + + _ = cimgui.c.ImGui_TableSetColumnIndex(8); + cimgui.c.ImGui_Text("%s", @tagName(row.semantic_prompt).ptr); + + _ = cimgui.c.ImGui_TableSetColumnIndex(9); + flagCell(row.kitty_virtual_placeholder); + } +} + +fn stylesList(page: *const Page) void { + const items = page.styles.items.ptr(page.memory)[0..page.styles.layout.cap]; + + var count: usize = 0; + for (items, 0..) |item, index| { + if (index == 0) continue; + if (item.meta.ref == 0) continue; + count += 1; + } + + if (count == 0) { + cimgui.c.ImGui_TextDisabled("(no styles in use)"); + return; + } + + const visible_rows: usize = @min(count, 8); + const row_height: f32 = cimgui.c.ImGui_GetTextLineHeightWithSpacing(); + const child_height: f32 = row_height * (@as(f32, @floatFromInt(visible_rows)) + 2.0); + + defer cimgui.c.ImGui_EndChild(); + if (!cimgui.c.ImGui_BeginChild( + "##page_styles", + .{ .x = 0.0, .y = child_height }, + cimgui.c.ImGuiChildFlags_Borders, + cimgui.c.ImGuiWindowFlags_None, + )) return; + + if (!cimgui.c.ImGui_BeginTable( + "##page_styles_table", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupScrollFreeze(0, 1); + cimgui.c.ImGui_TableSetupColumn("ID", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Refs", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Style", cimgui.c.ImGuiTableColumnFlags_WidthStretch); + cimgui.c.ImGui_TableHeadersRow(); + + for (items, 0..) |item, index| { + if (index == 0) continue; + if (item.meta.ref == 0) continue; + + cimgui.c.ImGui_TableNextRow(); + cimgui.c.ImGui_PushIDInt(@intCast(index)); + defer cimgui.c.ImGui_PopID(); + + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%d", index); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", item.meta.ref); + + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + if (cimgui.c.ImGui_TreeNodeEx("Details", cimgui.c.ImGuiTreeNodeFlags_None)) { + defer cimgui.c.ImGui_TreePop(); + widgets.style.table(item.value, null); + } + } +} + +fn hyperlinksList(page: *const Page) void { + const items = page.hyperlink_set.items.ptr(page.memory)[0..page.hyperlink_set.layout.cap]; + + var count: usize = 0; + for (items, 0..) |item, index| { + if (index == 0) continue; + if (item.meta.ref == 0) continue; + count += 1; + } + + if (count == 0) { + cimgui.c.ImGui_TextDisabled("(no hyperlinks in use)"); + return; + } + + const visible_rows: usize = @min(count, 8); + const row_height: f32 = cimgui.c.ImGui_GetTextLineHeightWithSpacing(); + const child_height: f32 = row_height * (@as(f32, @floatFromInt(visible_rows)) + 2.0); + + defer cimgui.c.ImGui_EndChild(); + if (!cimgui.c.ImGui_BeginChild( + "##page_hyperlinks", + .{ .x = 0.0, .y = child_height }, + cimgui.c.ImGuiChildFlags_Borders, + cimgui.c.ImGuiWindowFlags_None, + )) return; + + if (!cimgui.c.ImGui_BeginTable( + "##page_hyperlinks_table", + 4, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupScrollFreeze(0, 1); + cimgui.c.ImGui_TableSetupColumn("ID", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Refs", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Explicit ID", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("URI", cimgui.c.ImGuiTableColumnFlags_WidthStretch); + cimgui.c.ImGui_TableHeadersRow(); + + for (items, 0..) |item, index| { + if (index == 0) continue; + if (item.meta.ref == 0) continue; + + cimgui.c.ImGui_TableNextRow(); + + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%d", index); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", item.meta.ref); + + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + switch (item.value.id) { + .explicit => |slice| { + const explicit_id = slice.slice(page.memory); + cimgui.c.ImGui_Text("%.*s", explicit_id.len, explicit_id.ptr); + }, + .implicit => cimgui.c.ImGui_TextDisabled("-"), + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + const uri = item.value.uri.slice(page.memory); + cimgui.c.ImGui_Text("%.*s", uri.len, uri.ptr); + } +} + +fn flagCell(value: bool) void { + if (value) { + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "yes"); + } else { + cimgui.c.ImGui_TextDisabled("-"); + } +} diff --git a/src/inspector/widgets/pagelist.zig b/src/inspector/widgets/pagelist.zig new file mode 100644 index 000000000..bedbfb599 --- /dev/null +++ b/src/inspector/widgets/pagelist.zig @@ -0,0 +1,852 @@ +const std = @import("std"); +const cimgui = @import("dcimgui"); +const terminal = @import("../../terminal/main.zig"); +const stylepkg = @import("../../terminal/style.zig"); +const widgets = @import("../widgets.zig"); +const units = @import("../units.zig"); + +const PageList = terminal.PageList; + +/// PageList inspector widget. +pub const Inspector = struct { + pub const empty: Inspector = .{}; + + pub fn draw(_: *const Inspector, pages: *PageList) void { + cimgui.c.ImGui_TextWrapped( + "PageList manages the backing pages that hold scrollback and the active " ++ + "terminal grid. Each page is a contiguous memory buffer with its " ++ + "own rows, cells, style set, grapheme map, and hyperlink storage.", + ); + + if (cimgui.c.ImGui_CollapsingHeader( + "Overview", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + summaryTable(pages); + } + + if (cimgui.c.ImGui_CollapsingHeader( + "Scrollbar & Regions", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + cimgui.c.ImGui_SeparatorText("Scrollbar"); + scrollbarInfo(pages); + cimgui.c.ImGui_SeparatorText("Regions"); + regionsTable(pages); + } + + if (cimgui.c.ImGui_CollapsingHeader( + "Tracked Pins", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + trackedPinsTable(pages); + } + + if (cimgui.c.ImGui_CollapsingHeader( + "Pages", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + widgets.helpMarker( + "Pages are shown most-recent first. Each page holds a grid of rows/cells " ++ + "plus metadata tables for styles, graphemes, strings, and hyperlinks.", + ); + + const active_pin = pages.getTopLeft(.active); + const viewport_pin = pages.getTopLeft(.viewport); + + var row_offset = pages.total_rows; + var index: usize = pages.totalPages(); + var node = pages.pages.last; + while (node) |page_node| : (node = page_node.prev) { + const page = &page_node.data; + row_offset -= page.size.rows; + index -= 1; + + // We use our location as the ID so that even if reallocations + // happen we remain open if we're open already. + cimgui.c.ImGui_PushIDInt(@intCast(index)); + defer cimgui.c.ImGui_PopID(); + + // Open up the tree node. + if (!widgets.page.treeNode(.{ + .page = page, + .index = index, + .row_range = .{ row_offset, row_offset + page.size.rows - 1 }, + .active = node == active_pin.node, + .viewport = node == viewport_pin.node, + })) continue; + defer cimgui.c.ImGui_TreePop(); + widgets.page.inspector(page); + } + } + } +}; + +fn summaryTable(pages: *const PageList) void { + if (!cimgui.c.ImGui_BeginTable( + "pagelist_summary", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Active Grid"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Active viewport size in columns x rows."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%dc x %dr", pages.cols, pages.rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Pages"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Total number of pages in the linked list."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", pages.totalPages()); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Total Rows"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Total rows represented by scrollback + active area."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", pages.total_rows); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Page Bytes"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Total bytes allocated for active pages."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text( + "%d KiB", + units.toKibiBytes(pages.page_size), + ); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Max Size"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker( + \\Maximum bytes before pages must be evicated. The total + \\used bytes may be higher due to minimum individual page + \\sizes but the next allocation that would exceed this limit + \\will evict pages from the front of the list to free up space. + ); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text( + "%d KiB", + units.toKibiBytes(pages.maxSize()), + ); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Viewport"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Current viewport anchoring mode."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Tracked Pins"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Number of pins tracked for automatic updates."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", pages.countTrackedPins()); +} + +fn scrollbarInfo(pages: *PageList) void { + const scrollbar = pages.scrollbar(); + + // If we have a scrollbar, show it. + if (scrollbar.total > 0) { + var delta_row: isize = 0; + scrollbarWidget(&scrollbar, &delta_row); + if (delta_row != 0) { + pages.scroll(.{ .delta_row = delta_row }); + } + } + + if (!cimgui.c.ImGui_BeginTable( + "scrollbar_info", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Total"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Total number of scrollable rows including scrollback and active area."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", scrollbar.total); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Offset"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Current scroll position as row offset from the top of scrollback."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", scrollbar.offset); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Length"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Number of rows visible in the viewport."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", scrollbar.len); +} + +fn regionsTable(pages: *PageList) void { + if (!cimgui.c.ImGui_BeginTable( + "pagelist_regions", + 4, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn("Region", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Top-Left", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Bottom-Right", cimgui.c.ImGuiTableColumnFlags_WidthStretch); + cimgui.c.ImGui_TableHeadersRow(); + + inline for (comptime std.meta.tags(terminal.point.Tag)) |tag| { + regionRow(pages, tag); + } +} + +fn regionRow(pages: *const PageList, comptime tag: terminal.point.Tag) void { + const tl_pin = pages.getTopLeft(tag); + const br_pin = pages.getBottomRight(tag); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%s", @tagName(tag).ptr); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker(comptime regionHelpText(tag)); + + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + if (pages.pointFromPin(tag, tl_pin)) |pt| { + const coord = pt.coord(); + cimgui.c.ImGui_Text("(%d, %d)", coord.x, coord.y); + } else { + cimgui.c.ImGui_TextDisabled("(n/a)"); + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + if (br_pin) |br| { + if (pages.pointFromPin(tag, br)) |pt| { + const coord = pt.coord(); + cimgui.c.ImGui_Text("(%d, %d)", coord.x, coord.y); + } else { + cimgui.c.ImGui_TextDisabled("(n/a)"); + } + } else { + cimgui.c.ImGui_TextDisabled("(empty)"); + } +} + +fn regionHelpText(comptime tag: terminal.point.Tag) [:0]const u8 { + return switch (tag) { + .active => "The active area where a running program can jump the cursor " ++ + "and make changes. This is the 'editable' part of the screen. " ++ + "Bottom-right includes the full height of the screen, including " ++ + "rows that may not be written yet.", + .viewport => "The visible viewport. If the user has scrolled, top-left changes. " ++ + "Bottom-right is the last written row from the top-left.", + .screen => "Top-left is the furthest back in scrollback history. Bottom-right " ++ + "is the last written row. Unlike 'active', this only contains " ++ + "written rows.", + .history => "Same top-left as 'screen' but bottom-right is the line just before " ++ + "the top of 'active'. Contains only the scrollback history.", + }; +} + +fn trackedPinsTable(pages: *const PageList) void { + if (!cimgui.c.ImGui_BeginTable( + "tracked_pins", + 5, + cimgui.c.ImGuiTableFlags_Borders | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn("Index", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Pin", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Context", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Dirty", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("State", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableHeadersRow(); + + const active_pin = pages.getTopLeft(.active); + const viewport_pin = pages.getTopLeft(.viewport); + + for (pages.trackedPins(), 0..) |tracked, idx| { + const pin = tracked.*; + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%d", idx); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (pin.garbage) { + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.5, .z = 0.3, .w = 1.0 }, "(%d, %d)", pin.x, pin.y); + } else { + cimgui.c.ImGui_Text("(%d, %d)", pin.x, pin.y); + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + if (pages.pointFromPin(.screen, pin)) |pt| { + const coord = pt.coord(); + cimgui.c.ImGui_Text( + "screen (%d, %d)", + coord.x, + coord.y, + ); + } else { + cimgui.c.ImGui_TextDisabled("screen (out of range)"); + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(3); + const dirty = pin.isDirty(); + if (dirty) { + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + } else { + cimgui.c.ImGui_TextDisabled("clean"); + } + + _ = cimgui.c.ImGui_TableSetColumnIndex(4); + if (pin.eql(active_pin)) { + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.9, .z = 0.4, .w = 1.0 }, "active top"); + } else if (pin.eql(viewport_pin)) { + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport top"); + } else if (pin.garbage) { + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.5, .z = 0.3, .w = 1.0 }, "garbage"); + } else if (tracked == pages.viewport_pin) { + cimgui.c.ImGui_Text("viewport pin"); + } else { + cimgui.c.ImGui_TextDisabled("tracked"); + } + } +} + +fn scrollbarWidget( + scrollbar: *const PageList.Scrollbar, + delta_row: *isize, +) void { + delta_row.* = 0; + + const avail_width = cimgui.c.ImGui_GetContentRegionAvail().x; + const bar_height: f32 = cimgui.c.ImGui_GetFrameHeight(); + const cursor_pos = cimgui.c.ImGui_GetCursorScreenPos(); + + const total_f: f32 = @floatFromInt(scrollbar.total); + const offset_f: f32 = @floatFromInt(scrollbar.offset); + const len_f: f32 = @floatFromInt(scrollbar.len); + + const grab_start = (offset_f / total_f) * avail_width; + const grab_width = @max((len_f / total_f) * avail_width, 4.0); + + const draw_list = cimgui.c.ImGui_GetWindowDrawList(); + const bg_color = cimgui.c.ImGui_GetColorU32(cimgui.c.ImGuiCol_ScrollbarBg); + const grab_color = cimgui.c.ImGui_GetColorU32(cimgui.c.ImGuiCol_ScrollbarGrab); + + const bg_min: cimgui.c.ImVec2 = cursor_pos; + const bg_max: cimgui.c.ImVec2 = .{ .x = cursor_pos.x + avail_width, .y = cursor_pos.y + bar_height }; + cimgui.c.ImDrawList_AddRectFilledEx( + draw_list, + bg_min, + bg_max, + bg_color, + 0, + 0, + ); + + const grab_min: cimgui.c.ImVec2 = .{ + .x = cursor_pos.x + grab_start, + .y = cursor_pos.y, + }; + const grab_max: cimgui.c.ImVec2 = .{ + .x = cursor_pos.x + grab_start + grab_width, + .y = cursor_pos.y + bar_height, + }; + cimgui.c.ImDrawList_AddRectFilledEx( + draw_list, + grab_min, + grab_max, + grab_color, + 0, + 0, + ); + _ = cimgui.c.ImGui_InvisibleButton( + "scrollbar_drag", + .{ .x = avail_width, .y = bar_height }, + 0, + ); + if (cimgui.c.ImGui_IsItemActive()) { + const drag_delta = cimgui.c.ImGui_GetMouseDragDelta( + cimgui.c.ImGuiMouseButton_Left, + 0.0, + ); + if (drag_delta.x != 0) { + const row_delta = (drag_delta.x / avail_width) * total_f; + delta_row.* = @intFromFloat(row_delta); + cimgui.c.ImGui_ResetMouseDragDelta(); + } + } + + if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { + cimgui.c.ImGui_SetTooltip( + "offset=%d len=%d total=%d", + scrollbar.offset, + scrollbar.len, + scrollbar.total, + ); + } +} + +/// Grid inspector widget for choosing and inspecting a specific cell. +pub const CellChooser = struct { + lookup_region: terminal.point.Tag, + lookup_coord: terminal.point.Coordinate, + cell_info: CellInfo, + + pub const empty: CellChooser = .{ + .lookup_region = .viewport, + .lookup_coord = .{ .x = 0, .y = 0 }, + .cell_info = .empty, + }; + + pub fn draw( + self: *CellChooser, + pages: *const PageList, + ) void { + cimgui.c.ImGui_TextWrapped( + "Inspect a cell by choosing a coordinate space and entering the X/Y position. " ++ + "The inspector resolves the point into the page list and displays the cell contents.", + ); + + cimgui.c.ImGui_SeparatorText("Cell Inspector"); + + const region_max = maxCoord(pages, self.lookup_region); + if (region_max) |coord| { + self.lookup_coord.x = @min(self.lookup_coord.x, coord.x); + self.lookup_coord.y = @min(self.lookup_coord.y, coord.y); + } else { + self.lookup_coord = .{ .x = 0, .y = 0 }; + } + + { + const disabled = region_max == null; + cimgui.c.ImGui_BeginDisabled(disabled); + defer cimgui.c.ImGui_EndDisabled(); + + const preview = @tagName(self.lookup_region); + const combo_width = comptime blk: { + var max_len: usize = 0; + for (std.meta.tags(terminal.point.Tag)) |tag| { + max_len = @max(max_len, @tagName(tag).len); + } + break :blk max_len + 4; + }; + cimgui.c.ImGui_SetNextItemWidth(cimgui.c.ImGui_CalcTextSize("X" ** combo_width).x); + if (cimgui.c.ImGui_BeginCombo( + "##grid_region", + preview.ptr, + cimgui.c.ImGuiComboFlags_HeightSmall, + )) { + inline for (comptime std.meta.tags(terminal.point.Tag)) |tag| { + const selected = tag == self.lookup_region; + if (cimgui.c.ImGui_SelectableEx( + @tagName(tag).ptr, + selected, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + )) { + self.lookup_region = tag; + } + if (selected) cimgui.c.ImGui_SetItemDefaultFocus(); + } + cimgui.c.ImGui_EndCombo(); + } + + cimgui.c.ImGui_SameLine(); + + const width = cimgui.c.ImGui_CalcTextSize("00000").x; + var x_value: terminal.size.CellCountInt = self.lookup_coord.x; + var y_value: u32 = self.lookup_coord.y; + var changed = false; + + cimgui.c.ImGui_AlignTextToFramePadding(); + cimgui.c.ImGui_Text("x:"); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_SetNextItemWidth(width); + if (cimgui.c.ImGui_InputScalar( + "##grid_x", + cimgui.c.ImGuiDataType_U16, + &x_value, + )) changed = true; + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_AlignTextToFramePadding(); + cimgui.c.ImGui_Text("y:"); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_SetNextItemWidth(width); + if (cimgui.c.ImGui_InputScalar( + "##grid_y", + cimgui.c.ImGuiDataType_U32, + &y_value, + )) changed = true; + + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Choose the coordinate space and X/Y position (0-indexed)."); + + if (changed) { + if (region_max) |coord| { + self.lookup_coord.x = @min(x_value, coord.x); + self.lookup_coord.y = @min(y_value, coord.y); + } + } + } + + if (region_max) |coord| { + cimgui.c.ImGui_TextDisabled( + "Range: x 0..%d, y 0..%d", + coord.x, + coord.y, + ); + } else { + cimgui.c.ImGui_TextDisabled("(region has no rows)"); + return; + } + + const pt = switch (self.lookup_region) { + .active => terminal.Point{ .active = self.lookup_coord }, + .viewport => terminal.Point{ .viewport = self.lookup_coord }, + .screen => terminal.Point{ .screen = self.lookup_coord }, + .history => terminal.Point{ .history = self.lookup_coord }, + }; + + const cell = pages.getCell(pt) orelse { + cimgui.c.ImGui_TextDisabled("(cell out of range)"); + return; + }; + + self.cell_info.draw(cell, pt); + + if (cell.cell.style_id != stylepkg.default_id) { + cimgui.c.ImGui_SeparatorText("Style"); + const style = cell.node.data.styles.get( + cell.node.data.memory, + cell.cell.style_id, + ).*; + widgets.style.table(style, null); + } + + if (cell.cell.hyperlink) { + cimgui.c.ImGui_SeparatorText("Hyperlink"); + hyperlinkTable(cell); + } + + if (cell.cell.hasGrapheme()) { + cimgui.c.ImGui_SeparatorText("Grapheme"); + graphemeTable(cell); + } + } +}; + +fn maxCoord( + pages: *const PageList, + tag: terminal.point.Tag, +) ?terminal.point.Coordinate { + const br_pin = pages.getBottomRight(tag) orelse return null; + const br_point = pages.pointFromPin(tag, br_pin) orelse return null; + return br_point.coord(); +} + +fn hyperlinkTable(cell: PageList.Cell) void { + if (!cimgui.c.ImGui_BeginTable( + "cell_hyperlink", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + const page = &cell.node.data; + const link_id = page.lookupHyperlink(cell.cell) orelse { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Status"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_TextDisabled("(missing link data)"); + return; + }; + + const entry = page.hyperlink_set.get(page.memory, link_id); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("ID"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + switch (entry.id) { + .implicit => |value| cimgui.c.ImGui_Text("implicit %d", value), + .explicit => |slice| { + const id = slice.slice(page.memory); + if (id.len == 0) { + cimgui.c.ImGui_TextDisabled("(empty)"); + } else { + cimgui.c.ImGui_Text("%.*s", id.len, id.ptr); + } + }, + } + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("URI"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + const uri = entry.uri.slice(page.memory); + if (uri.len == 0) { + cimgui.c.ImGui_TextDisabled("(empty)"); + } else { + cimgui.c.ImGui_Text("%.*s", uri.len, uri.ptr); + } + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Ref Count"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + const refs = page.hyperlink_set.refCount(page.memory, link_id); + cimgui.c.ImGui_Text("%d", refs); +} + +fn graphemeTable(cell: PageList.Cell) void { + if (!cimgui.c.ImGui_BeginTable( + "cell_grapheme", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + const page = &cell.node.data; + const cps = page.lookupGrapheme(cell.cell) orelse { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Status"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_TextDisabled("(missing grapheme data)"); + return; + }; + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Extra Codepoints"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (cps.len == 0) { + cimgui.c.ImGui_TextDisabled("(none)"); + return; + } + + var buf: [96]u8 = undefined; + if (cimgui.c.ImGui_BeginListBox("##grapheme_list", .{ .x = 0, .y = 0 })) { + defer cimgui.c.ImGui_EndListBox(); + for (cps) |cp| { + const label = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch "U+?"; + _ = cimgui.c.ImGui_SelectableEx( + label.ptr, + false, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + ); + } + } +} + +/// Cell inspector widget. +pub const CellInfo = struct { + pub const empty: CellInfo = .{}; + + pub fn draw( + _: *const CellInfo, + cell: PageList.Cell, + point: terminal.Point, + ) void { + if (!cimgui.c.ImGui_BeginTable( + "cell_info", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grid Position"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("The cell's X/Y coordinates in the selected region."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + const coord = point.coord(); + cimgui.c.ImGui_Text("(%d, %d)", coord.x, coord.y); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Page Location"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Row and column indices within the backing page."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("row=%d col=%d", cell.row_idx, cell.col_idx); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Content"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Content tag describing how the cell data is stored."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%s", @tagName(cell.cell.content_tag).ptr); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Codepoint"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Primary Unicode codepoint for the cell."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + const cp = cell.cell.codepoint(); + if (cp == 0) { + cimgui.c.ImGui_TextDisabled("(empty)"); + } else { + cimgui.c.ImGui_Text("U+%04X", @as(u32, cp)); + } + } + + if (cell.cell.hasGrapheme()) { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grapheme"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Extra codepoints that combine with the primary codepoint to form the grapheme cluster."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + if (cimgui.c.ImGui_BeginListBox("##cell_grapheme", .{ .x = 0, .y = 0 })) { + defer cimgui.c.ImGui_EndListBox(); + if (cell.node.data.lookupGrapheme(cell.cell)) |cps| { + var buf: [96]u8 = undefined; + for (cps) |cp| { + const label = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch "U+?"; + _ = cimgui.c.ImGui_SelectableEx( + label.ptr, + false, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + ); + } + } else { + _ = cimgui.c.ImGui_SelectableEx( + "(missing)", + false, + cimgui.c.ImGuiSelectableFlags_None, + .{ .x = 0, .y = 0 }, + ); + } + } + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Width Property"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Character width property (narrow, wide, spacer, etc.)."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%s", @tagName(cell.cell.wide).ptr); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Row Flags"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Flags set on the row containing this cell."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + const row = cell.row; + if (row.wrap or row.wrap_continuation or row.grapheme or row.styled or row.hyperlink) { + if (row.wrap) { + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "wrap"); + cimgui.c.ImGui_SameLine(); + } + if (row.wrap_continuation) { + cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "cont"); + cimgui.c.ImGui_SameLine(); + } + if (row.grapheme) { + cimgui.c.ImGui_TextColored(.{ .x = 0.9, .y = 0.7, .z = 0.3, .w = 1.0 }, "grapheme"); + cimgui.c.ImGui_SameLine(); + } + if (row.styled) { + cimgui.c.ImGui_TextColored(.{ .x = 0.7, .y = 0.9, .z = 0.5, .w = 1.0 }, "styled"); + cimgui.c.ImGui_SameLine(); + } + if (row.hyperlink) { + cimgui.c.ImGui_TextColored(.{ .x = 0.8, .y = 0.6, .z = 1.0, .w = 1.0 }, "link"); + cimgui.c.ImGui_SameLine(); + } + } else { + cimgui.c.ImGui_TextDisabled("(none)"); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Style ID"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Internal style reference ID for this cell."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%d", cell.cell.style_id); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Style"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("Resolved style for the cell (colors, attributes, etc.)."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + if (cell.cell.style_id == stylepkg.default_id) { + cimgui.c.ImGui_TextDisabled("(default)"); + } else { + cimgui.c.ImGui_TextDisabled("(see below)"); + } + } + + if (cell.cell.hyperlink) { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hyperlink"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker("OSC8 hyperlink ID associated with this cell."); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + + const link_id = cell.node.data.lookupHyperlink(cell.cell) orelse 0; + cimgui.c.ImGui_Text("id=%d", link_id); + } + } +}; diff --git a/src/inspector/widgets/renderer.zig b/src/inspector/widgets/renderer.zig new file mode 100644 index 000000000..1003b02ce --- /dev/null +++ b/src/inspector/widgets/renderer.zig @@ -0,0 +1,123 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const cimgui = @import("dcimgui"); +const widgets = @import("../widgets.zig"); +const renderer = @import("../../renderer.zig"); + +const log = std.log.scoped(.inspector_renderer); + +/// Renderer information inspector widget. +pub const Info = struct { + features: std.AutoArrayHashMapUnmanaged( + std.meta.Tag(renderer.Overlay.Feature), + renderer.Overlay.Feature, + ), + + pub const empty: Info = .{ + .features = .empty, + }; + + pub fn deinit(self: *Info, alloc: Allocator) void { + self.features.deinit(alloc); + } + + /// Grab the features into a new allocated slice. This is used by + pub fn overlayFeatures( + self: *const Info, + alloc: Allocator, + ) Allocator.Error![]renderer.Overlay.Feature { + // The features from our internal state. + const features = self.features.values(); + + // For now we do a dumb copy since the features have no managed + // memory. + const result = try alloc.dupe( + renderer.Overlay.Feature, + features, + ); + errdefer alloc.free(result); + + return result; + } + + /// Draw the renderer info window. + pub fn draw( + self: *Info, + alloc: Allocator, + open: bool, + ) void { + if (!open) return; + + cimgui.c.ImGui_SetNextItemOpen(true, cimgui.c.ImGuiCond_Once); + if (!cimgui.c.ImGui_CollapsingHeader("Overlays", cimgui.c.ImGuiTreeNodeFlags_None)) return; + + cimgui.c.ImGui_SeparatorText("Hyperlinks"); + self.overlayHyperlinks(alloc); + cimgui.c.ImGui_SeparatorText("Semantic Prompts"); + self.overlaySemanticPrompts(alloc); + } + + fn overlayHyperlinks(self: *Info, alloc: Allocator) void { + var hyperlinks: bool = self.features.contains(.highlight_hyperlinks); + _ = cimgui.c.ImGui_Checkbox("Overlay Hyperlinks", &hyperlinks); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("When enabled, highlights OSC8 hyperlinks."); + + if (!hyperlinks) { + _ = self.features.swapRemove(.highlight_hyperlinks); + } else { + self.features.put( + alloc, + .highlight_hyperlinks, + .highlight_hyperlinks, + ) catch log.warn("error enabling hyperlink overlay feature", .{}); + } + } + + fn overlaySemanticPrompts(self: *Info, alloc: Allocator) void { + var semantic_prompts: bool = self.features.contains(.semantic_prompts); + _ = cimgui.c.ImGui_Checkbox("Overlay Semantic Prompts", &semantic_prompts); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("When enabled, highlights OSC 133 semantic prompts."); + + // Handle the checkbox results + if (!semantic_prompts) { + _ = self.features.swapRemove(.semantic_prompts); + } else { + self.features.put( + alloc, + .semantic_prompts, + .semantic_prompts, + ) catch log.warn("error enabling semantic prompt overlay feature", .{}); + } + + // Help + cimgui.c.ImGui_Indent(); + defer cimgui.c.ImGui_Unindent(); + + cimgui.c.ImGui_TextDisabled("Colors:"); + + const prompt_rgb = renderer.Overlay.Color.semantic_prompt.rgb(); + const input_rgb = renderer.Overlay.Color.semantic_input.rgb(); + const prompt_col: cimgui.c.ImVec4 = .{ + .x = @as(f32, @floatFromInt(prompt_rgb.r)) / 255.0, + .y = @as(f32, @floatFromInt(prompt_rgb.g)) / 255.0, + .z = @as(f32, @floatFromInt(prompt_rgb.b)) / 255.0, + .w = 1.0, + }; + const input_col: cimgui.c.ImVec4 = .{ + .x = @as(f32, @floatFromInt(input_rgb.r)) / 255.0, + .y = @as(f32, @floatFromInt(input_rgb.g)) / 255.0, + .z = @as(f32, @floatFromInt(input_rgb.b)) / 255.0, + .w = 1.0, + }; + + _ = cimgui.c.ImGui_ColorButton("##prompt_color", prompt_col, cimgui.c.ImGuiColorEditFlags_NoTooltip); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("Prompt"); + + _ = cimgui.c.ImGui_ColorButton("##input_color", input_col, cimgui.c.ImGuiColorEditFlags_NoTooltip); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("Input"); + } +}; diff --git a/src/inspector/widgets/screen.zig b/src/inspector/widgets/screen.zig new file mode 100644 index 000000000..481413b4a --- /dev/null +++ b/src/inspector/widgets/screen.zig @@ -0,0 +1,369 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const cimgui = @import("dcimgui"); +const widgets = @import("../widgets.zig"); +const units = @import("../units.zig"); +const terminal = @import("../../terminal/main.zig"); +const stylepkg = @import("../../terminal/style.zig"); + +/// Window names for the screen dockspace. +const window_info = "Info"; +const window_cell = "Cell"; +const window_pagelist = "PageList"; + +/// Screen information inspector widget. +pub const Info = struct { + pagelist: widgets.pagelist.Inspector, + cell_chooser: widgets.pagelist.CellChooser, + + pub const empty: Info = .{ + .pagelist = .empty, + .cell_chooser = .empty, + }; + + /// Draw the screen info contents. + pub fn draw(self: *Info, open: bool, data: struct { + /// The screen that we're inspecting. + screen: *terminal.Screen, + + /// Which screen key we're viewing. + key: terminal.ScreenSet.Key, + + /// Which screen is active (primary or alternate). + active_key: terminal.ScreenSet.Key, + + /// Whether xterm modify other keys mode 2 is enabled. + modify_other_keys_2: bool, + + /// Color palette for cursor color resolution. + color_palette: *const terminal.color.DynamicPalette, + }) void { + // Create the dockspace for this screen + const dockspace_id = cimgui.c.ImGui_GetID("Screen Dockspace"); + _ = createDockSpace(dockspace_id); + + const screen = data.screen; + + // Info window + info: { + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + window_info, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) break :info; + + if (cimgui.c.ImGui_CollapsingHeader( + "Cursor", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + cursorTable(&screen.cursor); + cimgui.c.ImGui_Separator(); + cursorStyle( + &screen.cursor, + &data.color_palette.current, + ); + } + + if (cimgui.c.ImGui_CollapsingHeader( + "Keyboard", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) keyboardTable( + screen, + data.modify_other_keys_2, + ); + + if (cimgui.c.ImGui_CollapsingHeader( + "Kitty Graphics", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) kittyGraphicsTable(&screen.kitty_images); + + if (cimgui.c.ImGui_CollapsingHeader( + "Other Screen State", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) internalStateTable(screen); + } + + // Cell window + cell: { + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + window_cell, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) break :cell; + self.cell_chooser.draw(&screen.pages); + } + + // PageList window + pagelist: { + defer cimgui.c.ImGui_End(); + if (!cimgui.c.ImGui_Begin( + window_pagelist, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) break :pagelist; + self.pagelist.draw(&screen.pages); + } + + // The remainder is the open state + if (!open) return; + + // Show warning if viewing an inactive screen + if (data.key != data.active_key) { + cimgui.c.ImGui_TextColored( + .{ .x = 1.0, .y = 0.8, .z = 0.0, .w = 1.0 }, + "âš  Viewing inactive screen", + ); + cimgui.c.ImGui_Separator(); + } + } + + /// Create the dock space for the screen inspector. This creates + /// a dedicated dock space for the screen inspector windows. But they + /// can of course be undocked and moved around as desired. + fn createDockSpace(dockspace_id: cimgui.c.ImGuiID) bool { + // Check if we need to set up the dockspace + const setup = cimgui.ImGui_DockBuilderGetNode(dockspace_id) == null; + + if (setup) { + // Register our dockspace node + assert(cimgui.ImGui_DockBuilderAddNodeEx( + dockspace_id, + cimgui.ImGuiDockNodeFlagsPrivate.DockSpace, + ) == dockspace_id); + + // Dock windows into the space + cimgui.ImGui_DockBuilderDockWindow(window_info, dockspace_id); + cimgui.ImGui_DockBuilderDockWindow(window_cell, dockspace_id); + cimgui.ImGui_DockBuilderDockWindow(window_pagelist, dockspace_id); + cimgui.ImGui_DockBuilderFinish(dockspace_id); + } + + // Create the dockspace + assert(cimgui.c.ImGui_DockSpaceEx( + dockspace_id, + .{ .x = 0, .y = 0 }, + cimgui.c.ImGuiDockNodeFlags_None, + null, + ) == dockspace_id); + return setup; + } +}; + +/// Render cursor state with a table of cursor-specific fields. +pub fn cursorTable( + cursor: *const terminal.Screen.Cursor, +) void { + if (!cimgui.c.ImGui_BeginTable( + "table_cursor", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Position (x, y)"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The current cursor position in the terminal grid (0-indexed)."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("(%d, %d)", cursor.x, cursor.y); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hyperlink"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The active OSC8 hyperlink for newly printed characters."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (cursor.hyperlink) |link| { + cimgui.c.ImGui_Text("%.*s", link.uri.len, link.uri.ptr); + } else { + cimgui.c.ImGui_TextDisabled("(none)"); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Pending Wrap"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The 'last column flag' (LCF). If set, the next character will force a soft-wrap to the next line."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + var value: bool = cursor.pending_wrap; + _ = cimgui.c.ImGui_Checkbox("##pending_wrap", &value); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Protected"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("If enabled, new characters will have the protected attribute set, preventing erasure by certain sequences."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + var value: bool = cursor.protected; + _ = cimgui.c.ImGui_Checkbox("##protected", &value); + } +} + +/// Render cursor style information using the shared style table. +pub fn cursorStyle(cursor: *const terminal.Screen.Cursor, palette: ?*const terminal.color.Palette) void { + widgets.style.table(cursor.style, palette); +} + +/// Render keyboard information with a table. +fn keyboardTable( + screen: *const terminal.Screen, + modify_other_keys_2: bool, +) void { + if (!cimgui.c.ImGui_BeginTable( + "table_keyboard", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + const kitty_flags = screen.kitty_keyboard.current(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Mode"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + const mode = if (kitty_flags.int() != 0) "kitty" else "legacy"; + cimgui.c.ImGui_Text("%s", mode.ptr); + } + } + + if (kitty_flags.int() != 0) { + const Flags = @TypeOf(kitty_flags); + inline for (@typeInfo(Flags).@"struct".fields) |field| { + { + const value = @field(kitty_flags, field.name); + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + const field_name = std.fmt.comptimePrint("{s}", .{field.name}); + cimgui.c.ImGui_Text("%s", field_name.ptr); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%s", + if (value) "true".ptr else "false".ptr, + ); + } + } + } + } else { + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Xterm modify keys"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%s", + if (modify_other_keys_2) "true".ptr else "false".ptr, + ); + } + } + } // keyboard mode info +} + +/// Render kitty graphics information table. +pub fn kittyGraphicsTable( + kitty_images: *const terminal.kitty.graphics.ImageStorage, +) void { + if (!kitty_images.enabled()) { + cimgui.c.ImGui_TextDisabled("(Kitty graphics are disabled)"); + return; + } + + if (!cimgui.c.ImGui_BeginTable( + "##kitty_graphics", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Usage"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_bytes, units.toKibiBytes(kitty_images.total_bytes)); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Limit"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", kitty_images.total_limit, units.toKibiBytes(kitty_images.total_limit)); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Image Count"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", kitty_images.images.count()); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Placement Count"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d", kitty_images.placements.count()); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Image Loading"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", if (kitty_images.loading != null) "true".ptr else "false".ptr); +} + +/// Render internal terminal state table. +pub fn internalStateTable( + screen: *const terminal.Screen, +) void { + const pages = &screen.pages; + + if (!cimgui.c.ImGui_BeginTable( + "##terminal_state", + 2, + cimgui.c.ImGuiTableFlags_None, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Usage"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.page_size, units.toKibiBytes(pages.page_size)); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Memory Limit"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%d bytes (%d KiB)", pages.maxSize(), units.toKibiBytes(pages.maxSize())); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Viewport Location"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Semantic Content"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Whether semantic prompt markers (OSC 133) have been seen."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + var value: bool = screen.flags.semantic_content; + _ = cimgui.c.ImGui_Checkbox("##semantic_content", &value); + } +} diff --git a/src/inspector/widgets/style.zig b/src/inspector/widgets/style.zig new file mode 100644 index 000000000..bdc1816e0 --- /dev/null +++ b/src/inspector/widgets/style.zig @@ -0,0 +1,125 @@ +const std = @import("std"); +const cimgui = @import("dcimgui"); +const terminal = @import("../../terminal/main.zig"); +const widgets = @import("../widgets.zig"); + +/// Render a style as a table. +pub fn table( + st: terminal.Style, + palette: ?*const terminal.color.Palette, +) void { + { + _ = cimgui.c.ImGui_BeginTable( + "style", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Foreground"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The foreground (text) color"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + color("fg", st.fg_color, palette); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Background"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The background (cell) color"); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + color("bg", st.bg_color, palette); + } + + { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Underline"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The underline color, if underlines are enabled."); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + color("underline", st.underline_color, palette); + } + + const style_flags = .{ + .{ "bold", "Text will be rendered with bold weight." }, + .{ "italic", "Text will be rendered in italic style." }, + .{ "faint", "Text will be rendered with reduced intensity." }, + .{ "blink", "Text will blink." }, + .{ "inverse", "Foreground and background colors are swapped." }, + .{ "invisible", "Text will be invisible (hidden)." }, + .{ "strikethrough", "Text will have a line through it." }, + }; + inline for (style_flags) |entry| entry: { + const style = entry[0]; + const help = entry[1]; + if (!@field(st.flags, style)) break :entry; + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text(style.ptr); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker(help); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("true"); + } + } + } + + cimgui.c.ImGui_TextDisabled("(Any styles not shown are not currently set)"); +} + +/// Render a style color. +pub fn color( + id: [:0]const u8, + c: terminal.Style.Color, + palette: ?*const terminal.color.Palette, +) void { + cimgui.c.ImGui_PushID(id); + defer cimgui.c.ImGui_PopID(); + + switch (c) { + .none => cimgui.c.ImGui_Text("default"), + + .palette => |idx| { + cimgui.c.ImGui_Text("Palette %d", idx); + if (palette) |p| { + const rgb = p[idx]; + var data: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.ImGui_ColorEdit3( + "color_fg", + &data, + cimgui.c.ImGuiColorEditFlags_DisplayHex | + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + } + }, + + .rgb => |rgb| { + var data: [3]f32 = .{ + @as(f32, @floatFromInt(rgb.r)) / 255, + @as(f32, @floatFromInt(rgb.g)) / 255, + @as(f32, @floatFromInt(rgb.b)) / 255, + }; + _ = cimgui.c.ImGui_ColorEdit3( + "color_fg", + &data, + cimgui.c.ImGuiColorEditFlags_DisplayHex | + cimgui.c.ImGuiColorEditFlags_NoPicker | + cimgui.c.ImGuiColorEditFlags_NoLabel, + ); + }, + } +} diff --git a/src/inspector/widgets/surface.zig b/src/inspector/widgets/surface.zig new file mode 100644 index 000000000..d73e784ce --- /dev/null +++ b/src/inspector/widgets/surface.zig @@ -0,0 +1,503 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const cimgui = @import("dcimgui"); +const inspector = @import("../main.zig"); +const widgets = @import("../widgets.zig"); +const input = @import("../../input.zig"); +const renderer = @import("../../renderer.zig"); +const terminal = @import("../../terminal/main.zig"); +const Surface = @import("../../Surface.zig"); + +/// This is discovered via the hardcoded string in the ImGui demo window. +const window_imgui_demo = "Dear ImGui Demo"; +const window_keyboard = "Keyboard"; +const window_terminal = "Terminal"; +const window_surface = "Surface"; +const window_termio = "Terminal IO"; +const window_renderer = "Renderer"; + +pub const Inspector = struct { + /// Internal GUI state + surface_info: Info, + key_stream: widgets.key.Stream, + terminal_info: widgets.terminal.Info, + vt_stream: widgets.termio.Stream, + renderer_info: widgets.renderer.Info, + show_demo_window: bool, + + pub fn init(alloc: Allocator) !Inspector { + return .{ + .surface_info = .empty, + .key_stream = try .init(alloc), + .terminal_info = .empty, + .vt_stream = try .init(alloc), + .renderer_info = .empty, + .show_demo_window = true, + }; + } + + pub fn deinit(self: *Inspector, alloc: Allocator) void { + self.key_stream.deinit(alloc); + self.vt_stream.deinit(alloc); + self.renderer_info.deinit(alloc); + } + + pub fn draw( + self: *Inspector, + surface: *const Surface, + mouse: Mouse, + ) void { + // Create our dockspace first. If we had to setup our dockspace, + // then it is a first render. + const dockspace_id = cimgui.c.ImGui_GetID("Main Dockspace"); + const first_render = createDockSpace(dockspace_id); + + // Draw everything that requires the terminal state mutex. + { + surface.renderer_state.mutex.lock(); + defer surface.renderer_state.mutex.unlock(); + const t = surface.renderer_state.terminal; + + // Terminal info window + { + const open = cimgui.c.ImGui_Begin( + window_terminal, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + defer cimgui.c.ImGui_End(); + self.terminal_info.draw(open, t); + } + + // Surface info window + { + const open = cimgui.c.ImGui_Begin( + window_surface, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + defer cimgui.c.ImGui_End(); + self.surface_info.draw( + open, + surface, + mouse, + ); + } + + // Keyboard info window + { + const open = cimgui.c.ImGui_Begin( + window_keyboard, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + defer cimgui.c.ImGui_End(); + self.key_stream.draw( + open, + surface.alloc, + ); + } + + // Terminal IO window + { + const open = cimgui.c.ImGui_Begin( + window_termio, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + defer cimgui.c.ImGui_End(); + if (open) { + self.vt_stream.draw( + surface.alloc, + &t.colors.palette.current, + ); + } + } + + // Renderer info window + { + const open = cimgui.c.ImGui_Begin( + window_renderer, + null, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + defer cimgui.c.ImGui_End(); + self.renderer_info.draw( + surface.alloc, + open, + ); + } + } + + // In debug we show the ImGui demo window so we can easily view + // available widgets and such. + if (comptime builtin.mode == .Debug) { + if (self.show_demo_window) { + cimgui.c.ImGui_ShowDemoWindow(&self.show_demo_window); + } + } + + if (first_render) { + // On first render, setup our initial focus state. We only + // do this on first render so that we can let the user change + // focus afterward without it snapping back. + cimgui.c.ImGui_SetWindowFocusStr(window_terminal); + } + } + + /// Create the global dock space for the inspector. A dock space + /// is a special area where windows can be docked into. The global + /// dock space fills the entire main viewport. + /// + /// Returns true if this was the first time the dock space was created. + fn createDockSpace(dockspace_id: cimgui.c.ImGuiID) bool { + const viewport: *cimgui.c.ImGuiViewport = cimgui.c.ImGui_GetMainViewport(); + + // Initial Docking setup + const setup = cimgui.ImGui_DockBuilderGetNode(dockspace_id) == null; + if (setup) { + // Register our dockspace node + assert(cimgui.ImGui_DockBuilderAddNodeEx( + dockspace_id, + cimgui.ImGuiDockNodeFlagsPrivate.DockSpace, + ) == dockspace_id); + + // Ensure it is the full size of the viewport + cimgui.ImGui_DockBuilderSetNodeSize( + dockspace_id, + viewport.Size, + ); + + // We only initialize one central docking point now but + // this is the point we'd pre-split and so on for the initial + // layout. + const dock_id_main: cimgui.c.ImGuiID = dockspace_id; + cimgui.ImGui_DockBuilderDockWindow(window_imgui_demo, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_terminal, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_surface, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_keyboard, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_termio, dock_id_main); + cimgui.ImGui_DockBuilderDockWindow(window_renderer, dock_id_main); + cimgui.ImGui_DockBuilderFinish(dockspace_id); + } + + // Put the dockspace over the viewport. + assert(cimgui.c.ImGui_DockSpaceOverViewportEx( + dockspace_id, + viewport, + cimgui.c.ImGuiDockNodeFlags_PassthruCentralNode, + null, + ) == dockspace_id); + return setup; + } +}; + +pub const Mouse = struct { + /// Last hovered x/y + last_xpos: f64 = 0, + last_ypos: f64 = 0, + + // Last hovered screen point + last_point: ?terminal.Pin = null, +}; + +/// Surface information inspector widget. +pub const Info = struct { + pub const empty: Info = .{}; + + /// Draw the surface info window. + pub fn draw( + self: *Info, + open: bool, + surface: *const Surface, + mouse: Mouse, + ) void { + _ = self; + if (!open) return; + + if (cimgui.c.ImGui_CollapsingHeader( + "Help", + cimgui.c.ImGuiTreeNodeFlags_None, + )) { + cimgui.c.ImGui_TextWrapped( + "This window displays information about the surface (window). " ++ + "A surface is the graphical area that displays the terminal " ++ + "content. It includes dimensions, font sizing, and mouse state " ++ + "information specific to this window instance.", + ); + } + + cimgui.c.ImGui_SeparatorText("Dimensions"); + dimensionsTable(surface); + + cimgui.c.ImGui_SeparatorText("Font"); + fontTable(surface); + + cimgui.c.ImGui_SeparatorText("Mouse"); + mouseTable(surface, mouse); + } +}; + +fn dimensionsTable(surface: *const Surface) void { + _ = cimgui.c.ImGui_BeginTable( + "table_size", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + // Screen Size + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Screen Size"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%dpx x %dpx", + surface.size.screen.width, + surface.size.screen.height, + ); + } + } + + // Grid Size + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grid Size"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + const grid_size = surface.size.grid(); + cimgui.c.ImGui_Text( + "%dc x %dr", + grid_size.columns, + grid_size.rows, + ); + } + } + + // Cell Size + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Cell Size"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%dpx x %dpx", + surface.size.cell.width, + surface.size.cell.height, + ); + } + } + + // Padding + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Window Padding"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "T=%d B=%d L=%d R=%d px", + surface.size.padding.top, + surface.size.padding.bottom, + surface.size.padding.left, + surface.size.padding.right, + ); + } + } +} + +fn fontTable(surface: *const Surface) void { + _ = cimgui.c.ImGui_BeginTable( + "table_font", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Size (Points)"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%.2f pt", + surface.font_size.points, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Size (Pixels)"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%.2f px", + surface.font_size.pixels(), + ); + } + } +} + +fn mouseTable( + surface: *const Surface, + mouse: Mouse, +) void { + _ = cimgui.c.ImGui_BeginTable( + "table_mouse", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + const surface_mouse = &surface.mouse; + const t = surface.renderer_state.terminal; + + { + const hover_point: terminal.point.Coordinate = pt: { + const p = mouse.last_point orelse break :pt .{}; + const pt = t.screens.active.pages.pointFromPin( + .active, + p, + ) orelse break :pt .{}; + break :pt pt.coord(); + }; + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hover Grid"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "row=%d, col=%d", + hover_point.y, + hover_point.x, + ); + } + } + + { + const coord: renderer.Coordinate.Terminal = (renderer.Coordinate{ + .surface = .{ + .x = mouse.last_xpos, + .y = mouse.last_ypos, + }, + }).convert(.terminal, surface.size).terminal; + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Hover Point"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "(%dpx, %dpx)", + @as(i64, @intFromFloat(coord.x)), + @as(i64, @intFromFloat(coord.y)), + ); + } + } + + const any_click = for (surface_mouse.click_state) |state| { + if (state == .press) break true; + } else false; + + click: { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Click State"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (!any_click) { + cimgui.c.ImGui_Text("none"); + break :click; + } + + for (surface_mouse.click_state, 0..) |state, i| { + if (state != .press) continue; + const button: input.MouseButton = @enumFromInt(i); + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("%s", (switch (button) { + .unknown => "?", + .left => "L", + .middle => "M", + .right => "R", + .four => "{4}", + .five => "{5}", + .six => "{6}", + .seven => "{7}", + .eight => "{8}", + .nine => "{9}", + .ten => "{10}", + .eleven => "{11}", + }).ptr); + } + } + } + + { + const left_click_point: terminal.point.Coordinate = pt: { + const p = surface_mouse.left_click_pin orelse break :pt .{}; + const pt = t.screens.active.pages.pointFromPin( + .active, + p.*, + ) orelse break :pt .{}; + break :pt pt.coord(); + }; + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Click Grid"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "row=%d, col=%d", + left_click_point.y, + left_click_point.x, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Click Point"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "(%dpx, %dpx)", + @as(u32, @intFromFloat(surface_mouse.left_click_xpos)), + @as(u32, @intFromFloat(surface_mouse.left_click_ypos)), + ); + } + } +} diff --git a/src/inspector/widgets/terminal.zig b/src/inspector/widgets/terminal.zig new file mode 100644 index 000000000..3dbea3886 --- /dev/null +++ b/src/inspector/widgets/terminal.zig @@ -0,0 +1,726 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const cimgui = @import("dcimgui"); +const widgets = @import("../widgets.zig"); +const terminal = @import("../../terminal/main.zig"); +const modes = terminal.modes; +const Terminal = terminal.Terminal; + +/// Terminal information inspector widget. +pub const Info = struct { + /// True if we're showing the 256-color palette window. + show_palette: bool, + + /// The various detachable headers. + misc_header: widgets.DetachableHeader, + layout_header: widgets.DetachableHeader, + mouse_header: widgets.DetachableHeader, + color_header: widgets.DetachableHeader, + modes_header: widgets.DetachableHeader, + + /// Screen detail windows for each screen key. + screens: ScreenMap, + + pub const empty: Info = .{ + .show_palette = false, + .misc_header = .{}, + .layout_header = .{}, + .mouse_header = .{}, + .color_header = .{}, + .modes_header = .{}, + .screens = .{}, + }; + + /// Draw the terminal info window. + pub fn draw( + self: *Info, + open: bool, + t: *Terminal, + ) void { + // Draw our open state if we're open. + if (open) self.drawOpen(t); + + // Draw our detached state that draws regardless of if + // we're open or not. + if (self.misc_header.window("Terminal Misc")) |visible| { + defer self.misc_header.windowEnd(); + if (visible) miscTable(t); + } + if (self.layout_header.window("Terminal Layout")) |visible| { + defer self.layout_header.windowEnd(); + if (visible) layoutTable(t); + } + if (self.mouse_header.window("Terminal Mouse")) |visible| { + defer self.mouse_header.windowEnd(); + if (visible) mouseTable(t); + } + if (self.color_header.window("Terminal Color")) |visible| { + defer self.color_header.windowEnd(); + if (visible) colorTable(t, &self.show_palette); + } + if (self.modes_header.window("Terminal Modes")) |visible| { + defer self.modes_header.windowEnd(); + if (visible) modesTable(t); + } + + // Palette pop-out window + if (self.show_palette) { + defer cimgui.c.ImGui_End(); + if (cimgui.c.ImGui_Begin( + "256-Color Palette", + &self.show_palette, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + )) { + palette("palette", &t.colors.palette.current); + } + } + + // Screen pop-out windows + var it = self.screens.iterator(); + while (it.next()) |entry| { + const screen = t.screens.get(entry.key) orelse { + // Could happen if we opened up a window for a screen + // and that screen was subsequently deinitialized. In + // this case, hide the window. + self.screens.remove(entry.key); + continue; + }; + + var title_buf: [128]u8 = undefined; + const title = std.fmt.bufPrintZ( + &title_buf, + "Screen: {t}", + .{entry.key}, + ) catch "Screen"; + + // Setup our next window so it has some size to it. + const viewport = cimgui.c.ImGui_GetMainViewport(); + cimgui.c.ImGui_SetNextWindowSize( + .{ + .x = @min(400, viewport.*.Size.x), + .y = @min(300, viewport.*.Size.y), + }, + cimgui.c.ImGuiCond_FirstUseEver, + ); + + var screen_open: bool = true; + defer cimgui.c.ImGui_End(); + const screen_draw = cimgui.c.ImGui_Begin( + title, + &screen_open, + cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, + ); + entry.value.draw(screen_draw, .{ + .screen = screen, + .key = entry.key, + .active_key = t.screens.active_key, + .modify_other_keys_2 = t.flags.modify_other_keys_2, + .color_palette = &t.colors.palette, + }); + + // If the window was closed, remove it from our map so future + // renders don't draw it. + if (!screen_open) self.screens.remove(entry.key); + } + } + + fn drawOpen(self: *Info, t: *Terminal) void { + // Show our screens up top. + screensTable(t, &self.screens); + + if (self.misc_header.header("Misc")) miscTable(t); + if (self.layout_header.header("Layout")) layoutTable(t); + if (self.mouse_header.header("Mouse")) mouseTable(t); + if (self.color_header.header("Color")) colorTable(t, &self.show_palette); + if (self.modes_header.header("Modes")) modesTable(t); + } +}; + +pub const ScreenMap = std.EnumMap( + terminal.ScreenSet.Key, + widgets.screen.Info, +); + +/// Render the table of possible screens with various actions. +fn screensTable( + t: *Terminal, + map: *ScreenMap, +) void { + if (!cimgui.c.ImGui_BeginTable( + "screens", + 3, + cimgui.c.ImGuiTableFlags_Borders | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn("Screen", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("Status", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_WidthFixed); + + // Custom header row to include help marker before "Screen" + { + cimgui.c.ImGui_TableNextRowEx(cimgui.c.ImGuiTableRowFlags_Headers, 0.0); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_PushStyleVarImVec2(cimgui.c.ImGuiStyleVar_FramePadding, .{ .x = 0, .y = 0 }); + widgets.helpMarker( + "A terminal can have multiple screens, only one of which is active at " ++ + "a time. Each screen has its own grid, contents, and other state. " ++ + "This section allows you to inspect the different screens managed by " ++ + "the terminal.", + ); + cimgui.c.ImGui_PopStyleVar(); + cimgui.c.ImGui_SameLineEx(0.0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); + cimgui.c.ImGui_TableHeader("Screen"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_TableHeader("Status"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_TableHeader(""); + } + } + + for (std.meta.tags(terminal.ScreenSet.Key)) |key| { + const is_initialized = t.screens.get(key) != null; + const is_active = t.screens.active_key == key; + + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%s", @tagName(key).ptr); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (is_active) { + cimgui.c.ImGui_TextColored( + .{ .x = 0.4, .y = 1.0, .z = 0.4, .w = 1.0 }, + "active", + ); + } else if (is_initialized) { + cimgui.c.ImGui_TextColored( + .{ .x = 0.6, .y = 0.6, .z = 0.6, .w = 1.0 }, + "initialized", + ); + } else { + cimgui.c.ImGui_TextColored( + .{ .x = 0.4, .y = 0.4, .z = 0.4, .w = 1.0 }, + "(not initialized)", + ); + } + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_PushIDInt(@intFromEnum(key)); + defer cimgui.c.ImGui_PopID(); + cimgui.c.ImGui_BeginDisabled(!is_initialized); + defer cimgui.c.ImGui_EndDisabled(); + if (cimgui.c.ImGui_Button("View")) { + if (!map.contains(key)) { + map.put(key, .empty); + } + } + } + } +} + +/// Table of miscellaneous terminal information. +fn miscTable(t: *Terminal) void { + _ = cimgui.c.ImGui_BeginTable( + "table_misc", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Working Directory"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The current working directory reported by the shell."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (t.pwd.items.len > 0) { + cimgui.c.ImGui_Text( + "%.*s", + t.pwd.items.len, + t.pwd.items.ptr, + ); + } else { + cimgui.c.ImGui_TextDisabled("(none)"); + } + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Focused"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Whether the terminal itself is currently focused."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + var value: bool = t.flags.focused; + _ = cimgui.c.ImGui_Checkbox("##focused", &value); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Previous Char"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The previously printed character, used only for the REP sequence."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (t.previous_char) |c| { + cimgui.c.ImGui_Text("U+%04X", @as(u32, c)); + } else { + cimgui.c.ImGui_TextDisabled("(none)"); + } + } + } +} + +/// Table of terminal layout information. +fn layoutTable(t: *Terminal) void { + _ = cimgui.c.ImGui_BeginTable( + "table_layout", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Grid"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The size of the terminal grid in columns and rows."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%dc x %dr", + t.cols, + t.rows, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Pixels"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The size of the terminal grid in pixels."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%dw x %dh", + t.width_px, + t.height_px, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Scroll Region"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The scrolling region boundaries (top, bottom, left, right)."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_PushItemWidth(cimgui.c.ImGui_CalcTextSize("00000").x); + defer cimgui.c.ImGui_PopItemWidth(); + + var override = t.scrolling_region; + var changed = false; + + cimgui.c.ImGui_AlignTextToFramePadding(); + cimgui.c.ImGui_Text("T:"); + cimgui.c.ImGui_SameLine(); + if (cimgui.c.ImGui_InputScalar( + "##scroll_top", + cimgui.c.ImGuiDataType_U16, + &override.top, + )) { + override.top = @min(override.top, t.rows -| 1); + changed = true; + } + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("B:"); + cimgui.c.ImGui_SameLine(); + if (cimgui.c.ImGui_InputScalar( + "##scroll_bottom", + cimgui.c.ImGuiDataType_U16, + &override.bottom, + )) { + override.bottom = @min(override.bottom, t.rows -| 1); + changed = true; + } + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("L:"); + cimgui.c.ImGui_SameLine(); + if (cimgui.c.ImGui_InputScalar( + "##scroll_left", + cimgui.c.ImGuiDataType_U16, + &override.left, + )) { + override.left = @min(override.left, t.cols -| 1); + changed = true; + } + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("R:"); + cimgui.c.ImGui_SameLine(); + if (cimgui.c.ImGui_InputScalar( + "##scroll_right", + cimgui.c.ImGuiDataType_U16, + &override.right, + )) { + override.right = @min(override.right, t.cols -| 1); + changed = true; + } + + if (changed and + override.top < override.bottom and + override.left < override.right) + { + t.scrolling_region = override; + } + } + } +} + +/// Table of mouse-related terminal information. +fn mouseTable(t: *Terminal) void { + _ = cimgui.c.ImGui_BeginTable( + "table_mouse", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Event Mode"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The mouse event reporting mode set by the application."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(t.flags.mouse_event).ptr); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Format"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The mouse event encoding format."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(t.flags.mouse_format).ptr); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Shape"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The current mouse cursor shape set by the application."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text("%s", @tagName(t.mouse_shape).ptr); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Shift Capture"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("XTSHIFTESCAPE state for capturing shift in mouse protocol."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (t.flags.mouse_shift_capture == .null) { + cimgui.c.ImGui_TextDisabled("(unset)"); + } else { + cimgui.c.ImGui_Text("%s", @tagName(t.flags.mouse_shift_capture).ptr); + } + } + } +} + +/// Table of color-related terminal information. +fn colorTable( + t: *Terminal, + show_palette: *bool, +) void { + cimgui.c.ImGui_TextWrapped( + "Color state for the terminal. Note these colors only apply " ++ + "to the palette and unstyled colors. Many modern terminal " ++ + "applications use direct RGB colors which are not reflected here.", + ); + cimgui.c.ImGui_Separator(); + + _ = cimgui.c.ImGui_BeginTable( + "table_color", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Background"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Unstyled cell background color."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + _ = dynamicRGB( + "bg_color", + &t.colors.background, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Foreground"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Unstyled cell foreground color."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + _ = dynamicRGB( + "fg_color", + &t.colors.foreground, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Cursor"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Cursor coloring set by escape sequences."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + _ = dynamicRGB( + "cursor_color", + &t.colors.cursor, + ); + } + } + + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Palette"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("The 256-color palette."); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + if (cimgui.c.ImGui_Button("View")) { + show_palette.* = true; + } + } + } +} + +/// Table of terminal modes. +fn modesTable(t: *Terminal) void { + _ = cimgui.c.ImGui_BeginTable( + "table_modes", + 3, + cimgui.c.ImGuiTableFlags_SizingFixedFit | + cimgui.c.ImGuiTableFlags_RowBg, + ); + defer cimgui.c.ImGui_EndTable(); + + { + cimgui.c.ImGui_TableSetupColumn("", cimgui.c.ImGuiTableColumnFlags_NoResize); + cimgui.c.ImGui_TableSetupColumn("Number", cimgui.c.ImGuiTableColumnFlags_PreferSortAscending); + cimgui.c.ImGui_TableSetupColumn("Name", cimgui.c.ImGuiTableColumnFlags_WidthStretch); + cimgui.c.ImGui_TableHeadersRow(); + } + + inline for (@typeInfo(terminal.Mode).@"enum".fields) |field| { + @setEvalBranchQuota(6000); + const tag: modes.ModeTag = @bitCast(@as(modes.ModeTag.Backing, field.value)); + + cimgui.c.ImGui_TableNextRow(); + cimgui.c.ImGui_PushIDInt(@intCast(field.value)); + defer cimgui.c.ImGui_PopID(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + var value: bool = t.modes.get(@field(terminal.Mode, field.name)); + _ = cimgui.c.ImGui_Checkbox("##checkbox", &value); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "%s%d", + if (tag.ansi) "" else "?", + @as(u32, @intCast(tag.value)), + ); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + const name = std.fmt.comptimePrint("{s}", .{field.name}); + cimgui.c.ImGui_Text("%s", name.ptr); + } + } +} + +/// Render a DynamicRGB color. +fn dynamicRGB( + label: [:0]const u8, + rgb: *terminal.color.DynamicRGB, +) bool { + _ = cimgui.c.ImGui_BeginTable( + label, + if (rgb.override != null) 2 else 1, + cimgui.c.ImGuiTableFlags_SizingFixedFit, + ); + defer cimgui.c.ImGui_EndTable(); + + if (rgb.override != null) cimgui.c.ImGui_TableSetupColumn( + "##label", + cimgui.c.ImGuiTableColumnFlags_WidthFixed, + ); + cimgui.c.ImGui_TableSetupColumn( + "##value", + cimgui.c.ImGuiTableColumnFlags_WidthStretch, + ); + + if (rgb.override) |c| { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("override:"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Overridden color set by escape sequences."); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + var col = [3]f32{ + @as(f32, @floatFromInt(c.r)) / 255.0, + @as(f32, @floatFromInt(c.g)) / 255.0, + @as(f32, @floatFromInt(c.b)) / 255.0, + }; + _ = cimgui.c.ImGui_ColorEdit3( + "##override", + &col, + cimgui.c.ImGuiColorEditFlags_None, + ); + } + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + if (rgb.default) |c| { + if (rgb.override != null) { + cimgui.c.ImGui_Text("default:"); + cimgui.c.ImGui_SameLine(); + widgets.helpMarker("Default color from configuration."); + + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + } + + var col = [3]f32{ + @as(f32, @floatFromInt(c.r)) / 255.0, + @as(f32, @floatFromInt(c.g)) / 255.0, + @as(f32, @floatFromInt(c.b)) / 255.0, + }; + _ = cimgui.c.ImGui_ColorEdit3( + "##default", + &col, + cimgui.c.ImGuiColorEditFlags_None, + ); + } else { + cimgui.c.ImGui_TextDisabled("(unset)"); + } + + return false; +} + +/// Render a color palette as a 16x16 grid of color buttons. +fn palette( + label: [:0]const u8, + pal: *const terminal.color.Palette, +) void { + cimgui.c.ImGui_PushID(label); + defer cimgui.c.ImGui_PopID(); + + for (0..16) |row| { + for (0..16) |col| { + const idx = row * 16 + col; + const rgb = pal[idx]; + var col_arr = [3]f32{ + @as(f32, @floatFromInt(rgb.r)) / 255.0, + @as(f32, @floatFromInt(rgb.g)) / 255.0, + @as(f32, @floatFromInt(rgb.b)) / 255.0, + }; + + if (col > 0) cimgui.c.ImGui_SameLine(); + + cimgui.c.ImGui_PushIDInt(@intCast(idx)); + _ = cimgui.c.ImGui_ColorEdit3( + "##color", + &col_arr, + cimgui.c.ImGuiColorEditFlags_NoInputs, + ); + if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { + cimgui.c.ImGui_SetTooltip( + "%d: #%02X%02X%02X", + idx, + rgb.r, + rgb.g, + rgb.b, + ); + } + cimgui.c.ImGui_PopID(); + } + } +} diff --git a/src/inspector/widgets/termio.zig b/src/inspector/widgets/termio.zig new file mode 100644 index 000000000..a6c8f6081 --- /dev/null +++ b/src/inspector/widgets/termio.zig @@ -0,0 +1,811 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const cimgui = @import("dcimgui"); +const terminal = @import("../../terminal/main.zig"); +const CircBuf = @import("../../datastruct/main.zig").CircBuf; +const Surface = @import("../../Surface.zig"); +const screen = @import("screen.zig"); + +/// VT event stream inspector widget. +pub const Stream = struct { + events: VTEvent.Ring, + parser_stream: VTHandler.Stream, + + /// The currently selected event sequence number for keyboard navigation + selected_event_seq: ?u32 = null, + + /// Flag indicating whether we need to scroll to the selected item + need_scroll_to_selected: bool = false, + + /// Flag indicating whether the selection was made by keyboard + is_keyboard_selection: bool = false, + + pub fn init(alloc: Allocator) !Stream { + var events: VTEvent.Ring = try .init(alloc, 2); + errdefer events.deinit(alloc); + + var handler: VTHandler = .init; + errdefer handler.deinit(); + + return .{ + .events = events, + .parser_stream = .initAlloc(alloc, handler), + }; + } + + pub fn deinit(self: *Stream, alloc: Allocator) void { + var it = self.events.iterator(.forward); + while (it.next()) |v| v.deinit(alloc); + self.events.deinit(alloc); + + self.parser_stream.deinit(); + } + + pub fn recordPtyRead( + self: *Stream, + alloc: Allocator, + t: *terminal.Terminal, + data: []const u8, + ) !void { + self.parser_stream.handler.state = .{ + .alloc = alloc, + .terminal = t, + .events = &self.events, + }; + defer self.parser_stream.handler.state = null; + try self.parser_stream.nextSlice(data); + } + + pub fn draw( + self: *Stream, + alloc: Allocator, + palette: *const terminal.color.Palette, + ) void { + const events = &self.events; + const handler = &self.parser_stream.handler; + const popup_filter = "Filter"; + + // Controls + { + const pause_play: [:0]const u8 = if (!handler.paused) + "Pause##pause_play" + else + "Resume##pause_play"; + if (cimgui.c.ImGui_Button(pause_play.ptr)) { + handler.paused = !handler.paused; + } + + cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); + if (cimgui.c.ImGui_Button("Filter")) { + cimgui.c.ImGui_OpenPopup( + popup_filter, + cimgui.c.ImGuiPopupFlags_None, + ); + } + + if (!events.empty()) { + cimgui.c.ImGui_SameLineEx(0, cimgui.c.ImGui_GetStyle().*.ItemInnerSpacing.x); + if (cimgui.c.ImGui_Button("Clear")) { + var it = events.iterator(.forward); + while (it.next()) |v| v.deinit(alloc); + events.clear(); + + handler.current_seq = 1; + } + } + } + + // Events Table + if (events.empty()) { + cimgui.c.ImGui_Text("Waiting for events..."); + } else { + // TODO: Eventually + // eventTable(events); + } + + { + cimgui.c.ImGui_Separator(); + + _ = cimgui.c.ImGui_BeginTable( + "table_vt_events", + 3, + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_Borders, + ); + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn( + "Seq", + cimgui.c.ImGuiTableColumnFlags_WidthFixed, + ); + cimgui.c.ImGui_TableSetupColumn( + "Kind", + cimgui.c.ImGuiTableColumnFlags_WidthFixed, + ); + cimgui.c.ImGui_TableSetupColumn( + "Description", + cimgui.c.ImGuiTableColumnFlags_WidthStretch, + ); + + // Handle keyboard navigation when window is focused + if (cimgui.c.ImGui_IsWindowFocused(cimgui.c.ImGuiFocusedFlags_RootAndChildWindows)) { + const key_pressed = getKeyAction(); + + switch (key_pressed) { + .none => {}, + .up, .down => { + // If no event is selected, select the first/last event based on direction + if (self.selected_event_seq == null) { + if (!events.empty()) { + var it = events.iterator(if (key_pressed == .up) .forward else .reverse); + if (it.next()) |ev| { + self.selected_event_seq = @as(u32, @intCast(ev.seq)); + } + } + } else { + // Find next/previous event based on current selection + var it = events.iterator(.reverse); + switch (key_pressed) { + .down => { + var found = false; + while (it.next()) |ev| { + if (found) { + self.selected_event_seq = @as(u32, @intCast(ev.seq)); + break; + } + if (ev.seq == self.selected_event_seq.?) { + found = true; + } + } + }, + .up => { + var prev_ev: ?*const VTEvent = null; + while (it.next()) |ev| { + if (ev.seq == self.selected_event_seq.?) { + if (prev_ev) |prev| { + self.selected_event_seq = @as(u32, @intCast(prev.seq)); + break; + } + } + prev_ev = ev; + } + }, + .none => unreachable, + } + } + + // Mark that we need to scroll to the newly selected item + self.need_scroll_to_selected = true; + self.is_keyboard_selection = true; + }, + } + } + + var it = events.iterator(.reverse); + while (it.next()) |ev| { + // Need to push an ID so that our selectable is unique. + cimgui.c.ImGui_PushIDPtr(ev); + defer cimgui.c.ImGui_PopID(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableNextColumn(); + + // Store the previous selection state to detect changes + const was_selected = ev.imgui_selected; + + // Update selection state based on keyboard navigation + if (self.selected_event_seq) |seq| { + ev.imgui_selected = (@as(u32, @intCast(ev.seq)) == seq); + } + + // Handle selectable widget + if (cimgui.c.ImGui_SelectableBoolPtr( + "##select", + &ev.imgui_selected, + cimgui.c.ImGuiSelectableFlags_SpanAllColumns, + )) { + // If selection state changed, update keyboard navigation state + if (ev.imgui_selected != was_selected) { + self.selected_event_seq = if (ev.imgui_selected) + @as(u32, @intCast(ev.seq)) + else + null; + self.is_keyboard_selection = false; + } + } + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("%d", ev.seq); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", @tagName(ev.kind).ptr); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", ev.raw_description.ptr); + + // If the event is selected, we render info about it. For now + // we put this in the last column because that's the widest and + // imgui has no way to make a column span. + if (ev.imgui_selected) { + { + screen.cursorTable(&ev.cursor); + screen.cursorStyle(&ev.cursor, palette); + + _ = cimgui.c.ImGui_BeginTable( + "details", + 2, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + { + cimgui.c.ImGui_TableNextRow(); + { + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("Scroll Region"); + } + { + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + cimgui.c.ImGui_Text( + "T=%d B=%d L=%d R=%d", + ev.scrolling_region.top, + ev.scrolling_region.bottom, + ev.scrolling_region.left, + ev.scrolling_region.right, + ); + } + } + + var md_it = ev.metadata.iterator(); + while (md_it.next()) |entry| { + var buf: [256]u8 = undefined; + const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch + ""; + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", key.ptr); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", entry.value_ptr.ptr); + } + } + + // If this is the selected event and scrolling is needed, scroll to it + if (self.need_scroll_to_selected and self.is_keyboard_selection) { + cimgui.c.ImGui_SetScrollHereY(0.5); + self.need_scroll_to_selected = false; + } + } + } + } // table + + if (cimgui.c.ImGui_BeginPopupModal( + popup_filter, + null, + cimgui.c.ImGuiWindowFlags_AlwaysAutoResize, + )) { + defer cimgui.c.ImGui_EndPopup(); + + cimgui.c.ImGui_Text("Changed filter settings will only affect future events."); + + cimgui.c.ImGui_Separator(); + + { + _ = cimgui.c.ImGui_BeginTable( + "table_filter_kind", + 3, + cimgui.c.ImGuiTableFlags_None, + ); + defer cimgui.c.ImGui_EndTable(); + + inline for (@typeInfo(terminal.Parser.Action.Tag).@"enum".fields) |field| { + const tag = @field(terminal.Parser.Action.Tag, field.name); + if (tag == .apc_put or tag == .dcs_put) continue; + + _ = cimgui.c.ImGui_TableNextColumn(); + var value = !handler.filter_exclude.contains(tag); + if (cimgui.c.ImGui_Checkbox(@tagName(tag).ptr, &value)) { + if (value) { + handler.filter_exclude.remove(tag); + } else { + handler.filter_exclude.insert(tag); + } + } + } + } // Filter kind table + + cimgui.c.ImGui_Separator(); + + cimgui.c.ImGui_Text( + "Filter by string. Empty displays all, \"abc\" finds lines\n" ++ + "containing \"abc\", \"abc,xyz\" finds lines containing \"abc\"\n" ++ + "or \"xyz\", \"-abc\" excludes lines containing \"abc\".", + ); + _ = cimgui.c.ImGuiTextFilter_Draw( + &handler.filter_text, + "##filter_text", + 0, + ); + + cimgui.c.ImGui_Separator(); + if (cimgui.c.ImGui_Button("Close")) { + cimgui.c.ImGui_CloseCurrentPopup(); + } + } // filter popup + } +}; + +/// Helper function to check keyboard state and determine navigation action. +fn getKeyAction() KeyAction { + const keys = .{ + .{ .key = cimgui.c.ImGuiKey_J, .action = KeyAction.down }, + .{ .key = cimgui.c.ImGuiKey_DownArrow, .action = KeyAction.down }, + .{ .key = cimgui.c.ImGuiKey_K, .action = KeyAction.up }, + .{ .key = cimgui.c.ImGuiKey_UpArrow, .action = KeyAction.up }, + }; + + inline for (keys) |k| { + if (cimgui.c.ImGui_IsKeyPressed(k.key)) { + return k.action; + } + } + return .none; +} + +pub fn eventTable(events: *const VTEvent.Ring) void { + if (!cimgui.c.ImGui_BeginTable( + "events", + 3, + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_Borders, + )) return; + defer cimgui.c.ImGui_EndTable(); + + cimgui.c.ImGui_TableSetupColumn( + "Seq", + cimgui.c.ImGuiTableColumnFlags_WidthFixed, + ); + cimgui.c.ImGui_TableSetupColumn( + "Kind", + cimgui.c.ImGuiTableColumnFlags_WidthFixed, + ); + cimgui.c.ImGui_TableSetupColumn( + "Description", + cimgui.c.ImGuiTableColumnFlags_WidthStretch, + ); + + var it = events.iterator(.reverse); + while (it.next()) |ev| { + // Need to push an ID so that our selectable is unique. + cimgui.c.ImGui_PushIDPtr(ev); + defer cimgui.c.ImGui_PopID(); + + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableNextColumn(); + + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_Text("%d", ev.seq); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", @tagName(ev.kind).ptr); + _ = cimgui.c.ImGui_TableNextColumn(); + cimgui.c.ImGui_Text("%s", ev.raw_description.ptr); + } +} + +/// VT event. This isn't public because this is just how we store internal +/// events. +const VTEvent = struct { + /// The arena that all allocated memory for this event is stored. + arena_state: ArenaAllocator.State, + + /// Sequence number, just monotonically increasing and wrapping if + /// it ever overflows. It gives us a nice way to visualize progress. + seq: usize = 1, + + /// Kind of event, for filtering + kind: Kind, + + /// The description of the raw event in a more human-friendly format. + /// For example for control sequences this is the full sequence but + /// control characters are replaced with human-readable names, e.g. + /// 0x07 (bell) becomes BEL. + raw_description: [:0]const u8, + + /// Various metadata at the time of the event (before processing). + cursor: terminal.Screen.Cursor, + scrolling_region: terminal.Terminal.ScrollingRegion, + metadata: Metadata.Unmanaged = .{}, + + /// imgui selection state + imgui_selected: bool = false, + + const Kind = enum { print, execute, csi, esc, osc, dcs, apc }; + const Metadata = std.StringHashMap([:0]const u8); + + /// Circular buffer of VT events. + pub const Ring = CircBuf(VTEvent, undefined); + + /// Initialize the event information for the given parser action. + pub fn init( + alloc_gpa: Allocator, + t: *const terminal.Terminal, + action: terminal.Parser.Action, + ) !VTEvent { + var arena: ArenaAllocator = .init(alloc_gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + var md = Metadata.init(alloc); + var buf: std.Io.Writer.Allocating = .init(alloc); + try encodeAction(alloc, &buf.writer, &md, action); + const desc = try buf.toOwnedSliceSentinel(0); + + const kind: Kind = switch (action) { + .print => .print, + .execute => .execute, + .csi_dispatch => .csi, + .esc_dispatch => .esc, + .osc_dispatch => .osc, + .dcs_hook, .dcs_put, .dcs_unhook => .dcs, + .apc_start, .apc_put, .apc_end => .apc, + }; + + return .{ + .arena_state = arena.state, + .kind = kind, + .raw_description = desc, + .cursor = t.screens.active.cursor, + .scrolling_region = t.scrolling_region, + .metadata = md.unmanaged, + }; + } + + pub fn deinit(self: *VTEvent, alloc_gpa: Allocator) void { + var arena = self.arena_state.promote(alloc_gpa); + arena.deinit(); + } + + /// Returns true if the event passes the given filter. + pub fn passFilter( + self: *const VTEvent, + filter: *const cimgui.c.ImGuiTextFilter, + ) bool { + // Check our main string + if (cimgui.c.ImGuiTextFilter_PassFilter( + filter, + self.raw_description.ptr, + null, + )) return true; + + // We also check all metadata keys and values + var it = self.metadata.iterator(); + while (it.next()) |entry| { + var buf: [256]u8 = undefined; + const key = std.fmt.bufPrintZ(&buf, "{s}", .{entry.key_ptr.*}) catch continue; + if (cimgui.c.ImGuiTextFilter_PassFilter( + filter, + key.ptr, + null, + )) return true; + if (cimgui.c.ImGuiTextFilter_PassFilter( + filter, + entry.value_ptr.ptr, + null, + )) return true; + } + + return false; + } + + /// Encode a parser action as a string that we show in the logs. + fn encodeAction( + alloc: Allocator, + writer: *std.Io.Writer, + md: *Metadata, + action: terminal.Parser.Action, + ) !void { + switch (action) { + .print => try encodePrint(writer, action), + .execute => try encodeExecute(writer, action), + .csi_dispatch => |v| try encodeCSI(writer, v), + .esc_dispatch => |v| try encodeEsc(writer, v), + .osc_dispatch => |v| try encodeOSC(alloc, writer, md, v), + else => try writer.print("{f}", .{action}), + } + } + + fn encodePrint(writer: *std.Io.Writer, action: terminal.Parser.Action) !void { + const ch = action.print; + try writer.print("'{u}' (U+{X})", .{ ch, ch }); + } + + fn encodeExecute(writer: *std.Io.Writer, action: terminal.Parser.Action) !void { + const ch = action.execute; + switch (ch) { + 0x00 => try writer.writeAll("NUL"), + 0x01 => try writer.writeAll("SOH"), + 0x02 => try writer.writeAll("STX"), + 0x03 => try writer.writeAll("ETX"), + 0x04 => try writer.writeAll("EOT"), + 0x05 => try writer.writeAll("ENQ"), + 0x06 => try writer.writeAll("ACK"), + 0x07 => try writer.writeAll("BEL"), + 0x08 => try writer.writeAll("BS"), + 0x09 => try writer.writeAll("HT"), + 0x0A => try writer.writeAll("LF"), + 0x0B => try writer.writeAll("VT"), + 0x0C => try writer.writeAll("FF"), + 0x0D => try writer.writeAll("CR"), + 0x0E => try writer.writeAll("SO"), + 0x0F => try writer.writeAll("SI"), + else => try writer.writeAll("?"), + } + try writer.print(" (0x{X})", .{ch}); + } + + fn encodeCSI(writer: *std.Io.Writer, csi: terminal.Parser.Action.CSI) !void { + for (csi.intermediates) |v| try writer.print("{c} ", .{v}); + for (csi.params, 0..) |v, i| { + if (i != 0) try writer.writeByte(';'); + try writer.print("{d}", .{v}); + } + if (csi.intermediates.len > 0 or csi.params.len > 0) try writer.writeByte(' '); + try writer.writeByte(csi.final); + } + + fn encodeEsc(writer: *std.Io.Writer, esc: terminal.Parser.Action.ESC) !void { + for (esc.intermediates) |v| try writer.print("{c} ", .{v}); + try writer.writeByte(esc.final); + } + + fn encodeOSC( + alloc: Allocator, + writer: *std.Io.Writer, + md: *Metadata, + osc: terminal.osc.Command, + ) !void { + // The description is just the tag + try writer.print("{s} ", .{@tagName(osc)}); + + // Add additional fields to metadata + switch (osc) { + inline else => |v, tag| if (tag == osc) { + try encodeMetadata(alloc, md, v); + }, + } + } + + fn encodeMetadata( + alloc: Allocator, + md: *Metadata, + v: anytype, + ) !void { + switch (@TypeOf(v)) { + void => {}, + []const u8, + [:0]const u8, + => try md.put("data", try alloc.dupeZ(u8, v)), + else => |T| switch (@typeInfo(T)) { + .@"struct" => |info| inline for (info.fields) |field| { + try encodeMetadataSingle( + alloc, + md, + field.name, + @field(v, field.name), + ); + }, + + .@"union" => |info| { + const Tag = info.tag_type orelse @compileError("Unions must have a tag"); + const tag_name = @tagName(@as(Tag, v)); + inline for (info.fields) |field| { + if (std.mem.eql(u8, field.name, tag_name)) { + if (field.type == void) { + break try md.put("data", tag_name); + } else { + break try encodeMetadataSingle(alloc, md, tag_name, @field(v, field.name)); + } + } + } + }, + + else => { + @compileLog(T); + @compileError("unsupported type, see log"); + }, + }, + } + } + + fn encodeMetadataSingle( + alloc: Allocator, + md: *Metadata, + key: []const u8, + value: anytype, + ) !void { + const Value = @TypeOf(value); + const info = @typeInfo(Value); + switch (info) { + .optional => if (value) |unwrapped| { + try encodeMetadataSingle(alloc, md, key, unwrapped); + } else { + try md.put(key, try alloc.dupeZ(u8, "(unset)")); + }, + + .bool => try md.put( + key, + try alloc.dupeZ(u8, if (value) "true" else "false"), + ), + + .@"enum" => try md.put( + key, + try alloc.dupeZ(u8, @tagName(value)), + ), + + .@"union" => |u| { + const Tag = u.tag_type orelse @compileError("Unions must have a tag"); + const tag_name = @tagName(@as(Tag, value)); + inline for (u.fields) |field| { + if (std.mem.eql(u8, field.name, tag_name)) { + const s = if (field.type == void) + try alloc.dupeZ(u8, tag_name) + else if (field.type == [:0]const u8 or field.type == []const u8) + try std.fmt.allocPrintSentinel(alloc, "{s}={s}", .{ + tag_name, + @field(value, field.name), + }, 0) + else + try std.fmt.allocPrintSentinel(alloc, "{s}={}", .{ + tag_name, + @field(value, field.name), + }, 0); + + try md.put(key, s); + } + } + }, + + .@"struct" => try md.put( + key, + try alloc.dupeZ(u8, @typeName(Value)), + ), + + else => switch (Value) { + []const u8, + [:0]const u8, + => try md.put(key, try alloc.dupeZ(u8, value)), + + else => |T| switch (@typeInfo(T)) { + .int => try md.put( + key, + try std.fmt.allocPrintSentinel(alloc, "{}", .{value}, 0), + ), + else => { + @compileLog(T); + @compileError("unsupported type, see log"); + }, + }, + }, + } + } +}; + +/// Our VT stream handler for the Stream widget. This isn't public +/// because there is no reason to use this directly. +const VTHandler = struct { + /// The capture state, must be set before use. If null, then + /// events are dropped. + state: ?State, + + /// True to pause this artificially. + paused: bool, + + /// Current sequence number + current_seq: usize, + + /// Exclude certain actions by tag. + filter_exclude: ActionTagSet, + filter_text: cimgui.c.ImGuiTextFilter, + + const Stream = terminal.Stream(VTHandler); + + pub const ActionTagSet = std.EnumSet(terminal.Parser.Action.Tag); + + pub const State = struct { + /// The allocator to use for the events. + alloc: Allocator, + + /// The terminal state at the time of the event. + terminal: *const terminal.Terminal, + + /// The event ring to write events to. + events: *VTEvent.Ring, + }; + + pub const init: VTHandler = .{ + .state = null, + .paused = false, + .current_seq = 1, + .filter_exclude = .initMany(&.{.print}), + .filter_text = .{}, + }; + + pub fn deinit(self: *VTHandler) void { + // Required for the parser stream interface + _ = self; + } + + pub fn vt( + self: *VTHandler, + comptime action: VTHandler.Stream.Action.Tag, + value: VTHandler.Stream.Action.Value(action), + ) !void { + _ = self; + _ = value; + } + + /// This is called with every single terminal action. + pub fn vtRaw(self: *VTHandler, action: terminal.Parser.Action) !bool { + const state: *State = if (self.state) |*s| s else return true; + const alloc = state.alloc; + const vt_events = state.events; + + // We always increment the sequence number, even if we're paused or + // filter out the event. This helps show the user that there is a gap + // between events and roughly how large that gap was. + defer self.current_seq +%= 1; + + // If we're manually paused, we ignore all events. + if (self.paused) return true; + + // We ignore certain action types that are too noisy. + switch (action) { + .dcs_put, .apc_put => return true, + else => {}, + } + + // If we requested a specific type to be ignored, ignore it. + // We return true because we did "handle" it by ignoring it. + if (self.filter_exclude.contains(std.meta.activeTag(action))) return true; + + // Build our event + var ev: VTEvent = try .init( + alloc, + state.terminal, + action, + ); + ev.seq = self.current_seq; + errdefer ev.deinit(alloc); + + // Check if the event passes the filter + if (!ev.passFilter(&self.filter_text)) { + ev.deinit(alloc); + return true; + } + + const max_capacity = 100; + vt_events.append(ev) catch |err| switch (err) { + error.OutOfMemory => if (vt_events.capacity() < max_capacity) { + // We're out of memory, but we can allocate to our capacity. + const new_capacity = @min(vt_events.capacity() * 2, max_capacity); + try vt_events.resize(alloc, new_capacity); + try vt_events.append(ev); + } else { + var it = vt_events.iterator(.forward); + if (it.next()) |old_ev| old_ev.deinit(alloc); + vt_events.deleteOldest(1); + try vt_events.append(ev); + }, + + else => return err, + }; + + // Do NOT skip it, because we want to record more information + // about this event. + return false; + } +}; + +/// Enum representing keyboard navigation actions +const KeyAction = enum { + down, + none, + up, +}; diff --git a/src/renderer.zig b/src/renderer.zig index 2d37ddd4c..9b5164e91 100644 --- a/src/renderer.zig +++ b/src/renderer.zig @@ -19,6 +19,7 @@ pub const Metal = @import("renderer/Metal.zig"); pub const OpenGL = @import("renderer/OpenGL.zig"); pub const WebGL = @import("renderer/WebGL.zig"); pub const Options = @import("renderer/Options.zig"); +pub const Overlay = @import("renderer/Overlay.zig"); pub const Thread = @import("renderer/Thread.zig"); pub const State = @import("renderer/State.zig"); pub const CursorStyle = cursor.Style; diff --git a/src/renderer/Overlay.zig b/src/renderer/Overlay.zig new file mode 100644 index 000000000..62eb004ba --- /dev/null +++ b/src/renderer/Overlay.zig @@ -0,0 +1,415 @@ +/// The debug overlay that can be drawn on top of the terminal +/// during the rendering process. +/// +/// This is implemented by doing all the drawing on the CPU via z2d, +/// since the debug overlay isn't that common, z2d is pretty fast, and +/// it simplifies our implementation quite a bit by not relying on us +/// having a bunch of shaders that we have to write per-platform. +/// +/// Initialize the overlay, apply features with `applyFeatures`, then +/// get the resulting image with `pendingImage` to upload to the GPU. +/// This works in concert with `renderer.image.State` to simplify. Draw +/// it on the GPU as an image composited on top of the terminal output. +const Overlay = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const z2d = @import("z2d"); +const terminal = @import("../terminal/main.zig"); +const size = @import("size.zig"); +const Size = size.Size; +const CellSize = size.CellSize; +const Image = @import("image.zig").Image; + +const log = std.log.scoped(.renderer_overlay); + +/// The colors we use for overlays. +pub const Color = enum { + hyperlink, // light blue + semantic_prompt, // orange/gold + semantic_input, // cyan + + pub fn rgb(self: Color) z2d.pixel.RGB { + return switch (self) { + .hyperlink => .{ .r = 180, .g = 180, .b = 255 }, + .semantic_prompt => .{ .r = 255, .g = 200, .b = 64 }, + .semantic_input => .{ .r = 64, .g = 200, .b = 255 }, + }; + } + + /// The fill color for rectangles. + pub fn rectFill(self: Color) z2d.Pixel { + return self.alphaPixel(96); + } + + /// The border color for rectangles. + pub fn rectBorder(self: Color) z2d.Pixel { + return self.alphaPixel(200); + } + + /// The raw RGB as a pixel. + pub fn pixel(self: Color) z2d.Pixel { + return self.rgb().asPixel(); + } + + fn alphaPixel(self: Color, alpha: u8) z2d.Pixel { + var rgba: z2d.pixel.RGBA = .fromPixel(self.pixel()); + rgba.a = alpha; + return rgba.multiply().asPixel(); + } +}; + +/// The surface we're drawing our overlay to. +surface: z2d.Surface, + +/// Cell size information so we can map grid coordinates to pixels. +cell_size: CellSize, + +/// The set of available features and their configuration. +pub const Feature = union(enum) { + highlight_hyperlinks, + semantic_prompts, +}; + +pub const InitError = Allocator.Error || error{ + // The terminal dimensions are invalid to support an overlay. + // Either too small or too big. + InvalidDimensions, +}; + +/// Initialize a new, blank overlay. +pub fn init(alloc: Allocator, sz: Size) InitError!Overlay { + // Our surface does NOT need to take into account padding because + // we render the overlay using the image subsystem and shaders which + // already take that into account. + const term_size = sz.terminal(); + var sfc = z2d.Surface.initPixel( + .{ .rgba = .{ .r = 0, .g = 0, .b = 0, .a = 0 } }, + alloc, + std.math.cast(i32, term_size.width) orelse + return error.InvalidDimensions, + std.math.cast(i32, term_size.height) orelse + return error.InvalidDimensions, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidWidth, error.InvalidHeight => return error.InvalidDimensions, + }; + errdefer sfc.deinit(alloc); + + return .{ + .surface = sfc, + .cell_size = sz.cell, + }; +} + +pub fn deinit(self: *Overlay, alloc: Allocator) void { + self.surface.deinit(alloc); +} + +/// Returns a pending image that can be used to copy, convert, upload, etc. +pub fn pendingImage(self: *const Overlay) Image.Pending { + return .{ + .width = @intCast(self.surface.getWidth()), + .height = @intCast(self.surface.getHeight()), + .pixel_format = .rgba, + .data = @ptrCast(self.surface.image_surface_rgba.buf.ptr), + }; +} + +/// Clear the overlay. +pub fn reset(self: *Overlay) void { + self.surface.paintPixel(.{ .rgba = .{ + .r = 0, + .g = 0, + .b = 0, + .a = 0, + } }); +} + +/// Apply the given features to this overlay. This will draw on top of +/// any pre-existing content in the overlay. +pub fn applyFeatures( + self: *Overlay, + alloc: Allocator, + state: *const terminal.RenderState, + features: []const Feature, +) void { + for (features) |f| switch (f) { + .highlight_hyperlinks => self.highlightHyperlinks( + alloc, + state, + ), + .semantic_prompts => self.highlightSemanticPrompts( + alloc, + state, + ), + }; +} + +/// Add rectangles around contiguous hyperlinks in the render state. +/// +/// Note: this currently doesn't take into account unique hyperlink IDs +/// because the render state doesn't contain this. This will be added +/// later. +fn highlightHyperlinks( + self: *Overlay, + alloc: Allocator, + state: *const terminal.RenderState, +) void { + const border_color = Color.hyperlink.rectBorder(); + const fill_color = Color.hyperlink.rectFill(); + + const row_slice = state.row_data.slice(); + const row_raw = row_slice.items(.raw); + const row_cells = row_slice.items(.cells); + for (row_raw, row_cells, 0..) |row, cells, y| { + if (!row.hyperlink) continue; + + const cells_slice = cells.slice(); + const raw_cells = cells_slice.items(.raw); + + var x: usize = 0; + while (x < raw_cells.len) { + // Skip cells without hyperlinks + if (!raw_cells[x].hyperlink) { + x += 1; + continue; + } + + // Found start of a hyperlink run + const start_x = x; + + // Find end of contiguous hyperlink cells + while (x < raw_cells.len and raw_cells[x].hyperlink) x += 1; + const end_x = x; + + self.highlightGridRect( + alloc, + start_x, + y, + end_x - start_x, + 1, + border_color, + fill_color, + ) catch |err| { + std.log.warn("Error drawing hyperlink border: {}", .{err}); + }; + } + } +} + +fn highlightSemanticPrompts( + self: *Overlay, + alloc: Allocator, + state: *const terminal.RenderState, +) void { + const row_slice = state.row_data.slice(); + const row_raw = row_slice.items(.raw); + const row_cells = row_slice.items(.cells); + + // Highlight the row-level semantic prompt bars. The prompts are easy + // because they're part of the row metadata. + { + const prompt_border = Color.semantic_prompt.rectBorder(); + const prompt_fill = Color.semantic_prompt.rectFill(); + + var y: usize = 0; + while (y < row_raw.len) { + // If its not a semantic prompt row, skip it. + if (row_raw[y].semantic_prompt == .none) { + y += 1; + continue; + } + + // Find the full length of the semantic prompt row by connecting + // all continuations. + const start_y = y; + y += 1; + while (y < row_raw.len and + row_raw[y].semantic_prompt == .prompt_continuation) + { + y += 1; + } + const end_y = y; // Exclusive + + const bar_width = @min(@as(usize, 5), self.cell_size.width); + self.highlightPixelRect( + alloc, + 0, + start_y, + bar_width, + end_y - start_y, + prompt_border, + prompt_fill, + ) catch |err| { + log.warn("Error drawing semantic prompt bar: {}", .{err}); + }; + } + } + + // Highlight contiguous semantic cells within rows. + for (row_cells, 0..) |cells, y| { + const cells_slice = cells.slice(); + const raw_cells = cells_slice.items(.raw); + + var x: usize = 0; + while (x < raw_cells.len) { + const cell = raw_cells[x]; + const content = cell.semantic_content; + const start_x = x; + + // We skip output because its just the rest of the non-prompt + // parts and it makes the overlay too noisy. + if (cell.semantic_content == .output) { + x += 1; + continue; + } + + // Find the end of this content. + x += 1; + while (x < raw_cells.len) { + const next = raw_cells[x]; + if (next.semantic_content != content) break; + x += 1; + } + + const color: Color = switch (content) { + .prompt => .semantic_prompt, + .input => .semantic_input, + .output => unreachable, + }; + + self.highlightGridRect( + alloc, + start_x, + y, + x - start_x, + 1, + color.rectBorder(), + color.rectFill(), + ) catch |err| { + log.warn("Error drawing semantic content highlight: {}", .{err}); + }; + } + } +} + +/// Creates a rectangle for highlighting a grid region. x/y/width/height +/// are all in grid cells. +fn highlightGridRect( + self: *Overlay, + alloc: Allocator, + x: usize, + y: usize, + width: usize, + height: usize, + border_color: z2d.Pixel, + fill_color: z2d.Pixel, +) !void { + // All math below uses checked arithmetic to avoid overflows. The + // inputs aren't trusted and the path this is in isn't hot enough + // to wrarrant unsafe optimizations. + + // Calculate our width/height in pixels. + const px_width = std.math.cast(i32, try std.math.mul( + usize, + width, + self.cell_size.width, + )) orelse return error.Overflow; + const px_height = std.math.cast(i32, try std.math.mul( + usize, + height, + self.cell_size.height, + )) orelse return error.Overflow; + + // Calculate pixel coordinates + const start_x: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + x, + self.cell_size.width, + )) orelse return error.Overflow); + const start_y: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + y, + self.cell_size.height, + )) orelse return error.Overflow); + const end_x: f64 = start_x + @as(f64, @floatFromInt(px_width)); + const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height)); + + // Grab our context to draw + var ctx: z2d.Context = .init(alloc, &self.surface); + defer ctx.deinit(); + + // Don't need AA because we use sharp edges + ctx.setAntiAliasingMode(.none); + // Can use hairline since we have 1px borders + ctx.setHairline(true); + + // Draw rectangle path + try ctx.moveTo(start_x, start_y); + try ctx.lineTo(end_x, start_y); + try ctx.lineTo(end_x, end_y); + try ctx.lineTo(start_x, end_y); + try ctx.closePath(); + + // Fill + ctx.setSourceToPixel(fill_color); + try ctx.fill(); + + // Border + ctx.setLineWidth(1); + ctx.setSourceToPixel(border_color); + try ctx.stroke(); +} + +/// Creates a rectangle for highlighting a region. x/y are grid cells and +/// width/height are pixels. +fn highlightPixelRect( + self: *Overlay, + alloc: Allocator, + x: usize, + y: usize, + width_px: usize, + height: usize, + border_color: z2d.Pixel, + fill_color: z2d.Pixel, +) !void { + const px_width = std.math.cast(i32, width_px) orelse return error.Overflow; + const px_height = std.math.cast(i32, try std.math.mul( + usize, + height, + self.cell_size.height, + )) orelse return error.Overflow; + + const start_x: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + x, + self.cell_size.width, + )) orelse return error.Overflow); + const start_y: f64 = @floatFromInt(std.math.cast(i32, try std.math.mul( + usize, + y, + self.cell_size.height, + )) orelse return error.Overflow); + const end_x: f64 = start_x + @as(f64, @floatFromInt(px_width)); + const end_y: f64 = start_y + @as(f64, @floatFromInt(px_height)); + + var ctx: z2d.Context = .init(alloc, &self.surface); + defer ctx.deinit(); + + ctx.setAntiAliasingMode(.none); + ctx.setHairline(true); + + try ctx.moveTo(start_x, start_y); + try ctx.lineTo(end_x, start_y); + try ctx.lineTo(end_x, end_y); + try ctx.lineTo(start_x, end_y); + try ctx.closePath(); + + ctx.setSourceToPixel(fill_color); + try ctx.fill(); + + ctx.setLineWidth(1); + ctx.setSourceToPixel(border_color); + try ctx.stroke(); +} diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index d651fed79..c6217fcd1 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -294,9 +294,6 @@ fn setQosClass(self: *const Thread) void { fn syncDrawTimer(self: *Thread) void { skip: { - // If we have an inspector, we always run the draw timer. - if (self.flags.has_inspector) break :skip; - // If our renderer supports animations and has them, then we // always have a draw timer. if (@hasDecl(rendererpkg.Renderer, "hasAnimations") and @@ -479,9 +476,6 @@ fn drainMailbox(self: *Thread) !void { .inspector => |v| { self.flags.has_inspector = v; - // Reset our draw timer state, which might change due - // to the inspector change. - self.syncDrawTimer(); }, .macos_display_id => |v| { @@ -614,11 +608,6 @@ fn renderCallback( return .disarm; }; - // If we have an inspector, let the app know we want to rerender that. - if (t.flags.has_inspector) { - _ = t.app_mailbox.push(.{ .redraw_inspector = t.surface }, .{ .instant = {} }); - } - // Update our frame data t.renderer.updateFrame( t.state, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index e75171721..c8acebd0f 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -17,10 +17,9 @@ const noMinContrast = cellpkg.noMinContrast; const constraintWidth = cellpkg.constraintWidth; const isCovering = cellpkg.isCovering; const rowNeverExtendBg = @import("row.zig").neverExtendBg; +const Overlay = @import("Overlay.zig"); const imagepkg = @import("image.zig"); -const Image = imagepkg.Image; -const ImageMap = imagepkg.ImageMap; -const ImagePlacementList = std.ArrayListUnmanaged(imagepkg.Placement); +const ImageState = imagepkg.State; const shadertoy = @import("shadertoy.zig"); const assert = @import("../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; @@ -169,11 +168,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { font_shaper_cache: font.ShaperCache, /// The images that we may render. - images: ImageMap = .{}, - image_placements: ImagePlacementList = .{}, - image_bg_end: u32 = 0, - image_text_end: u32 = 0, - image_virtual: bool = false, + images: ImageState = .empty, /// Background image, if we have one. bg_image: ?imagepkg.Image = null, @@ -227,6 +222,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// a large screen. terminal_state_frame_count: usize = 0, + /// Our overlay state, if any. + overlay: ?Overlay = null, + const HighlightTag = enum(u8) { search_match, search_match_selected, @@ -787,6 +785,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } pub fn deinit(self: *Self) void { + if (self.overlay) |*overlay| overlay.deinit(self.alloc); self.terminal_state.deinit(self.alloc); if (self.search_selected_match) |*m| m.arena.deinit(); if (self.search_matches) |*m| m.arena.deinit(); @@ -806,12 +805,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.config.deinit(); - { - var it = self.images.iterator(); - while (it.next()) |kv| kv.value_ptr.image.deinit(self.alloc); - self.images.deinit(self.alloc); - } - self.image_placements.deinit(self.alloc); + self.images.deinit(self.alloc); if (self.bg_image) |img| img.deinit(self.alloc); @@ -1118,6 +1112,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { state: *renderer.State, cursor_blink_visible: bool, ) Allocator.Error!void { + // const start = std.time.Instant.now() catch unreachable; + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // log.warn( + // "[updateFrame time] start_micro={} duration={}ns", + // .{ start_micro, end.since(start) / std.time.ns_per_us }, + // ); + // } + // We fully deinit and reset the terminal state every so often // so that a particularly large terminal state doesn't cause // the renderer to hold on to retained memory. @@ -1141,6 +1145,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { mouse: renderer.State.Mouse, preedit: ?renderer.State.Preedit, scrollbar: terminal.Scrollbar, + overlay_features: []const Overlay.Feature, }; // Update all our data as tightly as possible within the mutex. @@ -1190,10 +1195,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // If we have any virtual references, we must also rebuild our // kitty state on every frame because any cell change can move // an image. - if (state.terminal.screens.active.kitty_images.dirty or - self.image_virtual) - { - self.prepKittyGraphics(state.terminal); + if (self.images.kittyRequiresUpdate(state.terminal)) { + self.images.kittyUpdate( + self.alloc, + state.terminal, + .{ + .width = self.grid_metrics.cell_width, + .height = self.grid_metrics.cell_height, + }, + ); } // Get our OSC8 links we're hovering if we have a mouse. @@ -1215,11 +1225,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; }; + const overlay_features: []const Overlay.Feature = overlay: { + const insp = state.inspector orelse break :overlay &.{}; + const renderer_info = insp.rendererInfo(); + break :overlay renderer_info.overlayFeatures( + arena_alloc, + ) catch &.{}; + }; + break :critical .{ .links = links, .mouse = state.mouse, .preedit = preedit, .scrollbar = scrollbar, + .overlay_features = overlay_features, }; }; @@ -1288,6 +1307,17 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Reset our dirty state after updating. defer self.terminal_state.dirty = .false; + // Rebuild the overlay image if we have one. We can do this + // outside of any critical areas. + self.rebuildOverlay( + critical.overlay_features, + ) catch |err| { + log.warn( + "error rebuilding overlay surface err={}", + .{err}, + ); + }; + // Acquire the draw mutex for all remaining state updates. { self.draw_mutex.lock(); @@ -1337,6 +1367,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { else => {}, }; + // Prepare our overlay image for upload (or unload). This + // has to use our general allocator since it modifies + // state that survives frames. + self.images.overlayUpdate( + self.alloc, + self.overlay, + ) catch |err| { + log.warn("error updating overlay images err={}", .{err}); + }; + // Update custom shader uniforms that depend on terminal state. self.updateCustomShaderUniformsFromState(); } @@ -1354,6 +1394,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self: *Self, sync: bool, ) !void { + // const start = std.time.Instant.now() catch unreachable; + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // log.warn( + // "[drawFrame time] start_micro={} duration={}ns", + // .{ start_micro, end.since(start) / std.time.ns_per_us }, + // ); + // } + // We hold a the draw mutex to prevent changes to any // data we access while we're in the middle of drawing. self.draw_mutex.lock(); @@ -1460,7 +1510,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Upload images to the GPU as necessary. - try self.uploadKittyImages(); + _ = self.images.upload(self.alloc, &self.api); // Upload the background image to the GPU as necessary. try self.uploadBackgroundImage(); @@ -1542,9 +1592,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Then we draw any kitty images that need // to be behind text AND cell backgrounds. - try self.drawImagePlacements( + self.images.draw( + &self.api, + self.shaders.pipelines.image, &pass, - self.image_placements.items[0..self.image_bg_end], + .kitty_below_bg, ); // Then we draw any opaque cell backgrounds. @@ -1556,9 +1608,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }); // Kitty images between cell backgrounds and text. - try self.drawImagePlacements( + self.images.draw( + &self.api, + self.shaders.pipelines.image, &pass, - self.image_placements.items[self.image_bg_end..self.image_text_end], + .kitty_below_text, ); // Text. @@ -1581,9 +1635,20 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }); // Kitty images in front of text. - try self.drawImagePlacements( + self.images.draw( + &self.api, + self.shaders.pipelines.image, &pass, - self.image_placements.items[self.image_text_end..], + .kitty_above_text, + ); + + // Debug overlay. We do this before any custom shader state + // because our debug overlay is aligned with the grid. + if (self.overlay != null) self.images.draw( + &self.api, + self.shaders.pipelines.image, + &pass, + .overlay, ); } @@ -1639,426 +1704,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.swap_chain.releaseFrame(); } - fn drawImagePlacements( - self: *Self, - pass: *RenderPass, - placements: []const imagepkg.Placement, - ) !void { - if (placements.len == 0) return; - - for (placements) |p| { - - // Look up the image - const image = self.images.get(p.image_id) orelse { - log.warn("image not found for placement image_id={}", .{p.image_id}); - continue; - }; - - // Get the texture - const texture = switch (image.image) { - .ready, - .unload_ready, - => |t| t, - else => { - log.warn("image not ready for placement image_id={}", .{p.image_id}); - continue; - }, - }; - - // Create our vertex buffer, which is always exactly one item. - // future(mitchellh): we can group rendering multiple instances of a single image - var buf = try Buffer(shaderpkg.Image).initFill( - self.api.imageBufferOptions(), - &.{.{ - .grid_pos = .{ - @as(f32, @floatFromInt(p.x)), - @as(f32, @floatFromInt(p.y)), - }, - - .cell_offset = .{ - @as(f32, @floatFromInt(p.cell_offset_x)), - @as(f32, @floatFromInt(p.cell_offset_y)), - }, - - .source_rect = .{ - @as(f32, @floatFromInt(p.source_x)), - @as(f32, @floatFromInt(p.source_y)), - @as(f32, @floatFromInt(p.source_width)), - @as(f32, @floatFromInt(p.source_height)), - }, - - .dest_size = .{ - @as(f32, @floatFromInt(p.width)), - @as(f32, @floatFromInt(p.height)), - }, - }}, - ); - defer buf.deinit(); - - pass.step(.{ - .pipeline = self.shaders.pipelines.image, - .buffers = &.{buf.buffer}, - .textures = &.{texture}, - .draw = .{ - .type = .triangle_strip, - .vertex_count = 4, - }, - }); - } - } - - /// This goes through the Kitty graphic placements and accumulates the - /// placements we need to render on our viewport. - fn prepKittyGraphics( - self: *Self, - t: *terminal.Terminal, - ) void { - self.draw_mutex.lock(); - defer self.draw_mutex.unlock(); - - const storage = &t.screens.active.kitty_images; - defer storage.dirty = false; - - // We always clear our previous placements no matter what because - // we rebuild them from scratch. - self.image_placements.clearRetainingCapacity(); - self.image_virtual = false; - - // Go through our known images and if there are any that are no longer - // in use then mark them to be freed. - // - // This never conflicts with the below because a placement can't - // reference an image that doesn't exist. - { - var it = self.images.iterator(); - while (it.next()) |kv| { - if (storage.imageById(kv.key_ptr.*) == null) { - kv.value_ptr.image.markForUnload(); - } - } - } - - // The top-left and bottom-right corners of our viewport in screen - // points. This lets us determine offsets and containment of placements. - const top = t.screens.active.pages.getTopLeft(.viewport); - const bot = t.screens.active.pages.getBottomRight(.viewport).?; - const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y; - const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y; - - // Go through the placements and ensure the image is - // on the GPU or else is ready to be sent to the GPU. - var it = storage.placements.iterator(); - while (it.next()) |kv| { - const p = kv.value_ptr; - - // Special logic based on location - switch (p.location) { - .pin => {}, - .virtual => { - // We need to mark virtual placements on our renderer so that - // we know to rebuild in more scenarios since cell changes can - // now trigger placement changes. - self.image_virtual = true; - - // We also continue out because virtual placements are - // only triggered by the unicode placeholder, not by the - // placement itself. - continue; - }, - } - - // Get the image for the placement - const image = storage.imageById(kv.key_ptr.image_id) orelse { - log.warn( - "missing image for placement, ignoring image_id={}", - .{kv.key_ptr.image_id}, - ); - continue; - }; - - self.prepKittyPlacement( - t, - top_y, - bot_y, - &image, - p, - ) catch |err| { - // For errors we log and continue. We try to place - // other placements even if one fails. - log.warn("error preparing kitty placement err={}", .{err}); - }; - } - - // If we have virtual placements then we need to scan for placeholders. - if (self.image_virtual) { - var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot); - while (v_it.next()) |virtual_p| { - self.prepKittyVirtualPlacement( - t, - &virtual_p, - ) catch |err| { - // For errors we log and continue. We try to place - // other placements even if one fails. - log.warn("error preparing kitty placement err={}", .{err}); - }; - } - } - - // Sort the placements by their Z value. - std.mem.sortUnstable( - imagepkg.Placement, - self.image_placements.items, - {}, - struct { - fn lessThan( - ctx: void, - lhs: imagepkg.Placement, - rhs: imagepkg.Placement, - ) bool { - _ = ctx; - return lhs.z < rhs.z or (lhs.z == rhs.z and lhs.image_id < rhs.image_id); - } - }.lessThan, - ); - - // Find our indices. The values are sorted by z so we can - // find the first placement out of bounds to find the limits. - var bg_end: ?u32 = null; - var text_end: ?u32 = null; - const bg_limit = std.math.minInt(i32) / 2; - for (self.image_placements.items, 0..) |p, i| { - if (bg_end == null and p.z >= bg_limit) { - bg_end = @intCast(i); - } - if (text_end == null and p.z >= 0) { - text_end = @intCast(i); - } - } - - // If we didn't see any images with a z > the bg limit, - // then our bg end is the end of our placement list. - self.image_bg_end = - bg_end orelse @intCast(self.image_placements.items.len); - - // Same idea for the image_text_end. - self.image_text_end = - text_end orelse @intCast(self.image_placements.items.len); - } - - fn prepKittyVirtualPlacement( - self: *Self, - t: *terminal.Terminal, - p: *const terminal.kitty.graphics.unicode.Placement, - ) PrepKittyImageError!void { - const storage = &t.screens.active.kitty_images; - const image = storage.imageById(p.image_id) orelse { - log.warn( - "missing image for virtual placement, ignoring image_id={}", - .{p.image_id}, - ); - return; - }; - - const rp = p.renderPlacement( - storage, - &image, - self.grid_metrics.cell_width, - self.grid_metrics.cell_height, - ) catch |err| { - log.warn("error rendering virtual placement err={}", .{err}); - return; - }; - - // If our placement is zero sized then we don't do anything. - if (rp.dest_width == 0 or rp.dest_height == 0) return; - - const viewport: terminal.point.Point = t.screens.active.pages.pointFromPin( - .viewport, - rp.top_left, - ) orelse { - // This is unreachable with virtual placements because we should - // only ever be looking at virtual placements that are in our - // viewport in the renderer and virtual placements only ever take - // up one row. - unreachable; - }; - - // Prepare the image for the GPU and store the placement. - try self.prepKittyImage(&image); - try self.image_placements.append(self.alloc, .{ - .image_id = image.id, - .x = @intCast(rp.top_left.x), - .y = @intCast(viewport.viewport.y), - .z = -1, - .width = rp.dest_width, - .height = rp.dest_height, - .cell_offset_x = rp.offset_x, - .cell_offset_y = rp.offset_y, - .source_x = rp.source_x, - .source_y = rp.source_y, - .source_width = rp.source_width, - .source_height = rp.source_height, - }); - } - - /// Get the viewport-relative position for this - /// placement and add it to the placements list. - fn prepKittyPlacement( - self: *Self, - t: *terminal.Terminal, - top_y: u32, - bot_y: u32, - image: *const terminal.kitty.graphics.Image, - p: *const terminal.kitty.graphics.ImageStorage.Placement, - ) PrepKittyImageError!void { - // Get the rect for the placement. If this placement doesn't have - // a rect then its virtual or something so skip it. - const rect = p.rect(image.*, t) orelse return; - - // This is expensive but necessary. - const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; - - // If the selection isn't within our viewport then skip it. - if (img_top_y > bot_y) return; - if (img_bot_y < top_y) return; - - // We need to prep this image for upload if it isn't in the - // cache OR it is in the cache but the transmit time doesn't - // match meaning this image is different. - try self.prepKittyImage(image); - - // Calculate the dimensions of our image, taking in to - // account the rows / columns specified by the placement. - const dest_size = p.calculatedSize(image.*, t); - - // Calculate the source rectangle - const source_x = @min(image.width, p.source_x); - const source_y = @min(image.height, p.source_y); - const source_width = if (p.source_width > 0) - @min(image.width - source_x, p.source_width) - else - image.width; - const source_height = if (p.source_height > 0) - @min(image.height - source_y, p.source_height) - else - image.height; - - // Get the viewport-relative Y position of the placement. - const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); - - // Accumulate the placement - if (dest_size.width > 0 and dest_size.height > 0) { - try self.image_placements.append(self.alloc, .{ - .image_id = image.id, - .x = @intCast(rect.top_left.x), - .y = y_pos, - .z = p.z, - .width = dest_size.width, - .height = dest_size.height, - .cell_offset_x = p.x_offset, - .cell_offset_y = p.y_offset, - .source_x = source_x, - .source_y = source_y, - .source_width = source_width, - .source_height = source_height, - }); - } - } - - const PrepKittyImageError = error{ - OutOfMemory, - ImageConversionError, - }; - - /// Prepare the provided image for upload to the GPU by copying its - /// data with our allocator and setting it to the pending state. - fn prepKittyImage( - self: *Self, - image: *const terminal.kitty.graphics.Image, - ) PrepKittyImageError!void { - // If this image exists and its transmit time is the same we assume - // it is the identical image so we don't need to send it to the GPU. - const gop = try self.images.getOrPut(self.alloc, image.id); - if (gop.found_existing and - gop.value_ptr.transmit_time.order(image.transmit_time) == .eq) - { - return; - } - - // Copy the data into the pending state. - const data = if (self.alloc.dupe( - u8, - image.data, - )) |v| v else |_| { - if (!gop.found_existing) { - // If this is a new entry we can just remove it since it - // was never sent to the GPU. - _ = self.images.remove(image.id); - } else { - // If this was an existing entry, it is invalid and - // we must unload it. - gop.value_ptr.image.markForUnload(); - } - - return error.OutOfMemory; - }; - // Note: we don't need to errdefer free the data because it is - // put into the map immediately below and our errdefer to - // handle our map state will fix this up. - - // Store it in the map - const new_image: Image = .{ - .pending = .{ - .width = image.width, - .height = image.height, - .pixel_format = switch (image.format) { - .gray => .gray, - .gray_alpha => .gray_alpha, - .rgb => .rgb, - .rgba => .rgba, - .png => unreachable, // should be decoded by now - }, - .data = data.ptr, - }, - }; - if (!gop.found_existing) { - gop.value_ptr.* = .{ - .image = new_image, - .transmit_time = undefined, - }; - } else { - gop.value_ptr.image.markForReplace( - self.alloc, - new_image, - ); - } - - // If any error happens, we unload the image and it is invalid. - errdefer gop.value_ptr.image.markForUnload(); - - gop.value_ptr.image.prepForUpload(self.alloc) catch |err| { - log.warn("error preparing kitty image for upload err={}", .{err}); - return error.ImageConversionError; - }; - gop.value_ptr.transmit_time = image.transmit_time; - } - - /// Upload any images to the GPU that need to be uploaded, - /// and remove any images that are no longer needed on the GPU. - fn uploadKittyImages(self: *Self) !void { - var image_it = self.images.iterator(); - while (image_it.next()) |kv| { - const img = &kv.value_ptr.image; - if (img.isUnloading()) { - img.deinit(self.alloc); - self.images.removeByPtr(kv.key_ptr); - continue; - } - if (img.isPending()) try img.upload(self.alloc, &self.api); - } - } - /// Call this any time the background image path changes. /// /// Caller must hold the draw mutex. @@ -2531,6 +2176,51 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } } + /// Build the overlay as configured. Returns null if there is no + /// overlay currently configured. + fn rebuildOverlay( + self: *Self, + features: []const Overlay.Feature, + ) Overlay.InitError!void { + // const start = std.time.Instant.now() catch unreachable; + // const start_micro = std.time.microTimestamp(); + // defer { + // const end = std.time.Instant.now() catch unreachable; + // log.warn( + // "[rebuildOverlay time] start_micro={} duration={}ns", + // .{ start_micro, end.since(start) / std.time.ns_per_us }, + // ); + // } + + const alloc = self.alloc; + + // If we have no features enabled, don't build an overlay. + // If we had a previous overlay, deallocate it. + if (features.len == 0) { + if (self.overlay) |*old| { + old.deinit(alloc); + self.overlay = null; + } + + return; + } + + // If we had a previous overlay, clear it. Otherwise, init. + const overlay: *Overlay = if (self.overlay) |*v| overlay: { + v.reset(); + break :overlay v; + } else overlay: { + const new: Overlay = try .init(alloc, self.size); + self.overlay = new; + break :overlay &self.overlay.?; + }; + overlay.applyFeatures( + alloc, + &self.terminal_state, + features, + ); + } + const PreeditRange = struct { y: terminal.size.CellCountInt, x: [2]terminal.size.CellCountInt, diff --git a/src/renderer/image.zig b/src/renderer/image.zig index bf0f7b736..85f3a01ed 100644 --- a/src/renderer/image.zig +++ b/src/renderer/image.zig @@ -2,16 +2,629 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = @import("../quirks.zig").inlineAssert; const wuffs = @import("wuffs"); +const terminal = @import("../terminal/main.zig"); const Renderer = @import("../renderer.zig").Renderer; const GraphicsAPI = Renderer.API; const Texture = GraphicsAPI.Texture; +const CellSize = @import("size.zig").CellSize; +const Overlay = @import("Overlay.zig"); + +const log = std.log.scoped(.renderer_image); + +/// Generic image rendering state for the renderer. This stores all +/// images and their placements and exposes only a limited public API +/// for adding images and placements and drawing them. +pub const State = struct { + /// The full image state for the renderer that specifies what images + /// need to be uploaded, pruned, etc. + images: ImageMap, + + /// The placements for the Kitty image protocol. + kitty_placements: std.ArrayListUnmanaged(Placement), + + /// The end index (exclusive) for placements that should be + /// drawn below the background, below the text, etc. + kitty_bg_end: u32, + kitty_text_end: u32, + + /// True if there are any virtual placements. This needs to be known + /// because virtual placements need to be recalculated more often + /// on frame builds and are generally more expensive to handle. + kitty_virtual: bool, + + /// Overlays + overlay_placements: std.ArrayListUnmanaged(Placement), + + pub const empty: State = .{ + .images = .empty, + .kitty_placements = .empty, + .kitty_bg_end = 0, + .kitty_text_end = 0, + .kitty_virtual = false, + .overlay_placements = .empty, + }; + + pub fn deinit(self: *State, alloc: Allocator) void { + { + var it = self.images.iterator(); + while (it.next()) |kv| kv.value_ptr.image.deinit(alloc); + self.images.deinit(alloc); + } + self.kitty_placements.deinit(alloc); + self.overlay_placements.deinit(alloc); + } + + /// Upload any images to the GPU that need to be uploaded, + /// and remove any images that are no longer needed on the GPU. + /// + /// If any uploads fail, they are ignored. The return value + /// can be used to detect if upload was a total success (true) + /// or not (false). + pub fn upload( + self: *State, + alloc: Allocator, + api: *GraphicsAPI, + ) bool { + var success: bool = true; + var image_it = self.images.iterator(); + while (image_it.next()) |kv| { + const img = &kv.value_ptr.image; + if (img.isUnloading()) { + img.deinit(alloc); + self.images.removeByPtr(kv.key_ptr); + continue; + } + + if (img.isPending()) { + img.upload( + alloc, + api, + ) catch |err| { + log.warn("error uploading image to GPU err={}", .{err}); + success = false; + }; + } + } + + return success; + } + + pub const DrawPlacements = enum { + kitty_below_bg, + kitty_below_text, + kitty_above_text, + overlay, + }; + + /// Draw the given named set of placements. + /// + /// Any placements that have non-uploaded images are ignored. Any + /// graphics API errors during drawing are also ignored. + pub fn draw( + self: *State, + api: *GraphicsAPI, + pipeline: GraphicsAPI.Pipeline, + pass: *GraphicsAPI.RenderPass, + placement_type: DrawPlacements, + ) void { + const placements: []const Placement = switch (placement_type) { + .kitty_below_bg => self.kitty_placements.items[0..self.kitty_bg_end], + .kitty_below_text => self.kitty_placements.items[self.kitty_bg_end..self.kitty_text_end], + .kitty_above_text => self.kitty_placements.items[self.kitty_text_end..], + .overlay => self.overlay_placements.items, + }; + + for (placements) |p| { + // Look up the image + const image = self.images.get(p.image_id) orelse { + log.warn("image not found for placement image_id={}", .{p.image_id}); + continue; + }; + + // Get the texture + const texture = switch (image.image) { + .ready, + .unload_ready, + => |t| t, + else => { + log.warn("image not ready for placement image_id={}", .{p.image_id}); + continue; + }, + }; + + // Create our vertex buffer, which is always exactly one item. + // future(mitchellh): we can group rendering multiple instances of a single image + var buf = GraphicsAPI.Buffer(GraphicsAPI.shaders.Image).initFill( + api.imageBufferOptions(), + &.{.{ + .grid_pos = .{ + @as(f32, @floatFromInt(p.x)), + @as(f32, @floatFromInt(p.y)), + }, + + .cell_offset = .{ + @as(f32, @floatFromInt(p.cell_offset_x)), + @as(f32, @floatFromInt(p.cell_offset_y)), + }, + + .source_rect = .{ + @as(f32, @floatFromInt(p.source_x)), + @as(f32, @floatFromInt(p.source_y)), + @as(f32, @floatFromInt(p.source_width)), + @as(f32, @floatFromInt(p.source_height)), + }, + + .dest_size = .{ + @as(f32, @floatFromInt(p.width)), + @as(f32, @floatFromInt(p.height)), + }, + }}, + ) catch |err| { + log.warn("error creating image vertex buffer err={}", .{err}); + continue; + }; + defer buf.deinit(); + + pass.step(.{ + .pipeline = pipeline, + .buffers = &.{buf.buffer}, + .textures = &.{texture}, + .draw = .{ + .type = .triangle_strip, + .vertex_count = 4, + }, + }); + } + } + + /// Update our overlay state. Null value deletes any existing overlay. + pub fn overlayUpdate( + self: *State, + alloc: Allocator, + overlay_: ?Overlay, + ) !void { + const overlay = overlay_ orelse { + // If we don't have an overlay, remove any existing one. + if (self.images.getPtr(.overlay)) |data| { + data.image.markForUnload(); + } + return; + }; + + // For transmit time we always just use the current time + // and overwrite the overlay. + const transmit_time = try std.time.Instant.now(); + + // Ensure we have space for our overlay placement. Do this before + // we upload our image so we don't have to deal with cleaning + // that up. + self.overlay_placements.clearRetainingCapacity(); + try self.overlay_placements.ensureUnusedCapacity(alloc, 1); + + // Setup our image. + const pending = overlay.pendingImage(); + try self.prepImage( + alloc, + .overlay, + transmit_time, + pending, + ); + errdefer comptime unreachable; + + // Setup our placement + self.overlay_placements.appendAssumeCapacity(.{ + .image_id = .overlay, + .x = 0, + .y = 0, + .z = 0, + .width = pending.width, + .height = pending.height, + .cell_offset_x = 0, + .cell_offset_y = 0, + .source_x = 0, + .source_y = 0, + .source_width = pending.width, + .source_height = pending.height, + }); + } + + /// Returns true if the Kitty graphics state requires an update based + /// on the terminal state and our internal state. + /// + /// This does not read/write state used by drawing. + pub fn kittyRequiresUpdate( + self: *const State, + t: *const terminal.Terminal, + ) bool { + // If the terminal kitty image state is dirty, we must update. + if (t.screens.active.kitty_images.dirty) return true; + + // If we have any virtual references, we must also rebuild our + // kitty state on every frame because any cell change can move + // an image. If the virtual placements were removed, this will + // be set to false on the next update. + if (self.kitty_virtual) return true; + + return false; + } + + /// Update the Kitty graphics state from the terminal. + /// + /// This reads/writes state used by drawing. + pub fn kittyUpdate( + self: *State, + alloc: Allocator, + t: *const terminal.Terminal, + cell_size: CellSize, + ) void { + const storage = &t.screens.active.kitty_images; + defer storage.dirty = false; + + // We always clear our previous placements no matter what because + // we rebuild them from scratch. + self.kitty_placements.clearRetainingCapacity(); + self.kitty_virtual = false; + + // Go through our known images and if there are any that are no longer + // in use then mark them to be freed. + // + // This never conflicts with the below because a placement can't + // reference an image that doesn't exist. + { + var it = self.images.iterator(); + while (it.next()) |kv| { + switch (kv.key_ptr.*) { + // We're only looking at Kitty images + .kitty => |id| if (storage.imageById(id) == null) { + kv.value_ptr.image.markForUnload(); + }, + + .overlay => {}, + } + } + } + + // The top-left and bottom-right corners of our viewport in screen + // points. This lets us determine offsets and containment of placements. + const top = t.screens.active.pages.getTopLeft(.viewport); + const bot = t.screens.active.pages.getBottomRight(.viewport).?; + const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y; + const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y; + + // Go through the placements and ensure the image is + // on the GPU or else is ready to be sent to the GPU. + var it = storage.placements.iterator(); + while (it.next()) |kv| { + const p = kv.value_ptr; + + // Special logic based on location + switch (p.location) { + .pin => {}, + .virtual => { + // We need to mark virtual placements on our renderer so that + // we know to rebuild in more scenarios since cell changes can + // now trigger placement changes. + self.kitty_virtual = true; + + // We also continue out because virtual placements are + // only triggered by the unicode placeholder, not by the + // placement itself. + continue; + }, + } + + // Get the image for the placement + const image = storage.imageById(kv.key_ptr.image_id) orelse { + log.warn( + "missing image for placement, ignoring image_id={}", + .{kv.key_ptr.image_id}, + ); + continue; + }; + + self.prepKittyPlacement( + alloc, + t, + top_y, + bot_y, + &image, + p, + ) catch |err| { + // For errors we log and continue. We try to place + // other placements even if one fails. + log.warn("error preparing kitty placement err={}", .{err}); + }; + } + + // If we have virtual placements then we need to scan for placeholders. + if (self.kitty_virtual) { + var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot); + while (v_it.next()) |virtual_p| { + self.prepKittyVirtualPlacement( + alloc, + t, + &virtual_p, + cell_size, + ) catch |err| { + // For errors we log and continue. We try to place + // other placements even if one fails. + log.warn("error preparing kitty placement err={}", .{err}); + }; + } + } + + // Sort the placements by their Z value. + std.mem.sortUnstable( + Placement, + self.kitty_placements.items, + {}, + struct { + fn lessThan( + ctx: void, + lhs: Placement, + rhs: Placement, + ) bool { + _ = ctx; + return lhs.z < rhs.z or + (lhs.z == rhs.z and lhs.image_id.zLessThan(rhs.image_id)); + } + }.lessThan, + ); + + // Find our indices. The values are sorted by z so we can + // find the first placement out of bounds to find the limits. + const bg_limit = std.math.minInt(i32) / 2; + var bg_end: ?u32 = null; + var text_end: ?u32 = null; + for (self.kitty_placements.items, 0..) |p, i| { + if (bg_end == null and p.z >= bg_limit) bg_end = @intCast(i); + if (text_end == null and p.z >= 0) text_end = @intCast(i); + } + + // If we didn't see any images with a z > the bg limit, + // then our bg end is the end of our placement list. + self.kitty_bg_end = + bg_end orelse @intCast(self.kitty_placements.items.len); + // Same idea for the image_text_end. + self.kitty_text_end = + text_end orelse @intCast(self.kitty_placements.items.len); + } + + const PrepImageError = error{ + OutOfMemory, + ImageConversionError, + }; + + /// Get the viewport-relative position for this + /// placement and add it to the placements list. + fn prepKittyPlacement( + self: *State, + alloc: Allocator, + t: *const terminal.Terminal, + top_y: u32, + bot_y: u32, + image: *const terminal.kitty.graphics.Image, + p: *const terminal.kitty.graphics.ImageStorage.Placement, + ) PrepImageError!void { + // Get the rect for the placement. If this placement doesn't have + // a rect then its virtual or something so skip it. + const rect = p.rect(image.*, t) orelse return; + + // This is expensive but necessary. + const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y; + const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; + + // If the selection isn't within our viewport then skip it. + if (img_top_y > bot_y) return; + if (img_bot_y < top_y) return; + + // We need to prep this image for upload if it isn't in the + // cache OR it is in the cache but the transmit time doesn't + // match meaning this image is different. + try self.prepKittyImage(alloc, image); + + // Calculate the dimensions of our image, taking in to + // account the rows / columns specified by the placement. + const dest_size = p.calculatedSize(image.*, t); + + // Calculate the source rectangle + const source_x = @min(image.width, p.source_x); + const source_y = @min(image.height, p.source_y); + const source_width = if (p.source_width > 0) + @min(image.width - source_x, p.source_width) + else + image.width; + const source_height = if (p.source_height > 0) + @min(image.height - source_y, p.source_height) + else + image.height; + + // Get the viewport-relative Y position of the placement. + const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); + + // Accumulate the placement + if (dest_size.width > 0 and dest_size.height > 0) { + try self.kitty_placements.append(alloc, .{ + .image_id = .{ .kitty = image.id }, + .x = @intCast(rect.top_left.x), + .y = y_pos, + .z = p.z, + .width = dest_size.width, + .height = dest_size.height, + .cell_offset_x = p.x_offset, + .cell_offset_y = p.y_offset, + .source_x = source_x, + .source_y = source_y, + .source_width = source_width, + .source_height = source_height, + }); + } + } + + fn prepKittyVirtualPlacement( + self: *State, + alloc: Allocator, + t: *const terminal.Terminal, + p: *const terminal.kitty.graphics.unicode.Placement, + cell_size: CellSize, + ) PrepImageError!void { + const storage = &t.screens.active.kitty_images; + const image = storage.imageById(p.image_id) orelse { + log.warn( + "missing image for virtual placement, ignoring image_id={}", + .{p.image_id}, + ); + return; + }; + + const rp = p.renderPlacement( + storage, + &image, + cell_size.width, + cell_size.height, + ) catch |err| { + log.warn("error rendering virtual placement err={}", .{err}); + return; + }; + + // If our placement is zero sized then we don't do anything. + if (rp.dest_width == 0 or rp.dest_height == 0) return; + + const viewport: terminal.point.Point = t.screens.active.pages.pointFromPin( + .viewport, + rp.top_left, + ) orelse { + // This is unreachable with virtual placements because we should + // only ever be looking at virtual placements that are in our + // viewport in the renderer and virtual placements only ever take + // up one row. + unreachable; + }; + + // Prepare the image for the GPU and store the placement. + try self.prepKittyImage(alloc, &image); + try self.kitty_placements.append(alloc, .{ + .image_id = .{ .kitty = image.id }, + .x = @intCast(rp.top_left.x), + .y = @intCast(viewport.viewport.y), + .z = -1, + .width = rp.dest_width, + .height = rp.dest_height, + .cell_offset_x = rp.offset_x, + .cell_offset_y = rp.offset_y, + .source_x = rp.source_x, + .source_y = rp.source_y, + .source_width = rp.source_width, + .source_height = rp.source_height, + }); + } + + /// Prepare an image for upload to the GPU. + fn prepImage( + self: *State, + alloc: Allocator, + id: Id, + transmit_time: std.time.Instant, + pending: Image.Pending, + ) PrepImageError!void { + // If this image exists and its transmit time is the same we assume + // it is the identical image so we don't need to send it to the GPU. + const gop = try self.images.getOrPut(alloc, id); + if (gop.found_existing and + gop.value_ptr.transmit_time.order(transmit_time) == .eq) + { + return; + } + + // Copy the data so we own it. + const data = if (alloc.dupe( + u8, + pending.dataSlice(), + )) |v| v else |_| { + if (!gop.found_existing) { + // If this is a new entry we can just remove it since it + // was never sent to the GPU. + _ = self.images.remove(id); + } else { + // If this was an existing entry, it is invalid and + // we must unload it. + gop.value_ptr.image.markForUnload(); + } + + return error.OutOfMemory; + }; + // Note: we don't need to errdefer free the data because it is + // put into the map immediately below and our errdefer to + // handle our map state will fix this up. + + // Store it in the map + const new_image: Image = .{ + .pending = .{ + .width = pending.width, + .height = pending.height, + .pixel_format = pending.pixel_format, + .data = data.ptr, + }, + }; + if (!gop.found_existing) { + gop.value_ptr.* = .{ + .image = new_image, + .transmit_time = undefined, + }; + } else { + gop.value_ptr.image.markForReplace( + alloc, + new_image, + ); + } + + // If any error happens, we unload the image and it is invalid. + errdefer gop.value_ptr.image.markForUnload(); + + gop.value_ptr.image.prepForUpload(alloc) catch |err| { + log.warn("error preparing image for upload err={}", .{err}); + return error.ImageConversionError; + }; + gop.value_ptr.transmit_time = transmit_time; + } + + /// Prepare the provided Kitty image for upload to the GPU by copying its + /// data with our allocator and setting it to the pending state. + fn prepKittyImage( + self: *State, + alloc: Allocator, + image: *const terminal.kitty.graphics.Image, + ) PrepImageError!void { + try self.prepImage( + alloc, + .{ .kitty = image.id }, + image.transmit_time, + .{ + .width = image.width, + .height = image.height, + .pixel_format = switch (image.format) { + .gray => .gray, + .gray_alpha => .gray_alpha, + .rgb => .rgb, + .rgba => .rgba, + .png => unreachable, // should be decoded by now + }, + + // constCasts are always gross but this one is safe is because + // the data is only read from here and copied into its own + // buffer. + .data = @constCast(image.data.ptr), + }, + ); + } +}; /// Represents a single image placement on the grid. /// A placement is a request to render an instance of an image. pub const Placement = struct { /// The image being rendered. This MUST be in the image map. - image_id: u32, + image_id: Id, /// The grid x/y where this placement is located. x: i32, @@ -34,8 +647,46 @@ pub const Placement = struct { source_height: u32, }; +/// Image identifier used to store and lookup images. +/// +/// This is tagged by different image types to make it easier to +/// store different kinds of images in the same map without having +/// to worry about ID collisions. +pub const Id = union(enum) { + /// Image sent to the terminal state via the kitty graphics protocol. + /// The value is the ID assigned by the terminal. + kitty: u32, + + /// Debug overlay. This is always composited down to a single + /// image for now. In the future we can support layers here if we want. + overlay, + + /// Z-ordering tie-breaker for images with the same z value. + pub fn zLessThan(lhs: Id, rhs: Id) bool { + // If our tags aren't the same, we sort by tag. + if (std.meta.activeTag(lhs) != std.meta.activeTag(rhs)) { + return switch (lhs) { + // Kitty images always sort before (lower z) non-kitty images. + .kitty => true, + + .overlay => false, + }; + } + + switch (lhs) { + .kitty => |lhs_id| { + const rhs_id = rhs.kitty; + return lhs_id < rhs_id; + }, + + // No sensical ordering + .overlay => return false, + } + } +}; + /// The map used for storing images. -pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct { +pub const ImageMap = std.AutoHashMapUnmanaged(Id, struct { image: Image, transmit_time: std.time.Instant, }); @@ -221,27 +872,33 @@ pub const Image = union(enum) { try self.convert(alloc); } - /// Upload the pending image to the GPU and - /// change the state of this image to ready. + /// Upload the pending image to the GPU and change the state of this + /// image to ready. pub fn upload( self: *Image, alloc: Allocator, api: *const GraphicsAPI, - ) !void { + ) (wuffs.Error || error{ + /// Texture creation failed, usually a GPU memory issue. + UploadFailed, + })!void { assert(self.isPending()); + // No error recover is required after this call because it just + // converts in place and is idempotent. try self.prepForUpload(alloc); // Get our pending info const p = self.getPending().?; // Create our texture - const texture = try Texture.init( + const texture = Texture.init( api.imageTextureOptions(.rgba, true), @intCast(p.width), @intCast(p.height), p.dataSlice(), - ); + ) catch return error.UploadFailed; + errdefer comptime unreachable; // Uploaded. We can now clear our data and change our state. // diff --git a/src/renderer/row.zig b/src/renderer/row.zig index 933bb338b..74a641012 100644 --- a/src/renderer/row.zig +++ b/src/renderer/row.zig @@ -16,8 +16,8 @@ pub fn neverExtendBg( // because prompts often contain special formatting (such as // powerline) that looks bad when extended. switch (row.semantic_prompt) { - .prompt, .prompt_continuation, .input => return true, - .unknown, .command => {}, + .prompt, .prompt_continuation => return true, + .none => {}, } for (0.., cells) |x, *cell| { diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index 799d0cff6..40fd71b19 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -16,7 +16,7 @@ # along with this program. If not, see . # We need to be in interactive mode to proceed. -if [[ "$-" != *i* ]] ; then builtin return; fi +if [[ "$-" != *i* ]]; then builtin return; fi # When automatic shell integration is active, we were started in POSIX # mode and need to manually recreate the bash startup sequence. @@ -49,7 +49,10 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then if [[ $__ghostty_bash_flags != *"--noprofile"* ]]; then [ -r /etc/profile ] && builtin source "/etc/profile" for __ghostty_rcfile in "$HOME/.bash_profile" "$HOME/.bash_login" "$HOME/.profile"; do - [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + [ -r "$__ghostty_rcfile" ] && { + builtin source "$__ghostty_rcfile" + break + } done fi else @@ -61,7 +64,10 @@ if [ -n "$GHOSTTY_BASH_INJECT" ]; then # Void Linux uses /etc/bash/bashrc # Nixos uses /etc/bashrc for __ghostty_rcfile in /etc/bash.bashrc /etc/bash/bashrc /etc/bashrc; do - [ -r "$__ghostty_rcfile" ] && { builtin source "$__ghostty_rcfile"; break; } + [ -r "$__ghostty_rcfile" ] && { + builtin source "$__ghostty_rcfile" + break + } done if [[ -z "$GHOSTTY_BASH_RCFILE" ]]; then GHOSTTY_BASH_RCFILE="$HOME/.bashrc"; fi [ -r "$GHOSTTY_BASH_RCFILE" ] && builtin source "$GHOSTTY_BASH_RCFILE" @@ -101,9 +107,9 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *"sudo"* && -n "$TERMINFO" ]]; then fi done if [[ "$sudo_has_sudoedit_flags" == "yes" ]]; then - builtin command sudo "$@"; + builtin command sudo "$@" else - builtin command sudo --preserve-env=TERMINFO "$@"; + builtin command sudo --preserve-env=TERMINFO "$@" fi } fi @@ -127,8 +133,8 @@ if [[ "$GHOSTTY_SHELL_FEATURES" == *ssh-* ]]; then while IFS=' ' read -r ssh_key ssh_value; do case "$ssh_key" in - user) ssh_user="$ssh_value" ;; - hostname) ssh_hostname="$ssh_value" ;; + user) ssh_user="$ssh_value" ;; + hostname) ssh_hostname="$ssh_value" ;; esac [[ -n "$ssh_user" && -n "$ssh_hostname" ]] && break done < <(builtin command ssh -G "$@" 2>/dev/null) @@ -187,66 +193,71 @@ _ghostty_executing="" _ghostty_last_reported_cwd="" function __ghostty_precmd() { - local ret="$?" - if test "$_ghostty_executing" != "0"; then - _GHOSTTY_SAVE_PS1="$PS1" - _GHOSTTY_SAVE_PS2="$PS2" + local ret="$?" + if test "$_ghostty_executing" != "0"; then + _GHOSTTY_SAVE_PS1="$PS1" + _GHOSTTY_SAVE_PS2="$PS2" - # Marks - PS1=$PS1'\[\e]133;B\a\]' - PS2=$PS2'\[\e]133;B\a\]' + # Marks. We need to do fresh line (A) at the beginning of the prompt + # since if the cursor is not at the beginning of a line, the terminal + # will emit a newline. + PS1='\[\e]133;A;redraw=last\a\]'$PS1'\[\e]133;B\a\]' + PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]' - # bash doesn't redraw the leading lines in a multiline prompt so - # mark the last line as a secondary prompt (k=s) to prevent the - # preceding lines from being erased by ghostty after a resize. - if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then - PS1=$PS1'\[\e]133;A;k=s\a\]' - fi - - # Cursor - if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then - [[ "$PS1" != *'\[\e[5 q\]'* ]] && PS1=$PS1'\[\e[5 q\]' # input - [[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset - fi - - # Title (working directory) - if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then - PS1=$PS1'\[\e]2;\w\a\]' - fi + # Bash doesn't redraw the leading lines in a multiline prompt so + # we mark the start of each line (after each newline) as a secondary + # prompt. This correctly handles multiline prompts by setting the first + # to primary and the subsequent lines to secondary. + if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then + builtin local __ghostty_mark=$'\\[\\e]133;A;k=s\\a\\]' + PS1="${PS1//$'\n'/$'\n'$__ghostty_mark}" + PS1="${PS1//\\n/\\n$__ghostty_mark}" fi - if test "$_ghostty_executing" != ""; then - # End of current command. Report its status. - builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" + # Cursor + if [[ "$GHOSTTY_SHELL_FEATURES" == *"cursor"* ]]; then + [[ "$PS1" != *'\[\e[5 q\]'* ]] && PS1=$PS1'\[\e[5 q\]' # input + [[ "$PS0" != *'\[\e[0 q\]'* ]] && PS0=$PS0'\[\e[0 q\]' # reset fi - # unfortunately bash provides no hooks to detect cwd changes - # in particular this means cwd reporting will not happen for a - # command like cd /test && cat. PS0 is evaluated before cd is run. - if [[ "$_ghostty_last_reported_cwd" != "$PWD" ]]; then - _ghostty_last_reported_cwd="$PWD" - builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD" + # Title (working directory) + if [[ "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then + PS1=$PS1'\[\e]2;\w\a\]' fi + fi - # Fresh line and start of prompt. - builtin printf "\e]133;A;aid=%s\a" "$BASHPID" - _ghostty_executing=0 + if test "$_ghostty_executing" != ""; then + # End of current command. Report its status. + builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" + fi + + # unfortunately bash provides no hooks to detect cwd changes + # in particular this means cwd reporting will not happen for a + # command like cd /test && cat. PS0 is evaluated before cd is run. + if [[ "$_ghostty_last_reported_cwd" != "$PWD" ]]; then + _ghostty_last_reported_cwd="$PWD" + builtin printf "\e]7;kitty-shell-cwd://%s%s\a" "$HOSTNAME" "$PWD" + fi + + # Fresh line and start of prompt. + builtin printf "\e]133;A;redraw=last;aid=%s\a" "$BASHPID" + _ghostty_executing=0 } function __ghostty_preexec() { - builtin local cmd="$1" + builtin local cmd="$1" - PS1="$_GHOSTTY_SAVE_PS1" - PS2="$_GHOSTTY_SAVE_PS2" + PS1="$_GHOSTTY_SAVE_PS1" + PS2="$_GHOSTTY_SAVE_PS2" - # Title (current command) - if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then - builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]}" - fi + # Title (current command) + if [[ -n $cmd && "$GHOSTTY_SHELL_FEATURES" == *"title"* ]]; then + builtin printf "\e]2;%s\a" "${cmd//[[:cntrl:]]/}" + fi - # End of input, start of output. - builtin printf "\e]133;C;\a" - _ghostty_executing=1 + # End of input, start of output. + builtin printf "\e]133;C;\a" + _ghostty_executing=1 } preexec_functions+=(__ghostty_preexec) diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index 3fb3ec19b..ac609d6a0 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -132,6 +132,7 @@ _ghostty_deferred_init() { # asynchronously from a `zle -F` handler might still remove our # marks. Oh well. builtin local mark2=$'%{\e]133;A;k=s\a%}' + builtin local markB=$'%{\e]133;B\a%}' # Add marks conditionally to avoid a situation where we have # several marks in place. These conditions can have false # positives and false negatives though. @@ -139,8 +140,17 @@ _ghostty_deferred_init() { # - False positive (with prompt_percent): PS1="%(?.$mark1.)" # - False negative (with prompt_subst): PS1='$mark1' [[ $PS1 == *$mark1* ]] || PS1=${mark1}${PS1} + [[ $PS1 == *$markB* ]] || PS1=${PS1}${markB} + # Handle multiline prompts by marking continuation lines as + # secondary by replacing newlines with being prefixed + # with k=s + if [[ $PS1 == *$'\n'* ]]; then + PS1=${PS1//$'\n'/$'\n'${mark2}} + fi + # PS2 mark is needed when clearing the prompt on resize [[ $PS2 == *$mark2* ]] || PS2=${mark2}${PS2} + [[ $PS2 == *$markB* ]] || PS2=${PS2}${markB} (( _ghostty_state = 2 )) else # If our precmd hook is not the last, we cannot rely on prompt @@ -179,7 +189,10 @@ _ghostty_deferred_init() { # top. We cannot force prompt_subst on the user though, so we would # still need this code for the no_prompt_subst case. PS1=${PS1//$'%{\e]133;A\a%}'} + PS1=${PS1//$'%{\e]133;A;k=s\a%}'} + PS1=${PS1//$'%{\e]133;B\a%}'} PS2=${PS2//$'%{\e]133;A;k=s\a%}'} + PS2=${PS2//$'%{\e]133;B\a%}'} # This will work incorrectly in the presence of a preexec hook that # prints. For example, if MichaelAquilina/zsh-you-should-use installs diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index f7d3c735f..71534d0aa 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -12,6 +12,7 @@ const fastmem = @import("../fastmem.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); +const highlight = @import("highlight.zig"); const kitty = @import("kitty.zig"); const point = @import("point.zig"); const pagepkg = @import("page.zig"); @@ -1228,7 +1229,7 @@ const ReflowCursor = struct { // If the row has a semantic prompt then the blank row is meaningful // so we just consider pretend the first cell of the row isn't empty. - if (cols_len == 0 and src_row.semantic_prompt != .unknown) cols_len = 1; + if (cols_len == 0 and src_row.semantic_prompt != .none) cols_len = 1; } // Handle tracked pin adjustments. @@ -1972,7 +1973,7 @@ const ReflowCursor = struct { // If the row has a semantic prompt then the blank row is meaningful // so we always return all but one so that the row is drawn. - if (self.page_row.semantic_prompt != .unknown) return len - 1; + if (self.page_row.semantic_prompt != .none) return len - 1; return len; } @@ -2668,21 +2669,26 @@ fn scrollPrompt(self: *PageList, delta: isize) void { const delta_start: usize = @intCast(if (delta > 0) delta else -delta); var delta_rem: usize = delta_start; - // Iterate and count the number of prompts we see. - const viewport_pin = self.getTopLeft(.viewport); - var it = viewport_pin.rowIterator(if (delta > 0) .right_down else .left_up, null); - _ = it.next(); // skip our own row + // We start at the row before or after our viewport depending on the + // delta so that we don't land back on our current viewport. + const start_pin = start: { + const tl = self.getTopLeft(.viewport); + const adjusted: ?Pin = if (delta > 0) + tl.down(1) + else + tl.up(1); + break :start adjusted orelse return; + }; + + // Go through prompts delta times + var it = start_pin.promptIterator( + if (delta > 0) .right_down else .left_up, + null, + ); var prompt_pin: ?Pin = null; while (it.next()) |next| { - const row = next.rowAndCell().row; - switch (row.semantic_prompt) { - .command, .unknown => {}, - .prompt, .prompt_continuation, .input => { - delta_rem -= 1; - prompt_pin = next; - }, - } - + prompt_pin = next; + delta_rem -= 1; if (delta_rem == 0) break; } @@ -3889,6 +3895,12 @@ pub fn countTrackedPins(self: *const PageList) usize { return self.tracked_pins.count(); } +/// Returns the tracked pins for this pagelist. The slice is owned by the +/// pagelist and is only valid until the pagelist is modified. +pub fn trackedPins(self: *const PageList) []const *Pin { + return self.tracked_pins.keys(); +} + /// Checks if a pin is valid for this pagelist. This is a very slow and /// expensive operation since we traverse the entire linked list in the /// worst case. Only for runtime safety/debug. @@ -4204,9 +4216,321 @@ pub fn diagram( } } +/// Returns the boundaries of the given semantic content type for +/// the prompt at the given pin. The pin row MUST be the first row +/// of a prompt, otherwise the results may be nonsense. +/// +/// To get prompt pins, use promptIterator. Warning that if there are +/// no semantic prompts ever present, promptIterator will iterate the +/// entire PageList. Downstream callers should keep track of a flag if +/// they've ever seen semantic prompt operations to prevent this performance +/// case. +/// +/// Note that some semantic content type such as "input" is usually +/// nested within prompt boundaries, so the returned boundaries may include +/// prompt text. +pub fn highlightSemanticContent( + self: *const PageList, + at: Pin, + content: pagepkg.Cell.SemanticContent, +) ?highlight.Untracked { + // Performance note: we can do this more efficiently in a single + // forward-pass. Semantic content operations aren't usually fast path + // but if someone wants to optimize them someday that's great. + + const end: Pin = end: { + // Safety assertion, our starting point should be a prompt row. + // so the first returned prompt should be ourselves. + var it = at.promptIterator(.right_down, null); + assert(it.next().?.y == at.y); + + // Our end is the end of the line just before the next prompt + // line, which should exist since we verified we have at least + // two prompts here. + if (it.next()) |next| next: { + var prev = next.up(1) orelse break :next; + prev.x = prev.node.data.size.cols - 1; + break :end prev; + } + + // Didn't find any further prompt so the end of our zone is + // the end of the screen. + break :end self.getBottomRight(.screen).?; + }; + + switch (content) { + // For the prompt, we select all the way up to command output. + // We include all the input lines, too. + .prompt => { + var result: highlight.Untracked = .{ + .start = at.left(at.x), + .end = at, + }; + + var it = at.cellIterator(.right_down, end); + while (it.next()) |p| { + switch (p.rowAndCell().cell.semantic_content) { + .prompt, .input => result.end = p, + .output => break, + } + } + + return result; + }, + + // For input, we include the start of the input to the end of + // the input, which may include all the prompts in the middle, too. + .input => { + var result: highlight.Untracked = .{ + .start = undefined, + .end = undefined, + }; + + // Find the start + var it = at.cellIterator(.right_down, end); + while (it.next()) |p| { + switch (p.rowAndCell().cell.semantic_content) { + .prompt => {}, + .input => { + result.start = p; + result.end = p; + break; + }, + .output => return null, + } + } else { + // No input found + return null; + } + + // Find the end + while (it.next()) |p| { + switch (p.rowAndCell().cell.semantic_content) { + // Prompts can be nested in our input for continuation + .prompt => {}, + + // Output means we're done + .output => break, + + .input => result.end = p, + } + } + + return result; + }, + + .output => { + var result: highlight.Untracked = .{ + .start = undefined, + .end = undefined, + }; + + // Find the start + var it = at.cellIterator(.right_down, end); + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + switch (cell.semantic_content) { + .prompt, .input => {}, + .output => { + // Skip empty cells - they default to .output but aren't real output + if (!cell.hasText()) continue; + result.start = p; + result.end = p; + break; + }, + } + } else { + // No output found + return null; + } + + // Find the end + while (it.next()) |p| { + const cell = p.rowAndCell().cell; + switch (cell.semantic_content) { + .prompt, .input => break, + .output => { + // Only extend to cells with actual text + if (cell.hasText()) result.end = p; + }, + } + } + + return result; + }, + } +} + /// Direction that iterators can move. pub const Direction = enum { left_up, right_down }; +pub const PromptIterator = struct { + /// The pin that we are currently at. Also the starting pin when + /// initializing. + current: ?Pin, + + /// The pin to end at or null if we end when we can't traverse + /// anymore. + limit: ?Pin, + + /// The direction to do the traversal. + direction: Direction, + + pub const empty: PromptIterator = .{ + .current = null, + .limit = null, + .direction = .left_up, + }; + + /// Return the next pin that represents the first row in a prompt. + /// From here, you can find the prompt input, command output, etc. + pub fn next(self: *PromptIterator) ?Pin { + switch (self.direction) { + .left_up => return self.nextLeftUp(), + .right_down => return self.nextRightDown(), + } + } + + pub fn nextRightDown(self: *PromptIterator) ?Pin { + // Start at our current pin. If we have no current it means + // we reached the end and we're done. + const start: Pin = self.current orelse return null; + + // We need to traverse downwards and look for prompts. + var current: ?Pin = start; + while (current) |p| : (current = p.down(1)) { + // Check our limit. + const at_limit = if (self.limit) |limit| limit.eql(p) else false; + + const rac = p.rowAndCell(); + switch (rac.row.semantic_prompt) { + // This row isn't a prompt. Keep looking. + .none => if (at_limit) break, + + // This is a prompt line or continuation line. In either + // case we consider the first line the prompt, and then + // skip over any remaining prompt lines. This handles the + // case where scrollback pruned the prompt. + .prompt, .prompt_continuation => { + // If we're at our limit just return this prompt. + if (at_limit) { + self.current = null; + return p.left(p.x); + } + + // Skip over any continuation lines that follow this prompt, + // up to our limit. + var end_pin = p; + while (end_pin.down(1)) |next_pin| : (end_pin = next_pin) { + switch (next_pin.rowAndCell().row.semantic_prompt) { + .prompt_continuation => if (self.limit) |limit| { + if (limit.eql(next_pin)) break; + }, + + .prompt, .none => { + self.current = next_pin; + return p.left(p.x); + }, + } + } + + self.current = null; + return p.left(p.x); + }, + } + } + + self.current = null; + return null; + } + + pub fn nextLeftUp(self: *PromptIterator) ?Pin { + // Start at our current pin. If we have no current it means + // we reached the end and we're done. + const start: Pin = self.current orelse return null; + + // We need to traverse upwards and look for prompts. + var current: ?Pin = start; + while (current) |p| : (current = p.up(1)) { + // Check our limit. + const at_limit = if (self.limit) |limit| limit.eql(p) else false; + + const rac = p.rowAndCell(); + switch (rac.row.semantic_prompt) { + // This row isn't a prompt. Keep looking. + .none => if (at_limit) break, + + // This is a prompt line. + .prompt => { + self.current = if (at_limit) null else p.up(1); + return p.left(p.x); + }, + + // If this is a prompt continuation, then we continue + // looking for the start of the prompt OR a non-prompt + // line, whichever is first. The non-prompt line is to handle + // poorly behaved programs or scrollback that's been cut-off. + .prompt_continuation => { + // If we're at our limit just return this continuation as prompt. + if (at_limit) { + self.current = null; + return p.left(p.x); + } + + var end_pin = p; + while (end_pin.up(1)) |prior| : (end_pin = prior) { + if (self.limit) |limit| { + if (limit.eql(prior)) break; + } + + switch (prior.rowAndCell().row.semantic_prompt) { + // No prompt. That means our last pin is good! + .none => { + self.current = prior; + return end_pin.left(end_pin.x); + }, + + // Prompt continuation, keep looking. + .prompt_continuation => {}, + + // Prompt! Found it! + .prompt => { + self.current = prior.up(1); + return prior.left(prior.x); + }, + } + } + + // No prior rows, trimmed scrollback probably. + self.current = null; + return p.left(p.x); + }, + } + } + + self.current = null; + return null; + } +}; + +pub fn promptIterator( + self: *const PageList, + direction: Direction, + tl_pt: point.Point, + bl_pt: ?point.Point, +) PromptIterator { + const tl_pin = self.pin(tl_pt).?; + const bl_pin = if (bl_pt) |pt| + self.pin(pt).? + else + self.getBottomRight(tl_pt) orelse return .empty; + + return switch (direction) { + .right_down => tl_pin.promptIterator(.right_down, bl_pin), + .left_up => bl_pin.promptIterator(.left_up, tl_pin), + }; +} + pub const CellIterator = struct { row_it: RowIterator, cell: ?Pin = null, @@ -4810,6 +5134,18 @@ pub const Pin = struct { return .{ .row_it = row_it, .cell = cell }; } + pub inline fn promptIterator( + self: Pin, + direction: Direction, + limit: ?Pin, + ) PromptIterator { + return .{ + .current = self, + .limit = limit, + .direction = direction, + }; + } + /// Returns true if this pin is between the top and bottom, inclusive. // // Note: this is primarily unit tested as part of the Kitty @@ -5085,7 +5421,7 @@ pub const Pin = struct { } }; -const Cell = struct { +pub const Cell = struct { node: *List.Node, row: *pagepkg.Row, cell: *pagepkg.Cell, @@ -7559,6 +7895,1330 @@ test "PageList cellIterator reverse" { try testing.expect(it.next() == null); } +test "PageList promptIterator left_up" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + // Normal prompt + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt = .prompt; + } + // Continuation + { + const rac = page.getRowAndCell(0, 6); + rac.row.semantic_prompt = .prompt; + } + { + const rac = page.getRowAndCell(0, 7); + rac.row.semantic_prompt = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 8); + rac.row.semantic_prompt = .prompt_continuation; + } + // Broken continuation that has non-prompts in between + { + const rac = page.getRowAndCell(0, 12); + rac.row.semantic_prompt = .prompt_continuation; + } + + var it = s.promptIterator(.left_up, .{ .screen = .{} }, null); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 12, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator right_down" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + // Normal prompt + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt = .prompt; + } + // Continuation (prompt on row 6, continuation on rows 7-8) + { + const rac = page.getRowAndCell(0, 6); + rac.row.semantic_prompt = .prompt; + } + { + const rac = page.getRowAndCell(0, 7); + rac.row.semantic_prompt = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 8); + rac.row.semantic_prompt = .prompt_continuation; + } + // Broken continuation that has non-prompts in between (orphaned continuation at row 12) + { + const rac = page.getRowAndCell(0, 12); + rac.row.semantic_prompt = .prompt_continuation; + } + + var it = s.promptIterator(.right_down, .{ .screen = .{} }, null); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 12, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator right_down continuation at start" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt continuation at row 0 (no prior rows - simulates trimmed scrollback) + { + const rac = page.getRowAndCell(0, 0); + rac.row.semantic_prompt = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 1); + rac.row.semantic_prompt = .prompt_continuation; + } + // Normal prompt later + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + } + + var it = s.promptIterator(.right_down, .{ .screen = .{} }, null); + { + // Should return the first continuation line since there's no prior prompt + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 0, + } }, s.pointFromPin(.screen, p).?); + } + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator right_down with prompt before continuation" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 2, continuation on rows 3-4 + // Starting iteration from row 3 should still find the prompt at row 2 + { + const rac = page.getRowAndCell(0, 2); + rac.row.semantic_prompt = .prompt; + } + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt = .prompt_continuation; + } + { + const rac = page.getRowAndCell(0, 4); + rac.row.semantic_prompt = .prompt_continuation; + } + + // Start iteration from row 3 (middle of the continuation) + // Since we start on a continuation line, we treat it as the prompt start + // (handles case where scrollback pruned the actual prompt) + var it = s.promptIterator(.right_down, .{ .screen = .{ .y = 3 } }, null); + { + const p = it.next().?; + // Returns row 3 since that's the first prompt-related line we encounter + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 3, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator right_down limit inclusive" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Iterate with limit at row 5 (the prompt row) - should include it + var it = s.promptIterator(.right_down, .{ .screen = .{} }, .{ .screen = .{ .y = 5 } }); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList promptIterator left_up limit inclusive" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Iterate with limit at row 10 (the prompt row) - should include it + // tl_pt is the limit (upper bound), bl_pt is the start point for left_up + var it = s.promptIterator(.left_up, .{ .screen = .{ .y = 10 } }, .{ .screen = .{ .y = 15 } }); + { + const p = it.next().?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 10, + } }, s.pointFromPin(.screen, p).?); + } + try testing.expect(it.next() == null); +} + +test "PageList highlightSemanticContent prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // Start the prompt for the first 5 cols + for (0..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + .semantic_content = .prompt, + }; + } + + // Next 3 let's make input + for (5..8) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'B' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 2, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt with output" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 4 are input + for (3..7) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Rest is output (shouldn't be included in prompt highlight) + for (7..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting from prompt should include prompt and input, but stop at output + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 6, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt multiline" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt starts on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First row is all prompt + for (0..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Row 6 continues with input + { + for (0..5) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting should span both rows + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 2, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 6, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt only" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt content (no input) + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + for (0..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting should only include the prompt cells + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent prompt to end of screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Single prompt on row 15, no following prompt + { + const rac = page.getRowAndCell(0, 15); + rac.row.semantic_prompt = .prompt; + + for (0..3) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + for (3..8) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + + // Highlighting should include prompt and input up to column 7 + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?, + .prompt, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 15, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 15, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 5 are input + for (3..8) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting input should only include input cells + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 3, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input with output" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 3 are input + for (2..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + + // Rest is output + for (5..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting input should stop at output + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input multiline with continuation" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Rest is input + for (2..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + // Row 6 has continuation prompt then more input + { + // Continuation prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '>' }, + .semantic_content = .prompt, + }; + } + + // More input + for (2..6) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'd' }, + .semantic_content = .input, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting input should span both rows, skipping continuation prompts + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 6, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input no input returns null" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt, then immediately output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Rest is output (no input!) + for (3..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting input should return null when there's no input + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ); + try testing.expect(hl == null); +} + +test "PageList highlightSemanticContent input to end of screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Single prompt on row 15, no following prompt + { + const rac = page.getRowAndCell(0, 15); + rac.row.semantic_prompt = .prompt; + + for (0..2) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + for (2..7) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + + // Highlighting input with no following prompt + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?, + .input, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 15, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 6, + .y = 15, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent input prompt only returns null" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt content, no input or output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // All cells are prompt + for (0..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Mark rows 6-9 as prompt to ensure no input before next prompt + { + for (6..10) |y| { + for (0..10) |x| { + const cell = page.getRowAndCell(x, y).cell; + cell.semantic_content = .prompt; + } + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting input should return null when there's only prompts + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .input, + ); + try testing.expect(hl == null); +} + +test "PageList highlightSemanticContent output basic" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 3 are input + for (2..5) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Cols 5-7 are output + for (5..8) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + + // Mark remaining cells as prompt to bound the output + for (8..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.semantic_content = .prompt; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting output should only include output cells + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 5, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 5, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output multiline" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 2 are input + for (2..4) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Rest of row 5 is output + for (4..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 6 is all output + { + for (0..10) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 7 has partial output then input to bound it + { + for (0..5) |x| { + const cell = page.getRowAndCell(x, 7).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + for (5..10) |x| { + const cell = page.getRowAndCell(x, 7).cell; + cell.semantic_content = .input; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting output should span multiple rows + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 7, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output stops at next prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 2 cols are prompt + for (0..2) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Next 2 are input + for (2..4) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + + // Rest is output + for (4..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 6 has output then prompt starts + { + for (0..3) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + // Next prompt marker on same row + for (3..6) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + } + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting output should stop before prompt/input + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 5, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 2, + .y = 6, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output to end of screen" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Single prompt on row 15, no following prompt + { + const rac = page.getRowAndCell(0, 15); + rac.row.semantic_prompt = .prompt; + + for (0..2) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + for (2..4) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + + for (4..10) |x| { + const cell = page.getRowAndCell(x, 15).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + // Row 16 has output then prompt to bound it + { + for (0..8) |x| { + const cell = page.getRowAndCell(x, 16).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + for (8..10) |x| { + const cell = page.getRowAndCell(x, 16).cell; + cell.semantic_content = .prompt; + } + } + + // Highlighting output with no following prompt + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?, + .output, + ).?; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 15, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 7, + .y = 16, + } }, s.pointFromPin(.screen, hl.end).?); +} + +test "PageList highlightSemanticContent output no output returns null" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 with only prompt and input, no output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + + // Rest is input (must explicitly mark all cells to avoid default .output) + for (3..10) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'c' }, + .semantic_content = .input, + }; + } + } + // Mark rows 6-9 as input to ensure no output between prompts + { + for (6..10) |y| { + for (0..10) |x| { + const cell = page.getRowAndCell(x, y).cell; + cell.semantic_content = .input; + } + } + } + // Prompt on row 10 (no output between prompts) + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting output should return null when there's no output + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ); + try testing.expect(hl == null); +} + +test "PageList highlightSemanticContent output skips empty cells" { + // Tests that empty cells with default .output semantic content are + // not selected as output. This can happen when a prompt/input line + // doesn't fill the entire row - trailing cells have default .output. + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 10, 20, 0); + defer s.deinit(); + try testing.expect(s.pages.first == s.pages.last); + const page = &s.pages.first.?.data; + + // Prompt on row 5 - only fills first 3 cells, rest are empty with default .output + { + const rac = page.getRowAndCell(0, 5); + rac.row.semantic_prompt = .prompt; + + // First 3 cols are prompt with text + for (0..3) |x| { + const cell = page.getRowAndCell(x, 5).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + // Cells 3-9 are empty (codepoint = 0) with default .output semantic content + // This simulates what happens when a short prompt is written + } + + // Row 6 has input (short, doesn't fill line) + { + for (0..4) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'l' }, + .semantic_content = .input, + }; + } + // Cells 4-9 are empty with default .output + } + + // Row 7-8 have actual output with text + { + for (7..9) |y| { + for (0..5) |x| { + const cell = page.getRowAndCell(x, y).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'o' }, + .semantic_content = .output, + }; + } + } + } + + // Prompt on row 10 + { + const rac = page.getRowAndCell(0, 10); + rac.row.semantic_prompt = .prompt; + } + + // Highlighting output should skip empty cells on rows 5-6 and find + // the actual output starting at row 7 + const hl = s.highlightSemanticContent( + s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, + .output, + ).?; + // Output should start at row 7, not row 5 (where empty cells have default .output) + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 7, + } }, s.pointFromPin(.screen, hl.start).?); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 4, + .y = 8, + } }, s.pointFromPin(.screen, hl.end).?); +} + test "PageList erase" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 45fe9dfc6..2d27eb2d6 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -15,6 +15,7 @@ const Selection = @import("Selection.zig"); const PageList = @import("PageList.zig"); const StringMap = @import("StringMap.zig"); const ScreenFormatter = @import("formatter.zig").ScreenFormatter; +const osc = @import("osc.zig"); const pagepkg = @import("page.zig"); const point = @import("point.zig"); const size = @import("size.zig"); @@ -76,6 +77,9 @@ else /// Dirty flags for the renderer. dirty: Dirty = .{}, +/// Packed flags for the screen, internal state. +flags: Flags = .{}, + /// See Terminal.Dirty. This behaves the same way. pub const Dirty = packed struct { /// Set when the selection is set or unset, regardless of if the @@ -87,6 +91,17 @@ pub const Dirty = packed struct { hyperlink_hover: bool = false, }; +/// A set of internal state that we pack for memory size. +pub const Flags = packed struct { + /// This is flipped to true when any sort of semantic content is + /// seen. In particular, this is set to true only when a `prompt` type + /// is ever set on our cursor. + /// + /// This is used to optimize away semantic content operations if we know + /// we've never seen them. + semantic_content: bool = false, +}; + /// The cursor position and style. pub const Cursor = struct { // The x/y position within the active area. @@ -134,6 +149,11 @@ pub const Cursor = struct { /// because its most likely null. hyperlink: ?*hyperlink.Hyperlink = null, + /// The current semantic content type for the cursor that will be + /// applied to any newly written cells. + semantic_content: pagepkg.Cell.SemanticContent = .output, + semantic_content_clear_eol: bool = false, + /// The pointers into the page list where the cursor is currently /// located. This makes it faster to move the cursor. page_pin: *PageList.Pin, @@ -358,6 +378,7 @@ pub fn reset(self: *Screen) void { self.charset = .{}; self.kitty_keyboard = .{}; self.protected_mode = .off; + self.flags = .{}; self.clearSelection(); } @@ -1440,61 +1461,6 @@ pub fn clearUnprotectedCells( self.assertIntegrity(); } -/// Clears the prompt lines if the cursor is currently at a prompt. This -/// clears the entire line. This is used for resizing when the shell -/// handles reflow. -/// -/// The cleared cells are not colored with the current style background -/// color like other clear functions, because this is a special case used -/// for a specific purpose that does not want that behavior. -pub fn clearPrompt(self: *Screen) void { - var found: ?Pin = null; - - // From our cursor, move up and find all prompt lines. - var it = self.cursor.page_pin.rowIterator( - .left_up, - self.pages.pin(.{ .active = .{} }), - ); - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - // We are at a prompt but we're not at the start of the prompt. - // We mark our found value and continue because the prompt - // may be multi-line, unless this is the second time we've - // seen an .input marker, in which case we've run into an - // earlier prompt. - .input => { - if (found != null) break; - found = p; - }, - - // If we find the prompt then we're done. We are also done - // if we find any prompt continuation, because the shells - // that send this currently (zsh) cannot redraw every line. - .prompt, .prompt_continuation => { - found = p; - break; - }, - - // If we have command output, then we're most certainly not - // at a prompt. Break out of the loop. - .command => break, - - // If we don't know, we keep searching. - .unknown => {}, - } - } - - // If we found a prompt, we clear it. - if (found) |top| { - var clear_it = top.rowIterator(.right_down, null); - while (clear_it.next()) |p| { - const row = p.rowAndCell().row; - p.node.data.clearCells(row, 0, p.node.data.size.cols); - } - } -} - /// Clean up boundary conditions where a cell will become discontiguous with /// a neighboring cell because either one of them will be moved and/or cleared. /// @@ -1630,12 +1596,27 @@ pub inline fn blankCell(self: *const Screen) Cell { return self.cursor.style.bgCell() orelse .{}; } +pub const Resize = struct { + /// The new size to resize to + cols: size.CellCountInt, + rows: size.CellCountInt, + + /// Whether to reflow soft-wrapped text. + /// + /// This will reflow soft-wrapped text. If the screen size is getting + /// smaller and the maximum scrollback size is exceeded, data will be + /// lost from the top of the scrollback. + reflow: bool = true, + + /// Set this to enable prompt redraw on resize. This signals + /// that the running program can redraw the prompt if the cursor is + /// currently at a prompt. This detects OSC133 prompts lines and clears + /// them. If set to `.last`, only the most recent prompt line is cleared. + prompt_redraw: osc.semantic_prompt.Redraw = .false, +}; + /// Resize the screen. The rows or cols can be bigger or smaller. /// -/// This will reflow soft-wrapped text. If the screen size is getting -/// smaller and the maximum scrollback size is exceeded, data will be -/// lost from the top of the scrollback. -/// /// If this returns an error, the screen is left in a likely garbage state. /// It is very hard to undo this operation without blowing up our memory /// usage. The only way to recover is to reset the screen. The only way @@ -1645,29 +1626,7 @@ pub inline fn blankCell(self: *const Screen) Cell { /// (resize) is difficult. pub inline fn resize( self: *Screen, - cols: size.CellCountInt, - rows: size.CellCountInt, -) !void { - try self.resizeInternal(cols, rows, true); -} - -/// Resize the screen without any reflow. In this mode, columns/rows will -/// be truncated as they are shrunk. If they are grown, the new space is filled -/// with zeros. -pub inline fn resizeWithoutReflow( - self: *Screen, - cols: size.CellCountInt, - rows: size.CellCountInt, -) !void { - try self.resizeInternal(cols, rows, false); -} - -/// Resize the screen. -fn resizeInternal( - self: *Screen, - cols: size.CellCountInt, - rows: size.CellCountInt, - reflow: bool, + opts: Resize, ) !void { defer self.assertIntegrity(); @@ -1721,11 +1680,65 @@ fn resizeInternal( }; defer if (saved_cursor_pin) |p| self.pages.untrackPin(p); + // If our cursor is on a prompt or input line, clear it so the shell can + // redraw it. This works with OSC 133 semantic prompts. + // + // We check cursor.semantic_content rather than page_row.semantic_prompt + // because some shells (e.g., Nu) mark input areas with OSC 133 B but don't + // mark continuation lines with k=s. If the input spans multiple lines and + // continuation lines are unmarked, checking only page_row.semantic_prompt + // would miss them. By checking semantic_content, we assume that if the + // cursor is on anything other than command output, we're at a prompt/input + // line and should clear from there. + if (opts.prompt_redraw != .false and + self.cursor.semantic_content != .output) + prompt: { + switch (opts.prompt_redraw) { + .false => unreachable, + + // For `.last`, only clear the current line where the cursor is. + // For `.true`, clear all prompt lines starting from the beginning. + .last => { + const page = &self.cursor.page_pin.node.data; + const row = self.cursor.page_row; + const cells = page.getCells(row); + self.clearCells(page, row, cells); + }, + + .true => { + const start = start: { + var it = self.cursor.page_pin.promptIterator( + .left_up, + null, + ); + break :start it.next() orelse { + // This should never happen because promptIterator should always + // find a prompt if we already verified our row is some kind of + // prompt. + log.warn("cursor on prompt line but promptIterator found no prompt", .{}); + break :prompt; + }; + }; + + // Clear cells from our start down. We replace it with spaces, + // and do not physically erase the rows (eraseRows) because the + // shell is going to expect this space to be available. + var it = start.rowIterator(.right_down, null); + while (it.next()) |pin| { + const page = &pin.node.data; + const row = pin.rowAndCell().row; + const cells = page.getCells(row); + self.clearCells(page, row, cells); + } + }, + } + } + // Perform the resize operation. try self.pages.resize(.{ - .rows = rows, - .cols = cols, - .reflow = reflow, + .rows = opts.rows, + .cols = opts.cols, + .reflow = opts.reflow, .cursor = .{ .x = self.cursor.x, .y = self.cursor.y }, }); @@ -1752,7 +1765,7 @@ fn resizeInternal( // If we had pending wrap set and we're no longer at the end of // the line, we unset the pending wrap and move the cursor to // reflect the correct next position. - if (sc.pending_wrap and sc.x != cols - 1) { + if (sc.pending_wrap and sc.x != opts.cols - 1) { sc.pending_wrap = false; sc.x += 1; } @@ -2324,6 +2337,42 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { } } +/// Modify the semantic content type of the cursor. This should +/// be preferred over setting it manually since it handles all the +/// proper accounting. +pub fn cursorSetSemanticContent(self: *Screen, t: union(enum) { + prompt: osc.semantic_prompt.PromptKind, + output, + input: enum { clear_explicit, clear_eol }, +}) void { + const cursor = &self.cursor; + + switch (t) { + .output => { + cursor.semantic_content = .output; + cursor.semantic_content_clear_eol = false; + }, + + .input => |clear| { + cursor.semantic_content = .input; + cursor.semantic_content_clear_eol = switch (clear) { + .clear_explicit => false, + .clear_eol => true, + }; + }, + + .prompt => |kind| { + self.flags.semantic_content = true; + cursor.semantic_content = .prompt; + cursor.semantic_content_clear_eol = false; + cursor.page_row.semantic_prompt = switch (kind) { + .initial, .right => .prompt, + .continuation, .secondary => .prompt_continuation, + }; + }, + } +} + /// Set the selection to the given selection. If this is a tracked selection /// then the screen will take ownership of the selection. If this is untracked /// then the screen will convert it to tracked internally. This will automatically @@ -2456,16 +2505,36 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { // only happen within the same prompt state. For example, if you triple // click output, but the shell uses spaces to soft-wrap to the prompt // then the selection will stop prior to the prompt. See issue #1329. - const semantic_prompt_state: ?bool = state: { + const semantic_prompt_state: ?Cell.SemanticContent = state: { if (!opts.semantic_prompt_boundary) break :state null; const rac = opts.pin.rowAndCell(); - break :state rac.row.semantic_prompt.promptOrInput(); + break :state rac.cell.semantic_content; }; // The real start of the row is the first row in the soft-wrap. const start_pin: Pin = start_pin: { var it = opts.pin.rowIterator(.left_up, null); var it_prev: Pin = it.next().?; // skip self + + // First, check the current row for semantic boundaries before the clicked position. + if (semantic_prompt_state) |v| { + const row = it_prev.rowAndCell().row; + const cells = it_prev.node.data.getCells(row); + // Scan backwards from clicked position to find where our content starts + for (0..opts.pin.x + 1) |i| { + const x_rev = opts.pin.x - i; + if (cells[x_rev].semantic_content != v) { + var copy = it_prev; + copy.x = @intCast(x_rev + 1); + break :start_pin copy; + } + } + + // No boundary found before clicked position on current row. + // If row doesn't wrap from above, start is at column 0. + // Otherwise, continue checking previous rows. + } + while (it.next()) |p| { const row = p.rowAndCell().row; @@ -2476,13 +2545,18 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { } if (semantic_prompt_state) |v| { - // See semantic_prompt_state comment for why - const current_prompt = row.semantic_prompt.promptOrInput(); - if (current_prompt != v) { - var copy = it_prev; - copy.x = 0; - break :start_pin copy; + // We need to check every cell in this row in reverse + // order since we're going up and back. + const cells = p.node.data.getCells(row); + for (0..cells.len) |x| { + const x_rev = cells.len - 1 - x; + const cell = cells[x_rev]; + if (cell.semantic_content != v) break :start_pin it_prev; + it_prev = p; + it_prev.x = @intCast(x_rev); } + + continue; } it_prev = p; @@ -2500,13 +2574,32 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { const row = p.rowAndCell().row; if (semantic_prompt_state) |v| { - // See semantic_prompt_state comment for why - const current_prompt = row.semantic_prompt.promptOrInput(); - if (current_prompt != v) { + // We need to check every cell in this row + const cells = p.node.data.getCells(row); + + // If this is our pin row we can start from our x because + // the start_pin logic already found the real start. + const start_offset = if (p.node == opts.pin.node and + p.y == opts.pin.y) opts.pin.x else 0; + + // Handle the zero case specially because if the first + // col doesn't match then we end at the end of the prior + // row. But if this is the first row, we can't go back, + // so we scan forward to find where our content ends. + if (start_offset == 0 and cells[0].semantic_content != v) { var prev = p.up(1).?; prev.x = p.node.data.size.cols - 1; break :end_pin prev; } + + // For every other case, we end at the prior cell. + for (start_offset.., cells[start_offset..]) |x, cell| { + if (cell.semantic_content != v) { + var copy = p; + copy.x = @intCast(x - 1); + break :end_pin copy; + } + } } if (!row.wrap) { @@ -2749,181 +2842,60 @@ pub fn selectWord( /// are determined by semantic prompt information provided by shell integration. /// A selection can span multiple physical lines if they are soft-wrapped. /// -/// This will return null if a selection is impossible. The only scenarios -/// this happens is if: +/// This will return null if a selection is impossible: /// - the point pt is outside of the written screen space. /// - the point pt is on a prompt / input line. pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { - _ = self; + // If our pin right now is not on output, then we return nothing. + if (pin.rowAndCell().cell.semantic_content != .output) return null; - switch (pin.rowAndCell().row.semantic_prompt) { - .input, .prompt_continuation, .prompt => { - // Cursor on a prompt line, selection impossible - return null; - }, + // Get the post prior prompt from this pin. This is the prompt whose + // output we'll be capturing. + const prompt_pin: Pin = prompt: { + // If we have a prompt above this point (including this point), + // then thats the prompt we want to capture output from. + var it = pin.promptIterator(.left_up, null); + if (it.next()) |p| break :prompt p; - else => {}, + // If we don't have a prompt, then we assume that we're + // capturing all the output up to the next prompt. + it = pin.promptIterator(.right_down, null); + const next = it.next() orelse return null; + + // We'll capture from the start of the screen to just above + // the prompt and will trim the trailing whitespace. + const start_pin = self.pages.getTopLeft(.screen); + var end_pin = next.up(1) orelse return null; + end_pin.x = end_pin.node.data.size.cols - 1; + var cell_it = end_pin.cellIterator(.left_up, start_pin); + while (cell_it.next()) |p| { + const cell = p.rowAndCell().cell; + end_pin = p; + if (cell.hasText()) break; + } + + return .init( + start_pin, + end_pin, + false, + ); + }; + + // Grab our content + var hl = self.pages.highlightSemanticContent( + prompt_pin, + .output, + ) orelse return null; + + // Trim our trailing whitespace + var cell_it = hl.end.cellIterator(.left_up, hl.start); + while (cell_it.next()) |p| { + const cell = p.rowAndCell().cell; + hl.end = p; + if (cell.hasText()) break; } - // Go forwards to find our end boundary - // We are looking for input start / prompt markers - const end: Pin = boundary: { - var it = pin.rowIterator(.right_down, null); - var it_prev = pin; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - .input, .prompt_continuation, .prompt => { - var copy = it_prev; - copy.x = it_prev.node.data.size.cols - 1; - break :boundary copy; - }, - else => {}, - } - - it_prev = p; - } - - // Find the last non-blank row - it = it_prev.rowIterator(.left_up, null); - while (it.next()) |p| { - const row = p.rowAndCell().row; - const cells = p.node.data.getCells(row); - if (Cell.hasTextAny(cells)) { - var copy = p; - copy.x = p.node.data.size.cols - 1; - break :boundary copy; - } - } - - // In this case it means that all our rows are blank. Let's - // just return no selection, this is a weird case. - return null; - }; - - // Go backwards to find our start boundary - // We are looking for output start markers - const start: Pin = boundary: { - var it = pin.rowIterator(.left_up, null); - var it_prev = pin; - - // First, iterate until we find the first line of command output - while (it.next()) |p| { - it_prev = p; - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - .command => break, - - .unknown, - .prompt, - .prompt_continuation, - .input, - => {}, - } - } - - // Because the first line of command output may span multiple visual rows we must now - // iterate until we find the first row of anything other than command output and then - // yield the previous row. - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - .command => {}, - - .unknown, - .prompt, - .prompt_continuation, - .input, - => break :boundary it_prev, - } - it_prev = p; - } - - break :boundary it_prev; - }; - - return .init(start, end, false); -} - -/// Returns the selection bounds for the prompt at the given point. If the -/// point is not on a prompt line, this returns null. Note that due to -/// the underlying protocol, this will only return the y-coordinates of -/// the prompt. The x-coordinates of the start will always be zero and -/// the x-coordinates of the end will always be the last column. -/// -/// Note that this feature requires shell integration. If shell integration -/// is not enabled, this will always return null. -pub fn selectPrompt(self: *Screen, pin: Pin) ?Selection { - _ = self; - - // Ensure that the line the point is on is a prompt. - const is_known = switch (pin.rowAndCell().row.semantic_prompt) { - .prompt, .prompt_continuation, .input => true, - .command => return null, - - // We allow unknown to continue because not all shells output any - // semantic prompt information for continuation lines. This has the - // possibility of making this function VERY slow (we look at all - // scrollback) so we should try to avoid this in the future by - // setting a flag or something if we have EVER seen a semantic - // prompt sequence. - .unknown => false, - }; - - // Find the start of the prompt. - var saw_semantic_prompt = is_known; - const start: Pin = start: { - var it = pin.rowIterator(.left_up, null); - var it_prev = it.next().?; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => saw_semantic_prompt = true, - - // See comment about "unknown" a few lines above. If we have - // previously seen a semantic prompt then if we see an unknown - // we treat it as a boundary. - .unknown => if (saw_semantic_prompt) break :start it_prev, - - // Command output or unknown, definitely not a prompt. - .command => break :start it_prev, - } - - it_prev = p; - } - - break :start it_prev; - }; - - // If we never saw a semantic prompt flag, then we can't trust our - // start value and we return null. This scenario usually means that - // semantic prompts aren't enabled via the shell. - if (!saw_semantic_prompt) return null; - - // Find the end of the prompt. - const end: Pin = end: { - var it = pin.rowIterator(.right_down, null); - var it_prev = it.next().?; - it_prev.x = it_prev.node.data.size.cols - 1; - while (it.next()) |p| { - const row = p.rowAndCell().row; - switch (row.semantic_prompt) { - // A prompt, we continue searching. - .prompt, .prompt_continuation, .input => {}, - - // Command output or unknown, definitely not a prompt. - .command, .unknown => break :end it_prev, - } - - it_prev = p; - it_prev.x = it_prev.node.data.size.cols - 1; - } - - break :end it_prev; - }; - - return .init(start, end, false); + return .init(hl.start, hl.end, false); } pub const LineIterator = struct { @@ -2969,8 +2941,16 @@ pub fn promptPath( x: isize, y: isize, } { + // Verify "from" is on a prompt row before calling highlightSemanticContent. + // highlightSemanticContent asserts the starting point is a prompt. + switch (from.rowAndCell().row.semantic_prompt) { + .prompt, .prompt_continuation => {}, + .none => return .{ .x = 0, .y = 0 }, + } + // Get our prompt bounds assuming "from" is at a prompt. - const bounds = self.selectPrompt(from) orelse return .{ .x = 0, .y = 0 }; + const hl = self.pages.highlightSemanticContent(from, .prompt) orelse return .{ .x = 0, .y = 0 }; + const bounds: Selection = .init(hl.start, hl.end, false); // Get our actual "to" point clamped to the bounds of the prompt. const to_clamped = if (bounds.contains(self, to)) @@ -3082,6 +3062,12 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { try self.cursorDownOrScroll(); self.cursorHorizontalAbsolute(0); self.cursor.pending_wrap = false; + if (self.cursor.semantic_content_clear_eol) { + self.cursorSetSemanticContent(.output); + } else switch (self.cursor.semantic_content) { + .input, .output => {}, + .prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation, + } continue; } @@ -3114,6 +3100,10 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { try self.cursorDownOrScroll(); self.cursorHorizontalAbsolute(0); self.cursor.page_row.wrap_continuation = true; + switch (self.cursor.semantic_content) { + .input, .output => {}, + .prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation, + } } assert(width == 1 or width == 2); @@ -3124,6 +3114,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .content = .{ .codepoint = c }, .style_id = self.cursor.style_id, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a ref-counted style, increase. @@ -3145,6 +3136,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .content = .{ .codepoint = 0 }, .wide = .spacer_head, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a hyperlink, add it to the cell. @@ -3163,6 +3155,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .style_id = self.cursor.style_id, .wide = .wide, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a hyperlink, add it to the cell. @@ -3175,6 +3168,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { .content = .{ .codepoint = 0 }, .wide = .spacer_tail, .protected = self.cursor.protected, + .semantic_content = self.cursor.semantic_content, }; // If we have a hyperlink, add it to the cell. @@ -3200,29 +3194,6 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { } } -/// Write text that's marked as a semantic prompt. -fn testWriteSemanticString(self: *Screen, text: []const u8, semantic_prompt: Row.SemanticPrompt) !void { - // Determine the first row using the cursor position. If we know that our - // first write is going to start on the next line because of a pending - // wrap, we'll proactively start there. - const start_y = if (self.cursor.pending_wrap) self.cursor.y + 1 else self.cursor.y; - - try self.testWriteString(text); - - // Determine the last row that we actually wrote by inspecting the cursor's - // position. If we're in the first column, we haven't actually written any - // characters to it, so we end at the preceding row instead. - const end_y = if (self.cursor.x > 0) self.cursor.y else self.cursor.y - 1; - - // Mark the full range of written rows with our semantic prompt. - var y = start_y; - while (y <= end_y) { - const pin = self.pages.pin(.{ .active = .{ .y = y } }).?; - pin.rowAndCell().row.semantic_prompt = semantic_prompt; - y += 1; - } -} - test "Screen read and write" { const testing = std.testing; const alloc = testing.allocator; @@ -3835,88 +3806,6 @@ test "Screen eraseRows active partial" { } } -test "Screen: clearPrompt" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer s.deinit(); - - // Set one of the rows to be a prompt - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .prompt); - try s.testWriteSemanticString("3IJKL", .input); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD", contents); - } -} - -test "Screen: clearPrompt continuation" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 0 }); - defer s.deinit(); - - // Set one of the rows to be a prompt followed by a continuation row - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .prompt); - try s.testWriteSemanticString("3IJKL\n", .prompt_continuation); - try s.testWriteSemanticString("4MNOP", .input); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } -} - -test "Screen: clearPrompt consecutive inputs" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer s.deinit(); - - // Set both rows to be inputs - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .input); - try s.testWriteSemanticString("3IJKL", .input); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings("1ABCD\n2EFGH", contents); - } -} - -test "Screen: clearPrompt no prompt" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); - defer s.deinit(); - const str = "1ABCD\n2EFGH\n3IJKL"; - try s.testWriteString(str); - - s.clearPrompt(); - - { - const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); - defer alloc.free(contents); - try testing.expectEqualStrings(str, contents); - } -} - test "Screen: cursorDown across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; @@ -5730,7 +5619,7 @@ test "Screen: resize (no reflow) more rows" { try s.testWriteString(str); // Resize - try s.resizeWithoutReflow(10, 10); + try s.resize(.{ .cols = 10, .rows = 10, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -5748,7 +5637,7 @@ test "Screen: resize (no reflow) less rows" { try s.testWriteString(str); try testing.expectEqual(5, s.cursor.x); try testing.expectEqual(2, s.cursor.y); - try s.resizeWithoutReflow(10, 2); + try s.resize(.{ .cols = 10, .rows = 2, .reflow = false }); // Since we shrunk, we should adjust our cursor try testing.expectEqual(5, s.cursor.x); @@ -5783,7 +5672,7 @@ test "Screen: resize (no reflow) less rows trims blank lines" { } const cursor = s.cursor; - try s.resizeWithoutReflow(6, 2); + try s.resize(.{ .cols = 6, .rows = 2, .reflow = false }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -5818,7 +5707,7 @@ test "Screen: resize (no reflow) more rows trims blank lines" { } const cursor = s.cursor; - try s.resizeWithoutReflow(10, 7); + try s.resize(.{ .cols = 10, .rows = 7, .reflow = false }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -5839,7 +5728,7 @@ test "Screen: resize (no reflow) more cols" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - try s.resizeWithoutReflow(20, 3); + try s.resize(.{ .cols = 20, .rows = 3, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5856,7 +5745,7 @@ test "Screen: resize (no reflow) less cols" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - try s.resizeWithoutReflow(4, 3); + try s.resize(.{ .cols = 4, .rows = 3, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5874,7 +5763,7 @@ test "Screen: resize (no reflow) more rows with scrollback cursor end" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resizeWithoutReflow(7, 10); + try s.resize(.{ .cols = 7, .rows = 10, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5891,7 +5780,7 @@ test "Screen: resize (no reflow) less rows with scrollback" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resizeWithoutReflow(7, 2); + try s.resize(.{ .cols = 7, .rows = 2, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -5915,7 +5804,7 @@ test "Screen: resize (no reflow) less rows with empty trailing" { try s.testWriteString("A\nB"); const cursor = s.cursor; - try s.resizeWithoutReflow(5, 2); + try s.resize(.{ .cols = 5, .rows = 2, .reflow = false }); try testing.expectEqual(cursor.x, s.cursor.x); try testing.expectEqual(cursor.y, s.cursor.y); @@ -5947,7 +5836,7 @@ test "Screen: resize (no reflow) more rows with soft wrapping" { } // Resize - try s.resizeWithoutReflow(2, 10); + try s.resize(.{ .cols = 2, .rows = 10, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -5976,7 +5865,7 @@ test "Screen: resize more rows no scrollback" { const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); const cursor = s.cursor; - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6003,7 +5892,7 @@ test "Screen: resize more rows with empty scrollback" { const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); const cursor = s.cursor; - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6047,7 +5936,7 @@ test "Screen: resize more rows with populated scrollback" { } // Resize - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should still be on the "4" { @@ -6076,7 +5965,7 @@ test "Screen: resize more cols no reflow" { try s.testWriteString(str); const cursor = s.cursor; - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6103,7 +5992,7 @@ test "Screen: resize more cols perfect split" { defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -6133,7 +6022,7 @@ test "Screen: resize (no reflow) more cols with scrollback scrolled up" { try testing.expectEqualStrings("2\n3\n4", contents); } - try s.resize(8, 3); + try s.resize(.{ .cols = 8, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -6166,7 +6055,7 @@ test "Screen: resize (no reflow) less cols with scrollback scrolled up" { try testing.expectEqualStrings("2\n3\n4", contents); } - try s.resize(4, 3); + try s.resize(.{ .cols = 4, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -6198,11 +6087,14 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { defer s.deinit(); // Set one of the rows to be a prompt - try s.testWriteSemanticString("1ABCD\n", .unknown); - try s.testWriteSemanticString("2EFGH\n", .prompt); - try s.testWriteSemanticString("3IJKL", .unknown); + s.cursorSetSemanticContent(.output); + try s.testWriteString("1ABCD\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("2EFGH"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("\n3IJKL"); - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3, .reflow = false }); const expected = "1ABCD\n2EFGH\n3IJKL"; { @@ -6219,7 +6111,7 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { // Our one row should still be a semantic prompt, the others should not. { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .unknown); + try testing.expect(list_cell.row.semantic_prompt == .none); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; @@ -6227,7 +6119,7 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; - try testing.expect(list_cell.row.semantic_prompt == .unknown); + try testing.expect(list_cell.row.semantic_prompt == .none); } } @@ -6259,7 +6151,7 @@ test "Screen: resize more cols with reflow that fits full width" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6299,7 +6191,7 @@ test "Screen: resize more cols with reflow that ends in newline" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6344,7 +6236,7 @@ test "Screen: resize more cols with reflow that forces more wrapping" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(7, 3); + try s.resize(.{ .cols = 7, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6385,7 +6277,7 @@ test "Screen: resize more cols with reflow that unwraps multiple times" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(15, 3); + try s.resize(.{ .cols = 15, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6424,7 +6316,7 @@ test "Screen: resize more cols with populated scrollback" { } // Resize - try s.resize(10, 3); + try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6470,7 +6362,7 @@ test "Screen: resize more cols with reflow" { } // Resize and verify we undid the soft wrap because we have space now - try s.resize(7, 3); + try s.resize(.{ .cols = 7, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -6498,7 +6390,7 @@ test "Screen: resize more rows and cols with wrapping" { try testing.expectEqualStrings(expected, contents); } - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); // Cursor should move due to wrapping try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.x); @@ -6527,7 +6419,7 @@ test "Screen: resize less rows no scrollback" { s.cursorAbsolute(0, 0); const cursor = s.cursor; - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6567,7 +6459,7 @@ test "Screen: resize less rows moving cursor" { } // Resize - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6595,7 +6487,7 @@ test "Screen: resize less rows with empty scrollback" { defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -6626,7 +6518,7 @@ test "Screen: resize less rows with populated scrollback" { } // Resize - try s.resize(5, 1); + try s.resize(.{ .cols = 5, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -6660,7 +6552,7 @@ test "Screen: resize less rows with full scrollback" { try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); // Resize - try s.resize(5, 2); + try s.resize(.{ .cols = 5, .rows = 2 }); // Cursor should stay in the same relative place (bottom of the // screen, same character). @@ -6692,7 +6584,7 @@ test "Screen: resize less cols no reflow" { s.cursorAbsolute(0, 0); const cursor = s.cursor; - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); // Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); @@ -6729,7 +6621,7 @@ test "Screen: resize less cols with reflow but row space" { try testing.expectEqual(@as(u32, 'D'), list_cell.cell.content.codepoint); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -6756,7 +6648,7 @@ test "Screen: resize less cols with reflow with trimmed rows" { defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6780,7 +6672,7 @@ test "Screen: resize less cols with reflow with trimmed rows and scrollback" { defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6813,7 +6705,7 @@ test "Screen: resize less cols with reflow previously wrapped" { try testing.expectEqualStrings(expected, contents); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); // { // const contents = try s.testString(alloc, .viewport); @@ -6848,7 +6740,7 @@ test "Screen: resize less cols with reflow and scrollback" { try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6889,7 +6781,7 @@ test "Screen: resize less cols with reflow previously wrapped and scrollback" { try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); } - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6931,7 +6823,7 @@ test "Screen: resize less cols with scrollback keeps cursor row" { // Move our cursor to the beginning s.cursorAbsolute(0, 0); - try s.resize(3, 3); + try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6967,7 +6859,7 @@ test "Screen: resize more rows, less cols with reflow with scrollback" { try testing.expectEqualStrings(expected, contents); } - try s.resize(2, 10); + try s.resize(.{ .cols = 2, .rows = 10 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); @@ -6996,7 +6888,7 @@ test "Screen: resize more rows then shrink again" { try s.testWriteString(str); // Grow - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -7009,7 +6901,7 @@ test "Screen: resize more rows then shrink again" { } // Shrink - try s.resize(5, 3); + try s.resize(.{ .cols = 5, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7022,7 +6914,7 @@ test "Screen: resize more rows then shrink again" { } // Grow again - try s.resize(5, 10); + try s.resize(.{ .cols = 5, .rows = 10 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); @@ -7056,7 +6948,7 @@ test "Screen: resize less cols to eliminate wide char" { } // Resize to 1 column can't fit a wide char. So it should be deleted. - try s.resize(1, 1); + try s.resize(.{ .cols = 1, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7095,7 +6987,7 @@ test "Screen: resize less cols to wrap wide char" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(2, 3); + try s.resize(.{ .cols = 2, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7134,7 +7026,7 @@ test "Screen: resize less cols to eliminate wide char with row space" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(1, 2); + try s.resize(.{ .cols = 1, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7176,7 +7068,7 @@ test "Screen: resize more cols with wide spacer head" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(4, 2); + try s.resize(.{ .cols = 4, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7227,7 +7119,7 @@ test "Screen: resize more cols with wide spacer head multiple lines" { try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } - try s.resize(8, 2); + try s.resize(.{ .cols = 8, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7273,7 +7165,7 @@ test "Screen: resize more cols requiring a wide spacer head" { // This resizes to 3 columns, which isn't enough space for our wide // char to enter row 1. But we need to mark the wide spacer head on the // end of the first row since we're wrapping to the next row. - try s.resize(3, 2); + try s.resize(.{ .cols = 3, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); @@ -7297,6 +7189,216 @@ test "Screen: resize more cols requiring a wide spacer head" { } } +test "Screen: resize more cols with cursor at prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); + defer s.deinit(); + + // zig fmt: off + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_eol }); + try s.testWriteString("echo"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> echo"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(.{ + .cols = 20, + .rows = 3, + .prompt_redraw = .true, + }); + + // Cursor should not move + try testing.expectEqual(6, s.cursor.x); + try testing.expectEqual(1, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize more cols with cursor not at prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); + defer s.deinit(); + + // zig fmt: off + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_eol }); + try s.testWriteString("echo\n"); + try s.testWriteString("output"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> echo\noutput"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(.{ + .cols = 20, + .rows = 3, + .prompt_redraw = .true, + }); + + // Cursor should not move + try testing.expectEqual(6, s.cursor.x); + try testing.expectEqual(2, s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> echo\noutput"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize with prompt_redraw last clears only one line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 5 }); + defer s.deinit(); + + // zig fmt: off + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("hello\n"); + try s.testWriteString("world"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> hello\nworld"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor is at end of "world" line with semantic_content = .input + try s.resize(.{ + .cols = 20, + .rows = 4, + .prompt_redraw = .last, + }); + + // With .last, only the current line where cursor is should be cleared + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "ABCDE\n> hello"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize with prompt_redraw last multiline prompt clears only last line" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 5 }); + defer s.deinit(); + + // Create a 3-line prompt: 1 initial + 2 continuation lines + // zig fmt: off + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("line1\n"); + s.cursorSetSemanticContent(.{ .prompt = .continuation }); + try s.testWriteString("line2\n"); + s.cursorSetSemanticContent(.{ .prompt = .continuation }); + try s.testWriteString("line3"); + // zig fmt: on + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "line1\nline2\nline3"; + try testing.expectEqualStrings(expected, contents); + } + + // Cursor is at end of line3 (the last continuation line) + try s.resize(.{ + .cols = 30, + .rows = 5, + .prompt_redraw = .last, + }); + + // With .last, only line3 (where cursor is) should be cleared + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "line1\nline2"; + try testing.expectEqualStrings(expected, contents); + } +} + +test "Screen: resize with prompt_redraw clears input line without row semantic prompt" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 5 }); + defer s.deinit(); + + // Simulate Nu shell behavior: marks input area with OSC 133 B but does not + // mark continuation lines with k=s sequence. This means: + // - cursor.semantic_content = .input + // - cursor.page_row.semantic_prompt = .none (not marked) + // The fix ensures we still clear based on semantic_content. + // zig fmt: off + try s.testWriteString("output\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("hello\n"); + // Continue typing on next line - no prompt marking, but still in input mode + try s.testWriteString("world"); + // zig fmt: on + + // Verify the row has no semantic prompt marking (simulating Nu behavior) + try testing.expectEqual(.none, s.cursor.page_row.semantic_prompt); + // But the cursor's semantic content is input + try testing.expectEqual(.input, s.cursor.semantic_content); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "output\n> hello\nworld"; + try testing.expectEqualStrings(expected, contents); + } + + try s.resize(.{ + .cols = 30, + .rows = 5, + .prompt_redraw = .true, + }); + + // All prompt/input lines should be cleared even though the continuation + // row's semantic_prompt is .none + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + const expected = "output"; + try testing.expectEqualStrings(expected, contents); + } +} + test "Screen: select untracked" { const testing = std.testing; const alloc = testing.allocator; @@ -7644,9 +7746,11 @@ test "Screen: selectLine semantic prompt boundary" { var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); - try s.testWriteSemanticString("ABCDE\n", .unknown); - try s.testWriteSemanticString("A ", .prompt); - try s.testWriteSemanticString("> ", .unknown); + try s.testWriteString("ABCDE\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("A "); + s.cursorSetSemanticContent(.output); + try s.testWriteString("> "); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); @@ -7661,14 +7765,13 @@ test "Screen: selectLine semantic prompt boundary" { .y = 1, } }).? }).?; defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.end()).?); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + const expected = "A"; + try testing.expectEqualStrings(expected, contents); } { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ @@ -7687,6 +7790,359 @@ test "Screen: selectLine semantic prompt boundary" { } } +test "Screen: selectLine semantic prompt to input boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Write prompt followed by user input on same row: "$>command" + // Using non-whitespace to avoid whitespace trimming affecting the test + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$>"); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("command"); + + // Selecting from prompt should only select prompt + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from input should only select input + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 5, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 8, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } +} + +test "Screen: selectLine semantic input to output boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Row 0: user input + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("ls -la\n"); + // Row 1: command output + s.cursorSetSemanticContent(.output); + try s.testWriteString("file.txt"); + + // Selecting from input should only select input + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("ls -la", contents); + } + + // Selecting from output should only select output + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("file.txt", contents); + } +} + +test "Screen: selectLine semantic mid-row boundary" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Single row with output then prompt then input: "out$>cmd" + // Using non-whitespace to avoid whitespace trimming affecting the test + s.cursorSetSemanticContent(.output); + try s.testWriteString("out"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$>"); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("cmd"); + + // Selecting from output should stop at prompt + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 1, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 2, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from prompt should only select prompt + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 3, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from input should only select input + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 6, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 5, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 7, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } +} + +test "Screen: selectLine semantic boundary soft-wrap with mid-row transition" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Row 0: prompt "$ " + input "cmd" (soft-wraps) + // Row 1: input continues "12" + output "out" + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$ "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("cmd12"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("out"); + + // Verify layout + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("$ cmd\n12out", contents); + } + + // Selecting from input on row 0 should get all input across soft-wrap + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 3, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("cmd12", contents); + } + + // Selecting from input on row 1 should get all input across soft-wrap + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("cmd12", contents); + } + + // Selecting from output should only get output + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 3, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("out", contents); + } +} + +test "Screen: selectLine semantic boundary disabled" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Write prompt followed by input + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$ "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("command"); + + // With semantic_prompt_boundary = false, should select entire line + { + var sel = s.selectLine(.{ + .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?, + .semantic_prompt_boundary = false, + }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("$ command", contents); + } +} + +test "Screen: selectLine semantic boundary first cell of row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // Row 0: input that soft-wraps + // Row 1: output starts at first cell + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("12345"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("ABCDE"); + + // Verify soft-wrap happened + { + const pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = pin.rowAndCell().row; + try testing.expect(row.wrap); + } + + // Selecting from input should stop before output on row 1 + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 0, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + // Selecting from output should only get output + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = 4, + .y = 1, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } +} + +test "Screen: selectLine semantic all same content" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + // All prompt content that soft-wraps + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("prompt text"); + + // Verify soft-wrap + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("promp\nt tex\nt", contents); + } + + // Should select all prompt content across soft-wraps + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 2, + .y = 1, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("prompt text", contents); + } +} + test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator; @@ -8060,59 +8516,64 @@ test "Screen: selectOutput" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); - // zig fmt: off - { - // line number: - try s.testWriteSemanticString("output1\n", .command); // 0 - try s.testWriteSemanticString("output1\n", .command); // 1 - try s.testWriteSemanticString("prompt2\n", .prompt); // 2 - try s.testWriteSemanticString("input2\n", .input); // 3 - try s.testWriteSemanticString( // - "output2output2output2output2\n", // 4, 5, 6 due to overflow - .command, // - ); // - try s.testWriteSemanticString("output2\n", .command); // 7 - try s.testWriteSemanticString("$ ", .prompt); // 8 prompt - try s.testWriteSemanticString("input3\n", .input); // 8 input - try s.testWriteSemanticString("output3\n", .command); // 9 - try s.testWriteSemanticString("output3\n", .command); // 10 - try s.testWriteSemanticString("output3", .command); // 11 - } - // zig fmt: on + // Build content with cell-level semantic content: + // Row 0-1: output1 (output) + // Row 2: prompt2 (prompt) + // Row 3: input2 (input) + // Row 4-7: output2 (output, with overflow causing wrap) + // Row 8: "$ " (prompt) + "input3" (input) + // Row 9-11: output3 (output) + s.cursorSetSemanticContent(.output); + try s.testWriteString("output1\n"); + try s.testWriteString("output1\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("prompt2\n"); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("input2\n"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("output2output2output2output2\n"); + try s.testWriteString("output2\n"); + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("$ "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("input3\n"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("output3\n"); + try s.testWriteString("output3\n"); + try s.testWriteString("output3"); - // No start marker, should select from the beginning + // First output block (rows 0-1), should select those rows { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, } }).?).?; defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 9, - .y = 1, - } }, s.pages.pointFromPin(.active, sel.end()).?); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("output1\noutput1", contents); } - // Both start and end markers, should select between them + // Second output block (rows 4-7) { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 3, .y = 7, } }).?).?; defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .active = .{ - .x = 0, - .y = 4, - } }, s.pages.pointFromPin(.active, sel.start()).?); - try testing.expectEqual(point.Point{ .active = .{ - .x = 9, - .y = 7, - } }, s.pages.pointFromPin(.active, sel.end()).?); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings( + "output2output2output2output2\noutput2", + contents, + ); } - // No end marker, should select till the end + // Third output block (rows 9-11) { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 2, @@ -8124,185 +8585,23 @@ test "Screen: selectOutput" { .y = 9, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ - .x = 9, + .x = 6, .y = 11, } }, s.pages.pointFromPin(.active, sel.end()).?); } - // input / prompt at y = 0, pt.y = 0 + // Click on prompt should return null { - s.deinit(); - s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); - try s.testWriteSemanticString("$ ", .prompt); - try s.testWriteSemanticString("input1\n", .input); - try s.testWriteSemanticString("output1\n", .command); - try s.testWriteSemanticString("prompt2\n", .prompt); try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ - .x = 2, - .y = 0, + .x = 1, + .y = 8, } }).?) == null); } -} - -test "Screen: selectPrompt basics" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); - defer s.deinit(); - - // zig fmt: off + // Click on input should return null { - // line number: - try s.testWriteSemanticString("output1\n", .command); // 0 - try s.testWriteSemanticString("output1\n", .command); // 1 - try s.testWriteSemanticString("prompt2\n", .prompt); // 2 - try s.testWriteSemanticString("input2\n", .input); // 3 - try s.testWriteSemanticString("output2\n", .command); // 4 - try s.testWriteSemanticString("output2\n", .command); // 5 - try s.testWriteSemanticString("$ ", .prompt); // 6 prompt - try s.testWriteSemanticString("input3\n", .input); // 6 input - try s.testWriteSemanticString("output3\n", .command); // 7 - try s.testWriteSemanticString("output3\n", .command); // 8 - try s.testWriteSemanticString("output3", .command); // 9 - } - // zig fmt: on - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 1, - } }).?); - try testing.expect(sel == null); - } - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, + try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ + .x = 5, .y = 8, - } }).?); - try testing.expect(sel == null); - } - - // Single line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 6, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 6, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 6, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 3, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectPrompt prompt at start" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteSemanticString("prompt1\n", .prompt); // 0 - try s.testWriteSemanticString("input1\n", .input); // 1 - try s.testWriteSemanticString("output2\n", .command); // 2 - try s.testWriteSemanticString("output2\n", .command); // 3 - } - // zig fmt: on - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 3, - } }).?); - try testing.expect(sel == null); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 1, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 0, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 1, - } }, s.pages.pointFromPin(.screen, sel.end()).?); - } -} - -test "Screen: selectPrompt prompt at end" { - const testing = std.testing; - const alloc = testing.allocator; - - var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); - defer s.deinit(); - - // zig fmt: off - { - // line number: - try s.testWriteSemanticString("output2\n", .command); // 0 - try s.testWriteSemanticString("output2\n", .command); // 1 - try s.testWriteSemanticString("prompt1\n", .prompt); // 2 - try s.testWriteSemanticString("input1\n", .input); // 3 - } - // zig fmt: on - - // Not at a prompt - { - const sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 0, - .y = 1, - } }).?); - try testing.expect(sel == null); - } - - // Multi line prompt - { - var sel = s.selectPrompt(s.pages.pin(.{ .active = .{ - .x = 1, - .y = 2, - } }).?).?; - defer sel.deinit(&s); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 0, - .y = 2, - } }, s.pages.pointFromPin(.screen, sel.start()).?); - try testing.expectEqual(point.Point{ .screen = .{ - .x = 9, - .y = 3, - } }, s.pages.pointFromPin(.screen, sel.end()).?); + } }).?) == null); } } @@ -8313,22 +8612,74 @@ test "Screen: promptPath" { var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit(); - // zig fmt: off + try testing.expect(s.pages.pages.first == s.pages.pages.last); + const page = &s.pages.pages.first.?.data; + + // Set up: + // Row 0-1: output + // Row 2: prompt + // Row 3: input + // Row 4-5: output + // Row 6: prompt + input + // Row 7-9: output + + // Row 2: prompt (with prompt cells) and input { - // line number: - try s.testWriteSemanticString("output1\n", .command); // 0 - try s.testWriteSemanticString("output1\n", .command); // 1 - try s.testWriteSemanticString("prompt2\n", .prompt); // 2 - try s.testWriteSemanticString("input2\n", .input); // 3 - try s.testWriteSemanticString("output2\n", .command); // 4 - try s.testWriteSemanticString("output2\n", .command); // 5 - try s.testWriteSemanticString("$ ", .prompt); // 6 prompt - try s.testWriteSemanticString("input3\n", .input); // 6 input - try s.testWriteSemanticString("output3\n", .command); // 7 - try s.testWriteSemanticString("output3\n", .command); // 8 - try s.testWriteSemanticString("output3", .command); // 9 + const rac = page.getRowAndCell(0, 2); + rac.row.semantic_prompt = .prompt; + // First 3 cols are prompt + for (0..3) |x| { + const cell = page.getRowAndCell(x, 2).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'P' }, + .semantic_content = .prompt, + }; + } + // Next cols are input + for (3..10) |x| { + const cell = page.getRowAndCell(x, 2).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'I' }, + .semantic_content = .input, + }; + } + } + // Row 3: continuation line with input cells (same prompt block) + { + const rac = page.getRowAndCell(0, 3); + rac.row.semantic_prompt = .prompt_continuation; + for (0..6) |x| { + const cell = page.getRowAndCell(x, 3).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'I' }, + .semantic_content = .input, + }; + } + } + // Row 6: next prompt + input on same line + { + const rac = page.getRowAndCell(0, 6); + rac.row.semantic_prompt = .prompt; + for (0..2) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = '$' }, + .semantic_content = .prompt, + }; + } + for (2..8) |x| { + const cell = page.getRowAndCell(x, 6).cell; + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'i' }, + .semantic_content = .input, + }; + } } - // zig fmt: on // From is not in the prompt { @@ -8371,12 +8722,13 @@ test "Screen: promptPath" { } // To is out of bounds after + // Prompt ends at (5, 3) since that's the last input cell { const path = s.promptPath( s.pages.pin(.{ .active = .{ .x = 6, .y = 2 } }).?, s.pages.pin(.{ .active = .{ .x = 3, .y = 9 } }).?, ); - try testing.expectEqual(@as(isize, 3), path.x); + try testing.expectEqual(@as(isize, -1), path.x); try testing.expectEqual(@as(isize, 1), path.y); } } @@ -8990,7 +9342,7 @@ test "Screen: hyperlink cursor state on resize" { } // Resize. Any column growth will trigger a page to be reallocated. - try s.resize(10, 10); + try s.resize(.{ .cols = 10, .rows = 10 }); try testing.expect(s.cursor.hyperlink_id != 0); { const page = &s.cursor.page_pin.node.data; diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index df711af0b..ea5bb224f 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -17,6 +17,7 @@ const charsets = @import("charsets.zig"); const csi = @import("csi.zig"); const hyperlink = @import("hyperlink.zig"); const kitty = @import("kitty.zig"); +const osc = @import("osc.zig"); const point = @import("point.zig"); const sgr = @import("sgr.zig"); const Tabstops = @import("Tabstops.zig"); @@ -79,11 +80,10 @@ mouse_shape: mouse_shape_pkg.MouseShape = .text, /// These are just a packed set of flags we may set on the terminal. flags: packed struct { - // This isn't a mode, this is set by OSC 133 using the "A" event. - // If this is true, it tells us that the shell supports redrawing - // the prompt and that when we resize, if the cursor is at a prompt, - // then we should clear the screen below and allow the shell to redraw. - shell_redraws_prompt: bool = false, + // This supports a Kitty extension where programs using semantic + // prompts (OSC133) can annotate their new prompts with `redraw=0` to + // disable clearing the prompt on resize. + shell_redraws_prompt: osc.semantic_prompt.Redraw = .true, // This is set via ESC[4;2m. Any other modify key mode just sets // this to false and we act in mode 1 by default. @@ -778,6 +778,7 @@ fn printCell( .style_id = self.screens.active.cursor.style_id, .wide = wide, .protected = self.screens.active.cursor.protected, + .semantic_content = self.screens.active.cursor.semantic_content, }; if (style_changed) { @@ -820,22 +821,35 @@ fn printWrap(self: *Terminal) !void { // We only mark that we soft-wrapped if we're at the edge of our // full screen. We don't mark the row as wrapped if we're in the // middle due to a right margin. - const mark_wrap = self.screens.active.cursor.x == self.cols - 1; - if (mark_wrap) self.screens.active.cursor.page_row.wrap = true; + const cursor: *Screen.Cursor = &self.screens.active.cursor; + const mark_wrap = cursor.x == self.cols - 1; + if (mark_wrap) cursor.page_row.wrap = true; // Get the old semantic prompt so we can extend it to the next // line. We need to do this before we index() because we may // modify memory. - const old_prompt = self.screens.active.cursor.page_row.semantic_prompt; + const old_semantic = cursor.semantic_content; + const old_semantic_clear = cursor.semantic_content_clear_eol; // Move to the next line try self.index(); self.screens.active.cursorHorizontalAbsolute(self.scrolling_region.left); + // Our pointer should never move + assert(cursor == &self.screens.active.cursor); + + // We always reset our semantic prompt state + cursor.semantic_content = old_semantic; + cursor.semantic_content_clear_eol = old_semantic_clear; + switch (old_semantic) { + .output, .input => {}, + .prompt => cursor.page_row.semantic_prompt = .prompt_continuation, + } + if (mark_wrap) { - // New line must inherit semantic prompt of the old line - self.screens.active.cursor.page_row.semantic_prompt = old_prompt; - self.screens.active.cursor.page_row.wrap_continuation = true; + const row = self.screens.active.cursor.page_row; + // Always mark the row as a continuation + row.wrap_continuation = true; } // Assure that our screen is consistent @@ -1124,6 +1138,127 @@ pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { } } +/// Perform a semantic prompt command. +/// +/// If there is an error, we do our best to get the terminal into +/// some coherent state, since callers typically can't handle errors +/// (since they're sending sequences via the pty). +pub fn semanticPrompt( + self: *Terminal, + cmd: osc.Command.SemanticPrompt, +) !void { + switch (cmd.action) { + .fresh_line => try self.semanticPromptFreshLine(), + + .fresh_line_new_prompt => { + // "First do a fresh-line." + try self.semanticPromptFreshLine(); + + // "Subsequent text (until a OSC "133;B" or OSC "133;I" command) + // is a prompt string (as if followed by OSC 133;P;k=i\007)." + self.screens.active.cursorSetSemanticContent(.{ + .prompt = cmd.readOption(.prompt_kind) orelse .initial, + }); + + // This is a kitty-specific flag that notes that the shell + // is NOT capable of redraw. Redraw defaults to true so this + // usually just disables it, but either is possible. + if (cmd.readOption(.redraw)) |v| { + self.flags.shell_redraws_prompt = v; + } + + // The "aid" and "cl" options are also valid for this + // command but we don't yet handle these in any meaningful way. + }, + + .new_command => { + // Same as OSC "133;A" but may first implicitly terminate a + // previous command: if the options specify an aid and there + // is an active (open) command with matching aid, finish the + // innermost such command (as well as any other commands + // nested more deeply). If no aid is specified, treat as an + // aid whose value is the empty string. + try self.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = cmd.options_unvalidated, + }); + }, + + .prompt_start => { + // Explicit start of prompt. Optional after an A or N command. + // The k (kind) option specifies the type of prompt: + // regular primary prompt (k=i or default), + // right-side prompts (k=r), or prompts for continuation lines (k=c or k=s). + self.screens.active.cursorSetSemanticContent(.{ + .prompt = cmd.readOption(.prompt_kind) orelse .initial, + }); + }, + + .end_prompt_start_input => { + // End of prompt and start of user input, terminated by a OSC + // "133;C" or another prompt (OSC "133;P"). + self.screens.active.cursorSetSemanticContent(.{ + .input = .clear_explicit, + }); + }, + + .end_prompt_start_input_terminate_eol => { + // End of prompt and start of user input, terminated by end-of-line. + self.screens.active.cursorSetSemanticContent(.{ + .input = .clear_eol, + }); + }, + + .end_input_start_output => { + // "End of input, and start of output." + self.screens.active.cursorSetSemanticContent(.output); + + // If our current row is marked as a prompt and we're + // at column zero then we assume we're un-prompting. This + // is a heuristic to deal with fish, mostly. The issue that + // fish brings up is that it has no PS2 equivalent and its + // builtin OSC133 marking doesn't output continuation lines + // as k=s. So, we assume when we get a newline with a prompt + // cursor that the new line is also a prompt. But fish changes + // to output on the newline. So if we're at col 0 we just assume + // we're overwriting the prompt. + if (self.screens.active.cursor.page_row.semantic_prompt != .none and + self.screens.active.cursor.x == 0) + { + self.screens.active.cursor.page_row.semantic_prompt = .none; + } + }, + + .end_command => { + // From a terminal state perspective, this doesn't really do + // anything. Other terminals appear to do nothing here. I think + // its reasonable at this point to reset our semantic content + // state but the spec doesn't really say what to do. + self.screens.active.cursorSetSemanticContent(.output); + }, + } +} + +// OSC 133;L +fn semanticPromptFreshLine(self: *Terminal) !void { + const left_margin = if (self.screens.active.cursor.x < self.scrolling_region.left) + 0 + else + self.scrolling_region.left; + + // Spec: "If the cursor is the initial column (left, assuming + // left-to-right writing), do nothing" This specification is very under + // specified. We are taking the liberty to assume that in a left/right + // margin context, if the cursor is outside of the left margin, we treat + // it as being at the left margin for the purposes of this command. + // This is arbitrary. If someone has a better reasonable idea we can + // apply it. + if (self.screens.active.cursor.x == left_margin) return; + + self.carriageReturn(); + try self.index(); +} + /// The semantic prompt type. This is used when tracking a line type and /// requires integration with the shell. By default, we mark a line as "none" /// meaning we don't know what type it is. @@ -1136,19 +1271,6 @@ pub const SemanticPrompt = enum { command, }; -/// Mark the current semantic prompt information. Current escape sequences -/// (OSC 133) only allow setting this for wherever the current active cursor -/// is located. -pub fn markSemanticPrompt(self: *Terminal, p: SemanticPrompt) void { - //log.debug("semantic_prompt y={} p={}", .{ self.screens.active.cursor.y, p }); - self.screens.active.cursor.page_row.semantic_prompt = switch (p) { - .prompt => .prompt, - .prompt_continuation => .prompt_continuation, - .input => .input, - .command => .command, - }; -} - /// Returns true if the cursor is currently at a prompt. Another way to look /// at this is it returns false if the shell is currently outputting something. /// This requires shell integration (semantic prompt integration). @@ -1158,29 +1280,15 @@ pub fn cursorIsAtPrompt(self: *Terminal) bool { // If we're on the secondary screen, we're never at a prompt. if (self.screens.active_key == .alternate) return false; - // Reverse through the active - const start_x, const start_y = .{ self.screens.active.cursor.x, self.screens.active.cursor.y }; - defer self.screens.active.cursorAbsolute(start_x, start_y); + // If our page row is a prompt then we're always at a prompt + const cursor: *const Screen.Cursor = &self.screens.active.cursor; + if (cursor.page_row.semantic_prompt != .none) return true; - for (0..start_y + 1) |i| { - if (i > 0) self.screens.active.cursorUp(1); - switch (self.screens.active.cursor.page_row.semantic_prompt) { - // If we're at a prompt or input area, then we are at a prompt. - .prompt, - .prompt_continuation, - .input, - => return true, - - // If we have command output, then we're most certainly not - // at a prompt. - .command => return false, - - // If we don't know, we keep searching. - .unknown => {}, - } - } - - return false; + // Otherwise, determine our cursor state + return switch (cursor.semantic_content) { + .input, .prompt => true, + .output => false, + }; } /// Horizontal tab moves the cursor to the next tabstop, clearing @@ -1245,17 +1353,48 @@ pub fn tabReset(self: *Terminal) void { /// /// This unsets the pending wrap state without wrapping. pub fn index(self: *Terminal) !void { + const screen: *Screen = self.screens.active; + // Unset pending wrap state - self.screens.active.cursor.pending_wrap = false; + screen.cursor.pending_wrap = false; + + // We handle our cursor semantic prompt state AFTER doing the + // scrolling, because we may need to apply to new rows. + defer if (screen.cursor.semantic_content != .output) { + @branchHint(.unlikely); + + // Always reset any semantic content clear-eol state. + // + // The specification is not clear what "end-of-line" means. If we + // discover that there are more scenarios we should be unsetting + // this we should document and test it. + if (screen.cursor.semantic_content_clear_eol) { + screen.cursor.semantic_content = .output; + screen.cursor.semantic_content_clear_eol = false; + } else { + // If we aren't clearing our state at EOL and we're not output, + // then we mark the new row as a prompt continuation. This is + // to work around shells that don't send OSC 133 k=s sequences + // for continuations. + // + // This can be a false positive if the shell changes content + // type later and outputs something. We handle that in the + // semanticPrompt function. + screen.cursor.page_row.semantic_prompt = .prompt_continuation; + } + } else { + // This should never be set in the output mode. + assert(!screen.cursor.semantic_content_clear_eol); + }; // Outside of the scroll region we move the cursor one line down. - if (self.screens.active.cursor.y < self.scrolling_region.top or - self.screens.active.cursor.y > self.scrolling_region.bottom) + if (screen.cursor.y < self.scrolling_region.top or + screen.cursor.y > self.scrolling_region.bottom) { // We only move down if we're not already at the bottom of // the screen. - if (self.screens.active.cursor.y < self.rows - 1) { - self.screens.active.cursorDown(1); + if (screen.cursor.y < self.rows - 1) { + screen.cursorDown(1); } return; @@ -1264,13 +1403,13 @@ pub fn index(self: *Terminal) !void { // If the cursor is inside the scrolling region and on the bottom-most // line, then we scroll up. If our scrolling region is the full screen // we create scrollback. - if (self.screens.active.cursor.y == self.scrolling_region.bottom and - self.screens.active.cursor.x >= self.scrolling_region.left and - self.screens.active.cursor.x <= self.scrolling_region.right) + if (screen.cursor.y == self.scrolling_region.bottom and + screen.cursor.x >= self.scrolling_region.left and + screen.cursor.x <= self.scrolling_region.right) { if (comptime build_options.kitty_graphics) { // Scrolling dirties the images because it updates their placements pins. - self.screens.active.kitty_images.dirty = true; + screen.kitty_images.dirty = true; } // If our scrolling region is at the top, we create scrollback. @@ -1278,7 +1417,7 @@ pub fn index(self: *Terminal) !void { self.scrolling_region.left == 0 and self.scrolling_region.right == self.cols - 1) { - try self.screens.active.cursorScrollAbove(); + try screen.cursorScrollAbove(); return; } @@ -1292,7 +1431,7 @@ pub fn index(self: *Terminal) !void { // However, scrollUp is WAY slower. We should optimize this // case to work in the eraseRowBounded codepath and remove // this check. - !self.screens.active.blankCell().isZero()) + !screen.blankCell().isZero()) { try self.scrollUp(1); return; @@ -1302,9 +1441,9 @@ pub fn index(self: *Terminal) !void { // scroll the contents of the scrolling region. // Preserve old cursor just for assertions - const old_cursor = self.screens.active.cursor; + const old_cursor = screen.cursor; - try self.screens.active.pages.eraseRowBounded( + try screen.pages.eraseRowBounded( .{ .active = .{ .y = self.scrolling_region.top } }, self.scrolling_region.bottom - self.scrolling_region.top, ); @@ -1313,26 +1452,26 @@ pub fn index(self: *Terminal) !void { // up by 1, so we need to move it back down. A `cursorReload` // would be better option but this is more efficient and this is // a super hot path so we do this instead. - assert(self.screens.active.cursor.x == old_cursor.x); - assert(self.screens.active.cursor.y == old_cursor.y); - self.screens.active.cursor.y -= 1; - self.screens.active.cursorDown(1); + assert(screen.cursor.x == old_cursor.x); + assert(screen.cursor.y == old_cursor.y); + screen.cursor.y -= 1; + screen.cursorDown(1); // The operations above can prune our cursor style so we need to // update. This should never fail because the above can only FREE // memory. - self.screens.active.manualStyleUpdate() catch |err| { + screen.manualStyleUpdate() catch |err| { std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); - self.screens.active.cursor.style = .{}; - self.screens.active.manualStyleUpdate() catch unreachable; + screen.cursor.style = .{}; + screen.manualStyleUpdate() catch unreachable; }; return; } // Increase cursor by 1, maximum to bottom of scroll region - if (self.screens.active.cursor.y < self.scrolling_region.bottom) { - self.screens.active.cursorDown(1); + if (screen.cursor.y < self.scrolling_region.bottom) { + screen.cursorDown(1); } } @@ -2314,15 +2453,11 @@ pub fn eraseDisplay( // If we're at a prompt or input area, then we are at a prompt. .prompt, .prompt_continuation, - .input, => break, // If we have command output, then we're most certainly not // at a prompt. - .command => break :at_prompt, - - // If we don't know, we keep searching. - .unknown => {}, + .none => break :at_prompt, } } else break :at_prompt; @@ -2629,21 +2764,19 @@ pub fn resize( // Resize primary screen, which supports reflow const primary = self.screens.get(.primary).?; - if (self.screens.active_key == .primary and - self.flags.shell_redraws_prompt) - { - primary.clearPrompt(); - } - if (self.modes.get(.wraparound)) { - try primary.resize(cols, rows); - } else { - try primary.resizeWithoutReflow(cols, rows); - } + try primary.resize(.{ + .cols = cols, + .rows = rows, + .reflow = self.modes.get(.wraparound), + .prompt_redraw = self.flags.shell_redraws_prompt, + }); // Alternate screen, if it exists, doesn't reflow - if (self.screens.get(.alternate)) |alt| { - try alt.resizeWithoutReflow(cols, rows); - } + if (self.screens.get(.alternate)) |alt| try alt.resize(.{ + .cols = cols, + .rows = rows, + .reflow = false, + }); // Whenever we resize we just mark it as a screen clear self.flags.dirty.clear = true; @@ -4555,19 +4688,20 @@ test "Terminal: soft wrap with semantic prompt" { var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); defer t.deinit(testing.allocator); - // Mark our prompt. Should not make anything dirty on its own. - t.markSemanticPrompt(.prompt); + // Mark our prompt. + try t.semanticPrompt(.init(.prompt_start)); + // Should not make anything dirty on its own. try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + // Write and wrap for ("hello") |c| try t.print(c); - { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; - try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; - try testing.expectEqual(Row.SemanticPrompt.prompt, list_cell.row.semantic_prompt); + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); } } @@ -11518,33 +11652,343 @@ test "Terminal: eraseDisplay complete preserves cursor" { try testing.expect(t.screens.active.cursor.style_id != style.default_id); } +test "Terminal: semantic prompt" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Prompt + try t.semanticPrompt(.init(.fresh_line_new_prompt)); + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt, row.semantic_prompt); + } + + // Start input but end it on EOL + try t.semanticPrompt(.init(.end_prompt_start_input_terminate_eol)); + t.carriageReturn(); + try t.linefeed(); + + // Write some output + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + for ("world") |c| try t.print(c); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.output, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.none, row.semantic_prompt); + } +} + +test "Terminal: semantic prompt continuations" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Prompt + try t.semanticPrompt(.init(.fresh_line_new_prompt)); + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt, row.semantic_prompt); + } + + // Start input but end it on EOL + t.carriageReturn(); + try t.linefeed(); + try t.semanticPrompt(.{ + .action = .prompt_start, + .options_unvalidated = "k=c", + }); + + // Write some output + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + for ("world") |c| try t.print(c); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt_continuation, row.semantic_prompt); + } +} + +test "Terminal: index in prompt mode marks new row as prompt continuation" { + // This tests the Fish shell workaround: when in prompt mode and we get + // a newline, assume the new row is a prompt continuation (since Fish + // doesn't emit OSC133 k=s markers for continuation lines). + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("hello") |c| try t.print(c); + + // Verify first row is marked as prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 0, + } }).?; + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); + } + + // Now do a linefeed while still in prompt mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should automatically be marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // The cursor semantic content should still be prompt + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} + +test "Terminal: index in input mode does not mark new row as prompt" { + // Input mode should NOT trigger prompt continuation on newline + // (only prompt mode does, not input mode) + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt then switch to input + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + try t.semanticPrompt(.init(.end_prompt_start_input)); + for ("echo \\") |c| try t.print(c); + + // Linefeed while in input mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should be marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // Our cursor should still be in input + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); +} + +test "Terminal: index in output mode does not mark new row as prompt" { + // Output mode should NOT trigger prompt continuation + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Complete prompt cycle: prompt -> input -> output + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + try t.semanticPrompt(.init(.end_prompt_start_input)); + for ("ls") |c| try t.print(c); + try t.semanticPrompt(.init(.end_input_start_output)); + + // Linefeed while in output mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should NOT be marked as a prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.none, list_cell.row.semantic_prompt); + } +} + +test "Terminal: OSC133C at x=0 on prompt row clears prompt mark" { + // This tests the second Fish heuristic: when Fish emits a newline + // then immediately sends OSC133C (start output) at column 0, we + // should clear the prompt continuation mark we just set. + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("$ echo \\") |c| try t.print(c); + + // Simulate Fish behavior: newline first (which marks next row as prompt) + t.carriageReturn(); + try t.linefeed(); + + // Verify the new row is marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // Now Fish sends OSC133C at column 0 (cursor is still at x=0) + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try t.semanticPrompt(.init(.end_input_start_output)); + + // The prompt continuation should be cleared + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.none, list_cell.row.semantic_prompt); + } +} + +test "Terminal: OSC133C at x>0 on prompt row does not clear prompt mark" { + // If we're not at column 0, we shouldn't clear the prompt mark + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt on a row + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + + // Move to a new line and mark it as prompt continuation manually + t.carriageReturn(); + try t.linefeed(); + try t.semanticPrompt(.{ + .action = .prompt_start, + .options_unvalidated = "k=c", + }); + for ("> ") |c| try t.print(c); + + // Verify the row is marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // Now send OSC133C but cursor is NOT at column 0 + try testing.expect(t.screens.active.cursor.x > 0); + try t.semanticPrompt(.init(.end_input_start_output)); + + // The prompt continuation should NOT be cleared (we're not at x=0) + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } +} + +test "Terminal: multiple newlines in prompt mode marks all rows" { + // Multiple newlines should each mark their row as prompt continuation + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("line1") |c| try t.print(c); + + // Multiple newlines + t.carriageReturn(); + try t.linefeed(); + for ("line2") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("line3") |c| try t.print(c); + + // First row should be prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 0, + } }).?; + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); + } + + // Second and third rows should be prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 2, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } +} + test "Terminal: cursorIsAtPrompt" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); + for ("$ ") |c| try t.print(c); // Input is also a prompt - t.markSemanticPrompt(.input); - try testing.expect(t.cursorIsAtPrompt()); - - // Newline -- we expect we're still at a prompt if we received - // prompt stuff before. - try t.linefeed(); + try t.semanticPrompt(.init(.end_prompt_start_input)); try testing.expect(t.cursorIsAtPrompt()); + for ("ls") |c| try t.print(c); // But once we say we're starting output, we're not a prompt - t.markSemanticPrompt(.command); - try testing.expect(!t.cursorIsAtPrompt()); + // (cursor is not at x=0, so the Fish heuristic doesn't trigger) + try t.semanticPrompt(.init(.end_input_start_output)); + // Still a prompt because this line has a prompt + try testing.expect(t.cursorIsAtPrompt()); try t.linefeed(); try testing.expect(!t.cursorIsAtPrompt()); // Until we know we're at a prompt again try t.linefeed(); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); } @@ -11554,13 +11998,13 @@ test "Terminal: cursorIsAtPrompt alternate screen" { defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(t.cursorIsAtPrompt()); // Secondary screen is never a prompt try t.switchScreenMode(.@"1049", true); try testing.expect(!t.cursorIsAtPrompt()); - t.markSemanticPrompt(.prompt); + try t.semanticPrompt(.init(.prompt_start)); try testing.expect(!t.cursorIsAtPrompt()); } @@ -11570,6 +12014,7 @@ test "Terminal: fullReset with a non-empty pen" { try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + t.screens.active.cursor.semantic_content = .input; t.fullReset(); { @@ -11582,6 +12027,7 @@ test "Terminal: fullReset with a non-empty pen" { } try testing.expectEqual(@as(style.Id, 0), t.screens.active.cursor.style_id); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); } test "Terminal: fullReset hyperlink" { diff --git a/src/terminal/bitmap_allocator.zig b/src/terminal/bitmap_allocator.zig index 23a5048e1..deeecf553 100644 --- a/src/terminal/bitmap_allocator.zig +++ b/src/terminal/bitmap_allocator.zig @@ -147,6 +147,20 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { } } + /// Returns the total capacity in bytes. + pub fn capacityBytes(self: Self) usize { + return self.bitmap_count * bitmap_bit_size * chunk_size; + } + + /// Returns the number of bytes currently in use. + pub fn usedBytes(self: Self, base: anytype) usize { + const bitmaps = self.bitmap.ptr(base); + var free_chunks: usize = 0; + for (bitmaps[0..self.bitmap_count]) |bitmap| free_chunks += @popCount(bitmap); + const total_chunks = self.bitmap_count * bitmap_bit_size; + return (total_chunks - free_chunks) * chunk_size; + } + /// For testing only. fn isAllocated(self: *Self, base: anytype, slice: anytype) bool { comptime assert(@import("builtin").is_test); diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index b9061e2e9..a1386d14b 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -17,6 +17,7 @@ const parsers = @import("osc/parsers.zig"); const encoding = @import("osc/encoding.zig"); pub const color = parsers.color; +pub const semantic_prompt = parsers.semantic_prompt; const log = std.log.scoped(.osc); diff --git a/src/terminal/osc/parsers/semantic_prompt.zig b/src/terminal/osc/parsers/semantic_prompt.zig index f6a0cb593..9014312f4 100644 --- a/src/terminal/osc/parsers/semantic_prompt.zig +++ b/src/terminal/osc/parsers/semantic_prompt.zig @@ -47,11 +47,10 @@ pub const Option = enum { cl, prompt_kind, err, + // https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers - // Kitty supports a "redraw" option for prompt_start. I can't find - // this documented anywhere but can see in the code that this is used - // by shell environments to tell the terminal that the shell will NOT - // redraw the prompt so we should attempt to resize it. + // Kitty supports a "redraw" option for prompt_start. This is extended + // by Ghostty with the "last" option. See Redraw the type for more details. redraw, // Use a special key instead of arrow keys to move the cursor on @@ -81,7 +80,7 @@ pub const Option = enum { .cl => Click, .prompt_kind => PromptKind, .err => []const u8, - .redraw => bool, + .redraw => Redraw, .special_key => bool, .click_events => bool, .exit_code => i32, @@ -169,7 +168,15 @@ pub const Option = enum { .cl => std.meta.stringToEnum(Click, value), .prompt_kind => if (value.len == 1) PromptKind.init(value[0]) else null, .err => value, - .redraw, .special_key, .click_events => if (value.len == 1) switch (value[0]) { + .redraw => if (std.mem.eql(u8, value, "0")) + .false + else if (std.mem.eql(u8, value, "1")) + .true + else if (std.mem.eql(u8, value, "last")) + .last + else + null, + .special_key, .click_events => if (value.len == 1) switch (value[0]) { '0' => false, '1' => true, else => null, @@ -208,6 +215,29 @@ pub const PromptKind = enum { } }; +/// The values for the `redraw` extension to OSC133. This was +/// started by Kitty[1] and extended by Ghostty (the "last" option). +/// +/// [1]: https://sw.kovidgoyal.net/kitty/shell-integration/#notes-for-shell-developers +pub const Redraw = enum(u2) { + /// The shell supports redrawing the full prompt and all continuations. + /// This is the default value, it does not need to be explicitly set + /// unless it is to reset a prior other value. + true, + + /// The shell does NOT support redrawing. In this case, Ghostty will NOT + /// clear any prompt lines on resize. + false, + + /// The shell supports redrawing only the LAST line of the prompt. + /// Ghostty will only clear the last line of the prompt on resize. + /// + /// This is specifically introduced because Bash only redraws the last + /// line. It is literally the only shell that does this and it does this + /// because its bad and they should feel bad. Don't be like Bash. + last, +}; + /// Parse OSC 133, semantic prompts pub fn parse(parser: *Parser, _: ?u8) ?*OSCCommand { const writer = parser.writer orelse { @@ -513,7 +543,7 @@ test "OSC 133: fresh_line_new_prompt with redraw=0" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == false); + try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == .false); } test "OSC 133: fresh_line_new_prompt with redraw=1" { @@ -527,7 +557,7 @@ test "OSC 133: fresh_line_new_prompt with redraw=1" { const cmd = p.end(null).?.*; try testing.expect(cmd == .semantic_prompt); try testing.expect(cmd.semantic_prompt.action == .fresh_line_new_prompt); - try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == true); + try testing.expect(cmd.semantic_prompt.readOption(.redraw).? == .true); } test "OSC 133: fresh_line_new_prompt with invalid redraw" { @@ -870,8 +900,9 @@ test "Option.read err" { test "Option.read redraw" { const testing = std.testing; - try testing.expect(Option.redraw.read("redraw=1").? == true); - try testing.expect(Option.redraw.read("redraw=0").? == false); + try testing.expect(Option.redraw.read("redraw=1").? == .true); + try testing.expect(Option.redraw.read("redraw=0").? == .false); + try testing.expect(Option.redraw.read("redraw=last").? == .last); try testing.expect(Option.redraw.read("redraw=2") == null); try testing.expect(Option.redraw.read("redraw=10") == null); try testing.expect(Option.redraw.read("redraw=") == null); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 93742774a..3e7ca9ac3 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1898,9 +1898,16 @@ pub const Row = packed struct(u64) { /// false negatives. This is used to optimize hyperlink operations. hyperlink: bool = false, - /// The semantic prompt type for this row as specified by the - /// running program, or "unknown" if it was never set. - semantic_prompt: SemanticPrompt = .unknown, + /// The semantic prompt state for this row. + /// + /// This is ONLY meant to note if there are ANY cells in this + /// row that are part of a prompt. This is an optimization for more + /// efficiently implementing jump-to-prompt operations. + /// + /// This may contain false positives but never false negatives. If + /// this is set, you should still check individual cells to see if they + /// have prompt semantics. + semantic_prompt: SemanticPrompt = .none, /// True if this row contains a virtual placeholder for the Kitty /// graphics protocol. (U+10EEEE) @@ -1922,29 +1929,22 @@ pub const Row = packed struct(u64) { /// screen. dirty: bool = false, - _padding: u22 = 0, + _padding: u23 = 0, - /// Semantic prompt type. - pub const SemanticPrompt = enum(u3) { - /// Unknown, the running application didn't tell us for this line. - unknown = 0, - - /// This is a prompt line, meaning it only contains the shell prompt. - /// For poorly behaving shells, this may also be the input. + /// The semantic prompt state of the row. See `semantic_prompt`. + pub const SemanticPrompt = enum(u2) { + /// No prompt cells in this row. + none = 0, + /// Prompt cells exist in this row and this is a primary prompt + /// line. A primary prompt line is one that is not a continuation + /// and is the beginning of a prompt. prompt = 1, + /// Prompt cells exist in this row that had k=c set (continuation) + /// line. This is used as a way to detect when a line should + /// be considered part of some prior prompt. If no prior prompt + /// is found, the last (most historical) prompt continuation line is + /// considered the prompt. prompt_continuation = 2, - - /// This line contains the input area. We don't currently track - /// where this actually is in the line, so we just assume it is somewhere. - input = 3, - - /// This line is the start of command output. - command = 4, - - /// True if this is a prompt or input line. - pub fn promptOrInput(self: SemanticPrompt) bool { - return self == .prompt or self == .prompt_continuation or self == .input; - } }; /// Returns true if this row has any managed memory outside of the @@ -1994,7 +1994,12 @@ pub const Cell = packed struct(u64) { /// the hyperlink_set to get the actual hyperlink data. hyperlink: bool = false, - _padding: u18 = 0, + /// The semantic type of the content of this cell. This is used + /// by the semantic prompt (OSC 133) set of sequences to understand + /// boundary points for content. + semantic_content: SemanticContent = .output, + + _padding: u16 = 0, pub const ContentTag = enum(u2) { /// A single codepoint, could be zero to be empty cell. @@ -2033,6 +2038,19 @@ pub const Cell = packed struct(u64) { spacer_head = 3, }; + pub const SemanticContent = enum(u2) { + /// Regular output content, such as command output. + output = 0, + + /// Content that is part of user input, such as the command + /// to execute at a prompt. + input = 1, + + /// Content that is part of prompt emitted by the interactive + /// application, such as "user@host >" + prompt = 2, + }; + /// Helper to make a cell that just has a codepoint. pub fn init(cp: u21) Cell { // We have to use this bitCast here to ensure that our memory is @@ -2166,6 +2184,10 @@ test "Cell is zero by default" { const cell = Cell.init(0); const cell_int: u64 = @bitCast(cell); try std.testing.expectEqual(@as(u64, 0), cell_int); + + // The zero value should be output type for semantic content. + // This is very important for our assumptions elsewhere. + try std.testing.expectEqual(Cell.SemanticContent.output, cell.semantic_content); } test "Page capacity adjust cols down" { diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index d0d2c1bb3..a78a4c336 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -707,19 +707,21 @@ pub fn Stream(comptime Handler: type) type { const action = action_opt orelse continue; if (comptime debug) log.info("action: {f}", .{action}); - // If this handler handles everything manually then we do nothing - // if it can be processed. - if (@hasDecl(T, "handleManually")) { - const processed = self.handler.handleManually(action) catch |err| err: { + // A handler can expose this to get the raw action before + // it is further parsed. If this returns `true` then we skip + // processing ourselves. + if (@hasDecl(T, "vtRaw")) { + const skip = self.handler.vtRaw(action) catch |err| err: { log.warn("error handling action manually err={} action={f}", .{ err, action, }); - - break :err false; + // Always skip erroneous actions because we can't + // be sure... + break :err true; }; - if (processed) continue; + if (skip) continue; } switch (action) { diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 18ed0dd42..eca13bf06 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -153,7 +153,7 @@ pub const Handler = struct { .full_reset => self.terminal.fullReset(), .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), .end_hyperlink => self.terminal.screens.active.endHyperlink(), - .semantic_prompt => self.semanticPrompt(value), + .semantic_prompt => try self.terminal.semanticPrompt(value), .mouse_shape => self.terminal.mouse_shape = value, .color_operation => try self.colorOperation(value.op, &value.requests), .kitty_color_report => try self.kittyColorOperation(value), @@ -209,42 +209,6 @@ pub const Handler = struct { } } - fn semanticPrompt( - self: *Handler, - cmd: Action.SemanticPrompt, - ) void { - switch (cmd.action) { - .fresh_line_new_prompt => { - const kind = cmd.readOption(.prompt_kind) orelse .initial; - switch (kind) { - .initial, .right => { - self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt; - if (cmd.readOption(.redraw)) |redraw| { - self.terminal.flags.shell_redraws_prompt = redraw; - } - }, - .continuation, .secondary => { - self.terminal.screens.active.cursor.page_row.semantic_prompt = .prompt_continuation; - }, - } - }, - - .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), - .end_input_start_output => self.terminal.markSemanticPrompt(.command), - .end_command => self.terminal.screens.active.cursor.page_row.semantic_prompt = .input, - - // All of these commands weren't previously handled by our - // semantic prompt code. I am PR-ing the parser separate from the - // handling so we just ignore these like we did before, even - // though we should handle them eventually. - .end_prompt_start_input_terminate_eol, - .fresh_line, - .new_command, - .prompt_start, - => {}, - } - } - fn setMode(self: *Handler, mode: modes.Mode, enabled: bool) !void { // Set the mode on the terminal self.terminal.modes.set(mode, enabled); @@ -905,3 +869,122 @@ test "palette dirty flag set on color change" { try s.nextSlice("\x1b]21;1=rgb:00/ff/00\x1b\\"); try testing.expect(t.flags.dirty.palette); } + +test "semantic prompt fresh line" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;L\x07"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); +} + +test "semantic prompt fresh line new prompt" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text and then send OSC 133;A (fresh_line_new_prompt) + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;A\x07"); + + // Should do a fresh line (carriage return + index) + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + + // Should set cursor semantic_content to prompt + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); + + // Test with redraw option + try s.nextSlice("prompt$ "); + try s.nextSlice("\x1b]133;A;redraw=1\x07"); + try testing.expect(t.flags.shell_redraws_prompt == .true); +} + +test "semantic prompt end of input, then start output" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text and then send OSC 133;A (fresh_line_new_prompt) + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;A\x07"); + try s.nextSlice("prompt$ "); + try s.nextSlice("\x1b]133;B\x07"); + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); + try s.nextSlice("\x1b]133;C\x07"); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); +} + +test "semantic prompt prompt_start" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text + try s.nextSlice("Hello"); + + // OSC 133;P marks the start of a prompt (without fresh line behavior) + try s.nextSlice("\x1b]133;P\x07"); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); +} + +test "semantic prompt new_command" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Write some text + try s.nextSlice("Hello"); + try s.nextSlice("\x1b]133;N\x07"); + + // Should behave like fresh_line_new_prompt - cursor moves to column 0 + // on next line since we had content + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} + +test "semantic prompt new_command at column zero" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // OSC 133;N when already at column 0 should stay on same line + try s.nextSlice("\x1b]133;N\x07"); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} + +test "semantic prompt end_prompt_start_input_terminate_eol clears on linefeed" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set input terminated by EOL + try s.nextSlice("\x1b]133;I\x07"); + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); + + // Linefeed should reset semantic content to output + try s.nextSlice("\n"); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); +} diff --git a/src/termio/Termio.zig b/src/termio/Termio.zig index a1bcea6d3..89ea7407b 100644 --- a/src/termio/Termio.zig +++ b/src/termio/Termio.zig @@ -610,8 +610,9 @@ pub fn clearScreen(self: *Termio, td: *ThreadData, history: bool) !void { // send a FF (0x0C) to the shell so that it can repaint the screen. // Mark the current row as a not a prompt so we can properly // clear the full screen in the next eraseDisplay call. - self.terminal.markSemanticPrompt(.command); - assert(!self.terminal.cursorIsAtPrompt()); + // TODO: fix this + // self.terminal.markSemanticPrompt(.command); + // assert(!self.terminal.cursorIsAtPrompt()); self.terminal.eraseDisplay(.complete, false); } @@ -694,7 +695,11 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { // below but at least users only pay for it if they're using the inspector. if (self.renderer_state.inspector) |insp| { for (buf, 0..) |byte, i| { - insp.recordPtyRead(buf[i .. i + 1]) catch |err| { + insp.recordPtyRead( + self.alloc, + &self.terminal, + buf[i .. i + 1], + ) catch |err| { log.err("error recording pty read in inspector err={}", .{err}); }; diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 63094b106..bc3edd185 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -320,7 +320,7 @@ pub const StreamHandler = struct { .progress_report => self.progressReport(value), .start_hyperlink => try self.startHyperlink(value.uri, value.id), .clipboard_contents => try self.clipboardContents(value.kind, value.data), - .semantic_prompt => self.semanticPrompt(value), + .semantic_prompt => try self.semanticPrompt(value), .mouse_shape => try self.setMouseShape(value), .configure_charset => self.configureCharset(value.slot, value.charset), .set_attribute => { @@ -1069,28 +1069,12 @@ pub const StreamHandler = struct { fn semanticPrompt( self: *StreamHandler, cmd: Stream.Action.SemanticPrompt, - ) void { + ) !void { switch (cmd.action) { - .fresh_line_new_prompt => { - const kind = cmd.readOption(.prompt_kind) orelse .initial; - switch (kind) { - .initial, .right => { - self.terminal.markSemanticPrompt(.prompt); - if (cmd.readOption(.redraw)) |redraw| { - self.terminal.flags.shell_redraws_prompt = redraw; - } - }, - .continuation, .secondary => { - self.terminal.markSemanticPrompt(.prompt_continuation); - }, - } - }, - - .end_prompt_start_input => self.terminal.markSemanticPrompt(.input), .end_input_start_output => { - self.terminal.markSemanticPrompt(.command); self.surfaceMessageWriter(.start_command); }, + .end_command => { // The specification seems to not specify the type but // other terminals accept 32-bits, but exit codes are really @@ -1103,16 +1087,19 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .stop_command = code }); }, - // All of these commands weren't previously handled by our - // semantic prompt code. I am PR-ing the parser separate from the - // handling so we just ignore these like we did before, even - // though we should handle them eventually. + // Handled by Terminal, no special handling by us + .end_prompt_start_input, .end_prompt_start_input_terminate_eol, .fresh_line, + .fresh_line_new_prompt, .new_command, .prompt_start, => {}, } + + // We do this last so failures are still processed correctly + // above. + try self.terminal.semanticPrompt(cmd); } fn reportPwd(self: *StreamHandler, url: []const u8) !void {