~animations~

This commit is contained in:
Zacharias-Brohn
2025-12-15 23:31:42 +01:00
parent 567c403912
commit f304fd18a8
6 changed files with 612 additions and 35 deletions
+6
View File
@@ -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<String>,
/// 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(),
}
}
+101 -21
View File
@@ -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<EdgeGlow>,
}
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,14 +1343,48 @@ impl App {
}
}
/// 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) {
if let Some(tab) = self.tabs.get_mut(self.active_tab) {
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();
}
}
}
fn close_active_pane(&mut self) {
let should_close_tab = if let Some(tab) = self.tabs.get_mut(self.active_tab) {
@@ -1851,7 +1914,11 @@ impl ApplicationHandler<UserEvent> 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<UserEvent> 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();
+25
View File
@@ -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<i32> {
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<String> {
let pgid = self.foreground_pgid()?;
// Read the command line from /proc/<pid>/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 {
+230 -1
View File
@@ -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>,
@@ -107,6 +163,11 @@ pub struct Renderer {
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,
atlas_data: Vec<u8>,
@@ -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::<EdgeGlowUniforms>() 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();
+186 -10
View File
@@ -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<f32>,
@location(1) color: vec4<f32>,
// 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<uniform> params: EdgeGlowParams;
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) color: vec4<f32>,
@location(0) uv: vec2<f32>, // 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<f32>(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<f32>;
switch vertex_index {
case 0u: { pos = vec2<f32>(-1.0, -1.0); }
case 1u: { pos = vec2<f32>(3.0, -1.0); }
case 2u: { pos = vec2<f32>(-1.0, 3.0); }
default: { pos = vec2<f32>(0.0, 0.0); }
}
out.clip_position = vec4<f32>(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<f32>((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<f32>, center: vec2<f32>, radius_along: f32, radius_perp: f32, is_horizontal: bool) -> f32 {
let delta = point - center;
var normalized: vec2<f32>;
if is_horizontal {
normalized = vec2<f32>(delta.x / radius_along, delta.y / radius_perp);
} else {
normalized = vec2<f32>(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<f32> {
return in.color;
// Early out if not enabled
if params.enabled == 0u {
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
let progress = params.progress;
// Convert UV to pixel coordinates
let pixel = vec2<f32>(
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<f32>;
var travel: vec2<f32>;
switch params.direction {
// Up - top edge
case 0u: {
edge_center = vec2<f32>(params.screen_width / 2.0, params.terminal_y_offset);
travel = vec2<f32>(params.screen_width / 2.0, 0.0);
}
// Down - bottom edge
case 1u: {
edge_center = vec2<f32>(params.screen_width / 2.0, params.screen_height);
travel = vec2<f32>(params.screen_width / 2.0, 0.0);
}
// Left - left edge
case 2u: {
edge_center = vec2<f32>(0.0, params.terminal_y_offset + terminal_height / 2.0);
travel = vec2<f32>(0.0, terminal_height / 2.0);
}
// Right - right edge
case 3u: {
edge_center = vec2<f32>(params.screen_width, params.terminal_y_offset + terminal_height / 2.0);
travel = vec2<f32>(0.0, terminal_height / 2.0);
}
default: {
edge_center = vec2<f32>(0.0, 0.0);
travel = vec2<f32>(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<f32>(params.color_r, params.color_g, params.color_b);
return vec4<f32>(color * final_alpha, final_alpha);
}
+61
View File
@@ -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;<direction> 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<Parser>,
/// 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<TerminalCommand>,
}
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<TerminalCommand> {
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);
}