~animations~
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
+104
-24
@@ -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,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<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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user