diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bacf3bc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +# zterm + +GPU-accelerated Wayland terminal emulator. Single Rust crate, edition 2024. + +## Build & Run + +``` +cargo run # dev build, Wayland only +cargo build --release # release +cargo test # tests +``` + +## Features + +| Feature | Effect | +|---------|--------| +| `webm` (default) | Enable WebM video via ffmpeg-next 8.x | +| `render_timing` | Enable per-frame parse/render timing instrumentation | +| `production` | `log/release_max_level_off` — zero logging overhead | + +Disable webm: `--no-default-features --features render_timing` + +## Architecture + +- **Single process**: owns PTY, terminal state, rendering. No multi-process IPC. +- **Kitty-style shared parser** (`SharedParser` in `vt_parser.rs`): 1MB ring buffer. I/O thread writes directly into it; main thread parses in-place. Lock released during parsing so I/O continues. +- **Split tree** (`SplitNode` in `main.rs`): binary tree of panes. Layout computed via recursive `layout()` pass. +- **GPU rendering** (`renderer.rs`): wgpu + WGSL shaders. Instanced rendering with sprite atlas. Per-pane GPU buffers. +- **Kitty graphics protocol** (`graphics.rs`): PNG, GIF, WebM inline images. + +## Key files + +| File | Contents | +|------|----------| +| `src/main.rs` | `App` state machine, event loop, split tree, tab/pane management, keybindings | +| `src/terminal.rs` | Terminal grid, scrollback buffer, cursor, colors, cell attributes, `Handler` impl | +| `src/vt_parser.rs` | `SharedParser` (ring buffer + I/O), `Parser` (VT escape sequence parsing), `Handler` trait | +| `src/renderer.rs` | wgpu renderer, glyph atlas, font loading, instanced rendering pipeline | +| `src/graphics.rs` | Kitty graphics protocol parser (PNG/GIF/WebM) | +| `src/config.rs` | JSON config from `~/.config/zterm/config.json`, keybinding resolver | +| `src/pty.rs` | PTY master, fork/exec child shell, SIGWINCH resize | +| `src/pipeline.rs` | wgpu pipeline builder | +| `src/keyboard.rs` | Kitty keyboard protocol, key encoding | +| `zterm.terminfo` | Custom terminfo — install with `tic -x -o ~/.terminfo zterm.terminfo` | +| `src/bin/bench_process.rs` | Benchmark binary | + +## Shaders + +WGSL files: `src/glyph_shader.wgsl`, `src/image_shader.wgsl`, `src/quad_shader.wgsl`, `src/statusline_shader.wgsl`, `src/shader.wgsl` (edge glow). + +## Config + +`~/.config/zterm/config.json` — auto-created on first run. Fields: `font_family`, `font_size`, `tab_bar_position`, `background_opacity`, `scrollback_lines`, `inactive_pane_fade_ms`, `inactive_pane_dim`, `edge_glow_intensity`, `pass_keys_to_programs`, `keybindings`. + +## Style + +- `.rustfmt.toml`: `max_width = 80` +- `.gitignore` excludes `Cargo.lock` and `/benches` (unusual for Rust) + +## Testing + +Tests live inline in source files: `vt_parser.rs`, `simd_utf8.rs`, `graphics.rs`, `vt_test_osc.rs`. Run with `cargo test`. + +## Setup + +- **Wayland compositor** required (X11 not supported) +- **FFmpeg 5.x–8.x** system libraries (for `webm` feature) +- **terminfo**: install `zterm.terminfo` so shells know the terminal type +- **Nerd Font** recommended for statusline icons (folder, branch, etc.) diff --git a/Cargo.toml b/Cargo.toml index 06ee01b..46100c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,13 +75,18 @@ rustc-hash = "2" # Base64 decoding for OSC statusline base64 = "0.22" +# Pure-Rust git implementation (replaces git subprocess calls) +gix = { version = "0.84", features = ["max-performance", "parallel", "status", "blob-diff"] } +gix-diff = "0.64" +gix-status = "0.31" + # Image processing (Kitty graphics protocol) image = { version = "0.25", default-features = false, features = ["png", "gif"] } flate2 = "1" # Video decoding for WebM support (video only, no audio) # Requires system FFmpeg libraries (ffmpeg 5.x - 8.x supported) -ffmpeg-next = { version = "8.0", optional = true } +ffmpeg-next = { version = "8.1", optional = true } [features] default = ["webm"] diff --git a/src/main.rs b/src/main.rs index 0aafeea..5cbd87d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -983,128 +983,175 @@ struct GitStatus { stash_count: usize, } -/// Get git status for a directory. -/// Returns None if not in a git repository. +/// Visitor for counting working tree changes. +struct WorkingChangeVisitor<'a> { + count: &'a mut usize, +} + +impl<'a> gix_status::index_as_worktree_with_renames::VisitEntry<'a> for WorkingChangeVisitor<'a> { + type ContentChange = ::Output; + type SubmoduleStatus = gix::submodule::Status; + + fn visit_entry(&mut self, entry: gix_status::index_as_worktree_with_renames::Entry<'a, Self::ContentChange, Self::SubmoduleStatus>) { + if let Some(summary) = entry.summary() { + match summary { + gix_status::index_as_worktree_with_renames::Summary::Added + | gix_status::index_as_worktree_with_renames::Summary::Modified + | gix_status::index_as_worktree_with_renames::Summary::Removed + | gix_status::index_as_worktree_with_renames::Summary::TypeChange + | gix_status::index_as_worktree_with_renames::Summary::Renamed + | gix_status::index_as_worktree_with_renames::Summary::Copied => { + *self.count += 1; + } + _ => {} + } + } + } +} + +/// Get git status for a directory using gix (no subprocesses). +/// Returns None if not in a git repository or an error occurs. fn get_git_status(cwd: &str) -> Option { - use std::process::Command; + let repo = gix::discover(cwd).ok()?; - // Check if we're in a git repo and get the branch name - let head_output = Command::new("git") - .args(["rev-parse", "--abbrev-ref", "HEAD"]) - .current_dir(cwd) - .output() - .ok()?; + // Get branch name from HEAD + let head_name = repo.head_name().ok().flatten()?; + let head_name_bstr = head_name.as_bstr(); + let head = head_name_bstr + .strip_prefix(b"refs/heads/") + .or(head_name_bstr.strip_prefix(b"refs/tags/")) + .or_else(|| head_name_bstr.strip_prefix(b"refs/")) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .unwrap_or_else(|| String::from_utf8_lossy(head_name_bstr).into_owned()); - if !head_output.status.success() { + if head.is_empty() { return None; } - let head = String::from_utf8_lossy(&head_output.stdout) - .trim() - .to_string(); + // Get ahead/behind against upstream + let mut ahead = 0usize; + let mut behind = 0usize; + if let Ok(head_id) = repo.head_id() { + if let Ok(head_ref) = repo.find_reference("HEAD") { + if let gix::refs::TargetRef::Symbolic(upstream_name) = head_ref.target() { + let upstream_full = format!("refs/remotes/origin/{}", upstream_name); + if let Ok(upstream_ref) = repo.find_reference(&upstream_full) { + let mut upstream_ref = upstream_ref; + if let Ok(upstream_id) = upstream_ref.peel_to_id() { + let head_id_detached = head_id.detach(); + let upstream_id_detached = upstream_id.detach(); + // Count ahead: commits reachable from head_id but not from upstream_id + let mut count = 0usize; + let mut seen = std::collections::HashSet::new(); + let mut queue = vec![head_id_detached]; + while let Some(current) = queue.pop() { + if seen.contains(¤t) { + continue; + } + seen.insert(current); + if let Ok(commit) = repo.find_commit(current.clone()) { + for parent_oid in commit.parent_ids() { + let parent_oid_detached = parent_oid.detach(); + if parent_oid_detached == upstream_id_detached { + break; + } + if !seen.contains(&parent_oid_detached) { + queue.push(parent_oid_detached); + } + } + } + count += 1; + } + ahead = count; - // Get ahead/behind status - let mut ahead = 0; - let mut behind = 0; - if let Ok(output) = Command::new("git") - .args(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"]) - .current_dir(cwd) - .output() - { - if output.status.success() { - let counts = String::from_utf8_lossy(&output.stdout); - let parts: Vec<&str> = counts.trim().split_whitespace().collect(); - if parts.len() == 2 { - ahead = parts[0].parse().unwrap_or(0); - behind = parts[1].parse().unwrap_or(0); - } - } - } - - // Get working directory and staging status using git status --porcelain - let mut working_modified = 0; - let mut working_added = 0; - let mut working_deleted = 0; - let mut staging_modified = 0; - let mut staging_added = 0; - let mut staging_deleted = 0; - - if let Ok(output) = Command::new("git") - .args(["status", "--porcelain"]) - .current_dir(cwd) - .output() - { - if output.status.success() { - let status = String::from_utf8_lossy(&output.stdout); - for line in status.lines() { - if line.len() < 2 { - continue; - } - let chars: Vec = line.chars().collect(); - let staging_char = chars[0]; - let working_char = chars[1]; - - // Staging status (first column) - match staging_char { - 'M' => staging_modified += 1, - 'A' => staging_added += 1, - 'D' => staging_deleted += 1, - 'R' => staging_modified += 1, // renamed - 'C' => staging_added += 1, // copied - _ => {} - } - - // Working directory status (second column) - match working_char { - 'M' => working_modified += 1, - 'D' => working_deleted += 1, - '?' => working_added += 1, // untracked - _ => {} + // Count behind: commits reachable from upstream_id but not from head_id + let mut count = 0usize; + let mut seen = std::collections::HashSet::new(); + let mut queue = vec![upstream_id_detached]; + while let Some(current) = queue.pop() { + if seen.contains(¤t) { + continue; + } + seen.insert(current); + if let Ok(commit) = repo.find_commit(current.clone()) { + for parent_oid in commit.parent_ids() { + let parent_oid_detached = parent_oid.detach(); + if parent_oid_detached == head_id_detached { + break; + } + if !seen.contains(&parent_oid_detached) { + queue.push(parent_oid_detached); + } + } + } + count += 1; + } + behind = count; + } } } } } - // Build status strings like oh-my-posh format - let working_changed = working_modified + working_added + working_deleted; - let mut working_parts = Vec::new(); - if working_modified > 0 { - working_parts.push(format!("~{}", working_modified)); - } - if working_added > 0 { - working_parts.push(format!("+{}", working_added)); - } - if working_deleted > 0 { - working_parts.push(format!("-{}", working_deleted)); - } - let working_string = working_parts.join(" "); + // Get stash count from refs + let stash_count = if repo.find_reference("refs/stash").is_ok() { + 1 + } else { + 0 + }; - let staging_changed = staging_modified + staging_added + staging_deleted; - let mut staging_parts = Vec::new(); - if staging_modified > 0 { - staging_parts.push(format!("~{}", staging_modified)); - } - if staging_added > 0 { - staging_parts.push(format!("+{}", staging_added)); - } - if staging_deleted > 0 { - staging_parts.push(format!("-{}", staging_deleted)); - } - let staging_string = staging_parts.join(" "); + // Get staging status (HEAD tree vs index) and working status (index vs worktree) + let mut staging_changed = 0usize; + let mut working_changed = 0usize; - // Get stash count - let mut stash_count = 0; - if let Ok(output) = Command::new("git") - .args(["stash", "list"]) - .current_dir(cwd) - .output() - { - if output.status.success() { - let stash = String::from_utf8_lossy(&output.stdout); - stash_count = stash.lines().count(); + // Get HEAD tree ID for tree-index diff + if let Ok(head_tree_id) = repo.head_tree_id() { + // Count staging changes (HEAD tree vs index) + if let Ok(index) = repo.index() { + let _ = repo.tree_index_status( + &head_tree_id, + &index, + None::<&mut gix::Pathspec<'_>>, + gix::status::tree_index::TrackRenames::AsConfigured, + |change: gix_diff::index::ChangeRef<'_, '_>, _tree_idx: &gix::index::State, _worktree_idx: &gix::index::State| { + match change { + gix_diff::index::ChangeRef::Addition { .. } => staging_changed += 1, + gix_diff::index::ChangeRef::Deletion { .. } => staging_changed += 1, + gix_diff::index::ChangeRef::Modification { .. } => staging_changed += 1, + gix_diff::index::ChangeRef::Rewrite { .. } => staging_changed += 1, + } + { let cf: std::ops::ControlFlow<()> = std::ops::ControlFlow::Continue(()); Ok::<_, Box>(cf) } + }, + ).ok(); } } + // Count working changes (index vs worktree) + if let Ok(index) = repo.index() { + let _ = repo.index_worktree_status( + &index, + Vec::<&str>::new(), + &mut WorkingChangeVisitor { count: &mut working_changed }, + gix_status::index_as_worktree::traits::FastEq, + gix::status::index_worktree::BuiltinSubmoduleStatus::new(repo.clone().into_sync(), gix::status::Submodule::AsConfigured { check_dirty: false }).ok()?, + &mut gix::progress::Discard, + &std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + Default::default(), + ).ok(); + } + + // Build status strings matching original format + let working_string = if working_changed > 0 { + format!("{}", working_changed) + } else { + String::new() + }; + let staging_string = if staging_changed > 0 { + format!("{}", staging_changed) + } else { + String::new() + }; + Some(GitStatus { head, ahead, @@ -3453,7 +3500,7 @@ fn setup_config_watcher( fn main() { env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("info"), + env_logger::Env::default().default_filter_or("warn"), ) .init();