font rendering

This commit is contained in:
Zacharias-Brohn
2025-12-16 16:55:10 +01:00
parent f304fd18a8
commit 5c3eee3448
14 changed files with 4060 additions and 874 deletions
+14 -1
View File
@@ -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"]
-87
View File
@@ -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);
}
}
}
+4
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -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);
}
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+1302 -402
View File
File diff suppressed because it is too large Load Diff
+186 -87
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+4
View File
@@ -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,