powerline but better
This commit is contained in:
@@ -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"
|
||||
|
||||
+46
-11
@@ -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<String>,
|
||||
}
|
||||
|
||||
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<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
|
||||
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()
|
||||
});
|
||||
|
||||
// Get the cwd from the active pane for the statusline
|
||||
let statusline_sections: Vec<StatuslineSection> = tab.panes.get(&active_pane_id)
|
||||
.and_then(|pane| pane.pty.foreground_cwd())
|
||||
.map(|cwd| {
|
||||
// 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)];
|
||||
// Add git section if in a git repository
|
||||
if let Some(git_section) = build_git_section(&cwd) {
|
||||
sections.push(git_section);
|
||||
}
|
||||
sections
|
||||
StatuslineContent::Sections(sections)
|
||||
} else {
|
||||
StatuslineContent::Sections(Vec::new())
|
||||
}
|
||||
})
|
||||
.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);
|
||||
|
||||
+259
-2
@@ -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.
|
||||
/// Creates an organic glow effect: a single light node appears at center,
|
||||
/// 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.
|
||||
/// Accounts for both the tab bar and the statusline.
|
||||
pub fn terminal_y_offset(&self) -> f32 {
|
||||
@@ -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,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() {
|
||||
let section_start_x = cursor_x;
|
||||
|
||||
@@ -4954,6 +5209,8 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// RENDER PANE BORDERS (only between adjacent panes)
|
||||
|
||||
@@ -11,6 +11,10 @@ pub enum TerminalCommand {
|
||||
/// Navigate to a neighboring pane in the given direction.
|
||||
/// Triggered by OSC 51;navigate;<direction> ST
|
||||
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.
|
||||
@@ -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;<content> 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;<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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user