diff --git a/src/config.rs b/src/config.rs index a96d4b7..c9a869d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -367,6 +367,11 @@ pub struct Config { pub inactive_pane_fade_ms: u64, /// Dim factor for inactive panes (0.0 = fully dimmed/black, 1.0 = no dimming). pub inactive_pane_dim: f32, + /// Process names that should receive pane navigation keys instead of zterm handling them. + /// When the foreground process matches one of these names, Alt+Arrow keys are passed + /// to the application (e.g., for Neovim buffer navigation) instead of switching panes. + /// Example: ["nvim", "vim", "helix"] + pub pass_keys_to_programs: Vec, /// Keybindings. pub keybindings: Keybindings, } @@ -380,6 +385,7 @@ impl Default for Config { scrollback_lines: 50_000, inactive_pane_fade_ms: 150, inactive_pane_dim: 0.6, + pass_keys_to_programs: vec!["nvim".to_string(), "vim".to_string()], keybindings: Keybindings::default(), } } diff --git a/src/main.rs b/src/main.rs index fc48b8c..119a8ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,8 @@ use zterm::config::{Action, Config}; use zterm::keyboard::{FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Modifiers}; use zterm::pty::Pty; -use zterm::renderer::{PaneRenderInfo, Renderer}; -use zterm::terminal::{Terminal, MouseTrackingMode}; +use zterm::renderer::{EdgeGlow, PaneRenderInfo, Renderer}; +use zterm::terminal::{Direction, Terminal, TerminalCommand, MouseTrackingMode}; use std::collections::HashMap; use std::io::Write; @@ -199,6 +199,19 @@ impl Pane { self.pty.child_exited() } + /// Check if the foreground process matches any of the given program names. + /// Used for pass-through keybindings (e.g., passing Alt+Arrow to Neovim). + fn foreground_matches(&self, programs: &[String]) -> bool { + if programs.is_empty() { + return false; + } + if let Some(fg_name) = self.pty.foreground_process_name() { + programs.iter().any(|p| p == &fg_name) + } else { + false + } + } + /// Calculate the current dim factor based on animation progress. /// Returns a value between `inactive_dim` (for unfocused) and 1.0 (for focused). fn calculate_dim_factor(&mut self, is_focused: bool, fade_duration_ms: u64, inactive_dim: f32) -> f32 { @@ -508,15 +521,6 @@ impl SplitNode { } } -/// Direction for pane navigation. -#[derive(Debug, Clone, Copy)] -enum Direction { - Up, - Down, - Left, - Right, -} - /// Unique identifier for a tab. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] struct TabId(u64); @@ -812,6 +816,8 @@ struct App { last_frame_log: std::time::Instant, /// Whether window should be created on next opportunity. should_create_window: bool, + /// Edge glow animation state (for when navigation fails). + edge_glow: Option, } const PTY_KEY: usize = 1; @@ -842,6 +848,7 @@ impl App { frame_count: 0, last_frame_log: std::time::Instant::now(), should_create_window: false, + edge_glow: None, } } @@ -1045,7 +1052,10 @@ impl App { /// Process PTY data for a specific pane. /// Returns true if any data was processed. fn poll_pane(&mut self, pane_id: PaneId) -> bool { - // Find the pane across all tabs + // Find the pane across all tabs and process data + let mut processed = false; + let mut commands = Vec::new(); + for tab in &mut self.tabs { if let Some(pane) = tab.get_pane_mut(pane_id) { // Take all pending data atomically @@ -1066,10 +1076,29 @@ impl App { len); } - return true; + // Collect any commands from the terminal + commands = pane.terminal.take_commands(); + processed = true; + break; + } + } + + // Handle commands outside the borrow + for cmd in commands { + self.handle_terminal_command(cmd); + } + + processed + } + + /// Handle a command from the terminal (triggered by OSC sequences). + fn handle_terminal_command(&mut self, cmd: TerminalCommand) { + match cmd { + TerminalCommand::NavigatePane(direction) => { + log::debug!("Terminal requested pane navigation: {:?}", direction); + self.focus_pane(direction); } } - false } /// Send bytes to the active tab's PTY. @@ -1259,16 +1288,16 @@ impl App { self.split_pane(false); } Action::FocusPaneUp => { - self.focus_pane(Direction::Up); + self.focus_pane_or_pass_key(Direction::Up, b'A'); } Action::FocusPaneDown => { - self.focus_pane(Direction::Down); + self.focus_pane_or_pass_key(Direction::Down, b'B'); } Action::FocusPaneLeft => { - self.focus_pane(Direction::Left); + self.focus_pane_or_pass_key(Direction::Left, b'D'); } Action::FocusPaneRight => { - self.focus_pane(Direction::Right); + self.focus_pane_or_pass_key(Direction::Right, b'C'); } } } @@ -1314,12 +1343,46 @@ impl App { } } - fn focus_pane(&mut self, direction: Direction) { - if let Some(tab) = self.tabs.get_mut(self.active_tab) { - tab.focus_neighbor(direction); - if let Some(window) = &self.window { - window.request_redraw(); + /// Focus neighbor pane or pass keys through to applications like Neovim. + /// If the foreground process matches `pass_keys_to_programs`, send the Alt+Arrow + /// escape sequence to the PTY. Otherwise, focus the neighboring pane. + fn focus_pane_or_pass_key(&mut self, direction: Direction, arrow_letter: u8) { + // Check if we should pass keys to the foreground process + let should_pass = if let Some(tab) = self.tabs.get(self.active_tab) { + if let Some(pane) = tab.active_pane() { + pane.foreground_matches(&self.config.pass_keys_to_programs) + } else { + false } + } else { + false + }; + + if should_pass { + // Send Alt+Arrow escape sequence: \x1b[1;3X where X is A/B/C/D + let escape_seq = [0x1b, b'[', b'1', b';', b'3', arrow_letter]; + self.write_to_pty(&escape_seq); + } else { + self.focus_pane(direction); + } + } + + fn focus_pane(&mut self, direction: Direction) { + let navigated = if let Some(tab) = self.tabs.get_mut(self.active_tab) { + let old_pane = tab.active_pane; + tab.focus_neighbor(direction); + tab.active_pane != old_pane + } else { + false + }; + + if !navigated { + // No neighbor in that direction - trigger edge glow animation + self.edge_glow = Some(EdgeGlow::new(direction)); + } + + if let Some(window) = &self.window { + window.request_redraw(); } } @@ -1851,7 +1914,11 @@ impl ApplicationHandler for App { } } - match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx) { + // Handle edge glow animation + let edge_glow_ref = self.edge_glow.as_ref(); + let glow_in_progress = edge_glow_ref.map(|g| !g.is_finished()).unwrap_or(false); + + match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx, edge_glow_ref) { Ok(_) => {} Err(wgpu::SurfaceError::Lost) => { renderer.resize(renderer.width, renderer.height); @@ -1864,8 +1931,21 @@ impl ApplicationHandler for App { log::error!("Render error: {:?}", e); } } + + // Request redraw if edge glow is animating + if glow_in_progress { + if let Some(window) = &self.window { + window.request_redraw(); + } + } } } + + // Clean up finished edge glow animation + if self.edge_glow.as_ref().map(|g| g.is_finished()).unwrap_or(false) { + self.edge_glow = None; + } + let render_time = render_start.elapsed(); let frame_time = frame_start.elapsed(); diff --git a/src/pty.rs b/src/pty.rs index e3bd9f5..ff1585f 100644 --- a/src/pty.rs +++ b/src/pty.rs @@ -188,6 +188,31 @@ impl Pty { // If it returns -1, there was an error (child might have already been reaped) result != 0 } + + /// Get the foreground process group ID of this PTY. + /// Returns None if the query fails. + pub fn foreground_pgid(&self) -> Option { + let fd = self.master.as_raw_fd(); + let pgid = unsafe { libc::tcgetpgrp(fd) }; + if pgid > 0 { + Some(pgid) + } else { + None + } + } + + /// Get the name of the foreground process running in this PTY. + /// Returns the process name (e.g., "nvim", "zsh") or None if unavailable. + pub fn foreground_process_name(&self) -> Option { + let pgid = self.foreground_pgid()?; + + // Read the command line from /proc//comm + // (comm gives just the process name, cmdline gives full command) + let comm_path = format!("/proc/{}/comm", pgid); + std::fs::read_to_string(&comm_path) + .ok() + .map(|s| s.trim().to_string()) + } } impl AsRawFd for Pty { diff --git a/src/renderer.rs b/src/renderer.rs index 0b12fea..09c46d0 100644 --- a/src/renderer.rs +++ b/src/renderer.rs @@ -2,7 +2,7 @@ //! Uses rustybuzz (HarfBuzz port) for text shaping to support font features. use crate::config::TabBarPosition; -use crate::terminal::{Color, ColorPalette, CursorShape, Terminal}; +use crate::terminal::{Color, ColorPalette, CursorShape, Direction, Terminal}; use fontdue::Font as FontdueFont; use rustybuzz::UnicodeBuffer; use ttf_parser::Tag; @@ -37,6 +37,43 @@ pub struct PaneRenderInfo { pub dim_factor: f32, } +/// 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. +/// Animation logic is handled in the shader (shader.wgsl). +#[derive(Debug, Clone, Copy)] +pub struct EdgeGlow { + /// Which edge to glow (based on the direction the user tried to navigate). + pub direction: Direction, + /// When the animation started. + pub start_time: std::time::Instant, +} + +impl EdgeGlow { + /// Duration of the glow animation in milliseconds. + pub const DURATION_MS: u64 = 500; + + /// Create a new edge glow animation. + pub fn new(direction: Direction) -> Self { + Self { + direction, + start_time: std::time::Instant::now(), + } + } + + /// Get the current animation progress (0.0 to 1.0). + pub fn progress(&self) -> f32 { + let elapsed = self.start_time.elapsed().as_millis() as f32; + let duration = Self::DURATION_MS as f32; + (elapsed / duration).min(1.0) + } + + /// Check if the animation has completed. + pub fn is_finished(&self) -> bool { + self.progress() >= 1.0 + } +} + /// Size of the glyph atlas texture. const ATLAS_SIZE: u32 = 1024; @@ -96,6 +133,25 @@ impl GlyphVertex { } } +/// GPU-compatible edge glow uniform data. +/// Must match the layout in shader.wgsl exactly. +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +struct EdgeGlowUniforms { + screen_width: f32, + screen_height: f32, + terminal_y_offset: f32, + direction: u32, + progress: f32, + color_r: f32, + color_g: f32, + color_b: f32, + enabled: u32, + _padding1: u32, + _padding2: u32, + _padding3: u32, +} + /// The terminal renderer. pub struct Renderer { surface: wgpu::Surface<'static>, @@ -106,6 +162,11 @@ pub struct Renderer { // Glyph rendering pipeline glyph_pipeline: wgpu::RenderPipeline, glyph_bind_group: wgpu::BindGroup, + + // Edge glow rendering pipeline + edge_glow_pipeline: wgpu::RenderPipeline, + edge_glow_bind_group: wgpu::BindGroup, + edge_glow_uniform_buffer: wgpu::Buffer, // Atlas texture atlas_texture: wgpu::Texture, @@ -873,6 +934,96 @@ impl Renderer { cache: None, }); + // ═══════════════════════════════════════════════════════════════════════════════ + // EDGE GLOW PIPELINE SETUP + // ═══════════════════════════════════════════════════════════════════════════════ + + // Create edge glow shader + let edge_glow_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Edge Glow Shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()), + }); + + // Create uniform buffer for edge glow parameters + let edge_glow_uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("Edge Glow Uniform Buffer"), + size: std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + // Create bind group layout for edge glow + let edge_glow_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("Edge Glow Bind Group Layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + + // Create bind group for edge glow + let edge_glow_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Edge Glow Bind Group"), + layout: &edge_glow_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: edge_glow_uniform_buffer.as_entire_binding(), + }, + ], + }); + + // Create pipeline layout for edge glow + let edge_glow_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("Edge Glow Pipeline Layout"), + bind_group_layouts: &[&edge_glow_bind_group_layout], + push_constant_ranges: &[], + }); + + // Create edge glow render pipeline + let edge_glow_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("Edge Glow Pipeline"), + layout: Some(&edge_glow_pipeline_layout), + vertex: wgpu::VertexState { + module: &edge_glow_shader, + entry_point: Some("vs_main"), + buffers: &[], // Fullscreen triangle, no vertex buffer needed + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &edge_glow_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: surface_config.format, + // Premultiplied alpha blending for proper glow compositing + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview: None, + cache: None, + }); + // Create initial buffers with some capacity let initial_vertex_capacity = 4096; let initial_index_capacity = 6144; @@ -898,6 +1049,9 @@ impl Renderer { surface_config, glyph_pipeline, glyph_bind_group, + edge_glow_pipeline, + edge_glow_bind_group, + edge_glow_uniform_buffer, atlas_texture, atlas_data: vec![0u8; (ATLAS_SIZE * ATLAS_SIZE) as usize], atlas_dirty: false, @@ -3271,17 +3425,51 @@ impl Renderer { ]); } + /// Prepare edge glow uniform data for shader-based rendering. + /// Returns the uniform data to be uploaded to the GPU. + fn prepare_edge_glow_uniforms(&self, glow: &EdgeGlow, terminal_y_offset: f32) -> EdgeGlowUniforms { + // Use the same color as the active pane border (palette color 4 - typically blue) + let [r, g, b] = self.palette.colors[4]; + let color_r = Self::srgb_to_linear(r as f32 / 255.0); + let color_g = Self::srgb_to_linear(g as f32 / 255.0); + let color_b = Self::srgb_to_linear(b as f32 / 255.0); + + let direction = match glow.direction { + Direction::Up => 0, + Direction::Down => 1, + Direction::Left => 2, + Direction::Right => 3, + }; + + EdgeGlowUniforms { + screen_width: self.width as f32, + screen_height: self.height as f32, + terminal_y_offset, + direction, + progress: glow.progress(), + color_r, + color_g, + color_b, + enabled: 1, + _padding1: 0, + _padding2: 0, + _padding3: 0, + } + } + /// Render multiple panes with borders. /// /// Arguments: /// - `panes`: List of (terminal, pane_info, selection) tuples /// - `num_tabs`: Number of tabs for the tab bar /// - `active_tab`: Index of the active tab + /// - `edge_glow`: Optional edge glow animation for visual feedback pub fn render_panes( &mut self, panes: &[(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)], num_tabs: usize, active_tab: usize, + edge_glow: Option<&EdgeGlow>, ) -> Result<(), wgpu::SurfaceError> { // Sync palette from first terminal if let Some((terminal, _, _)) = panes.first() { @@ -3560,6 +3748,15 @@ impl Renderer { } } + // ═══════════════════════════════════════════════════════════════════ + // PREPARE EDGE GLOW UNIFORMS (if navigation failed) + // ═══════════════════════════════════════════════════════════════════ + let edge_glow_uniforms = if let Some(glow) = edge_glow { + Some(self.prepare_edge_glow_uniforms(glow, terminal_y_offset)) + } else { + None + }; + // ═══════════════════════════════════════════════════════════════════ // SUBMIT TO GPU // ═══════════════════════════════════════════════════════════════════ @@ -3698,6 +3895,38 @@ impl Renderer { render_pass.draw_indexed(0..total_index_count as u32, 0, 0..1); } + // ═══════════════════════════════════════════════════════════════════ + // EDGE GLOW PASS (shader-based, after main rendering) + // ═══════════════════════════════════════════════════════════════════ + if let Some(uniforms) = edge_glow_uniforms { + // Upload uniforms + self.queue.write_buffer( + &self.edge_glow_uniform_buffer, + 0, + bytemuck::cast_slice(&[uniforms]), + ); + + // Second render pass for edge glow (load existing content) + let mut glow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Edge Glow Pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, // Preserve existing content + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + }); + + glow_pass.set_pipeline(&self.edge_glow_pipeline); + glow_pass.set_bind_group(0, &self.edge_glow_bind_group, &[]); + glow_pass.draw(0..3, 0..1); // Fullscreen triangle + } + self.queue.submit(std::iter::once(encoder.finish())); output.present(); diff --git a/src/shader.wgsl b/src/shader.wgsl index 6682c47..991850c 100644 --- a/src/shader.wgsl +++ b/src/shader.wgsl @@ -1,26 +1,202 @@ -// Vertex shader +// Edge Glow Shader +// Renders a soft glow effect at terminal edges for failed pane navigation feedback. +// The glow appears as a light node at center that splits into two and travels to corners. -struct VertexInput { - @location(0) position: vec2, - @location(1) color: vec4, +// Uniform buffer with glow parameters +struct EdgeGlowParams { + // Screen dimensions in pixels + screen_width: f32, + screen_height: f32, + // Terminal area offset (for tab bar) + terminal_y_offset: f32, + // Direction: 0=Up, 1=Down, 2=Left, 3=Right + direction: u32, + // Animation progress (0.0 to 1.0) + progress: f32, + // Glow color (linear RGB) - stored as separate floats to avoid vec3 alignment issues + color_r: f32, + color_g: f32, + color_b: f32, + // Whether glow is enabled (1 = yes, 0 = no) + enabled: u32, + // Padding to align to 16 bytes + _padding1: u32, + _padding2: u32, + _padding3: u32, } +@group(0) @binding(0) +var params: EdgeGlowParams; + struct VertexOutput { @builtin(position) clip_position: vec4, - @location(0) color: vec4, + @location(0) uv: vec2, // 0-1 normalized screen coordinates } +// Fullscreen triangle vertex shader +// Uses vertex_index 0,1,2 to create a triangle that covers the screen @vertex -fn vs_main(in: VertexInput) -> VertexOutput { +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { var out: VertexOutput; - out.clip_position = vec4(in.position, 0.0, 1.0); - out.color = in.color; + + // Generate fullscreen triangle vertices + // This creates a triangle that covers [-1,1] in clip space + let x = f32(i32(vertex_index) - 1); + let y = f32(i32(vertex_index & 1u) * 2 - 1); + + // Positions for a fullscreen triangle + var pos: vec2; + switch vertex_index { + case 0u: { pos = vec2(-1.0, -1.0); } + case 1u: { pos = vec2(3.0, -1.0); } + case 2u: { pos = vec2(-1.0, 3.0); } + default: { pos = vec2(0.0, 0.0); } + } + + out.clip_position = vec4(pos, 0.0, 1.0); + // Convert to 0-1 UV (flip Y since clip space Y is up, pixel Y is down) + out.uv = vec2((pos.x + 1.0) * 0.5, (1.0 - pos.y) * 0.5); + return out; } -// Fragment shader +// Constants +const PI: f32 = 3.14159265359; +const PHASE1_END: f32 = 0.15; // Phase 1 ends at 15% progress +const GLOW_RADIUS: f32 = 90.0; // Base radius of glow +const GLOW_ASPECT: f32 = 2.0; // Stretch factor along edge (ellipse) + +// Smooth gaussian-like falloff +fn glow_falloff(dist: f32, radius: f32) -> f32 { + let normalized = dist / radius; + if normalized > 1.0 { + return 0.0; + } + // Smooth falloff: (1 - x^2)^3 gives nice soft edges + let t = 1.0 - normalized * normalized; + return t * t * t; +} + +// Ease-out cubic +fn ease_out_cubic(t: f32) -> f32 { + let t1 = 1.0 - t; + return 1.0 - t1 * t1 * t1; +} + +// Calculate distance from point to glow center, accounting for ellipse shape +fn ellipse_distance(point: vec2, center: vec2, radius_along: f32, radius_perp: f32, is_horizontal: bool) -> f32 { + let delta = point - center; + var normalized: vec2; + if is_horizontal { + normalized = vec2(delta.x / radius_along, delta.y / radius_perp); + } else { + normalized = vec2(delta.x / radius_perp, delta.y / radius_along); + } + return length(normalized) * min(radius_along, radius_perp); +} @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - return in.color; + // Early out if not enabled + if params.enabled == 0u { + return vec4(0.0, 0.0, 0.0, 0.0); + } + + let progress = params.progress; + + // Convert UV to pixel coordinates + let pixel = vec2( + in.uv.x * params.screen_width, + in.uv.y * params.screen_height + ); + + let terminal_height = params.screen_height - params.terminal_y_offset; + let is_horizontal = params.direction == 0u || params.direction == 1u; + + // Calculate glow parameters based on animation phase + var alpha: f32; + var size_factor: f32; + var split: f32; + + if progress < PHASE1_END { + // Phase 1: Fade in, grow + let t = progress / PHASE1_END; + let ease = ease_out_cubic(t); + alpha = ease * 0.8; + size_factor = 0.3 + 0.7 * ease; + split = 0.0; + } else { + // Phase 2: Split and fade out + let t = (progress - PHASE1_END) / (1.0 - PHASE1_END); + let fade = 1.0 - t; + alpha = fade * fade * 0.8; + size_factor = 1.0 - 0.3 * t; + split = ease_out_cubic(t); + } + + let base_radius = GLOW_RADIUS * size_factor; + let radius_along = base_radius * GLOW_ASPECT; + let radius_perp = base_radius; + + // Calculate edge center and travel distance based on direction + var edge_center: vec2; + var travel: vec2; + + switch params.direction { + // Up - top edge + case 0u: { + edge_center = vec2(params.screen_width / 2.0, params.terminal_y_offset); + travel = vec2(params.screen_width / 2.0, 0.0); + } + // Down - bottom edge + case 1u: { + edge_center = vec2(params.screen_width / 2.0, params.screen_height); + travel = vec2(params.screen_width / 2.0, 0.0); + } + // Left - left edge + case 2u: { + edge_center = vec2(0.0, params.terminal_y_offset + terminal_height / 2.0); + travel = vec2(0.0, terminal_height / 2.0); + } + // Right - right edge + case 3u: { + edge_center = vec2(params.screen_width, params.terminal_y_offset + terminal_height / 2.0); + travel = vec2(0.0, terminal_height / 2.0); + } + default: { + edge_center = vec2(0.0, 0.0); + travel = vec2(0.0, 0.0); + } + } + + var glow_intensity: f32 = 0.0; + + if split < 0.01 { + // Single glow at center + let dist = ellipse_distance(pixel, edge_center, radius_along, radius_perp, is_horizontal); + glow_intensity = glow_falloff(dist, base_radius); + } else { + // Two glows splitting apart + let split_radius = base_radius * (1.0 - 0.2 * split); + let split_radius_along = radius_along * (1.0 - 0.2 * split); + let split_radius_perp = radius_perp * (1.0 - 0.2 * split); + + let center1 = edge_center - travel * split; + let center2 = edge_center + travel * split; + + let dist1 = ellipse_distance(pixel, center1, split_radius_along, split_radius_perp, is_horizontal); + let dist2 = ellipse_distance(pixel, center2, split_radius_along, split_radius_perp, is_horizontal); + + // Combine both glows (additive but capped) + let glow1 = glow_falloff(dist1, split_radius); + let glow2 = glow_falloff(dist2, split_radius); + glow_intensity = min(glow1 + glow2, 1.0); + } + + // Apply alpha + let final_alpha = glow_intensity * alpha; + + // Output with premultiplied alpha for proper blending + let color = vec3(params.color_r, params.color_g, params.color_b); + return vec4(color * final_alpha, final_alpha); } diff --git a/src/terminal.rs b/src/terminal.rs index 82df719..5181328 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -3,6 +3,24 @@ use crate::keyboard::{query_response, KeyboardState}; use crate::vt_parser::{CsiParams, Handler, Parser}; +/// Commands that the terminal can send to the application. +/// These are triggered by special escape sequences from programs like Neovim. +#[derive(Clone, Debug, PartialEq)] +pub enum TerminalCommand { + /// Navigate to a neighboring pane in the given direction. + /// Triggered by OSC 51;navigate; ST + NavigatePane(Direction), +} + +/// Direction for pane navigation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Direction { + Up, + Down, + Left, + Right, +} + /// A single cell in the terminal grid. #[derive(Clone, Copy, Debug)] pub struct Cell { @@ -467,6 +485,9 @@ pub struct Terminal { parser: Option, /// Performance timing stats (for debugging). pub stats: ProcessingStats, + /// Command queue for terminal-to-application communication. + /// Commands are added by OSC handlers and consumed by the application. + command_queue: Vec, } impl Terminal { @@ -523,6 +544,7 @@ impl Terminal { line_pool, parser: Some(Parser::new()), stats: ProcessingStats::default(), + command_queue: Vec::new(), } } @@ -570,6 +592,13 @@ impl Terminal { self.dirty_lines = [0u64; 4]; } + /// Take all pending commands from the queue. + /// Returns an empty Vec if no commands are pending. + #[inline] + pub fn take_commands(&mut self) -> Vec { + std::mem::take(&mut self.command_queue) + } + /// Get the dirty lines bitmap (for passing to shm). #[inline] pub fn get_dirty_lines(&self) -> u64 { @@ -1336,6 +1365,38 @@ impl Handler for Terminal { } } } + // OSC 51 - ZTerm custom commands + // Format: OSC 51;command;args ST + // Currently supported: + // OSC 51;navigate;up/down/left/right ST - Navigate to neighboring pane + 51 => { + if parts.len() >= 2 { + if let Ok(command) = std::str::from_utf8(parts[1]) { + match command { + "navigate" => { + if parts.len() >= 3 { + if let Ok(direction_str) = std::str::from_utf8(parts[2]) { + let direction = match direction_str { + "up" => Some(Direction::Up), + "down" => Some(Direction::Down), + "left" => Some(Direction::Left), + "right" => Some(Direction::Right), + _ => None, + }; + if let Some(dir) = direction { + log::debug!("OSC 51: Navigate {:?}", dir); + self.command_queue.push(TerminalCommand::NavigatePane(dir)); + } + } + } + } + _ => { + log::debug!("OSC 51: Unknown command '{}'", command); + } + } + } + } + } _ => { log::debug!("Unhandled OSC {}", osc_num); }