font rendering
This commit is contained in:
+14
-1
@@ -36,7 +36,7 @@ libc = "0.2"
|
||||
bitflags = "2"
|
||||
|
||||
# Font rasterization and shaping
|
||||
fontdue = "0.9"
|
||||
ab_glyph = "0.2"
|
||||
rustybuzz = "0.20"
|
||||
ttf-parser = "0.25"
|
||||
fontconfig = "0.10"
|
||||
@@ -47,9 +47,22 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
bincode = "1"
|
||||
dirs = "6"
|
||||
notify = "7"
|
||||
|
||||
# Shared memory for fast IPC
|
||||
memmap2 = "0.9"
|
||||
|
||||
# Fast byte searching
|
||||
memchr = "2"
|
||||
|
||||
# Image processing (Kitty graphics protocol)
|
||||
image = { version = "0.25", default-features = false, features = ["png", "gif"] }
|
||||
flate2 = "1"
|
||||
|
||||
# Video decoding for WebM support (video only, no audio)
|
||||
# Requires system FFmpeg libraries (ffmpeg 5.x - 8.x supported)
|
||||
ffmpeg-next = { version = "8.0", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["webm"]
|
||||
webm = ["ffmpeg-next"]
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
//! Test ligature detection - compare individual vs combined shaping
|
||||
use rustybuzz::{Face, UnicodeBuffer, Feature};
|
||||
use ttf_parser::Tag;
|
||||
use fontdue::Font;
|
||||
use std::fs;
|
||||
|
||||
fn main() {
|
||||
let path = "/usr/share/fonts/TTF/0xProtoNerdFontMono-Regular.ttf";
|
||||
println!("Using font: {}", path);
|
||||
|
||||
let font_data = fs::read(path).expect("Failed to read font");
|
||||
let face = Face::from_slice(&font_data, 0).expect("Failed to parse font");
|
||||
let fontdue_font = Font::from_bytes(&font_data[..], fontdue::FontSettings::default()).unwrap();
|
||||
|
||||
let font_size = 16.0;
|
||||
let units_per_em = face.units_per_em() as f32;
|
||||
|
||||
println!("Font units per em: {}", units_per_em);
|
||||
|
||||
// Get cell width from a regular character
|
||||
let (hyphen_metrics, _) = fontdue_font.rasterize('-', font_size);
|
||||
let cell_width = hyphen_metrics.advance_width;
|
||||
println!("Cell width (from '-'): {:.2}px", cell_width);
|
||||
|
||||
let features = vec![
|
||||
Feature::new(Tag::from_bytes(b"liga"), 1, ..),
|
||||
Feature::new(Tag::from_bytes(b"calt"), 1, ..),
|
||||
Feature::new(Tag::from_bytes(b"dlig"), 1, ..),
|
||||
];
|
||||
|
||||
let test_strings = ["->", "=>", "==", "!=", ">=", "<="];
|
||||
|
||||
for s in &test_strings {
|
||||
// Shape combined string
|
||||
let mut buffer = UnicodeBuffer::new();
|
||||
buffer.push_str(s);
|
||||
let combined = rustybuzz::shape(&face, &features, buffer);
|
||||
let combined_infos = combined.glyph_infos();
|
||||
let combined_positions = combined.glyph_positions();
|
||||
|
||||
// Shape each character individually
|
||||
let mut individual_glyphs = Vec::new();
|
||||
for c in s.chars() {
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.push_str(&c.to_string());
|
||||
let shaped = rustybuzz::shape(&face, &features, buf);
|
||||
individual_glyphs.push(shaped.glyph_infos()[0].glyph_id);
|
||||
}
|
||||
|
||||
println!("\n'{}' analysis:", s);
|
||||
println!(" Combined glyphs: {:?}", combined_infos.iter().map(|i| i.glyph_id).collect::<Vec<_>>());
|
||||
println!(" Individual glyphs: {:?}", individual_glyphs);
|
||||
|
||||
// Show advances for each glyph
|
||||
for (i, (info, pos)) in combined_infos.iter().zip(combined_positions.iter()).enumerate() {
|
||||
let advance_px = pos.x_advance as f32 * font_size / units_per_em;
|
||||
println!(" Glyph {}: id={}, advance={} units ({:.2}px)", i, info.glyph_id, pos.x_advance, advance_px);
|
||||
|
||||
// Rasterize and show metrics
|
||||
let (metrics, _) = fontdue_font.rasterize_indexed(info.glyph_id as u16, font_size);
|
||||
println!(" Rasterized: {}x{} px, xmin={}, ymin={}, advance_width={:.2}",
|
||||
metrics.width, metrics.height, metrics.xmin, metrics.ymin, metrics.advance_width);
|
||||
}
|
||||
|
||||
// Check if any glyph was substituted
|
||||
let has_substitution = combined_infos.iter().zip(individual_glyphs.iter())
|
||||
.any(|(combined, &individual)| combined.glyph_id != individual);
|
||||
println!(" Has substitution: {}", has_substitution);
|
||||
}
|
||||
|
||||
// Also test what Kitty does - check glyph names
|
||||
println!("\n=== Checking glyph names via ttf-parser ===");
|
||||
let ttf_face = ttf_parser::Face::parse(&font_data, 0).unwrap();
|
||||
|
||||
// Shape "->" and get glyph names
|
||||
let mut buffer = UnicodeBuffer::new();
|
||||
buffer.push_str("->");
|
||||
let combined = rustybuzz::shape(&face, &features, buffer);
|
||||
for info in combined.glyph_infos() {
|
||||
let glyph_id = ttf_parser::GlyphId(info.glyph_id as u16);
|
||||
if let Some(name) = ttf_face.glyph_name(glyph_id) {
|
||||
println!(" Glyph {} name: {}", info.glyph_id, name);
|
||||
} else {
|
||||
println!(" Glyph {} has no name", info.glyph_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -367,6 +367,9 @@ 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,
|
||||
/// Intensity of the edge glow effect when pane navigation fails (0.0 = disabled, 1.0 = full intensity).
|
||||
/// The edge glow provides visual feedback when you try to navigate to a pane that doesn't exist.
|
||||
pub edge_glow_intensity: 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.
|
||||
@@ -385,6 +388,7 @@ impl Default for Config {
|
||||
scrollback_lines: 50_000,
|
||||
inactive_pane_fade_ms: 150,
|
||||
inactive_pane_dim: 0.6,
|
||||
edge_glow_intensity: 1.0,
|
||||
pass_keys_to_programs: vec!["nvim".to_string(), "vim".to_string()],
|
||||
keybindings: Keybindings::default(),
|
||||
}
|
||||
|
||||
+71
-11
@@ -1,5 +1,63 @@
|
||||
// Glyph rendering shader for terminal emulator
|
||||
// Supports both legacy quad-based rendering and new instanced cell rendering
|
||||
// Uses Kitty-style "legacy" gamma-incorrect text blending for crisp rendering
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// GAMMA CONVERSION FUNCTIONS (for legacy text rendering)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Luminance weights for perceived brightness (ITU-R BT.709)
|
||||
const Y: vec3<f32> = vec3<f32>(0.2126, 0.7152, 0.0722);
|
||||
|
||||
// Convert linear RGB to sRGB
|
||||
fn linear2srgb(x: f32) -> f32 {
|
||||
if x <= 0.0031308 {
|
||||
return 12.92 * x;
|
||||
} else {
|
||||
return 1.055 * pow(x, 1.0 / 2.4) - 0.055;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert sRGB to linear RGB
|
||||
fn srgb2linear(x: f32) -> f32 {
|
||||
if x <= 0.04045 {
|
||||
return x / 12.92;
|
||||
} else {
|
||||
return pow((x + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
}
|
||||
|
||||
// Kitty's legacy gamma-incorrect text blending
|
||||
// This simulates how text was blended before gamma-correct rendering became standard.
|
||||
// It makes dark text on light backgrounds appear thicker and light text on dark
|
||||
// backgrounds appear thinner, which many users prefer for readability.
|
||||
//
|
||||
// The input colors are in sRGB space. We convert to linear for the luminance
|
||||
// calculation, then simulate gamma-incorrect blending.
|
||||
fn foreground_contrast_legacy(over_srgb: vec3<f32>, over_alpha: f32, under_srgb: vec3<f32>) -> f32 {
|
||||
// Convert sRGB colors to linear for luminance calculation
|
||||
let over_linear = vec3<f32>(srgb2linear(over_srgb.r), srgb2linear(over_srgb.g), srgb2linear(over_srgb.b));
|
||||
let under_linear = vec3<f32>(srgb2linear(under_srgb.r), srgb2linear(under_srgb.g), srgb2linear(under_srgb.b));
|
||||
|
||||
let under_luminance = dot(under_linear, Y);
|
||||
let over_luminance = dot(over_linear, Y);
|
||||
|
||||
// Avoid division by zero when luminances are equal
|
||||
let luminance_diff = over_luminance - under_luminance;
|
||||
if abs(luminance_diff) < 0.001 {
|
||||
return over_alpha;
|
||||
}
|
||||
|
||||
// Kitty's formula: simulate gamma-incorrect blending
|
||||
// This is the solution to:
|
||||
// linear2srgb(over * alpha2 + under * (1 - alpha2)) = linear2srgb(over) * alpha + linear2srgb(under) * (1 - alpha)
|
||||
// ^ gamma correct blending with new alpha ^ gamma incorrect blending with old alpha
|
||||
let blended_srgb = linear2srgb(over_luminance) * over_alpha + linear2srgb(under_luminance) * (1.0 - over_alpha);
|
||||
let blended_linear = srgb2linear(blended_srgb);
|
||||
let new_alpha = (blended_linear - under_luminance) / luminance_diff;
|
||||
|
||||
return clamp(new_alpha, 0.0, 1.0);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// LEGACY QUAD-BASED RENDERING (for backwards compatibility)
|
||||
@@ -47,9 +105,11 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Sample the glyph alpha from the atlas
|
||||
let glyph_alpha = textureSample(atlas_texture, atlas_sampler, in.uv).r;
|
||||
|
||||
// Output foreground color with glyph alpha for blending
|
||||
// The background was already rendered, so we just blend the glyph on top
|
||||
return vec4<f32>(in.color.rgb, in.color.a * glyph_alpha);
|
||||
// Apply legacy gamma-incorrect blending for crisp text
|
||||
let adjusted_alpha = foreground_contrast_legacy(in.color.rgb, glyph_alpha, in.bg_color.rgb);
|
||||
|
||||
// Output foreground color with adjusted alpha for blending
|
||||
return vec4<f32>(in.color.rgb, in.color.a * adjusted_alpha);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -232,9 +292,7 @@ fn vs_cell_bg(
|
||||
bg = tmp;
|
||||
}
|
||||
|
||||
// Convert to linear for sRGB surface
|
||||
fg = vec4<f32>(srgb_to_linear(fg.r), srgb_to_linear(fg.g), srgb_to_linear(fg.b), fg.a);
|
||||
bg = vec4<f32>(srgb_to_linear(bg.r), srgb_to_linear(bg.g), srgb_to_linear(bg.b), bg.a);
|
||||
// Keep colors in sRGB space for legacy blending
|
||||
|
||||
var out: CellVertexOutput;
|
||||
out.clip_position = vec4<f32>(ndc_pos, 0.0, 1.0);
|
||||
@@ -324,14 +382,13 @@ fn vs_cell_glyph(
|
||||
bg = tmp;
|
||||
}
|
||||
|
||||
// Convert to linear
|
||||
fg = vec4<f32>(srgb_to_linear(fg.r), srgb_to_linear(fg.g), srgb_to_linear(fg.b), fg.a);
|
||||
// Keep colors in sRGB space for legacy blending (conversion happens in fragment shader)
|
||||
|
||||
var out: CellVertexOutput;
|
||||
out.clip_position = vec4<f32>(ndc_pos, 0.0, 1.0);
|
||||
out.uv = uvs[vertex_index];
|
||||
out.fg_color = fg;
|
||||
out.bg_color = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
||||
out.bg_color = bg; // Pass background for legacy gamma blending
|
||||
out.is_background = 0u;
|
||||
out.is_colored_glyph = select(0u, 1u, is_colored);
|
||||
|
||||
@@ -356,6 +413,9 @@ fn fs_cell(in: CellVertexOutput) -> @location(0) vec4<f32> {
|
||||
return vec4<f32>(in.fg_color.rgb, glyph_alpha);
|
||||
}
|
||||
|
||||
// Normal glyph - tint with foreground color
|
||||
return vec4<f32>(in.fg_color.rgb, in.fg_color.a * glyph_alpha);
|
||||
// Apply legacy gamma-incorrect blending for crisp text
|
||||
let adjusted_alpha = foreground_contrast_legacy(in.fg_color.rgb, glyph_alpha, in.bg_color.rgb);
|
||||
|
||||
// Normal glyph - tint with foreground color using adjusted alpha
|
||||
return vec4<f32>(in.fg_color.rgb, in.fg_color.a * adjusted_alpha);
|
||||
}
|
||||
|
||||
+1826
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
// Image rendering shader for Kitty graphics protocol
|
||||
// Renders RGBA images with proper alpha blending
|
||||
|
||||
struct ImageUniforms {
|
||||
// Screen dimensions in pixels
|
||||
screen_width: f32,
|
||||
screen_height: f32,
|
||||
// Image position in pixels (top-left corner)
|
||||
pos_x: f32,
|
||||
pos_y: f32,
|
||||
// Image display size in pixels
|
||||
display_width: f32,
|
||||
display_height: f32,
|
||||
// Source rectangle in normalized coordinates (0-1)
|
||||
src_x: f32,
|
||||
src_y: f32,
|
||||
src_width: f32,
|
||||
src_height: f32,
|
||||
// Padding for alignment
|
||||
_padding1: f32,
|
||||
_padding2: f32,
|
||||
}
|
||||
|
||||
@group(0) @binding(0)
|
||||
var<uniform> uniforms: ImageUniforms;
|
||||
|
||||
@group(0) @binding(1)
|
||||
var image_texture: texture_2d<f32>;
|
||||
|
||||
@group(0) @binding(2)
|
||||
var image_sampler: sampler;
|
||||
|
||||
struct VertexOutput {
|
||||
@builtin(position) clip_position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
|
||||
// Convert pixel coordinate to NDC
|
||||
fn pixel_to_ndc(pixel: vec2<f32>, screen: vec2<f32>) -> vec2<f32> {
|
||||
return vec2<f32>(
|
||||
(pixel.x / screen.x) * 2.0 - 1.0,
|
||||
1.0 - (pixel.y / screen.y) * 2.0
|
||||
);
|
||||
}
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
|
||||
var out: VertexOutput;
|
||||
|
||||
// Calculate quad corners in pixel space
|
||||
let x0 = uniforms.pos_x;
|
||||
let y0 = uniforms.pos_y;
|
||||
let x1 = uniforms.pos_x + uniforms.display_width;
|
||||
let y1 = uniforms.pos_y + uniforms.display_height;
|
||||
|
||||
// Quad vertex positions (0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right)
|
||||
// Using triangle strip order
|
||||
var positions: array<vec2<f32>, 4>;
|
||||
positions[0] = vec2<f32>(x0, y0);
|
||||
positions[1] = vec2<f32>(x1, y0);
|
||||
positions[2] = vec2<f32>(x0, y1);
|
||||
positions[3] = vec2<f32>(x1, y1);
|
||||
|
||||
// UV coordinates mapping to source rectangle
|
||||
var uvs: array<vec2<f32>, 4>;
|
||||
let u0 = uniforms.src_x;
|
||||
let v0 = uniforms.src_y;
|
||||
let u1 = uniforms.src_x + uniforms.src_width;
|
||||
let v1 = uniforms.src_y + uniforms.src_height;
|
||||
|
||||
uvs[0] = vec2<f32>(u0, v0);
|
||||
uvs[1] = vec2<f32>(u1, v0);
|
||||
uvs[2] = vec2<f32>(u0, v1);
|
||||
uvs[3] = vec2<f32>(u1, v1);
|
||||
|
||||
let screen_size = vec2<f32>(uniforms.screen_width, uniforms.screen_height);
|
||||
let ndc_pos = pixel_to_ndc(positions[vertex_index], screen_size);
|
||||
|
||||
out.clip_position = vec4<f32>(ndc_pos, 0.0, 1.0);
|
||||
out.uv = uvs[vertex_index];
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Sample the image texture
|
||||
let color = textureSample(image_texture, image_sampler, in.uv);
|
||||
|
||||
// Return with premultiplied alpha for proper blending
|
||||
return vec4<f32>(color.rgb * color.a, color.a);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Single-process architecture: one process owns PTY, terminal state, and rendering.
|
||||
|
||||
pub mod config;
|
||||
pub mod graphics;
|
||||
pub mod keyboard;
|
||||
pub mod pty;
|
||||
pub mod renderer;
|
||||
|
||||
+186
-22
@@ -17,6 +17,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use polling::{Event, Events, Poller};
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::dpi::{PhysicalPosition, PhysicalSize};
|
||||
@@ -54,9 +55,18 @@ struct DoubleBuffer {
|
||||
|
||||
impl SharedPtyBuffer {
|
||||
fn new() -> Self {
|
||||
// Use with_capacity to avoid zeroing memory - we only need the allocation
|
||||
let mut buf1 = Vec::with_capacity(PTY_BUF_SIZE);
|
||||
let mut buf2 = Vec::with_capacity(PTY_BUF_SIZE);
|
||||
// SAFETY: We're setting length to capacity. The data is uninitialized but
|
||||
// we only read from portions that have been written to (tracked by write_len).
|
||||
unsafe {
|
||||
buf1.set_len(PTY_BUF_SIZE);
|
||||
buf2.set_len(PTY_BUF_SIZE);
|
||||
}
|
||||
Self {
|
||||
inner: Mutex::new(DoubleBuffer {
|
||||
bufs: [vec![0u8; PTY_BUF_SIZE], vec![0u8; PTY_BUF_SIZE]],
|
||||
bufs: [buf1, buf2],
|
||||
write_idx: 0,
|
||||
write_len: 0,
|
||||
}),
|
||||
@@ -158,8 +168,15 @@ impl Pane {
|
||||
let terminal = Terminal::new(cols, rows, scrollback_lines);
|
||||
let pty = Pty::spawn(None).map_err(|e| format!("Failed to spawn PTY: {}", e))?;
|
||||
|
||||
// Set terminal size
|
||||
if let Err(e) = pty.resize(cols as u16, rows as u16) {
|
||||
// Set terminal size (use default cell size estimate for initial pixel dimensions)
|
||||
let default_cell_width = 10u16;
|
||||
let default_cell_height = 20u16;
|
||||
if let Err(e) = pty.resize(
|
||||
cols as u16,
|
||||
rows as u16,
|
||||
cols as u16 * default_cell_width,
|
||||
rows as u16 * default_cell_height,
|
||||
) {
|
||||
log::warn!("Failed to set initial PTY size: {}", e);
|
||||
}
|
||||
|
||||
@@ -180,9 +197,9 @@ impl Pane {
|
||||
}
|
||||
|
||||
/// Resize the terminal and PTY.
|
||||
fn resize(&mut self, cols: usize, rows: usize) {
|
||||
fn resize(&mut self, cols: usize, rows: usize, width_px: u16, height_px: u16) {
|
||||
self.terminal.resize(cols, rows);
|
||||
if let Err(e) = self.pty.resize(cols as u16, rows as u16) {
|
||||
if let Err(e) = self.pty.resize(cols as u16, rows as u16, width_px, height_px) {
|
||||
log::warn!("Failed to resize PTY: {}", e);
|
||||
}
|
||||
}
|
||||
@@ -588,7 +605,11 @@ impl Tab {
|
||||
|
||||
for (pane_id, geom) in geometries {
|
||||
if let Some(pane) = self.panes.get_mut(&pane_id) {
|
||||
pane.resize(geom.cols, geom.rows);
|
||||
// Report pixel dimensions as exact cell grid size (cols * cell_width, rows * cell_height)
|
||||
// This ensures applications like kitten icat calculate image placement correctly
|
||||
let pixel_width = (geom.cols as f32 * cell_width) as u16;
|
||||
let pixel_height = (geom.rows as f32 * cell_height) as u16;
|
||||
pane.resize(geom.cols, geom.rows, pixel_width, pixel_height);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -784,6 +805,8 @@ enum UserEvent {
|
||||
ShowWindow,
|
||||
/// PTY has data available for a specific pane.
|
||||
PtyReadable(PaneId),
|
||||
/// Config file was modified and should be reloaded.
|
||||
ConfigReloaded,
|
||||
}
|
||||
|
||||
/// Main application state.
|
||||
@@ -816,8 +839,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>,
|
||||
/// Edge glow animations (for when navigation fails). Multiple can be active simultaneously.
|
||||
edge_glows: Vec<EdgeGlow>,
|
||||
}
|
||||
|
||||
const PTY_KEY: usize = 1;
|
||||
@@ -848,7 +871,7 @@ impl App {
|
||||
frame_count: 0,
|
||||
last_frame_log: std::time::Instant::now(),
|
||||
should_create_window: false,
|
||||
edge_glow: None,
|
||||
edge_glows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,6 +879,50 @@ impl App {
|
||||
self.event_loop_proxy = Some(proxy);
|
||||
}
|
||||
|
||||
/// Reload configuration from disk and apply changes.
|
||||
fn reload_config(&mut self) {
|
||||
log::info!("Reloading configuration...");
|
||||
let new_config = Config::load();
|
||||
|
||||
// Check what changed and apply updates
|
||||
let font_size_changed = (new_config.font_size - self.config.font_size).abs() > 0.01;
|
||||
let opacity_changed = (new_config.background_opacity - self.config.background_opacity).abs() > 0.01;
|
||||
let tab_bar_changed = new_config.tab_bar_position != self.config.tab_bar_position;
|
||||
|
||||
// Update the config
|
||||
self.config = new_config;
|
||||
|
||||
// Rebuild action map for keybindings
|
||||
self.action_map = self.config.keybindings.build_action_map();
|
||||
|
||||
// Apply renderer changes if we have a renderer
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
if opacity_changed {
|
||||
renderer.set_background_opacity(self.config.background_opacity);
|
||||
log::info!("Updated background opacity to {}", self.config.background_opacity);
|
||||
}
|
||||
|
||||
if tab_bar_changed {
|
||||
renderer.set_tab_bar_position(self.config.tab_bar_position);
|
||||
log::info!("Updated tab bar position to {:?}", self.config.tab_bar_position);
|
||||
}
|
||||
|
||||
if font_size_changed {
|
||||
renderer.set_font_size(self.config.font_size);
|
||||
log::info!("Updated font size to {}", self.config.font_size);
|
||||
// Font size change requires resize to recalculate cell dimensions
|
||||
self.resize_all_panes();
|
||||
}
|
||||
}
|
||||
|
||||
// Request redraw to apply visual changes
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
|
||||
log::info!("Configuration reloaded successfully");
|
||||
}
|
||||
|
||||
/// Create a new tab and start its I/O thread.
|
||||
/// Returns the index of the new tab.
|
||||
fn create_tab(&mut self, cols: usize, rows: usize) -> Option<usize> {
|
||||
@@ -1046,6 +1113,11 @@ impl App {
|
||||
|
||||
for tab in &mut self.tabs {
|
||||
tab.resize(width, height, cell_width, cell_height, border_width);
|
||||
|
||||
// Update cell size on all terminals (needed for Kitty graphics protocol)
|
||||
for pane in tab.panes.values_mut() {
|
||||
pane.terminal.set_cell_size(cell_width, cell_height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1368,6 +1440,13 @@ impl App {
|
||||
}
|
||||
|
||||
fn focus_pane(&mut self, direction: Direction) {
|
||||
// Get current active pane geometry before attempting navigation
|
||||
let active_pane_geom = if let Some(tab) = self.tabs.get(self.active_tab) {
|
||||
tab.split_root.find_geometry(tab.active_pane)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let navigated = if let Some(tab) = self.tabs.get_mut(self.active_tab) {
|
||||
let old_pane = tab.active_pane;
|
||||
tab.focus_neighbor(direction);
|
||||
@@ -1378,7 +1457,16 @@ impl App {
|
||||
|
||||
if !navigated {
|
||||
// No neighbor in that direction - trigger edge glow animation
|
||||
self.edge_glow = Some(EdgeGlow::new(direction));
|
||||
// Add to existing glows (don't replace) so multiple can be visible
|
||||
if let Some(geom) = active_pane_geom {
|
||||
self.edge_glows.push(EdgeGlow::new(
|
||||
direction,
|
||||
geom.x,
|
||||
geom.y,
|
||||
geom.width,
|
||||
geom.height,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(window) = &self.window {
|
||||
@@ -1605,9 +1693,11 @@ impl App {
|
||||
|
||||
impl ApplicationHandler<UserEvent> for App {
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
let start = std::time::Instant::now();
|
||||
if self.window.is_none() {
|
||||
self.create_window(event_loop);
|
||||
}
|
||||
log::info!("App resumed (window creation): {:?}", start.elapsed());
|
||||
}
|
||||
|
||||
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
|
||||
@@ -1633,6 +1723,9 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
log::info!("PTY process took {:?}", process_time);
|
||||
}
|
||||
}
|
||||
UserEvent::ConfigReloaded => {
|
||||
self.reload_config();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1851,13 +1944,16 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
let geometries = tab.collect_pane_geometries();
|
||||
let active_pane_id = tab.active_pane;
|
||||
|
||||
// First pass: calculate dim factors (needs mutable access)
|
||||
// First pass: sync images and calculate dim factors (needs mutable access)
|
||||
let mut dim_factors: Vec<(PaneId, f32)> = Vec::new();
|
||||
for (pane_id, _) in &geometries {
|
||||
if let Some(pane) = tab.panes.get_mut(pane_id) {
|
||||
let is_active = *pane_id == active_pane_id;
|
||||
let dim_factor = pane.calculate_dim_factor(is_active, fade_duration_ms, inactive_dim);
|
||||
dim_factors.push((*pane_id, dim_factor));
|
||||
|
||||
// Sync terminal images to GPU (Kitty graphics protocol)
|
||||
renderer.sync_images(&mut pane.terminal.image_storage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1914,11 +2010,15 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
// Handle edge glow animations
|
||||
let glow_in_progress = !self.edge_glows.is_empty();
|
||||
|
||||
match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx, edge_glow_ref) {
|
||||
// Check if any pane has animated images
|
||||
let image_animation_in_progress = tab.panes.values().any(|pane| {
|
||||
pane.terminal.image_storage.has_animations()
|
||||
});
|
||||
|
||||
match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx, &self.edge_glows, self.config.edge_glow_intensity) {
|
||||
Ok(_) => {}
|
||||
Err(wgpu::SurfaceError::Lost) => {
|
||||
renderer.resize(renderer.width, renderer.height);
|
||||
@@ -1932,8 +2032,8 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
}
|
||||
}
|
||||
|
||||
// Request redraw if edge glow is animating
|
||||
if glow_in_progress {
|
||||
// Request redraw if edge glow or image animation is in progress
|
||||
if glow_in_progress || image_animation_in_progress {
|
||||
if let Some(window) = &self.window {
|
||||
window.request_redraw();
|
||||
}
|
||||
@@ -1941,10 +2041,8 @@ impl ApplicationHandler<UserEvent> for App {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up finished edge glow animation
|
||||
if self.edge_glow.as_ref().map(|g| g.is_finished()).unwrap_or(false) {
|
||||
self.edge_glow = None;
|
||||
}
|
||||
// Clean up finished edge glow animations
|
||||
self.edge_glows.retain(|g| !g.is_finished());
|
||||
|
||||
let render_time = render_start.elapsed();
|
||||
let frame_time = frame_start.elapsed();
|
||||
@@ -2000,6 +2098,69 @@ impl Drop for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set up a file watcher to monitor the config file for changes.
|
||||
/// Returns the watcher (must be kept alive for watching to continue).
|
||||
fn setup_config_watcher(proxy: EventLoopProxy<UserEvent>) -> Option<RecommendedWatcher> {
|
||||
let config_path = match Config::config_path() {
|
||||
Some(path) => path,
|
||||
None => {
|
||||
log::warn!("Could not determine config path, config hot-reload disabled");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
// Watch the parent directory since the file might be replaced atomically
|
||||
let watch_path = match config_path.parent() {
|
||||
Some(parent) => parent.to_path_buf(),
|
||||
None => {
|
||||
log::warn!("Could not determine config directory, config hot-reload disabled");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let config_filename = config_path.file_name().map(|s| s.to_os_string());
|
||||
|
||||
let mut watcher = match notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
|
||||
match res {
|
||||
Ok(event) => {
|
||||
// Only trigger on modify/create events for the config file
|
||||
use notify::EventKind;
|
||||
match event.kind {
|
||||
EventKind::Modify(_) | EventKind::Create(_) => {
|
||||
// Check if the event is for our config file
|
||||
let is_config_file = event.paths.iter().any(|p| {
|
||||
p.file_name().map(|s| s.to_os_string()) == config_filename
|
||||
});
|
||||
|
||||
if is_config_file {
|
||||
log::debug!("Config file changed, triggering reload");
|
||||
let _ = proxy.send_event(UserEvent::ConfigReloaded);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Config watcher error: {:?}", e);
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to create config watcher: {:?}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = watcher.watch(&watch_path, RecursiveMode::NonRecursive) {
|
||||
log::warn!("Failed to watch config directory {:?}: {:?}", watch_path, e);
|
||||
return None;
|
||||
}
|
||||
|
||||
log::info!("Config hot-reload enabled, watching {:?}", watch_path);
|
||||
Some(watcher)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
|
||||
@@ -2035,9 +2196,12 @@ fn main() {
|
||||
|
||||
// Store proxy for signal handler (uses the global static defined below)
|
||||
unsafe {
|
||||
EVENT_PROXY = Some(proxy);
|
||||
EVENT_PROXY = Some(proxy.clone());
|
||||
}
|
||||
|
||||
// Set up config file watcher for hot-reloading
|
||||
let _config_watcher = setup_config_watcher(proxy);
|
||||
|
||||
event_loop.run_app(&mut app).expect("Event loop error");
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -150,12 +150,12 @@ impl Pty {
|
||||
}
|
||||
|
||||
/// Resizes the PTY window.
|
||||
pub fn resize(&self, cols: u16, rows: u16) -> Result<(), PtyError> {
|
||||
pub fn resize(&self, cols: u16, rows: u16, xpixel: u16, ypixel: u16) -> Result<(), PtyError> {
|
||||
let winsize = libc::winsize {
|
||||
ws_row: rows,
|
||||
ws_col: cols,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
ws_xpixel: xpixel,
|
||||
ws_ypixel: ypixel,
|
||||
};
|
||||
|
||||
let fd = std::os::fd::AsRawFd::as_raw_fd(&self.master);
|
||||
|
||||
+1256
-356
File diff suppressed because it is too large
Load Diff
+186
-87
@@ -1,28 +1,49 @@
|
||||
// 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.
|
||||
// Renders natural-looking light effects at terminal edges for failed pane navigation feedback.
|
||||
// Supports multiple simultaneous lights that blend together.
|
||||
// Features: bright hot center, colored mid-range, soft outer halo with bloom.
|
||||
|
||||
// Uniform buffer with glow parameters
|
||||
// Maximum number of simultaneous glows
|
||||
const MAX_GLOWS: u32 = 16u;
|
||||
|
||||
// Per-glow parameters (48 bytes each, aligned to 16 bytes)
|
||||
struct GlowInstance {
|
||||
// Direction: 0=Up, 1=Down, 2=Left, 3=Right
|
||||
direction: u32,
|
||||
// Animation progress (0.0 to 1.0)
|
||||
progress: f32,
|
||||
// Glow color (linear RGB)
|
||||
color_r: f32,
|
||||
color_g: f32,
|
||||
color_b: f32,
|
||||
// Pane bounds in pixels
|
||||
pane_x: f32,
|
||||
pane_y: f32,
|
||||
pane_width: f32,
|
||||
pane_height: f32,
|
||||
// Padding to align to 16 bytes
|
||||
_padding1: f32,
|
||||
_padding2: f32,
|
||||
_padding3: f32,
|
||||
}
|
||||
|
||||
// Global parameters + array of glow instances
|
||||
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
|
||||
// Glow intensity multiplier (0.0 = disabled, 1.0 = full)
|
||||
glow_intensity: f32,
|
||||
// Number of active glows
|
||||
glow_count: u32,
|
||||
// Padding to align to 16 bytes before array
|
||||
_padding1: u32,
|
||||
_padding2: u32,
|
||||
_padding3: u32,
|
||||
// Array of glow instances
|
||||
glows: array<GlowInstance, 16>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0)
|
||||
@@ -34,16 +55,10 @@ struct VertexOutput {
|
||||
}
|
||||
|
||||
// Fullscreen triangle vertex shader
|
||||
// Uses vertex_index 0,1,2 to create a triangle that covers the screen
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
|
||||
var out: VertexOutput;
|
||||
|
||||
// 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 {
|
||||
@@ -54,7 +69,6 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -63,19 +77,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
|
||||
// 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;
|
||||
}
|
||||
const GLOW_RADIUS: f32 = 80.0; // Core radius of the light
|
||||
const GLOW_ASPECT: f32 = 2.5; // Stretch factor along edge (ellipse)
|
||||
|
||||
// Ease-out cubic
|
||||
fn ease_out_cubic(t: f32) -> f32 {
|
||||
@@ -83,8 +86,9 @@ fn ease_out_cubic(t: f32) -> f32 {
|
||||
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 {
|
||||
// Calculate normalized distance from point to glow center (elliptical)
|
||||
// Returns 0 at center, 1 at edge of core, >1 outside
|
||||
fn ellipse_dist_normalized(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 {
|
||||
@@ -92,45 +96,76 @@ fn ellipse_distance(point: vec2<f32>, center: vec2<f32>, radius_along: f32, radi
|
||||
} else {
|
||||
normalized = vec2<f32>(delta.x / radius_perp, delta.y / radius_along);
|
||||
}
|
||||
return length(normalized) * min(radius_along, radius_perp);
|
||||
return length(normalized);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Early out if not enabled
|
||||
if params.enabled == 0u {
|
||||
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
||||
// Natural light intensity falloff
|
||||
// Creates a bright core with soft extended halo
|
||||
fn light_intensity(dist: f32) -> f32 {
|
||||
// Multi-layer falloff for natural light appearance:
|
||||
// 1. Bright core (inverse square-ish, clamped)
|
||||
// 2. Soft halo that extends further
|
||||
|
||||
if dist < 0.001 {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
let progress = params.progress;
|
||||
// Core intensity - bright center that falls off quickly
|
||||
// Using smoothed inverse for the hot center
|
||||
let core = 1.0 / (1.0 + dist * dist * 4.0);
|
||||
|
||||
// Convert UV to pixel coordinates
|
||||
let pixel = vec2<f32>(
|
||||
in.uv.x * params.screen_width,
|
||||
in.uv.y * params.screen_height
|
||||
);
|
||||
// Soft halo - gaussian-like falloff that extends further
|
||||
let halo = exp(-dist * dist * 1.5);
|
||||
|
||||
let terminal_height = params.screen_height - params.terminal_y_offset;
|
||||
let is_horizontal = params.direction == 0u || params.direction == 1u;
|
||||
// Combine: core dominates near center, halo extends the glow
|
||||
return core * 0.7 + halo * 0.5;
|
||||
}
|
||||
|
||||
// Calculate the "hotness" - how white/bright the center should be
|
||||
// Returns 0-1 where 1 = pure white (hottest), 0 = base color
|
||||
fn light_hotness(dist: f32) -> f32 {
|
||||
// Very bright white core that quickly transitions to color
|
||||
let hot = 1.0 / (1.0 + dist * dist * 12.0);
|
||||
return hot * hot; // Square it for sharper transition
|
||||
}
|
||||
|
||||
// Calculate contribution from a single glow at the given pixel
|
||||
// Returns (intensity, hotness, 1.0) packed in vec3
|
||||
fn calculate_glow(pixel: vec2<f32>, glow: GlowInstance) -> vec3<f32> {
|
||||
// Get pane bounds from the glow instance
|
||||
let pane_x = glow.pane_x;
|
||||
let pane_y = glow.pane_y;
|
||||
let pane_width = glow.pane_width;
|
||||
let pane_height = glow.pane_height;
|
||||
|
||||
// Mask: if pixel is outside pane bounds, return zero contribution
|
||||
if pixel.x < pane_x || pixel.x > pane_x + pane_width ||
|
||||
pixel.y < pane_y || pixel.y > pane_y + pane_height {
|
||||
return vec3<f32>(0.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
let progress = glow.progress;
|
||||
let is_horizontal = glow.direction == 0u || glow.direction == 1u;
|
||||
|
||||
// Calculate glow parameters based on animation phase
|
||||
var alpha: f32;
|
||||
var intensity_mult: f32;
|
||||
var size_factor: f32;
|
||||
var split: f32;
|
||||
|
||||
if progress < PHASE1_END {
|
||||
// Phase 1: Fade in, grow
|
||||
// Phase 1: Appear and grow
|
||||
let t = progress / PHASE1_END;
|
||||
let ease = ease_out_cubic(t);
|
||||
alpha = ease * 0.8;
|
||||
size_factor = 0.3 + 0.7 * ease;
|
||||
intensity_mult = ease;
|
||||
size_factor = 0.4 + 0.6 * 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;
|
||||
// Slower fade for more visible effect
|
||||
intensity_mult = fade * fade * fade;
|
||||
size_factor = 1.0 - 0.2 * t;
|
||||
split = ease_out_cubic(t);
|
||||
}
|
||||
|
||||
@@ -139,29 +174,30 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let radius_perp = base_radius;
|
||||
|
||||
// Calculate edge center and travel distance based on direction
|
||||
// Now using pane bounds instead of screen bounds
|
||||
var edge_center: vec2<f32>;
|
||||
var travel: vec2<f32>;
|
||||
|
||||
switch params.direction {
|
||||
// Up - top edge
|
||||
switch glow.direction {
|
||||
// Up - top edge of pane
|
||||
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);
|
||||
edge_center = vec2<f32>(pane_x + pane_width / 2.0, pane_y);
|
||||
travel = vec2<f32>(pane_width / 2.0, 0.0);
|
||||
}
|
||||
// Down - bottom edge
|
||||
// Down - bottom edge of pane
|
||||
case 1u: {
|
||||
edge_center = vec2<f32>(params.screen_width / 2.0, params.screen_height);
|
||||
travel = vec2<f32>(params.screen_width / 2.0, 0.0);
|
||||
edge_center = vec2<f32>(pane_x + pane_width / 2.0, pane_y + pane_height);
|
||||
travel = vec2<f32>(pane_width / 2.0, 0.0);
|
||||
}
|
||||
// Left - left edge
|
||||
// Left - left edge of pane
|
||||
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);
|
||||
edge_center = vec2<f32>(pane_x, pane_y + pane_height / 2.0);
|
||||
travel = vec2<f32>(0.0, pane_height / 2.0);
|
||||
}
|
||||
// Right - right edge
|
||||
// Right - right edge of pane
|
||||
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);
|
||||
edge_center = vec2<f32>(pane_x + pane_width, pane_y + pane_height / 2.0);
|
||||
travel = vec2<f32>(0.0, pane_height / 2.0);
|
||||
}
|
||||
default: {
|
||||
edge_center = vec2<f32>(0.0, 0.0);
|
||||
@@ -169,34 +205,97 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
var glow_intensity: f32 = 0.0;
|
||||
// Accumulate light from one or two sources
|
||||
var total_intensity: f32 = 0.0;
|
||||
var total_hotness: 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);
|
||||
// Single light at center
|
||||
let dist = ellipse_dist_normalized(pixel, edge_center, radius_along, radius_perp, is_horizontal);
|
||||
total_intensity = light_intensity(dist);
|
||||
total_hotness = light_hotness(dist);
|
||||
} 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);
|
||||
// Two lights splitting apart
|
||||
let split_factor = 1.0 - 0.15 * split;
|
||||
let r_along = radius_along * split_factor;
|
||||
let r_perp = radius_perp * split_factor;
|
||||
|
||||
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);
|
||||
let dist1 = ellipse_dist_normalized(pixel, center1, r_along, r_perp, is_horizontal);
|
||||
let dist2 = ellipse_dist_normalized(pixel, center2, r_along, r_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);
|
||||
let intensity1 = light_intensity(dist1);
|
||||
let intensity2 = light_intensity(dist2);
|
||||
let hotness1 = light_hotness(dist1);
|
||||
let hotness2 = light_hotness(dist2);
|
||||
|
||||
// Additive blending for overlapping lights (capped)
|
||||
total_intensity = min(intensity1 + intensity2, 1.5);
|
||||
total_hotness = max(hotness1, hotness2);
|
||||
}
|
||||
|
||||
// Apply alpha
|
||||
let final_alpha = glow_intensity * alpha;
|
||||
// Apply animation intensity multiplier
|
||||
total_intensity *= intensity_mult;
|
||||
total_hotness *= intensity_mult;
|
||||
|
||||
// Return intensity, hotness, and a flag that this glow contributed
|
||||
return vec3<f32>(total_intensity, total_hotness, 1.0);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
// Early out if no glows
|
||||
if params.glow_count == 0u {
|
||||
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
||||
}
|
||||
|
||||
// Convert UV to pixel coordinates
|
||||
let pixel = vec2<f32>(
|
||||
in.uv.x * params.screen_width,
|
||||
in.uv.y * params.screen_height
|
||||
);
|
||||
|
||||
// Accumulate contributions from all active glows
|
||||
var total_intensity: f32 = 0.0;
|
||||
var total_hotness: f32 = 0.0;
|
||||
var accum_color = vec3<f32>(0.0, 0.0, 0.0);
|
||||
var color_weight: f32 = 0.0;
|
||||
|
||||
for (var i: u32 = 0u; i < params.glow_count && i < MAX_GLOWS; i++) {
|
||||
let glow = params.glows[i];
|
||||
let result = calculate_glow(pixel, glow);
|
||||
let intensity = result.x;
|
||||
let hotness = result.y;
|
||||
|
||||
// Accumulate intensity and hotness additively
|
||||
total_intensity += intensity;
|
||||
total_hotness = max(total_hotness, hotness);
|
||||
|
||||
// Weight color contribution by intensity
|
||||
let base_color = vec3<f32>(glow.color_r, glow.color_g, glow.color_b);
|
||||
accum_color += base_color * intensity;
|
||||
color_weight += intensity;
|
||||
}
|
||||
|
||||
// Cap intensity for overlapping glows
|
||||
total_intensity = min(total_intensity, 1.5);
|
||||
|
||||
// Calculate final base color (weighted average)
|
||||
var base_color = vec3<f32>(0.0, 0.0, 0.0);
|
||||
if color_weight > 0.001 {
|
||||
base_color = accum_color / color_weight;
|
||||
}
|
||||
|
||||
// Mix between base color and white based on hotness
|
||||
// Hot center = white, outer regions = base color
|
||||
let white = vec3<f32>(1.0, 1.0, 1.0);
|
||||
let final_color = mix(base_color, white, total_hotness * 0.8);
|
||||
|
||||
// Final alpha based on intensity, scaled by global glow_intensity setting
|
||||
let final_alpha = clamp(total_intensity * 0.9 * params.glow_intensity, 0.0, 1.0);
|
||||
|
||||
// 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);
|
||||
return vec4<f32>(final_color * final_alpha, final_alpha);
|
||||
}
|
||||
|
||||
+126
-16
@@ -1,5 +1,6 @@
|
||||
//! Terminal state management and escape sequence handling.
|
||||
|
||||
use crate::graphics::{GraphicsCommand, ImageStorage};
|
||||
use crate::keyboard::{query_response, KeyboardState};
|
||||
use crate::vt_parser::{CsiParams, Handler, Parser};
|
||||
|
||||
@@ -320,15 +321,12 @@ pub struct ScrollbackBuffer {
|
||||
}
|
||||
|
||||
impl ScrollbackBuffer {
|
||||
/// Creates a new scrollback buffer with the given capacity and column width.
|
||||
/// All lines are pre-allocated to avoid any allocation during scrolling.
|
||||
pub fn new(capacity: usize, cols: usize) -> Self {
|
||||
// Pre-allocate all lines upfront
|
||||
let lines = if capacity > 0 {
|
||||
(0..capacity).map(|_| vec![Cell::default(); cols]).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
/// Creates a new scrollback buffer with the given capacity.
|
||||
/// Lines are allocated lazily as needed to avoid slow startup.
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
// Don't pre-allocate lines - allocate them lazily as content is added
|
||||
// This avoids allocating and zeroing potentially 20MB+ of memory at startup
|
||||
let lines = Vec::with_capacity(capacity.min(1024)); // Start with reasonable capacity
|
||||
|
||||
Self {
|
||||
lines,
|
||||
@@ -361,9 +359,9 @@ impl ScrollbackBuffer {
|
||||
/// If the buffer is full, the oldest line is overwritten and its slot is returned
|
||||
/// for reuse (the caller can swap content into it).
|
||||
///
|
||||
/// This is the key operation - it's O(1) with just modulo arithmetic, no allocation.
|
||||
/// Lines are allocated lazily on first use to avoid slow startup.
|
||||
#[inline]
|
||||
pub fn push(&mut self) -> &mut Vec<Cell> {
|
||||
pub fn push(&mut self, cols: usize) -> &mut Vec<Cell> {
|
||||
if self.capacity == 0 {
|
||||
// Shouldn't happen in normal use, but handle gracefully
|
||||
panic!("Cannot push to zero-capacity scrollback buffer");
|
||||
@@ -379,7 +377,11 @@ impl ScrollbackBuffer {
|
||||
self.start = (self.start + 1) % self.capacity;
|
||||
// count stays the same
|
||||
} else {
|
||||
// Buffer not full yet - just increment count
|
||||
// Buffer not full yet - allocate new line if needed
|
||||
if idx >= self.lines.len() {
|
||||
// Grow the lines vector and allocate the new line
|
||||
self.lines.push(vec![Cell::default(); cols]);
|
||||
}
|
||||
self.count += 1;
|
||||
}
|
||||
|
||||
@@ -488,6 +490,12 @@ pub struct Terminal {
|
||||
/// Command queue for terminal-to-application communication.
|
||||
/// Commands are added by OSC handlers and consumed by the application.
|
||||
command_queue: Vec<TerminalCommand>,
|
||||
/// Image storage for Kitty graphics protocol.
|
||||
pub image_storage: ImageStorage,
|
||||
/// Cell width in pixels (for image sizing).
|
||||
pub cell_width: f32,
|
||||
/// Cell height in pixels (for image sizing).
|
||||
pub cell_height: f32,
|
||||
}
|
||||
|
||||
impl Terminal {
|
||||
@@ -529,7 +537,7 @@ impl Terminal {
|
||||
keyboard: KeyboardState::new(),
|
||||
response_queue: Vec::new(),
|
||||
palette: ColorPalette::default(),
|
||||
scrollback: ScrollbackBuffer::new(scrollback_limit, cols),
|
||||
scrollback: ScrollbackBuffer::new(scrollback_limit),
|
||||
scroll_offset: 0,
|
||||
mouse_tracking: MouseTrackingMode::default(),
|
||||
mouse_encoding: MouseEncoding::default(),
|
||||
@@ -545,6 +553,9 @@ impl Terminal {
|
||||
parser: Some(Parser::new()),
|
||||
stats: ProcessingStats::default(),
|
||||
command_queue: Vec::new(),
|
||||
image_storage: ImageStorage::new(),
|
||||
cell_width: 10.0, // Default, will be set by renderer
|
||||
cell_height: 20.0, // Default, will be set by renderer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,7 +819,8 @@ impl Terminal {
|
||||
if self.scroll_top == 0 && !self.using_alternate_screen && self.scrollback.capacity > 0 {
|
||||
// Get a slot in the ring buffer - this is O(1) with just modulo arithmetic
|
||||
// If buffer is full, this overwrites the oldest line (perfect for our swap)
|
||||
let dest = self.scrollback.push();
|
||||
let cols = self.cols;
|
||||
let dest = self.scrollback.push(cols);
|
||||
// Swap grid row content into scrollback slot
|
||||
// The scrollback slot's old content (if any) moves to the grid row
|
||||
std::mem::swap(&mut self.grid[recycled_grid_row], dest);
|
||||
@@ -1184,7 +1196,8 @@ impl Terminal {
|
||||
for visual_row in 0..self.rows {
|
||||
let grid_row = self.line_map[visual_row];
|
||||
// Get a slot in the ring buffer and swap content into it
|
||||
let dest = self.scrollback.push();
|
||||
let cols = self.cols;
|
||||
let dest = self.scrollback.push(cols);
|
||||
std::mem::swap(&mut self.grid[grid_row], dest);
|
||||
}
|
||||
}
|
||||
@@ -1403,6 +1416,12 @@ impl Handler for Terminal {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an APC (Application Program Command) sequence.
|
||||
/// Used for Kitty graphics protocol.
|
||||
fn apc(&mut self, data: &[u8]) {
|
||||
self.handle_apc(data);
|
||||
}
|
||||
|
||||
/// Handle a complete CSI sequence.
|
||||
fn csi(&mut self, params: &CsiParams) {
|
||||
let action = params.final_char as char;
|
||||
@@ -1619,10 +1638,32 @@ impl Handler for Terminal {
|
||||
self.cursor_row = 0;
|
||||
self.cursor_col = 0;
|
||||
}
|
||||
// Window manipulation (CSI Ps t)
|
||||
// Window manipulation (CSI Ps t) - XTWINOPS
|
||||
't' => {
|
||||
let ps = params.get(0, 0);
|
||||
match ps {
|
||||
14 => {
|
||||
// Report text area size in pixels: CSI 4 ; height ; width t
|
||||
let pixel_height = (self.rows as f32 * self.cell_height) as u32;
|
||||
let pixel_width = (self.cols as f32 * self.cell_width) as u32;
|
||||
let response = format!("\x1b[4;{};{}t", pixel_height, pixel_width);
|
||||
self.response_queue.extend_from_slice(response.as_bytes());
|
||||
log::debug!("XTWINOPS 14: Reported text area size {}x{} pixels", pixel_width, pixel_height);
|
||||
}
|
||||
16 => {
|
||||
// Report cell size in pixels: CSI 6 ; height ; width t
|
||||
let cell_h = self.cell_height as u32;
|
||||
let cell_w = self.cell_width as u32;
|
||||
let response = format!("\x1b[6;{};{}t", cell_h, cell_w);
|
||||
self.response_queue.extend_from_slice(response.as_bytes());
|
||||
log::debug!("XTWINOPS 16: Reported cell size {}x{} pixels", cell_w, cell_h);
|
||||
}
|
||||
18 => {
|
||||
// Report text area size in characters: CSI 8 ; rows ; cols t
|
||||
let response = format!("\x1b[8;{};{}t", self.rows, self.cols);
|
||||
self.response_queue.extend_from_slice(response.as_bytes());
|
||||
log::debug!("XTWINOPS 18: Reported text area size {}x{} chars", self.cols, self.rows);
|
||||
}
|
||||
22 | 23 => {
|
||||
// Save/restore window title - ignore
|
||||
}
|
||||
@@ -2079,4 +2120,73 @@ impl Terminal {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set cell dimensions (called by renderer after font metrics are calculated).
|
||||
pub fn set_cell_size(&mut self, width: f32, height: f32) {
|
||||
self.cell_width = width;
|
||||
self.cell_height = height;
|
||||
}
|
||||
|
||||
/// Handle an APC (Application Program Command) sequence.
|
||||
/// This is used for the Kitty graphics protocol.
|
||||
fn handle_apc(&mut self, data: &[u8]) {
|
||||
// Kitty graphics protocol: APC starts with 'G'
|
||||
if let Some(cmd) = GraphicsCommand::parse(data) {
|
||||
log::debug!(
|
||||
"Graphics command: action={:?} format={:?} id={:?} size={}x{:?} C={} U={}",
|
||||
cmd.action,
|
||||
cmd.format,
|
||||
cmd.image_id,
|
||||
cmd.width.unwrap_or(0),
|
||||
cmd.height,
|
||||
cmd.cursor_movement,
|
||||
cmd.unicode_placeholder
|
||||
);
|
||||
|
||||
// Convert cursor_row to absolute row (accounting for scrollback)
|
||||
// This allows images to scroll with terminal content
|
||||
let absolute_row = self.scrollback.len() + self.cursor_row;
|
||||
|
||||
// Process the command
|
||||
let (response, placement_result) = self.image_storage.process_command(
|
||||
cmd,
|
||||
self.cursor_col,
|
||||
absolute_row,
|
||||
self.cell_width,
|
||||
self.cell_height,
|
||||
);
|
||||
|
||||
// Queue the response to send back to the application
|
||||
if let Some(resp) = response {
|
||||
self.response_queue.extend_from_slice(resp.as_bytes());
|
||||
}
|
||||
|
||||
// Move cursor after image placement per Kitty protocol spec:
|
||||
// "After placing an image on the screen the cursor must be moved to the
|
||||
// right by the number of cols in the image placement rectangle and down
|
||||
// by the number of rows in the image placement rectangle."
|
||||
// However, if C=1 was specified, don't move the cursor.
|
||||
if let Some(placement) = placement_result {
|
||||
if !placement.suppress_cursor_move && !placement.virtual_placement {
|
||||
// Move cursor down by (rows - 1) since we're already on the first row
|
||||
// Then set cursor to the column after the image
|
||||
let new_row = self.cursor_row + placement.rows.saturating_sub(1);
|
||||
if new_row >= self.rows {
|
||||
// Need to scroll
|
||||
let scroll_amount = new_row - self.rows + 1;
|
||||
self.scroll_up(scroll_amount);
|
||||
self.cursor_row = self.rows - 1;
|
||||
} else {
|
||||
self.cursor_row = new_row;
|
||||
}
|
||||
// Move cursor to after the image (or stay at column 0 of next line)
|
||||
// Per protocol, cursor ends at the last row of the image
|
||||
log::debug!(
|
||||
"Cursor moved after image placement: row={} (moved {} rows)",
|
||||
self.cursor_row, placement.rows.saturating_sub(1)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -558,7 +558,7 @@ impl Parser {
|
||||
self.csi.primary = ch;
|
||||
self.csi.state = CsiState::Body;
|
||||
}
|
||||
b' ' | b'\'' | b'"' | b'!' | b'$' => {
|
||||
b' ' | b'\'' | b'"' | b'!' | b'$' | b'#' | b'*' => {
|
||||
self.csi.secondary = ch;
|
||||
self.csi.state = CsiState::PostSecondary;
|
||||
}
|
||||
|
||||
@@ -68,3 +68,7 @@ zterm|ZTerm - GPU-accelerated terminal emulator,
|
||||
XM=\E[?1006;1000%?%p1%{1}%=%th%el%;,
|
||||
Ss=\E[%p1%d q,
|
||||
Se=\E[2 q,
|
||||
Sync=\E[?2026%?%p1%{1}%=%th%el%;,
|
||||
Smulx=\E[4:%p1%dm,
|
||||
setrgbf=\E[38:2:%p1%d:%p2%d:%p3%dm,
|
||||
setrgbb=\E[48:2:%p1%d:%p2%d:%p3%dm,
|
||||
|
||||
Reference in New Issue
Block a user