powerline but better

This commit is contained in:
Zacharias-Brohn
2025-12-16 22:58:41 +01:00
parent bdfc98b0bd
commit 05e94ac655
4 changed files with 348 additions and 17 deletions
+3
View File
@@ -55,6 +55,9 @@ memmap2 = "0.9"
# Fast byte searching # Fast byte searching
memchr = "2" memchr = "2"
# Base64 decoding for OSC statusline
base64 = "0.22"
# Image processing (Kitty graphics protocol) # Image processing (Kitty graphics protocol)
image = { version = "0.25", default-features = false, features = ["png", "gif"] } image = { version = "0.25", default-features = false, features = ["png", "gif"] }
flate2 = "1" flate2 = "1"
+46 -11
View File
@@ -6,7 +6,7 @@
use zterm::config::{Action, Config}; use zterm::config::{Action, Config};
use zterm::keyboard::{FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Modifiers}; use zterm::keyboard::{FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Modifiers};
use zterm::pty::Pty; 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 zterm::terminal::{Direction, Terminal, TerminalCommand, MouseTrackingMode};
use std::collections::HashMap; use std::collections::HashMap;
@@ -160,6 +160,10 @@ struct Pane {
focus_animation_start: std::time::Instant, focus_animation_start: std::time::Instant,
/// Whether this pane was focused before the current animation. /// Whether this pane was focused before the current animation.
was_focused: bool, 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<String>,
} }
impl Pane { impl Pane {
@@ -193,6 +197,7 @@ impl Pane {
last_scrollback_len: 0, last_scrollback_len: 0,
focus_animation_start: std::time::Instant::now(), focus_animation_start: std::time::Instant::now(),
was_focused: true, // New panes start as focused 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_width = renderer.cell_width;
let cell_height = renderer.cell_height; let cell_height = renderer.cell_height;
let width = renderer.width as f32; 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 let border_width = 2.0; // Border width in pixels
for tab in &mut self.tabs { for tab in &mut self.tabs {
@@ -1458,19 +1463,29 @@ impl App {
// Handle commands outside the borrow // Handle commands outside the borrow
for cmd in commands { for cmd in commands {
self.handle_terminal_command(cmd); self.handle_terminal_command(pane_id, cmd);
} }
processed processed
} }
/// Handle a command from the terminal (triggered by OSC sequences). /// 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 { match cmd {
TerminalCommand::NavigatePane(direction) => { TerminalCommand::NavigatePane(direction) => {
log::debug!("Terminal requested pane navigation: {:?}", direction); log::debug!("Terminal requested pane navigation: {:?}", direction);
self.focus_pane(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<UserEvent> 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 // Build render info for all panes
let mut pane_render_data: Vec<(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)> = Vec::new(); let mut pane_render_data: Vec<(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)> = Vec::new();
@@ -2319,20 +2347,27 @@ impl ApplicationHandler<UserEvent> for App {
pane.terminal.image_storage.has_animations() pane.terminal.image_storage.has_animations()
}); });
// Get the cwd from the active pane for the statusline // Get the statusline content for the active pane
let statusline_sections: Vec<StatuslineSection> = tab.panes.get(&active_pane_id) // If the pane has a custom statusline (from neovim), use raw ANSI content
.and_then(|pane| pane.pty.foreground_cwd()) let statusline_content: StatuslineContent = tab.panes.get(&active_pane_id)
.map(|cwd| { .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)]; 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) { if let Some(git_section) = build_git_section(&cwd) {
sections.push(git_section); sections.push(git_section);
} }
sections StatuslineContent::Sections(sections)
} else {
StatuslineContent::Sections(Vec::new())
}
}) })
.unwrap_or_default(); .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(_) => {} Ok(_) => {}
Err(wgpu::SurfaceError::Lost) => { Err(wgpu::SurfaceError::Lost) => {
renderer.resize(renderer.width, renderer.height); renderer.resize(renderer.width, renderer.height);
+259 -2
View File
@@ -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<StatuslineSection>),
/// 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. /// Edge glow animation state for visual feedback when navigation fails.
/// Creates an organic glow effect: a single light node appears at center, /// Creates an organic glow effect: a single light node appears at center,
/// then splits into two that travel outward to the corners while fading. /// then splits into two that travel outward to the corners while fading.
@@ -1663,6 +1680,231 @@ impl Renderer {
} }
} }
/// 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. /// Returns the Y offset where the terminal content starts.
/// Accounts for both the tab bar and the statusline. /// Accounts for both the tab bar and the statusline.
pub fn terminal_y_offset(&self) -> f32 { pub fn terminal_y_offset(&self) -> f32 {
@@ -4601,7 +4843,7 @@ impl Renderer {
/// - `active_tab`: Index of the active tab /// - `active_tab`: Index of the active tab
/// - `edge_glows`: Active edge glow animations for visual feedback /// - `edge_glows`: Active edge glow animations for visual feedback
/// - `edge_glow_intensity`: Intensity of edge glow effect (0.0 = disabled, 1.0 = full) /// - `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( pub fn render_panes(
&mut self, &mut self,
panes: &[(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)], panes: &[(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)],
@@ -4609,7 +4851,7 @@ impl Renderer {
active_tab: usize, active_tab: usize,
edge_glows: &[EdgeGlow], edge_glows: &[EdgeGlow],
edge_glow_intensity: f32, edge_glow_intensity: f32,
statusline_sections: &[StatuslineSection], statusline_content: &StatuslineContent,
) -> Result<(), wgpu::SurfaceError> { ) -> Result<(), wgpu::SurfaceError> {
// Sync palette from first terminal // Sync palette from first terminal
if let Some((terminal, _, _)) = panes.first() { if let Some((terminal, _, _)) = panes.first() {
@@ -4809,6 +5051,19 @@ impl Renderer {
} }
}; };
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() { for (section_idx, section) in statusline_sections.iter().enumerate() {
let section_start_x = cursor_x; let section_start_x = cursor_x;
@@ -4954,6 +5209,8 @@ impl Renderer {
} }
} }
} }
}
}
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// RENDER PANE BORDERS (only between adjacent panes) // RENDER PANE BORDERS (only between adjacent panes)
+36
View File
@@ -11,6 +11,10 @@ pub enum TerminalCommand {
/// Navigate to a neighboring pane in the given direction. /// Navigate to a neighboring pane in the given direction.
/// Triggered by OSC 51;navigate;<direction> ST /// Triggered by OSC 51;navigate;<direction> ST
NavigatePane(Direction), NavigatePane(Direction),
/// Set custom statusline content for this pane.
/// Triggered by OSC 51;statusline;<content> ST
/// Empty content clears the statusline (restores default).
SetStatusline(Option<String>),
} }
/// Direction for pane navigation. /// Direction for pane navigation.
@@ -1382,6 +1386,7 @@ impl Handler for Terminal {
// Format: OSC 51;command;args ST // Format: OSC 51;command;args ST
// Currently supported: // Currently supported:
// OSC 51;navigate;up/down/left/right ST - Navigate to neighboring pane // OSC 51;navigate;up/down/left/right ST - Navigate to neighboring pane
// OSC 51;statusline;<content> ST - Set custom statusline (empty to clear)
51 => { 51 => {
if parts.len() >= 2 { if parts.len() >= 2 {
if let Ok(command) = std::str::from_utf8(parts[1]) { if let Ok(command) = std::str::from_utf8(parts[1]) {
@@ -1403,6 +1408,37 @@ impl Handler for Terminal {
} }
} }
} }
"statusline" => {
// OSC 51;statusline;<content> 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); log::debug!("OSC 51: Unknown command '{}'", command);
} }