Merge remote-tracking branch 'upstream/main' into grapheme-width-changes

This commit is contained in:
Jacob Sandlund
2026-02-02 08:30:41 -05:00
60 changed files with 9836 additions and 3931 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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,
},
},

6
build.zig.zon.json generated
View File

@@ -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",

6
build.zig.zon.nix generated
View File

@@ -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=";
};
}
{

2
build.zig.zon.txt generated
View File

@@ -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

View File

@@ -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;

View File

@@ -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",

View File

@@ -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:

View File

@@ -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()
)
}
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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",
});

View File

@@ -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

View File

@@ -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,

View File

@@ -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.*;

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}
//---------------------------------------------------------------

12
src/inspector/AGENTS.md Normal file
View File

@@ -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:
<https://raw.githubusercontent.com/ocornut/imgui/refs/heads/master/imgui_demo.cpp>
- On macOS, run builds with `-Demit-macos-app=false` to verify API usage.
- There are no unit tests in this package.

File diff suppressed because it is too large Load Diff

View File

@@ -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
"<internal error>";
_ = cimgui.c.ImGui_SelectableEx(key.ptr, false, 0, .{});
}
// All extras
for (self.cps) |cp| {
const key = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch
"<internal error>";
_ = 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)");
}
};

View File

@@ -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");
}
}
}

View File

@@ -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));
}

View File

@@ -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());
}

View File

@@ -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
}

View File

@@ -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;
}
};

227
src/inspector/widgets.zig Normal file
View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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("-");
}
}

View File

@@ -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);
}
}
};

View File

@@ -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");
}
};

View File

@@ -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);
}
}

View File

@@ -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,
);
},
}
}

View File

@@ -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)),
);
}
}
}

View File

@@ -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();
}
}
}

View File

@@ -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
"<internal error>";
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,
};

View File

@@ -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;

415
src/renderer/Overlay.zig Normal file
View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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.
//

View File

@@ -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| {

View File

@@ -16,7 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# 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)

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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" {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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" {

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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});
};

View File

@@ -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 {