diff --git a/Cargo.toml b/Cargo.toml index c1f198e..e6888bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,9 @@ memmap2 = "0.9" # Fast byte searching memchr = "2" +# Base64 decoding for OSC statusline +base64 = "0.22" + # Image processing (Kitty graphics protocol) image = { version = "0.25", default-features = false, features = ["png", "gif"] } flate2 = "1" diff --git a/src/main.rs b/src/main.rs index d11c45c..e4d2e7e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use zterm::config::{Action, Config}; use zterm::keyboard::{FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Modifiers}; use zterm::pty::Pty; -use zterm::renderer::{EdgeGlow, PaneRenderInfo, Renderer, StatuslineComponent, StatuslineSection}; +use zterm::renderer::{EdgeGlow, PaneRenderInfo, Renderer, StatuslineComponent, StatuslineContent, StatuslineSection}; use zterm::terminal::{Direction, Terminal, TerminalCommand, MouseTrackingMode}; use std::collections::HashMap; @@ -160,6 +160,10 @@ struct Pane { focus_animation_start: std::time::Instant, /// Whether this pane was focused before the current animation. was_focused: bool, + /// Custom statusline content set by applications (e.g., neovim). + /// Contains raw ANSI escape sequences for colors. + /// When Some, this overrides the default CWD/git statusline. + custom_statusline: Option, } impl Pane { @@ -193,6 +197,7 @@ impl Pane { last_scrollback_len: 0, focus_animation_start: std::time::Instant::now(), was_focused: true, // New panes start as focused + custom_statusline: None, }) } @@ -1409,7 +1414,7 @@ impl App { let cell_width = renderer.cell_width; let cell_height = renderer.cell_height; let width = renderer.width as f32; - let height = renderer.height as f32 - renderer.tab_bar_height(); + let height = renderer.height as f32 - renderer.tab_bar_height() - renderer.statusline_height(); let border_width = 2.0; // Border width in pixels for tab in &mut self.tabs { @@ -1458,19 +1463,29 @@ impl App { // Handle commands outside the borrow for cmd in commands { - self.handle_terminal_command(cmd); + self.handle_terminal_command(pane_id, cmd); } processed } /// Handle a command from the terminal (triggered by OSC sequences). - fn handle_terminal_command(&mut self, cmd: TerminalCommand) { + fn handle_terminal_command(&mut self, pane_id: PaneId, cmd: TerminalCommand) { match cmd { TerminalCommand::NavigatePane(direction) => { log::debug!("Terminal requested pane navigation: {:?}", direction); self.focus_pane(direction); } + TerminalCommand::SetStatusline(statusline) => { + log::debug!("Pane {:?} set statusline: {:?}", pane_id, statusline.as_ref().map(|s| s.len())); + // Find the pane and set its custom statusline + for tab in &mut self.tabs { + if let Some(pane) = tab.get_pane_mut(pane_id) { + pane.custom_statusline = statusline; + break; + } + } + } } } @@ -2258,6 +2273,19 @@ impl ApplicationHandler for App { } } + // Clear custom statusline if the foreground process is no longer neovim/vim + // This handles the case where neovim exits but didn't send a clear command + if let Some(pane) = tab.panes.get_mut(&active_pane_id) { + if pane.custom_statusline.is_some() { + if let Some(proc_name) = pane.pty.foreground_process_name() { + let is_vim = proc_name == "nvim" || proc_name == "vim" || proc_name == "vi"; + if !is_vim { + pane.custom_statusline = None; + } + } + } + } + // Build render info for all panes let mut pane_render_data: Vec<(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)> = Vec::new(); @@ -2319,20 +2347,27 @@ impl ApplicationHandler for App { pane.terminal.image_storage.has_animations() }); - // Get the cwd from the active pane for the statusline - let statusline_sections: Vec = tab.panes.get(&active_pane_id) - .and_then(|pane| pane.pty.foreground_cwd()) - .map(|cwd| { - let mut sections = vec![build_cwd_section(&cwd)]; - // Add git section if in a git repository - if let Some(git_section) = build_git_section(&cwd) { - sections.push(git_section); + // Get the statusline content for the active pane + // If the pane has a custom statusline (from neovim), use raw ANSI content + let statusline_content: StatuslineContent = tab.panes.get(&active_pane_id) + .map(|pane| { + if let Some(ref custom) = pane.custom_statusline { + // Use raw ANSI content directly - no parsing into sections + StatuslineContent::Raw(custom.clone()) + } else if let Some(cwd) = pane.pty.foreground_cwd() { + // Default: CWD and git sections + let mut sections = vec![build_cwd_section(&cwd)]; + if let Some(git_section) = build_git_section(&cwd) { + sections.push(git_section); + } + StatuslineContent::Sections(sections) + } else { + StatuslineContent::Sections(Vec::new()) } - sections }) .unwrap_or_default(); - match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx, &self.edge_glows, self.config.edge_glow_intensity, &statusline_sections) { + match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx, &self.edge_glows, self.config.edge_glow_intensity, &statusline_content) { Ok(_) => {} Err(wgpu::SurfaceError::Lost) => { renderer.resize(renderer.width, renderer.height); diff --git a/src/renderer.rs b/src/renderer.rs index 6a89329..1cf50f2 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -159,6 +159,23 @@ impl StatuslineSection { } } +/// Content to display in the statusline. +/// Either structured sections (for ZTerm's default CWD/git display) or raw ANSI +/// content (from neovim or other programs that provide their own statusline). +#[derive(Debug, Clone)] +pub enum StatuslineContent { + /// Structured sections with powerline-style transitions. + Sections(Vec), + /// Raw ANSI-formatted string (rendered as-is without section styling). + Raw(String), +} + +impl Default for StatuslineContent { + fn default() -> Self { + StatuslineContent::Sections(Vec::new()) + } +} + /// Edge glow animation state for visual feedback when navigation fails. /// Creates an organic glow effect: a single light node appears at center, /// then splits into two that travel outward to the corners while fading. @@ -1662,6 +1679,231 @@ impl Renderer { TabBarPosition::Hidden => 0.0, } } + + /// Render raw ANSI-formatted content in the statusline. + /// This parses ANSI escape sequences inline and renders text with colors. + fn render_raw_ansi_statusline( + &mut self, + content: &str, + statusline_y: f32, + statusline_height: f32, + text_y: f32, + screen_width: f32, + screen_height: f32, + ) { + let mut cursor_x = 4.0_f32; // Small left padding + + // Current text attributes + let mut fg_color: [f32; 4] = { + let [r, g, b] = self.palette.default_fg; + [ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + 1.0, + ] + }; + let default_fg = fg_color; + let mut bg_color: Option<[f32; 4]> = None; + + let mut chars = content.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\x1b' { + // Parse escape sequence + if chars.peek() == Some(&'[') { + chars.next(); // consume '[' + + // Collect the CSI parameters + let mut params = String::new(); + while let Some(&ch) = chars.peek() { + if ch.is_ascii_digit() || ch == ';' { + params.push(chars.next().unwrap()); + } else { + break; + } + } + + // Get the final character + if let Some(final_char) = chars.next() { + if final_char == 'm' { + // SGR sequence - parse color codes + let parts: Vec<&str> = params.split(';').collect(); + let mut i = 0; + while i < parts.len() { + let code: u32 = parts[i].parse().unwrap_or(0); + match code { + 0 => { + // Reset + fg_color = default_fg; + bg_color = None; + } + 1 => { + // Bold - could brighten color + } + 30..=37 => { + // Standard foreground colors + let idx = (code - 30) as usize; + let [r, g, b] = self.palette.colors[idx]; + fg_color = [ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + 1.0, + ]; + } + 38 => { + // Extended foreground color + if i + 1 < parts.len() { + let mode: u32 = parts[i + 1].parse().unwrap_or(0); + if mode == 5 && i + 2 < parts.len() { + // 256 color + let idx: usize = parts[i + 2].parse().unwrap_or(0); + let [r, g, b] = self.palette.colors[idx.min(255)]; + fg_color = [ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + 1.0, + ]; + i += 2; + } else if mode == 2 && i + 4 < parts.len() { + // RGB color + let r: u8 = parts[i + 2].parse().unwrap_or(0); + let g: u8 = parts[i + 3].parse().unwrap_or(0); + let b: u8 = parts[i + 4].parse().unwrap_or(0); + fg_color = [ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + 1.0, + ]; + i += 4; + } + } + } + 40..=47 => { + // Standard background colors + let idx = (code - 40) as usize; + let [r, g, b] = self.palette.colors[idx]; + bg_color = Some([ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + 1.0, + ]); + } + 48 => { + // Extended background color + if i + 1 < parts.len() { + let mode: u32 = parts[i + 1].parse().unwrap_or(0); + if mode == 5 && i + 2 < parts.len() { + // 256 color + let idx: usize = parts[i + 2].parse().unwrap_or(0); + let [r, g, b] = self.palette.colors[idx.min(255)]; + bg_color = Some([ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + 1.0, + ]); + i += 2; + } else if mode == 2 && i + 4 < parts.len() { + // RGB color + let r: u8 = parts[i + 2].parse().unwrap_or(0); + let g: u8 = parts[i + 3].parse().unwrap_or(0); + let b: u8 = parts[i + 4].parse().unwrap_or(0); + bg_color = Some([ + Self::srgb_to_linear(r as f32 / 255.0), + Self::srgb_to_linear(g as f32 / 255.0), + Self::srgb_to_linear(b as f32 / 255.0), + 1.0, + ]); + i += 4; + } + } + } + 49 => { + // Default background + bg_color = None; + } + _ => {} + } + i += 1; + } + } + } + } + continue; + } + + // Render the character + if cursor_x + self.cell_width > screen_width { + break; + } + + // Draw background if set + if let Some(bg) = bg_color { + self.render_rect(cursor_x, statusline_y, self.cell_width, statusline_height, bg); + } + + if c == ' ' { + cursor_x += self.cell_width; + continue; + } + + let glyph = self.rasterize_char(c); + if glyph.size[0] > 0.0 && glyph.size[1] > 0.0 { + // Powerline characters (U+E0B0-U+E0BF) need to start at y=0 to fill the statusline height + let is_powerline_char = ('\u{E0B0}'..='\u{E0BF}').contains(&c); + + let glyph_x = (cursor_x + glyph.offset[0]).round(); + let glyph_y = if is_powerline_char { + statusline_y + } else { + let baseline_y = (text_y + self.cell_height * 0.8).round(); + (baseline_y - glyph.offset[1] - glyph.size[1]).round() + }; + + let left = Self::pixel_to_ndc_x(glyph_x, screen_width); + let right = Self::pixel_to_ndc_x(glyph_x + glyph.size[0], screen_width); + let top = Self::pixel_to_ndc_y(glyph_y, screen_height); + let bottom = Self::pixel_to_ndc_y(glyph_y + glyph.size[1], screen_height); + + let base_idx = self.glyph_vertices.len() as u32; + self.glyph_vertices.push(GlyphVertex { + position: [left, top], + uv: [glyph.uv[0], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_vertices.push(GlyphVertex { + position: [right, top], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_vertices.push(GlyphVertex { + position: [right, bottom], + uv: [glyph.uv[0] + glyph.uv[2], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_vertices.push(GlyphVertex { + position: [left, bottom], + uv: [glyph.uv[0], glyph.uv[1] + glyph.uv[3]], + color: fg_color, + bg_color: [0.0, 0.0, 0.0, 0.0], + }); + self.glyph_indices.extend_from_slice(&[ + base_idx, base_idx + 1, base_idx + 2, + base_idx, base_idx + 2, base_idx + 3, + ]); + } + + cursor_x += self.cell_width; + } + } /// Returns the Y offset where the terminal content starts. /// Accounts for both the tab bar and the statusline. @@ -4601,7 +4843,7 @@ impl Renderer { /// - `active_tab`: Index of the active tab /// - `edge_glows`: Active edge glow animations for visual feedback /// - `edge_glow_intensity`: Intensity of edge glow effect (0.0 = disabled, 1.0 = full) - /// - `statusline_sections`: Sections to render in the statusline + /// - `statusline_content`: Content to render in the statusline pub fn render_panes( &mut self, panes: &[(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)], @@ -4609,7 +4851,7 @@ impl Renderer { active_tab: usize, edge_glows: &[EdgeGlow], edge_glow_intensity: f32, - statusline_sections: &[StatuslineSection], + statusline_content: &StatuslineContent, ) -> Result<(), wgpu::SurfaceError> { // Sync palette from first terminal if let Some((terminal, _, _)) = panes.first() { @@ -4809,7 +5051,20 @@ impl Renderer { } }; - for (section_idx, section) in statusline_sections.iter().enumerate() { + match statusline_content { + StatuslineContent::Raw(ansi_content) => { + // Render raw ANSI content directly without section styling + self.render_raw_ansi_statusline( + ansi_content, + statusline_y, + statusline_height, + text_y, + width, + height, + ); + } + StatuslineContent::Sections(statusline_sections) => { + for (section_idx, section) in statusline_sections.iter().enumerate() { let section_start_x = cursor_x; // Calculate section width by counting characters @@ -4953,6 +5208,8 @@ impl Renderer { cursor_x += self.cell_width; } } + } + } } // ═══════════════════════════════════════════════════════════════════ diff --git a/src/terminal.rs b/src/terminal.rs index c86d8bf..c7a21fa 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -11,6 +11,10 @@ pub enum TerminalCommand { /// Navigate to a neighboring pane in the given direction. /// Triggered by OSC 51;navigate; ST NavigatePane(Direction), + /// Set custom statusline content for this pane. + /// Triggered by OSC 51;statusline; ST + /// Empty content clears the statusline (restores default). + SetStatusline(Option), } /// Direction for pane navigation. @@ -1382,6 +1386,7 @@ impl Handler for Terminal { // Format: OSC 51;command;args ST // Currently supported: // OSC 51;navigate;up/down/left/right ST - Navigate to neighboring pane + // OSC 51;statusline; ST - Set custom statusline (empty to clear) 51 => { if parts.len() >= 2 { if let Ok(command) = std::str::from_utf8(parts[1]) { @@ -1403,6 +1408,37 @@ impl Handler for Terminal { } } } + "statusline" => { + // OSC 51;statusline; ST + // If content is empty or missing, clear the statusline + // Content may be base64-encoded (prefixed with "b64:") to avoid + // escape sequence interpretation issues in the terminal + let prefix = b"51;statusline;"; + let raw_content = if data.len() > prefix.len() && data.starts_with(prefix) { + std::str::from_utf8(&data[prefix.len()..]).ok().map(|s| s.to_string()) + } else if parts.len() >= 3 { + std::str::from_utf8(parts[2]).ok().map(|s| s.to_string()) + } else { + None + }; + + // Decode base64 if prefixed with "b64:" + let content = raw_content.and_then(|s| { + if let Some(encoded) = s.strip_prefix("b64:") { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + } else { + Some(s) + } + }); + + let statusline = content.filter(|s| !s.is_empty()); + log::info!("OSC 51: Set statusline: {:?}", statusline.as_ref().map(|s| format!("{} bytes", s.len()))); + self.command_queue.push(TerminalCommand::SetStatusline(statusline)); + } _ => { log::debug!("OSC 51: Unknown command '{}'", command); }