From 32fa9c891be302d89762e9af1ca0537f8631c79d Mon Sep 17 00:00:00 2001 From: Zacharias-Brohn Date: Sun, 1 Feb 2026 19:20:35 +0100 Subject: [PATCH] box drawing fixes --- src/box_drawing.rs | 763 +++++++++++++++++++---- src/keyboard.rs | 49 +- src/main.rs | 1462 +++++++++++++++++++++++++++++--------------- src/terminal.rs | 762 +++++++++++++++-------- 4 files changed, 2181 insertions(+), 855 deletions(-) diff --git a/src/box_drawing.rs b/src/box_drawing.rs index a89fc5d..467a9d0 100644 --- a/src/box_drawing.rs +++ b/src/box_drawing.rs @@ -16,6 +16,13 @@ pub enum Corner { BottomRight, } +#[derive(Clone, Copy)] +enum ProgressSegment { + Left, + Middle, + Right, +} + // ═══════════════════════════════════════════════════════════════════════════════ // SUPERSAMPLED CANVAS // ═══════════════════════════════════════════════════════════════════════════════ @@ -55,14 +62,41 @@ impl SupersampledCanvas { } } + pub fn hline(&mut self, x1: usize, x2: usize, y: usize, thickness: usize) { + let y_start = y.saturating_sub(thickness / 2); + let y_end = (y_start + thickness).min(self.ss_height); + for py in y_start..y_end { + for px in x1..x2.min(self.ss_width) { + self.bitmap[py * self.ss_width + px] = 255; + } + } + } + + pub fn vline(&mut self, y1: usize, y2: usize, x: usize, thickness: usize) { + let x_start = x.saturating_sub(thickness / 2); + let x_end = (x_start + thickness).min(self.ss_width); + for py in y1..y2.min(self.ss_height) { + for px in x_start..x_end { + self.bitmap[py * self.ss_width + px] = 255; + } + } + } + /// Draw a thick line along x-axis with y computed by a function - pub fn thick_line_h(&mut self, x1: usize, x2: usize, y_at_x: impl Fn(usize) -> f64, thickness: usize) { + pub fn thick_line_h( + &mut self, + x1: usize, + x2: usize, + y_at_x: impl Fn(usize) -> f64, + thickness: usize, + ) { let delta = thickness / 2; let extra = thickness % 2; for x in x1..x2.min(self.ss_width) { let y_center = y_at_x(x) as i32; let y_start = (y_center - delta as i32).max(0) as usize; - let y_end = ((y_center + delta as i32 + extra as i32) as usize).min(self.ss_height); + let y_end = ((y_center + delta as i32 + extra as i32) as usize) + .min(self.ss_height); for y in y_start..y_end { self.bitmap[y * self.ss_width + x] = 255; } @@ -103,8 +137,10 @@ impl SupersampledCanvas { Corner::TopRight => (max_y - (max_y / max_x) * x, false), }; - let in_triangle = if fill_below { y >= edge_y } else { y <= edge_y }; - let should_fill = if inverted { !in_triangle } else { in_triangle }; + let in_triangle = + if fill_below { y >= edge_y } else { y <= edge_y }; + let should_fill = + if inverted { !in_triangle } else { in_triangle }; if should_fill { self.bitmap[py * w + px] = 255; @@ -155,11 +191,26 @@ impl SupersampledCanvas { let mid_y = max_y / 2.0; if left { - self.thick_line_h(0, w, |x| (mid_y / max_x) * (max_x - x as f64), thickness); - self.thick_line_h(0, w, |x| max_y - (mid_y / max_x) * (max_x - x as f64), thickness); + self.thick_line_h( + 0, + w, + |x| (mid_y / max_x) * (max_x - x as f64), + thickness, + ); + self.thick_line_h( + 0, + w, + |x| max_y - (mid_y / max_x) * (max_x - x as f64), + thickness, + ); } else { self.thick_line_h(0, w, |x| (mid_y / max_x) * x as f64, thickness); - self.thick_line_h(0, w, |x| max_y - (mid_y / max_x) * x as f64, thickness); + self.thick_line_h( + 0, + w, + |x| max_y - (mid_y / max_x) * x as f64, + thickness, + ); } } @@ -289,7 +340,13 @@ impl SupersampledCanvas { } /// Stroke an arc (partial circle) with anti-aliasing - pub fn stroke_arc(&mut self, radius: f64, line_width: f64, start_angle: f64, end_angle: f64) { + pub fn stroke_arc( + &mut self, + radius: f64, + line_width: f64, + start_angle: f64, + end_angle: f64, + ) { let cx = self.ss_width as f64 / 2.0; let cy = self.ss_height as f64 / 2.0; let half_thickness = line_width / 2.0; @@ -340,7 +397,8 @@ impl SupersampledCanvas { total += self.bitmap[sy * self.ss_width + sx] as u32; } } - output[y * self.width + x] = (total / (Self::FACTOR * Self::FACTOR) as u32) as u8; + output[y * self.width + x] = + (total / (Self::FACTOR * Self::FACTOR) as u32) as u8; } } } @@ -366,6 +424,91 @@ pub fn is_box_drawing(c: char) -> bool { || (0x25A0..=0x25FF).contains(&cp) || (0x2800..=0x28FF).contains(&cp) || (0xE0B0..=0xE0BF).contains(&cp) + || (0xEE00..=0xEE0B).contains(&cp) // Nerd Fonts progress bar/spinner +} + +fn render_progress_bar( + bitmap: &mut [u8], + w: usize, + h: usize, + dpi: f64, + segment: ProgressSegment, + filled: bool, +) { + let thickness_h = box_thickness(1, dpi).round() as usize; + let thickness_v = box_thickness(1, dpi).round() as usize; + + let draw_top = true; + let draw_bottom = true; + let draw_left = matches!(segment, ProgressSegment::Left); + let draw_right = matches!(segment, ProgressSegment::Right); + + let hline = |buf: &mut [u8], y_start: usize, y_end: usize| { + for y in y_start..y_end.min(h) { + for x in 0..w { + buf[y * w + x] = 255; + } + } + }; + + let vline = |buf: &mut [u8], x_start: usize, x_end: usize| { + for y in 0..h { + for x in x_start..x_end.min(w) { + buf[y * w + x] = 255; + } + } + }; + + if draw_top { + hline(bitmap, 0, thickness_h + 1); + } + if draw_bottom { + hline(bitmap, h.saturating_sub(thickness_h + 1), h); + } + if draw_left { + vline(bitmap, 0, thickness_v + 1); + } + if draw_right { + vline(bitmap, w.saturating_sub(thickness_v + 1), w); + } + + if filled { + let gap = 3 * thickness_h; + let y1 = gap; + let y2 = h.saturating_sub(gap); + let (x1, x2) = match segment { + ProgressSegment::Left => (gap, w), + ProgressSegment::Middle => (0, w), + ProgressSegment::Right => (0, w.saturating_sub(gap)), + }; + for y in y1..y2.min(h) { + for x in x1..x2.min(w) { + bitmap[y * w + x] = 255; + } + } + } +} + +fn render_spinner( + bitmap: &mut [u8], + supersampled: &mut bool, + w: usize, + h: usize, + dpi: f64, + start_degrees: f64, + end_degrees: f64, +) { + let mut canvas = SupersampledCanvas::new(w, h); + let line_width = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + let half_line = 0.5_f64.max(line_width / 2.0); + let cx = canvas.ss_width as f64 / 2.0; + let cy = canvas.ss_height as f64 / 2.0; + let radius = 0.0_f64.max(cx.min(cy) - half_line); + let start_rad = start_degrees.to_radians(); + let end_rad = end_degrees.to_radians(); + canvas.stroke_arc(radius, line_width, start_rad, end_rad); + canvas.downsample(bitmap); + *supersampled = true; } // ═══════════════════════════════════════════════════════════════════════════════ @@ -388,15 +531,15 @@ pub fn render_box_char( let mid_x = w / 2; let mid_y = h / 2; - let light = 2.max((font_size / 8.0).round() as usize); - let heavy = light * 2; - // For double lines + let light = 2.max((dpi / 72.0).round() as usize); + let heavy = (light * 2).max(4); + let double_gap = light + 2; let double_off = double_gap / 2; - // Helper: draw horizontal line let hline = |buf: &mut [u8], x1: usize, x2: usize, y: usize, t: usize| { + let t = t.max(1); let y_start = y.saturating_sub(t / 2); let y_end = (y_start + t).min(h); for py in y_start..y_end { @@ -406,8 +549,8 @@ pub fn render_box_char( } }; - // Helper: draw vertical line let vline = |buf: &mut [u8], y1: usize, y2: usize, x: usize, t: usize| { + let t = t.max(1); let x_start = x.saturating_sub(t / 2); let x_end = (x_start + t).min(w); for py in y1..y2.min(h) { @@ -418,13 +561,14 @@ pub fn render_box_char( }; // Helper: fill rectangle - let fill_rect = |buf: &mut [u8], x1: usize, y1: usize, x2: usize, y2: usize| { - for py in y1..y2.min(h) { - for px in x1..x2.min(w) { - buf[py * w + px] = 255; + let fill_rect = + |buf: &mut [u8], x1: usize, y1: usize, x2: usize, y2: usize| { + for py in y1..y2.min(h) { + for px in x1..x2.min(w) { + buf[py * w + px] = 255; + } } - } - }; + }; // Box drawing arm thickness encoding: 0=none, 1=light, 2=heavy let box_arms: Option<(u8, u8, u8, u8)> = match c { @@ -477,12 +621,10 @@ pub fn render_box_char( '\u{2525}' => Some((2, 0, 1, 1)), // ┥ '\u{252F}' => Some((2, 2, 0, 1)), // ┯ '\u{2537}' => Some((2, 2, 1, 0)), // ┷ - // Mixed cross (heavy horizontal, light vertical) '\u{2542}' => Some((1, 1, 2, 2)), // ╂ _ => None, }; - // Handle simple box drawing via lookup table if let Some((left, right, up, down)) = box_arms { let thickness = |arm: u8| -> usize { match arm { @@ -491,26 +633,54 @@ pub fn render_box_char( _ => 0, } }; + + let h_thick = thickness(left.max(right)); + let v_thick = thickness(up.max(down)); + let h_extend = v_thick / 2; + let v_extend = h_thick / 2; + if left > 0 { - hline(&mut bitmap, 0, mid_x + 1, mid_y, thickness(left)); + hline(&mut bitmap, 0, mid_x + h_extend + 1, mid_y, thickness(left)); } if right > 0 { - hline(&mut bitmap, mid_x, w, mid_y, thickness(right)); + hline( + &mut bitmap, + mid_x.saturating_sub(h_extend), + w, + mid_y, + thickness(right), + ); } if up > 0 { - vline(&mut bitmap, 0, mid_y + 1, mid_x, thickness(up)); + vline(&mut bitmap, 0, mid_y + v_extend + 1, mid_x, thickness(up)); } if down > 0 { - vline(&mut bitmap, mid_y, h, mid_x, thickness(down)); + vline( + &mut bitmap, + mid_y.saturating_sub(v_extend), + h, + mid_x, + thickness(down), + ); } - return Some((bitmap, supersampled)); + return Some((bitmap, false)); } - // Continue with match for remaining characters render_box_char_extended( - c, &mut bitmap, &mut supersampled, - w, h, mid_x, mid_y, light, heavy, double_off, - dpi, hline, vline, fill_rect, + c, + &mut bitmap, + &mut supersampled, + w, + h, + mid_x, + mid_y, + light, + heavy, + double_off, + dpi, + hline, + vline, + fill_rect, )?; Some((bitmap, supersampled)) @@ -678,9 +848,20 @@ where // Delegate to part 3 for double lines, blocks, etc. _ => { return render_box_char_part3( - c, bitmap, supersampled, - w, h, mid_x, mid_y, light, heavy, double_off, - dpi, hline, vline, fill_rect, + c, + bitmap, + supersampled, + w, + h, + mid_x, + mid_y, + light, + heavy, + double_off, + dpi, + hline, + vline, + fill_rect, ); } } @@ -724,72 +905,238 @@ where hline(bitmap, mid_x, w, mid_y.saturating_sub(double_off), light); hline(bitmap, mid_x + double_off, w, mid_y + double_off, light); vline(bitmap, mid_y, h, mid_x.saturating_sub(double_off), light); - vline(bitmap, mid_y.saturating_sub(double_off), h, mid_x + double_off, light); + vline( + bitmap, + mid_y.saturating_sub(double_off), + h, + mid_x + double_off, + light, + ); } '\u{2557}' => { - hline(bitmap, 0, mid_x + 1, mid_y.saturating_sub(double_off), light); - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); + hline( + bitmap, + 0, + mid_x + 1, + mid_y.saturating_sub(double_off), + light, + ); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y + double_off, + light, + ); vline(bitmap, mid_y, h, mid_x + double_off, light); - vline(bitmap, mid_y.saturating_sub(double_off), h, mid_x.saturating_sub(double_off), light); + vline( + bitmap, + mid_y.saturating_sub(double_off), + h, + mid_x.saturating_sub(double_off), + light, + ); } '\u{255A}' => { hline(bitmap, mid_x, w, mid_y + double_off, light); - hline(bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); - vline(bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + hline( + bitmap, + mid_x + double_off, + w, + mid_y.saturating_sub(double_off), + light, + ); + vline( + bitmap, + 0, + mid_y + 1, + mid_x.saturating_sub(double_off), + light, + ); vline(bitmap, 0, mid_y + double_off + 1, mid_x + double_off, light); } '\u{255D}' => { hline(bitmap, 0, mid_x + 1, mid_y + double_off, light); - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y.saturating_sub(double_off), + light, + ); vline(bitmap, 0, mid_y + 1, mid_x + double_off, light); - vline(bitmap, 0, mid_y + double_off + 1, mid_x.saturating_sub(double_off), light); + vline( + bitmap, + 0, + mid_y + double_off + 1, + mid_x.saturating_sub(double_off), + light, + ); } // Double T-junctions '\u{2560}' => { vline(bitmap, 0, h, mid_x.saturating_sub(double_off), light); - vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light); + vline( + bitmap, + 0, + mid_y.saturating_sub(double_off) + 1, + mid_x + double_off, + light, + ); vline(bitmap, mid_y + double_off, h, mid_x + double_off, light); - hline(bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); + hline( + bitmap, + mid_x + double_off, + w, + mid_y.saturating_sub(double_off), + light, + ); hline(bitmap, mid_x + double_off, w, mid_y + double_off, light); } '\u{2563}' => { vline(bitmap, 0, h, mid_x + double_off, light); - vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light); - vline(bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light); - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); + vline( + bitmap, + 0, + mid_y.saturating_sub(double_off) + 1, + mid_x.saturating_sub(double_off), + light, + ); + vline( + bitmap, + mid_y + double_off, + h, + mid_x.saturating_sub(double_off), + light, + ); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y.saturating_sub(double_off), + light, + ); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y + double_off, + light, + ); } '\u{2566}' => { hline(bitmap, 0, w, mid_y.saturating_sub(double_off), light); - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y + double_off, + light, + ); hline(bitmap, mid_x + double_off, w, mid_y + double_off, light); - vline(bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light); + vline( + bitmap, + mid_y + double_off, + h, + mid_x.saturating_sub(double_off), + light, + ); vline(bitmap, mid_y + double_off, h, mid_x + double_off, light); } '\u{2569}' => { hline(bitmap, 0, w, mid_y + double_off, light); - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); - hline(bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); - vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light); - vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y.saturating_sub(double_off), + light, + ); + hline( + bitmap, + mid_x + double_off, + w, + mid_y.saturating_sub(double_off), + light, + ); + vline( + bitmap, + 0, + mid_y.saturating_sub(double_off) + 1, + mid_x.saturating_sub(double_off), + light, + ); + vline( + bitmap, + 0, + mid_y.saturating_sub(double_off) + 1, + mid_x + double_off, + light, + ); } // Double cross '\u{256C}' => { - vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x.saturating_sub(double_off), light); - vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x + double_off, light); - vline(bitmap, mid_y + double_off, h, mid_x.saturating_sub(double_off), light); + vline( + bitmap, + 0, + mid_y.saturating_sub(double_off) + 1, + mid_x.saturating_sub(double_off), + light, + ); + vline( + bitmap, + 0, + mid_y.saturating_sub(double_off) + 1, + mid_x + double_off, + light, + ); + vline( + bitmap, + mid_y + double_off, + h, + mid_x.saturating_sub(double_off), + light, + ); vline(bitmap, mid_y + double_off, h, mid_x + double_off, light); - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y.saturating_sub(double_off), light); - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y + double_off, light); - hline(bitmap, mid_x + double_off, w, mid_y.saturating_sub(double_off), light); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y.saturating_sub(double_off), + light, + ); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y + double_off, + light, + ); + hline( + bitmap, + mid_x + double_off, + w, + mid_y.saturating_sub(double_off), + light, + ); hline(bitmap, mid_x + double_off, w, mid_y + double_off, light); } // Delegate remaining to part 4 _ => { return render_box_char_part4( - c, bitmap, supersampled, - w, h, mid_x, mid_y, light, double_off, - dpi, hline, vline, fill_rect, + c, + bitmap, + supersampled, + w, + h, + mid_x, + mid_y, + light, + double_off, + dpi, + hline, + vline, + fill_rect, ); } } @@ -829,7 +1176,13 @@ where vline(bitmap, mid_y, h, mid_x, light); } '\u{2555}' => { - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y, + light, + ); vline(bitmap, mid_y, h, mid_x.saturating_sub(double_off), light); vline(bitmap, mid_y, h, mid_x + double_off, light); } @@ -839,7 +1192,13 @@ where } '\u{2558}' => { hline(bitmap, mid_x + double_off, w, mid_y, light); - vline(bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + vline( + bitmap, + 0, + mid_y + 1, + mid_x.saturating_sub(double_off), + light, + ); vline(bitmap, 0, mid_y + 1, mid_x + double_off, light); } '\u{2559}' => { @@ -847,8 +1206,20 @@ where vline(bitmap, 0, mid_y + 1, mid_x, light); } '\u{255B}' => { - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); - vline(bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y, + light, + ); + vline( + bitmap, + 0, + mid_y + 1, + mid_x.saturating_sub(double_off), + light, + ); vline(bitmap, 0, mid_y + 1, mid_x + double_off, light); } '\u{255C}' => { @@ -869,11 +1240,23 @@ where '\u{2561}' => { vline(bitmap, 0, h, mid_x.saturating_sub(double_off), light); vline(bitmap, 0, h, mid_x + double_off, light); - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y, + light, + ); } '\u{2562}' => { vline(bitmap, 0, h, mid_x, light); - hline(bitmap, 0, mid_x + 1, mid_y.saturating_sub(double_off), light); + hline( + bitmap, + 0, + mid_x + 1, + mid_y.saturating_sub(double_off), + light, + ); hline(bitmap, 0, mid_x + 1, mid_y + double_off, light); } '\u{2564}' => { @@ -889,22 +1272,46 @@ where '\u{2567}' => { hline(bitmap, 0, w, mid_y.saturating_sub(double_off), light); hline(bitmap, 0, w, mid_y + double_off, light); - vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x, light); + vline( + bitmap, + 0, + mid_y.saturating_sub(double_off) + 1, + mid_x, + light, + ); } '\u{2568}' => { hline(bitmap, 0, w, mid_y, light); - vline(bitmap, 0, mid_y + 1, mid_x.saturating_sub(double_off), light); + vline( + bitmap, + 0, + mid_y + 1, + mid_x.saturating_sub(double_off), + light, + ); vline(bitmap, 0, mid_y + 1, mid_x + double_off, light); } // Mixed crosses '\u{256A}' => { hline(bitmap, 0, w, mid_y.saturating_sub(double_off), light); hline(bitmap, 0, w, mid_y + double_off, light); - vline(bitmap, 0, mid_y.saturating_sub(double_off) + 1, mid_x, light); + vline( + bitmap, + 0, + mid_y.saturating_sub(double_off) + 1, + mid_x, + light, + ); vline(bitmap, mid_y + double_off, h, mid_x, light); } '\u{256B}' => { - hline(bitmap, 0, mid_x.saturating_sub(double_off) + 1, mid_y, light); + hline( + bitmap, + 0, + mid_x.saturating_sub(double_off) + 1, + mid_y, + light, + ); hline(bitmap, mid_x + double_off, w, mid_y, light); vline(bitmap, 0, h, mid_x.saturating_sub(double_off), light); vline(bitmap, 0, h, mid_x + double_off, light); @@ -912,9 +1319,18 @@ where // Delegate to part 5 _ => { return render_box_char_part5( - c, bitmap, supersampled, - w, h, mid_x, mid_y, light, - dpi, hline, vline, fill_rect, + c, + bitmap, + supersampled, + w, + h, + mid_x, + mid_y, + light, + dpi, + hline, + vline, + fill_rect, ); } } @@ -942,7 +1358,7 @@ where F: Fn(&mut [u8], usize, usize, usize, usize), { let heavy = light * 2; - + match c { // Rounded corners '\u{256D}' | '\u{256E}' | '\u{256F}' | '\u{2570}' => { @@ -954,8 +1370,10 @@ where let vert_line_end = (vert_line_start + light).min(w); let vert_line_width = vert_line_end - vert_line_start; - let adjusted_hx = vert_line_start as f64 + vert_line_width as f64 / 2.0; - let adjusted_hy = hori_line_start as f64 + hori_line_height as f64 / 2.0; + let adjusted_hx = + vert_line_start as f64 + vert_line_width as f64 / 2.0; + let adjusted_hy = + hori_line_start as f64 + hori_line_height as f64 / 2.0; let stroke = (hori_line_height.max(vert_line_width)) as f64; let corner_radius = adjusted_hx.min(adjusted_hy); @@ -996,12 +1414,18 @@ where let qy = pos_y.abs() - by; let dx = if qx > 0.0 { qx } else { 0.0 }; let dy = if qy > 0.0 { qy } else { 0.0 }; - let dist = (dx * dx + dy * dy).sqrt() + qx.max(qy).min(0.0) - corner_radius; + let dist = (dx * dx + dy * dy).sqrt() + qx.max(qy).min(0.0) + - corner_radius; - let aa = if qx > 1e-7 && qy > 1e-7 { aa_corner } else { 0.0 }; + let aa = if qx > 1e-7 && qy > 1e-7 { + aa_corner + } else { + 0.0 + }; let outer = half_stroke - dist; let inner = -half_stroke - dist; - let alpha = smoothstep(-aa, aa, outer) - smoothstep(-aa, aa, inner); + let alpha = + smoothstep(-aa, aa, outer) - smoothstep(-aa, aa, inner); if alpha <= 0.0 { continue; @@ -1103,9 +1527,19 @@ where // Delegate to part 6 _ => { return render_box_char_part6( - c, bitmap, supersampled, - w, h, mid_x, mid_y, light, heavy, - dpi, hline, vline, fill_rect, + c, + bitmap, + supersampled, + w, + h, + mid_x, + mid_y, + light, + heavy, + dpi, + hline, + vline, + fill_rect, ); } } @@ -1167,7 +1601,9 @@ where let y = i * h / w.max(1); if x < w && y < h { for t in 0..light { - if x + t < w { bitmap[y * w + x + t] = 255; } + if x + t < w { + bitmap[y * w + x + t] = 255; + } } } } @@ -1178,7 +1614,9 @@ where let y = i * h / w.max(1); if x < w && y < h { for t in 0..light { - if x + t < w { bitmap[y * w + x + t] = 255; } + if x + t < w { + bitmap[y * w + x + t] = 255; + } } } } @@ -1190,8 +1628,12 @@ where let y = i * h / w.max(1); if y < h { for t in 0..light { - if x1 + t < w { bitmap[y * w + x1 + t] = 255; } - if x2 + t < w { bitmap[y * w + x2 + t] = 255; } + if x1 + t < w { + bitmap[y * w + x1 + t] = 255; + } + if x2 + t < w { + bitmap[y * w + x2 + t] = 255; + } } } } @@ -1218,21 +1660,27 @@ where '\u{2591}' => { for y in 0..h { for x in 0..w { - if (x + y) % 4 == 0 { bitmap[y * w + x] = 255; } + if (x + y) % 4 == 0 { + bitmap[y * w + x] = 255; + } } } } '\u{2592}' => { for y in 0..h { for x in 0..w { - if (x + y) % 2 == 0 { bitmap[y * w + x] = 255; } + if (x + y) % 2 == 0 { + bitmap[y * w + x] = 255; + } } } } '\u{2593}' => { for y in 0..h { for x in 0..w { - if (x + y) % 4 != 0 { bitmap[y * w + x] = 255; } + if (x + y) % 4 != 0 { + bitmap[y * w + x] = 255; + } } } } @@ -1270,10 +1718,7 @@ where } // Delegate to part 7 _ => { - return render_box_char_part7( - c, bitmap, supersampled, - w, h, dpi, - ); + return render_box_char_part7(c, bitmap, supersampled, w, h, dpi); } } Some(()) @@ -1385,7 +1830,9 @@ fn render_box_char_part8( // E0B1: Right-pointing chevron (outline) '\u{E0B1}' => { let mut canvas = SupersampledCanvas::new(w, h); - let thickness = (box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64).round() as usize; + let thickness = (box_thickness(1, dpi) + * SupersampledCanvas::FACTOR as f64) + .round() as usize; canvas.stroke_powerline_arrow(false, thickness); canvas.downsample(bitmap); *supersampled = true; @@ -1400,7 +1847,9 @@ fn render_box_char_part8( // E0B3: Left-pointing chevron (outline) '\u{E0B3}' => { let mut canvas = SupersampledCanvas::new(w, h); - let thickness = (box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64).round() as usize; + let thickness = (box_thickness(1, dpi) + * SupersampledCanvas::FACTOR as f64) + .round() as usize; canvas.stroke_powerline_arrow(true, thickness); canvas.downsample(bitmap); *supersampled = true; @@ -1415,7 +1864,8 @@ fn render_box_char_part8( // E0B5: Right semicircle (outline) '\u{E0B5}' => { let mut canvas = SupersampledCanvas::new(w, h); - let thickness = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + let thickness = + box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; canvas.stroke_bezier_d(false, thickness); canvas.downsample(bitmap); *supersampled = true; @@ -1430,7 +1880,8 @@ fn render_box_char_part8( // E0B7: Left semicircle (outline) '\u{E0B7}' => { let mut canvas = SupersampledCanvas::new(w, h); - let thickness = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + let thickness = + box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; canvas.stroke_bezier_d(true, thickness); canvas.downsample(bitmap); *supersampled = true; @@ -1512,7 +1963,8 @@ fn render_box_char_part9( // White circle (outline) '\u{25CB}' => { let mut canvas = SupersampledCanvas::new(w, h); - let line_width = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + let line_width = + box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; let half_line = line_width / 2.0; let cx = canvas.ss_width as f64 / 2.0; let cy = canvas.ss_height as f64 / 2.0; @@ -1529,7 +1981,8 @@ fn render_box_char_part9( let radius = cx.min(cy); let central_radius = (2.0 / 3.0) * radius; canvas.fill_circle_radius(central_radius); - let line_width = (SupersampledCanvas::FACTOR as f64).max((radius - central_radius) / 2.5); + let line_width = (SupersampledCanvas::FACTOR as f64) + .max((radius - central_radius) / 2.5); let outer_radius = 0.0_f64.max(cx.min(cy) - line_width / 2.0); canvas.stroke_circle(outer_radius, line_width); canvas.downsample(bitmap); @@ -1538,63 +1991,94 @@ fn render_box_char_part9( // Quadrant arcs '\u{25DC}' => { let mut canvas = SupersampledCanvas::new(w, h); - let line_width = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + let line_width = + box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; let half_line = 0.5_f64.max(line_width / 2.0); let cx = canvas.ss_width as f64 / 2.0; let cy = canvas.ss_height as f64 / 2.0; let radius = 0.0_f64.max(cx.min(cy) - half_line); - canvas.stroke_arc(radius, line_width, std::f64::consts::PI, 3.0 * std::f64::consts::PI / 2.0); + canvas.stroke_arc( + radius, + line_width, + std::f64::consts::PI, + 3.0 * std::f64::consts::PI / 2.0, + ); canvas.downsample(bitmap); *supersampled = true; } '\u{25DD}' => { let mut canvas = SupersampledCanvas::new(w, h); - let line_width = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + let line_width = + box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; let half_line = 0.5_f64.max(line_width / 2.0); let cx = canvas.ss_width as f64 / 2.0; let cy = canvas.ss_height as f64 / 2.0; let radius = 0.0_f64.max(cx.min(cy) - half_line); - canvas.stroke_arc(radius, line_width, 3.0 * std::f64::consts::PI / 2.0, 2.0 * std::f64::consts::PI); + canvas.stroke_arc( + radius, + line_width, + 3.0 * std::f64::consts::PI / 2.0, + 2.0 * std::f64::consts::PI, + ); canvas.downsample(bitmap); *supersampled = true; } '\u{25DE}' => { let mut canvas = SupersampledCanvas::new(w, h); - let line_width = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + let line_width = + box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; let half_line = 0.5_f64.max(line_width / 2.0); let cx = canvas.ss_width as f64 / 2.0; let cy = canvas.ss_height as f64 / 2.0; let radius = 0.0_f64.max(cx.min(cy) - half_line); - canvas.stroke_arc(radius, line_width, 0.0, std::f64::consts::PI / 2.0); + canvas.stroke_arc( + radius, + line_width, + 0.0, + std::f64::consts::PI / 2.0, + ); canvas.downsample(bitmap); *supersampled = true; } '\u{25DF}' => { let mut canvas = SupersampledCanvas::new(w, h); - let line_width = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + let line_width = + box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; let half_line = 0.5_f64.max(line_width / 2.0); let cx = canvas.ss_width as f64 / 2.0; let cy = canvas.ss_height as f64 / 2.0; let radius = 0.0_f64.max(cx.min(cy) - half_line); - canvas.stroke_arc(radius, line_width, std::f64::consts::PI / 2.0, std::f64::consts::PI); + canvas.stroke_arc( + radius, + line_width, + std::f64::consts::PI / 2.0, + std::f64::consts::PI, + ); canvas.downsample(bitmap); *supersampled = true; } // Half arcs '\u{25E0}' => { let mut canvas = SupersampledCanvas::new(w, h); - let line_width = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + let line_width = + box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; let half_line = 0.5_f64.max(line_width / 2.0); let cx = canvas.ss_width as f64 / 2.0; let cy = canvas.ss_height as f64 / 2.0; let radius = 0.0_f64.max(cx.min(cy) - half_line); - canvas.stroke_arc(radius, line_width, std::f64::consts::PI, 2.0 * std::f64::consts::PI); + canvas.stroke_arc( + radius, + line_width, + std::f64::consts::PI, + 2.0 * std::f64::consts::PI, + ); canvas.downsample(bitmap); *supersampled = true; } '\u{25E1}' => { let mut canvas = SupersampledCanvas::new(w, h); - let line_width = box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; + let line_width = + box_thickness(1, dpi) * SupersampledCanvas::FACTOR as f64; let half_line = 0.5_f64.max(line_width / 2.0); let cx = canvas.ss_width as f64 / 2.0; let cy = canvas.ss_height as f64 / 2.0; @@ -1603,7 +2087,58 @@ fn render_box_char_part9( canvas.downsample(bitmap); *supersampled = true; } - // Unimplemented character + // Nerd Fonts progress bar (U+EE00-U+EE05) + '\u{EE00}' => { + render_progress_bar(bitmap, w, h, dpi, ProgressSegment::Left, false) + } + '\u{EE01}' => render_progress_bar( + bitmap, + w, + h, + dpi, + ProgressSegment::Middle, + false, + ), + '\u{EE02}' => render_progress_bar( + bitmap, + w, + h, + dpi, + ProgressSegment::Right, + false, + ), + '\u{EE03}' => { + render_progress_bar(bitmap, w, h, dpi, ProgressSegment::Left, true) + } + '\u{EE04}' => render_progress_bar( + bitmap, + w, + h, + dpi, + ProgressSegment::Middle, + true, + ), + '\u{EE05}' => { + render_progress_bar(bitmap, w, h, dpi, ProgressSegment::Right, true) + } + '\u{EE06}' => { + render_spinner(bitmap, supersampled, w, h, dpi, 235.0, 305.0); + } + '\u{EE07}' => { + render_spinner(bitmap, supersampled, w, h, dpi, 270.0, 390.0); + } + '\u{EE08}' => { + render_spinner(bitmap, supersampled, w, h, dpi, 315.0, 470.0); + } + '\u{EE09}' => { + render_spinner(bitmap, supersampled, w, h, dpi, 360.0, 540.0); + } + '\u{EE0A}' => { + render_spinner(bitmap, supersampled, w, h, dpi, 80.0, 220.0); + } + '\u{EE0B}' => { + render_spinner(bitmap, supersampled, w, h, dpi, 170.0, 270.0); + } _ => return None, } Some(()) diff --git a/src/keyboard.rs b/src/keyboard.rs index adac077..7514dc9 100644 --- a/src/keyboard.rs +++ b/src/keyboard.rs @@ -294,11 +294,29 @@ impl KeyboardState { /// Encodes a key event according to the Kitty keyboard protocol. pub struct KeyEncoder<'a> { state: &'a KeyboardState, + /// Whether application cursor keys mode (DECCKM) is enabled. + /// When true, arrow keys send SS3 format (ESC O letter). + /// When false, arrow keys send CSI format (ESC [ letter). + application_cursor_keys: bool, } impl<'a> KeyEncoder<'a> { pub fn new(state: &'a KeyboardState) -> Self { - Self { state } + Self { + state, + application_cursor_keys: false, + } + } + + /// Creates a new KeyEncoder with application cursor keys mode setting. + pub fn with_cursor_mode( + state: &'a KeyboardState, + application_cursor_keys: bool, + ) -> Self { + Self { + state, + application_cursor_keys, + } } /// Encodes a functional key press to bytes. @@ -369,7 +387,9 @@ impl<'a> KeyEncoder<'a> { if has_event_type { result.push(b':'); - result.extend_from_slice((event_type as u8).to_string().as_bytes()); + result.extend_from_slice( + (event_type as u8).to_string().as_bytes(), + ); } } @@ -383,7 +403,11 @@ impl<'a> KeyEncoder<'a> { } /// Encodes functional keys in legacy mode. - fn encode_legacy_functional(&self, key: FunctionalKey, modifiers: Modifiers) -> Vec { + fn encode_legacy_functional( + &self, + key: FunctionalKey, + modifiers: Modifiers, + ) -> Vec { let mod_param = modifiers.encode(); match key { @@ -453,12 +477,20 @@ impl<'a> KeyEncoder<'a> { // Other functional keys - encode as CSI u _ => { let key_code = key as u32; - self.encode_csi_u(key_code, modifiers, KeyEventType::Press, None) + self.encode_csi_u( + key_code, + modifiers, + KeyEventType::Press, + None, + ) } } } - /// Encodes arrow/home/end keys: CSI 1;mod X (with modifiers) or SS3 X (no modifiers). + /// Encodes arrow/home/end keys based on DECCKM mode: + /// - Normal mode (application_cursor_keys=false): CSI letter (ESC [ letter) + /// - Application mode (application_cursor_keys=true): SS3 letter (ESC O letter) + /// With modifiers, always use CSI 1;mod letter format. fn encode_arrow(&self, letter: u8, mod_param: Option) -> Vec { if let Some(m) = mod_param { // With modifiers: CSI 1;mod letter @@ -466,9 +498,12 @@ impl<'a> KeyEncoder<'a> { result.extend_from_slice(m.to_string().as_bytes()); result.push(letter); result - } else { - // No modifiers: SS3 letter (application cursor mode) + } else if self.application_cursor_keys { + // Application cursor mode: SS3 letter (ESC O letter) vec![0x1b, b'O', letter] + } else { + // Normal cursor mode: CSI letter (ESC [ letter) + vec![0x1b, b'[', letter] } } diff --git a/src/main.rs b/src/main.rs index 422b26d..2e286af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,20 @@ //! ZTerm - GPU-accelerated terminal emulator. -//! +//! //! Single-process architecture: owns PTY, terminal state, and rendering. //! Supports window close/reopen without losing terminal state. use zterm::config::{Action, Config}; -use zterm::keyboard::{FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Modifiers}; +use zterm::keyboard::{ + FunctionalKey, KeyEncoder, KeyEventType, KeyboardState, Modifiers, +}; use zterm::pty::Pty; -use zterm::renderer::{EdgeGlow, PaneRenderInfo, Renderer, StatuslineComponent, StatuslineContent, StatuslineSection}; -use zterm::terminal::{Direction, Terminal, TerminalCommand, MouseTrackingMode}; +use zterm::renderer::{ + EdgeGlow, PaneRenderInfo, Renderer, StatuslineComponent, StatuslineContent, + StatuslineSection, +}; +use zterm::terminal::{ + Direction, MouseTrackingMode, Terminal, TerminalCommand, +}; use zterm::vt_parser::SharedParser; use std::collections::HashMap; @@ -22,8 +29,13 @@ use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use polling::{Event, Events, Poller}; use winit::application::ApplicationHandler; use winit::dpi::{PhysicalPosition, PhysicalSize}; -use winit::event::{ElementState, KeyEvent, MouseButton, Modifiers as WinitModifiers, MouseScrollDelta, WindowEvent}; -use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}; +use winit::event::{ + ElementState, KeyEvent, Modifiers as WinitModifiers, MouseButton, + MouseScrollDelta, WindowEvent, +}; +use winit::event_loop::{ + ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy, +}; use winit::keyboard::{Key, NamedKey}; use winit::platform::wayland::EventLoopBuilderExtWayland; use winit::window::{Window, WindowId}; @@ -71,28 +83,28 @@ struct Pane { impl Pane { /// Create a new pane with its own terminal and PTY. - fn new(cols: usize, rows: usize, scrollback_lines: usize) -> Result { + fn new( + cols: usize, + rows: usize, + scrollback_lines: usize, + ) -> Result { let terminal = Terminal::new(cols, rows, scrollback_lines); - + // Calculate pixel dimensions (use default cell size estimate) let default_cell_width = 10u16; let default_cell_height = 20u16; let width_px = cols as u16 * default_cell_width; let height_px = rows as u16 * default_cell_height; - + // Spawn PTY with initial size - this sets the size BEFORE forking, // so the shell inherits the correct terminal dimensions immediately. // This prevents race conditions where .zshrc runs before resize(). - let pty = Pty::spawn( - None, - cols as u16, - rows as u16, - width_px, - height_px - ).map_err(|e| format!("Failed to spawn PTY: {}", e))?; - + let pty = + Pty::spawn(None, cols as u16, rows as u16, width_px, height_px) + .map_err(|e| format!("Failed to spawn PTY: {}", e))?; + let pty_fd = pty.as_raw_fd(); - + Ok(Self { id: PaneId::new(), terminal, @@ -107,35 +119,45 @@ impl Pane { custom_statusline: None, }) } - + /// Resize the terminal and PTY. /// Only sends SIGWINCH to the PTY if the size actually changed. - fn resize(&mut self, cols: usize, rows: usize, width_px: u16, height_px: u16) { + fn resize( + &mut self, + cols: usize, + rows: usize, + width_px: u16, + height_px: u16, + ) { // Check if size actually changed before sending SIGWINCH // This prevents spurious signals that can interrupt programs like fastfetch - let size_changed = cols != self.terminal.cols || rows != self.terminal.rows; - + let size_changed = + cols != self.terminal.cols || rows != self.terminal.rows; + self.terminal.resize(cols, rows); - + if size_changed { - if let Err(e) = self.pty.resize(cols as u16, rows as u16, width_px, height_px) { + if let Err(e) = + self.pty + .resize(cols as u16, rows as u16, width_px, height_px) + { log::warn!("Failed to resize PTY: {}", e); } } } - + /// Write data to the PTY. fn write_to_pty(&mut self, data: &[u8]) { if let Err(e) = self.pty.write(data) { log::warn!("Failed to write to PTY: {}", e); } } - + /// Check if the shell has exited. fn child_exited(&self) -> bool { self.pty.child_exited() } - + /// Check if the foreground process matches any of the given program names. /// Used for pass-through keybindings (e.g., passing Alt+Arrow to Neovim). fn foreground_matches(&self, programs: &[String]) -> bool { @@ -148,28 +170,33 @@ impl Pane { false } } - + /// Calculate the current dim factor based on animation progress. /// Returns a value between `inactive_dim` (for unfocused) and 1.0 (for focused). - fn calculate_dim_factor(&mut self, is_focused: bool, fade_duration_ms: u64, inactive_dim: f32) -> f32 { + fn calculate_dim_factor( + &mut self, + is_focused: bool, + fade_duration_ms: u64, + inactive_dim: f32, + ) -> f32 { // Detect focus change if is_focused != self.was_focused { self.focus_animation_start = std::time::Instant::now(); self.was_focused = is_focused; } - + // If no animation (instant), return target value immediately if fade_duration_ms == 0 { return if is_focused { 1.0 } else { inactive_dim }; } - + let elapsed = self.focus_animation_start.elapsed().as_millis() as f32; let duration = fade_duration_ms as f32; let progress = (elapsed / duration).min(1.0); - + // Smooth easing (ease-out cubic) let eased = 1.0 - (1.0 - progress).powi(3); - + if is_focused { // Fading in: from inactive_dim to 1.0 inactive_dim + (1.0 - inactive_dim) * eased @@ -233,7 +260,7 @@ impl SplitNode { }, } } - + /// Split this node, replacing it with a split containing the original and a new pane. /// Returns the new node that should replace this one. fn split(self, new_pane_id: PaneId, horizontal: bool) -> Self { @@ -244,11 +271,20 @@ impl SplitNode { second: Box::new(SplitNode::leaf(new_pane_id)), } } - + /// Calculate layout for all nodes given the available space. /// Returns the actual used (width, height) after cell alignment. /// Note: border_width is kept for API compatibility but borders are now overlaid on panes. - fn layout(&mut self, x: f32, y: f32, width: f32, height: f32, cell_width: f32, cell_height: f32, _border_width: f32) -> (f32, f32) { + fn layout( + &mut self, + x: f32, + y: f32, + width: f32, + height: f32, + cell_width: f32, + cell_height: f32, + _border_width: f32, + ) -> (f32, f32) { match self { SplitNode::Leaf { geometry, .. } => { // Calculate how many cells fit @@ -259,8 +295,8 @@ impl SplitNode { *geometry = PaneGeometry { x, y, - width, // Full allocated width - height, // Full allocated height + width, // Full allocated width + height, // Full allocated height cols: cols.max(1), rows: rows.max(1), }; @@ -269,50 +305,99 @@ impl SplitNode { let actual_height = rows.max(1) as f32 * cell_height; (actual_width, actual_height) } - SplitNode::Split { horizontal, ratio, first, second } => { + SplitNode::Split { + horizontal, + ratio, + first, + second, + } => { if *horizontal { // Side-by-side split (horizontal means panes are side-by-side) // No border space reserved - border will be overlaid on pane edges let total_cols = (width / cell_width).floor() as usize; - + // Distribute columns by ratio - let first_cols = ((total_cols as f32) * *ratio).round() as usize; + let first_cols = + ((total_cols as f32) * *ratio).round() as usize; let second_cols = total_cols.saturating_sub(first_cols); - + // Convert back to pixel widths - let first_alloc_width = first_cols.max(1) as f32 * cell_width; - let second_alloc_width = second_cols.max(1) as f32 * cell_width; - + let first_alloc_width = + first_cols.max(1) as f32 * cell_width; + let second_alloc_width = + second_cols.max(1) as f32 * cell_width; + // Layout panes flush against each other (border overlays the edge) - let (first_actual_w, first_actual_h) = first.layout(x, y, first_alloc_width, height, cell_width, cell_height, _border_width); - let (second_actual_w, second_actual_h) = second.layout(x + first_actual_w, y, second_alloc_width, height, cell_width, cell_height, _border_width); - + let (first_actual_w, first_actual_h) = first.layout( + x, + y, + first_alloc_width, + height, + cell_width, + cell_height, + _border_width, + ); + let (second_actual_w, second_actual_h) = second.layout( + x + first_actual_w, + y, + second_alloc_width, + height, + cell_width, + cell_height, + _border_width, + ); + // Total used size: both panes (no border gap) - (first_actual_w + second_actual_w, first_actual_h.max(second_actual_h)) + ( + first_actual_w + second_actual_w, + first_actual_h.max(second_actual_h), + ) } else { // Stacked split (vertical means panes are stacked) // No border space reserved - border will be overlaid on pane edges let total_rows = (height / cell_height).floor() as usize; - + // Distribute rows by ratio - let first_rows = ((total_rows as f32) * *ratio).round() as usize; + let first_rows = + ((total_rows as f32) * *ratio).round() as usize; let second_rows = total_rows.saturating_sub(first_rows); - + // Convert back to pixel heights - let first_alloc_height = first_rows.max(1) as f32 * cell_height; - let second_alloc_height = second_rows.max(1) as f32 * cell_height; - + let first_alloc_height = + first_rows.max(1) as f32 * cell_height; + let second_alloc_height = + second_rows.max(1) as f32 * cell_height; + // Layout panes flush against each other (border overlays the edge) - let (first_actual_w, first_actual_h) = first.layout(x, y, width, first_alloc_height, cell_width, cell_height, _border_width); - let (second_actual_w, second_actual_h) = second.layout(x, y + first_actual_h, width, second_alloc_height, cell_width, cell_height, _border_width); - + let (first_actual_w, first_actual_h) = first.layout( + x, + y, + width, + first_alloc_height, + cell_width, + cell_height, + _border_width, + ); + let (second_actual_w, second_actual_h) = second.layout( + x, + y + first_actual_h, + width, + second_alloc_height, + cell_width, + cell_height, + _border_width, + ); + // Total used size: both panes (no border gap) - (first_actual_w.max(second_actual_w), first_actual_h + second_actual_h) + ( + first_actual_w.max(second_actual_w), + first_actual_h + second_actual_h, + ) } } } } - + /// Find the geometry for a specific pane. fn find_geometry(&self, target_id: PaneId) -> Option { match self { @@ -323,12 +408,12 @@ impl SplitNode { None } } - SplitNode::Split { first, second, .. } => { - first.find_geometry(target_id).or_else(|| second.find_geometry(target_id)) - } + SplitNode::Split { first, second, .. } => first + .find_geometry(target_id) + .or_else(|| second.find_geometry(target_id)), } } - + /// Collect all pane geometries. fn collect_geometries(&self, geometries: &mut Vec<(PaneId, PaneGeometry)>) { match self { @@ -341,57 +426,65 @@ impl SplitNode { } } } - + /// Find a neighbor pane in the given direction. /// Returns the pane ID of the neighbor, if any. - fn find_neighbor(&self, target_id: PaneId, direction: Direction) -> Option { + fn find_neighbor( + &self, + target_id: PaneId, + direction: Direction, + ) -> Option { // First, find the geometry of the target pane let target_geom = self.find_geometry(target_id)?; - + // Collect all geometries let mut all_geoms = Vec::new(); self.collect_geometries(&mut all_geoms); - + // Find the best candidate in the given direction let mut best: Option<(PaneId, f32)> = None; - + for (pane_id, geom) in all_geoms { if pane_id == target_id { continue; } - + let is_neighbor = match direction { Direction::Up => { // Neighbor is above: its bottom edge is near our top edge - geom.y + geom.height <= target_geom.y + 5.0 && - Self::overlaps_horizontally(&geom, &target_geom) + geom.y + geom.height <= target_geom.y + 5.0 + && Self::overlaps_horizontally(&geom, &target_geom) } Direction::Down => { // Neighbor is below: its top edge is near our bottom edge - geom.y >= target_geom.y + target_geom.height - 5.0 && - Self::overlaps_horizontally(&geom, &target_geom) + geom.y >= target_geom.y + target_geom.height - 5.0 + && Self::overlaps_horizontally(&geom, &target_geom) } Direction::Left => { // Neighbor is to the left: its right edge is near our left edge - geom.x + geom.width <= target_geom.x + 5.0 && - Self::overlaps_vertically(&geom, &target_geom) + geom.x + geom.width <= target_geom.x + 5.0 + && Self::overlaps_vertically(&geom, &target_geom) } Direction::Right => { // Neighbor is to the right: its left edge is near our right edge - geom.x >= target_geom.x + target_geom.width - 5.0 && - Self::overlaps_vertically(&geom, &target_geom) + geom.x >= target_geom.x + target_geom.width - 5.0 + && Self::overlaps_vertically(&geom, &target_geom) } }; - + if is_neighbor { // Calculate distance (for choosing closest) let distance = match direction { Direction::Up => target_geom.y - (geom.y + geom.height), - Direction::Down => geom.y - (target_geom.y + target_geom.height), + Direction::Down => { + geom.y - (target_geom.y + target_geom.height) + } Direction::Left => target_geom.x - (geom.x + geom.width), - Direction::Right => geom.x - (target_geom.x + target_geom.width), + Direction::Right => { + geom.x - (target_geom.x + target_geom.width) + } }; - + if distance >= 0.0 { if best.is_none() || distance < best.unwrap().1 { best = Some((pane_id, distance)); @@ -399,10 +492,10 @@ impl SplitNode { } } } - + best.map(|(id, _)| id) } - + fn overlaps_horizontally(a: &PaneGeometry, b: &PaneGeometry) -> bool { let a_left = a.x; let a_right = a.x + a.width; @@ -410,7 +503,7 @@ impl SplitNode { let b_right = b.x + b.width; a_left < b_right && a_right > b_left } - + fn overlaps_vertically(a: &PaneGeometry, b: &PaneGeometry) -> bool { let a_top = a.y; let a_bottom = a.y + a.height; @@ -418,7 +511,7 @@ impl SplitNode { let b_bottom = b.y + b.height; a_top < b_bottom && a_bottom > b_top } - + /// Remove a pane from the tree. Returns the new tree root (or None if tree is empty). fn remove_pane(self, target_id: PaneId) -> Option { match self { @@ -429,11 +522,16 @@ impl SplitNode { Some(self) // Keep this leaf } } - SplitNode::Split { horizontal, ratio, first, second } => { + SplitNode::Split { + horizontal, + ratio, + first, + second, + } => { // Check if target is in first or second subtree let first_has_target = first.contains_pane(target_id); let second_has_target = second.contains_pane(target_id); - + if first_has_target { match first.remove_pane(target_id) { Some(new_first) => Some(SplitNode::Split { @@ -455,40 +553,63 @@ impl SplitNode { None => Some(*first), // Second child removed, promote first } } else { - Some(SplitNode::Split { horizontal, ratio, first, second }) + Some(SplitNode::Split { + horizontal, + ratio, + first, + second, + }) } } } } - + /// Check if this tree contains the given pane. fn contains_pane(&self, target_id: PaneId) -> bool { match self { SplitNode::Leaf { pane_id, .. } => *pane_id == target_id, SplitNode::Split { first, second, .. } => { - first.contains_pane(target_id) || second.contains_pane(target_id) + first.contains_pane(target_id) + || second.contains_pane(target_id) } } } - + /// Split the pane with the given ID. - fn split_pane(self, target_id: PaneId, new_pane_id: PaneId, horizontal: bool) -> Self { + fn split_pane( + self, + target_id: PaneId, + new_pane_id: PaneId, + horizontal: bool, + ) -> Self { match self { SplitNode::Leaf { pane_id, geometry } => { if pane_id == target_id { - SplitNode::Leaf { pane_id, geometry }.split(new_pane_id, horizontal) + SplitNode::Leaf { pane_id, geometry } + .split(new_pane_id, horizontal) } else { SplitNode::Leaf { pane_id, geometry } } } - SplitNode::Split { horizontal: h, ratio, first, second } => { - SplitNode::Split { - horizontal: h, - ratio, - first: Box::new(first.split_pane(target_id, new_pane_id, horizontal)), - second: Box::new(second.split_pane(target_id, new_pane_id, horizontal)), - } - } + SplitNode::Split { + horizontal: h, + ratio, + first, + second, + } => SplitNode::Split { + horizontal: h, + ratio, + first: Box::new(first.split_pane( + target_id, + new_pane_id, + horizontal, + )), + second: Box::new(second.split_pane( + target_id, + new_pane_id, + horizontal, + )), + }, } } } @@ -526,13 +647,17 @@ struct Tab { impl Tab { /// Create a new tab with a single pane. - fn new(cols: usize, rows: usize, scrollback_lines: usize) -> Result { + fn new( + cols: usize, + rows: usize, + scrollback_lines: usize, + ) -> Result { let pane = Pane::new(cols, rows, scrollback_lines)?; let pane_id = pane.id; - + let mut panes = HashMap::new(); panes.insert(pane_id, pane); - + Ok(Self { id: TabId::new(), panes, @@ -542,29 +667,44 @@ impl Tab { grid_used_dimensions: (0.0, 0.0), // Will be set on first resize }) } - + /// Get the active pane. fn active_pane(&self) -> Option<&Pane> { self.panes.get(&self.active_pane) } - + /// Get the active pane mutably. fn active_pane_mut(&mut self) -> Option<&mut Pane> { self.panes.get_mut(&self.active_pane) } - + /// Resize all panes based on new window dimensions. - fn resize(&mut self, width: f32, height: f32, cell_width: f32, cell_height: f32, border_width: f32) { + fn resize( + &mut self, + width: f32, + height: f32, + cell_width: f32, + cell_height: f32, + border_width: f32, + ) { // Recalculate layout - returns actual used dimensions for centering - let used_dims = self.split_root.layout(0.0, 0.0, width, height, cell_width, cell_height, border_width); - + let used_dims = self.split_root.layout( + 0.0, + 0.0, + width, + height, + cell_width, + cell_height, + border_width, + ); + // Store the used dimensions for this tab self.grid_used_dimensions = used_dims; - + // Resize each pane's terminal based on its geometry let mut geometries = Vec::new(); self.split_root.collect_geometries(&mut geometries); - + for (pane_id, geom) in geometries { if let Some(pane) = self.panes.get_mut(&pane_id) { // Report pixel dimensions as exact cell grid size (cols * cell_width, rows * cell_height) @@ -575,61 +715,71 @@ impl Tab { } } } - + /// Write data to the active pane's PTY. fn write_to_pty(&mut self, data: &[u8]) { if let Some(pane) = self.active_pane_mut() { pane.write_to_pty(data); } } - + /// Check if any pane's shell has exited and clean up. /// Returns true if all panes have exited (tab should close). fn check_exited_panes(&mut self) -> bool { // Collect exited pane IDs - let exited: Vec = self.panes + let exited: Vec = self + .panes .iter() .filter(|(_, pane)| pane.child_exited()) .map(|(id, _)| *id) .collect(); - + // Remove exited panes for pane_id in exited { self.remove_pane(pane_id); } - + self.panes.is_empty() } - + /// Split the active pane. - fn split(&mut self, horizontal: bool, cols: usize, rows: usize, scrollback_lines: usize) -> Result { + fn split( + &mut self, + horizontal: bool, + cols: usize, + rows: usize, + scrollback_lines: usize, + ) -> Result { let new_pane = Pane::new(cols, rows, scrollback_lines)?; let new_pane_id = new_pane.id; - + // Add to panes map self.panes.insert(new_pane_id, new_pane); - + // Update split tree - let old_root = std::mem::replace(&mut self.split_root, SplitNode::leaf(PaneId(0))); - self.split_root = old_root.split_pane(self.active_pane, new_pane_id, horizontal); - + let old_root = + std::mem::replace(&mut self.split_root, SplitNode::leaf(PaneId(0))); + self.split_root = + old_root.split_pane(self.active_pane, new_pane_id, horizontal); + // Focus the new pane self.active_pane = new_pane_id; - + Ok(new_pane_id) } - + /// Remove a pane from the tab. fn remove_pane(&mut self, pane_id: PaneId) { // Remove from map self.panes.remove(&pane_id); - + // Update split tree - let old_root = std::mem::replace(&mut self.split_root, SplitNode::leaf(PaneId(0))); + let old_root = + std::mem::replace(&mut self.split_root, SplitNode::leaf(PaneId(0))); if let Some(new_root) = old_root.remove_pane(pane_id) { self.split_root = new_root; } - + // If we removed the active pane, select a new one if self.active_pane == pane_id { if let Some(first_pane_id) = self.panes.keys().next() { @@ -637,37 +787,39 @@ impl Tab { } } } - + /// Close the active pane. fn close_active_pane(&mut self) { let pane_id = self.active_pane; self.remove_pane(pane_id); } - + /// Navigate to a neighbor pane in the given direction. fn focus_neighbor(&mut self, direction: Direction) { - if let Some(neighbor_id) = self.split_root.find_neighbor(self.active_pane, direction) { + if let Some(neighbor_id) = + self.split_root.find_neighbor(self.active_pane, direction) + { self.active_pane = neighbor_id; } } - + /// Get pane by ID. fn get_pane(&self, pane_id: PaneId) -> Option<&Pane> { self.panes.get(&pane_id) } - + /// Get pane by ID mutably. fn get_pane_mut(&mut self, pane_id: PaneId) -> Option<&mut Pane> { self.panes.get_mut(&pane_id) } - + /// Collect all pane geometries for rendering. fn collect_pane_geometries(&self) -> Vec<(PaneId, PaneGeometry)> { let mut geometries = Vec::new(); self.split_root.collect_geometries(&mut geometries); geometries } - + /// Check if all panes have exited (tab should be closed). fn child_exited(&mut self) -> bool { self.check_exited_panes() @@ -676,8 +828,8 @@ impl Tab { /// PID file location for single-instance support. fn pid_file_path() -> std::path::PathBuf { - let runtime_dir = std::env::var("XDG_RUNTIME_DIR") - .unwrap_or_else(|_| "/tmp".to_string()); + let runtime_dir = + std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); std::path::PathBuf::from(runtime_dir).join("zterm.pid") } @@ -685,12 +837,12 @@ fn pid_file_path() -> std::path::PathBuf { /// Returns true if we signaled an existing instance (and should exit). fn signal_existing_instance() -> bool { let pid_path = pid_file_path(); - + if let Ok(contents) = std::fs::read_to_string(&pid_path) { if let Ok(pid) = contents.trim().parse::() { // Check if process is alive let alive = unsafe { libc::kill(pid, 0) == 0 }; - + if alive { // Send SIGUSR1 to show window log::info!("Signaling existing instance (PID {})", pid); @@ -702,7 +854,7 @@ fn signal_existing_instance() -> bool { } } } - + false } @@ -718,7 +870,7 @@ fn remove_pid_file() { } /// Build a statusline section for the current working directory. -/// +/// /// Transforms the path into styled segments within a section: /// - Replaces $HOME prefix with "~" /// - Each directory segment gets " " prefix and its own color @@ -730,9 +882,9 @@ fn remove_pid_file() { fn build_cwd_section(cwd: &str) -> StatuslineSection { // Colors to cycle through (skip 0 and 1 which are often near-white in custom schemes) const COLORS: [u8; 6] = [2, 3, 4, 5, 6, 7]; - + let mut components = Vec::new(); - + // Get home directory and replace prefix with ~ let display_path = if let Ok(home) = std::env::var("HOME") { if cwd.starts_with(&home) { @@ -743,35 +895,35 @@ fn build_cwd_section(cwd: &str) -> StatuslineSection { } else { cwd.to_string() }; - + // Split path into segments - let segments: Vec<&str> = display_path - .split('/') - .filter(|s| !s.is_empty()) - .collect(); - + let segments: Vec<&str> = + display_path.split('/').filter(|s| !s.is_empty()).collect(); + if segments.is_empty() { // Root directory components.push(StatuslineComponent::new(" \u{F07C} / ").fg(COLORS[0])); - return StatuslineSection::with_rgb_bg(0x28, 0x28, 0x28).with_components(components); + return StatuslineSection::with_rgb_bg(0x28, 0x28, 0x28) + .with_components(components); } - + // Add leading space for padding components.push(StatuslineComponent::new(" ")); - + let last_idx = segments.len() - 1; - + for (i, segment) in segments.iter().enumerate() { // Cycle through colors for each segment let color = COLORS[i % COLORS.len()]; - + if i > 0 { // Add arrow separator with previous segment's color // U+E0B1 is the powerline thin chevron right let prev_color = COLORS[(i - 1) % COLORS.len()]; - components.push(StatuslineComponent::new(" \u{E0B1} ").fg(prev_color)); + components + .push(StatuslineComponent::new(" \u{E0B1} ").fg(prev_color)); } - + // Directory segment with folder icon prefix // U+F07C is the folder-open icon from Nerd Fonts let text = format!("\u{F07C} {}", segment); @@ -781,13 +933,13 @@ fn build_cwd_section(cwd: &str) -> StatuslineSection { } else { StatuslineComponent::new(text).fg(color) }; - + components.push(component); } - + // Add trailing space for padding before the powerline arrow components.push(StatuslineComponent::new(" ")); - + // Use dark gray (#282828) as section background StatuslineSection::with_rgb_bg(0x28, 0x28, 0x28).with_components(components) } @@ -817,20 +969,22 @@ struct GitStatus { /// Returns None if not in a git repository. fn get_git_status(cwd: &str) -> Option { use std::process::Command; - + // Check if we're in a git repo and get the branch name let head_output = Command::new("git") .args(["rev-parse", "--abbrev-ref", "HEAD"]) .current_dir(cwd) .output() .ok()?; - + if !head_output.status.success() { return None; } - - let head = String::from_utf8_lossy(&head_output.stdout).trim().to_string(); - + + let head = String::from_utf8_lossy(&head_output.stdout) + .trim() + .to_string(); + // Get ahead/behind status let mut ahead = 0; let mut behind = 0; @@ -848,7 +1002,7 @@ fn get_git_status(cwd: &str) -> Option { } } } - + // Get working directory and staging status using git status --porcelain let mut working_modified = 0; let mut working_added = 0; @@ -856,7 +1010,7 @@ fn get_git_status(cwd: &str) -> Option { let mut staging_modified = 0; let mut staging_added = 0; let mut staging_deleted = 0; - + if let Ok(output) = Command::new("git") .args(["status", "--porcelain"]) .current_dir(cwd) @@ -871,7 +1025,7 @@ fn get_git_status(cwd: &str) -> Option { let chars: Vec = line.chars().collect(); let staging_char = chars[0]; let working_char = chars[1]; - + // Staging status (first column) match staging_char { 'M' => staging_modified += 1, @@ -881,7 +1035,7 @@ fn get_git_status(cwd: &str) -> Option { 'C' => staging_added += 1, // copied _ => {} } - + // Working directory status (second column) match working_char { 'M' => working_modified += 1, @@ -892,7 +1046,7 @@ fn get_git_status(cwd: &str) -> Option { } } } - + // Build status strings like oh-my-posh format let working_changed = working_modified + working_added + working_deleted; let mut working_parts = Vec::new(); @@ -906,7 +1060,7 @@ fn get_git_status(cwd: &str) -> Option { working_parts.push(format!("-{}", working_deleted)); } let working_string = working_parts.join(" "); - + let staging_changed = staging_modified + staging_added + staging_deleted; let mut staging_parts = Vec::new(); if staging_modified > 0 { @@ -919,7 +1073,7 @@ fn get_git_status(cwd: &str) -> Option { staging_parts.push(format!("-{}", staging_deleted)); } let staging_string = staging_parts.join(" "); - + // Get stash count let mut stash_count = 0; if let Ok(output) = Command::new("git") @@ -932,7 +1086,7 @@ fn get_git_status(cwd: &str) -> Option { stash_count = stash.lines().count(); } } - + Some(GitStatus { head, ahead, @@ -949,7 +1103,7 @@ fn get_git_status(cwd: &str) -> Option { /// Returns None if not in a git repository. fn build_git_section(cwd: &str) -> Option { let status = get_git_status(cwd)?; - + // Determine foreground color based on state (matching oh-my-posh template) // Priority order (last match wins in oh-my-posh): // 1. Default: #0da300 (green) @@ -965,17 +1119,23 @@ fn build_git_section(cwd: &str) -> Option { } else { (0x0d, 0xa3, 0x00) // #0da300 - green }; - + let mut components = Vec::new(); - + // Leading space - components.push(StatuslineComponent::new(" ").rgb_fg(fg_color.0, fg_color.1, fg_color.2)); - + components.push( + StatuslineComponent::new(" ") + .rgb_fg(fg_color.0, fg_color.1, fg_color.2), + ); + // Branch name (HEAD) // Use git branch icon U+E0A0 let head_text = format!("\u{E0A0} {}", status.head); - components.push(StatuslineComponent::new(head_text).rgb_fg(fg_color.0, fg_color.1, fg_color.2)); - + components.push( + StatuslineComponent::new(head_text) + .rgb_fg(fg_color.0, fg_color.1, fg_color.2), + ); + // Branch status (ahead/behind) if status.ahead > 0 || status.behind > 0 { let mut branch_status = String::new(); @@ -985,37 +1145,58 @@ fn build_git_section(cwd: &str) -> Option { if status.behind > 0 { branch_status.push_str(&format!(" ↓{}", status.behind)); } - components.push(StatuslineComponent::new(branch_status).rgb_fg(fg_color.0, fg_color.1, fg_color.2)); + components.push( + StatuslineComponent::new(branch_status) + .rgb_fg(fg_color.0, fg_color.1, fg_color.2), + ); } - + // Working directory changes - U+F044 is the edit/pencil icon if status.working_changed > 0 { let working_text = format!(" \u{F044} {}", status.working_string); - components.push(StatuslineComponent::new(working_text).rgb_fg(fg_color.0, fg_color.1, fg_color.2)); + components.push( + StatuslineComponent::new(working_text) + .rgb_fg(fg_color.0, fg_color.1, fg_color.2), + ); } - + // Separator between working and staging (if both have changes) if status.working_changed > 0 && status.staging_changed > 0 { - components.push(StatuslineComponent::new(" |").rgb_fg(fg_color.0, fg_color.1, fg_color.2)); + components.push( + StatuslineComponent::new(" |") + .rgb_fg(fg_color.0, fg_color.1, fg_color.2), + ); } - + // Staged changes - U+F046 is the check/staged icon if status.staging_changed > 0 { let staging_text = format!(" \u{F046} {}", status.staging_string); - components.push(StatuslineComponent::new(staging_text).rgb_fg(fg_color.0, fg_color.1, fg_color.2)); + components.push( + StatuslineComponent::new(staging_text) + .rgb_fg(fg_color.0, fg_color.1, fg_color.2), + ); } - + // Stash count - U+EB4B is the stash icon if status.stash_count > 0 { let stash_text = format!(" \u{EB4B} {}", status.stash_count); - components.push(StatuslineComponent::new(stash_text).rgb_fg(fg_color.0, fg_color.1, fg_color.2)); + components.push( + StatuslineComponent::new(stash_text) + .rgb_fg(fg_color.0, fg_color.1, fg_color.2), + ); } - + // Trailing space - components.push(StatuslineComponent::new(" ").rgb_fg(fg_color.0, fg_color.1, fg_color.2)); - + components.push( + StatuslineComponent::new(" ") + .rgb_fg(fg_color.0, fg_color.1, fg_color.2), + ); + // Background: #232323 - Some(StatuslineSection::with_rgb_bg(0x23, 0x23, 0x23).with_components(components)) + Some( + StatuslineSection::with_rgb_bg(0x23, 0x23, 0x23) + .with_components(components), + ) } /// A cell position in the terminal grid. @@ -1034,29 +1215,44 @@ struct Selection { impl Selection { fn normalized(&self) -> (CellPosition, CellPosition) { - if self.start.row < self.end.row - || (self.start.row == self.end.row && self.start.col <= self.end.col) { + if self.start.row < self.end.row + || (self.start.row == self.end.row + && self.start.col <= self.end.col) + { (self.start, self.end) } else { (self.end, self.start) } } - - fn to_screen_coords(&self, current_scroll_offset: usize, visible_rows: usize) -> Option<(usize, usize, usize, usize)> { + + fn to_screen_coords( + &self, + current_scroll_offset: usize, + visible_rows: usize, + ) -> Option<(usize, usize, usize, usize)> { let (start, end) = self.normalized(); let scroll_offset = current_scroll_offset as isize; let screen_start_row = start.row + scroll_offset; let screen_end_row = end.row + scroll_offset; - + if screen_end_row < 0 || screen_start_row >= visible_rows as isize { return None; } - + let screen_start_row = screen_start_row.max(0) as usize; - let screen_end_row = (screen_end_row as usize).min(visible_rows.saturating_sub(1)); - let start_col = if start.row + scroll_offset < 0 { 0 } else { start.col }; - let end_col = if end.row + scroll_offset >= visible_rows as isize { usize::MAX } else { end.col }; - + let screen_end_row = + (screen_end_row as usize).min(visible_rows.saturating_sub(1)); + let start_col = if start.row + scroll_offset < 0 { + 0 + } else { + start.col + }; + let end_col = if end.row + scroll_offset >= visible_rows as isize { + usize::MAX + } else { + end.col + }; + Some((start_col, screen_start_row, end_col, screen_end_row)) } } @@ -1130,13 +1326,13 @@ impl App { fn new() -> Self { let config = Config::load(); log::info!("Config: font_size={}", config.font_size); - + let action_map = config.keybindings.build_action_map(); log::info!("Action map built with {} bindings:", action_map.len()); for (key, action) in &action_map { log::info!(" {:?} => {:?}", key, action); } - + Self { window: None, renderer: None, @@ -1167,39 +1363,50 @@ impl App { render_fatal_error: false, } } - + fn set_event_loop_proxy(&mut self, proxy: EventLoopProxy) { 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; - + 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); + 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); + 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); @@ -1207,13 +1414,13 @@ impl App { self.resize_all_panes(); } } - + // Request redraw to apply visual changes self.request_redraw(); - + log::info!("Configuration reloaded successfully"); } - + /// Request a window redraw if window is available. #[inline] fn request_redraw(&self) { @@ -1221,25 +1428,29 @@ impl App { window.request_redraw(); } } - + /// 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 { log::info!("Creating new tab with {}x{} terminal", cols, rows); - + match Tab::new(cols, rows, self.config.scrollback_lines) { Ok(tab) => { let tab_idx = self.tabs.len(); - + // Start I/O threads for all panes in this tab for pane in tab.panes.values() { self.start_pane_io_thread(pane); } - + self.tabs.push(tab); self.active_tab = tab_idx; - - log::info!("Tab {} created (total: {})", tab_idx, self.tabs.len()); + + log::info!( + "Tab {} created (total: {})", + tab_idx, + self.tabs.len() + ); Some(tab_idx) } Err(e) => { @@ -1248,25 +1459,36 @@ impl App { } } } - + /// Start background I/O thread for a pane's PTY. fn start_pane_io_thread(&self, pane: &Pane) { - self.start_pane_io_thread_with_info(pane.id, pane.pty_fd, pane.shared_parser.clone()); + self.start_pane_io_thread_with_info( + pane.id, + pane.pty_fd, + pane.shared_parser.clone(), + ); } - + /// Start background I/O thread for a pane's PTY with explicit info. - /// + /// /// Kitty-style design: /// - I/O thread reads PTY data directly into SharedParser's buffer /// - Commits bytes atomically (pending counter) /// - Sends Tick to main thread after INPUT_DELAY /// - When buffer is full, disables PTY polling and waits for wakeup /// - Main thread wakes us after parsing (which frees space) - fn start_pane_io_thread_with_info(&self, pane_id: PaneId, pty_fd: i32, shared_parser: Arc) { - let Some(proxy) = self.event_loop_proxy.clone() else { return }; + fn start_pane_io_thread_with_info( + &self, + pane_id: PaneId, + pty_fd: i32, + shared_parser: Arc, + ) { + let Some(proxy) = self.event_loop_proxy.clone() else { + return; + }; let shutdown = self.shutdown.clone(); let wakeup_fd = shared_parser.wakeup_fd(); - + std::thread::Builder::new() .name(format!("pty-io-{}", pane_id.0)) .spawn(move || { @@ -1449,15 +1671,15 @@ impl App { }) .expect("Failed to spawn PTY I/O thread"); } - + /// Create the window and renderer. fn create_window(&mut self, event_loop: &ActiveEventLoop) { if self.window.is_some() { return; // Window already exists } - + log::info!("Creating window"); - + let mut window_attributes = Window::default_attributes() .with_title("ZTerm") .with_inner_size(PhysicalSize::new(800, 600)); @@ -1472,9 +1694,10 @@ impl App { .expect("Failed to create window"), ); - let renderer = pollster::block_on(Renderer::new(window.clone(), &self.config)); + let renderer = + pollster::block_on(Renderer::new(window.clone(), &self.config)); let (cols, rows) = renderer.terminal_size(); - + // Create first tab if no tabs exist if self.tabs.is_empty() { self.create_tab(cols, rows); @@ -1486,40 +1709,49 @@ impl App { self.window = Some(window); self.renderer = Some(renderer); self.should_create_window = false; - + log::info!("Window created: {}x{} cells", cols, rows); } - + /// Destroy the window but keep terminal state. fn destroy_window(&mut self) { log::info!("Destroying window (keeping terminal alive)"); self.renderer = None; self.window = None; } - + /// Resize all panes in all tabs based on renderer dimensions. fn resize_all_panes(&mut self) { // Extract values we need from renderer first // Use raw available pixel space so layout can handle cell alignment properly let (cell_width, cell_height, available_width, available_height) = { - let Some(renderer) = &self.renderer else { return }; + let Some(renderer) = &self.renderer else { + return; + }; let cell_width = renderer.cell_metrics.cell_width as f32; let cell_height = renderer.cell_metrics.cell_height as f32; - let (available_width, available_height) = renderer.available_grid_space(); + let (available_width, available_height) = + renderer.available_grid_space(); (cell_width, cell_height, available_width, available_height) }; - + let border_width = 2.0; // Border width in pixels - + for tab in self.tabs.iter_mut() { - tab.resize(available_width, available_height, cell_width, cell_height, border_width); - + tab.resize( + available_width, + available_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); } } - + // Update the renderer with the active tab's used dimensions for proper centering if let Some(tab) = self.tabs.get(self.active_tab) { let used_dims = tab.grid_used_dimensions; @@ -1528,12 +1760,12 @@ impl App { } } } - + /// Process PTY data for a specific pane using Kitty-style in-place parsing. - /// + /// /// Returns (processed_any, has_more_data, bytes_parsed) - processed_any is true if data was parsed, /// has_more_data is true if there's still pending data after the time budget expired. - /// + /// /// Key differences from previous design: /// 1. begin_parse_pass() compacts buffer and makes pending data visible /// 2. Parsing happens in-place - no copying data out of buffer @@ -1542,43 +1774,55 @@ impl App { fn poll_pane(&mut self, pane_id: PaneId) -> (bool, bool) { let mut ever_processed = false; let mut all_commands = Vec::new(); - + for tab in &mut self.tabs { if let Some(pane) = tab.get_pane_mut(pane_id) { // Use SharedParser's run_parse_pass - it releases lock during parsing // so I/O thread can continue writing while we parse - ever_processed = pane.shared_parser.run_parse_pass(&mut pane.terminal); - + ever_processed = + pane.shared_parser.run_parse_pass(&mut pane.terminal); + if ever_processed { pane.terminal.mark_dirty(); // Collect any commands from the terminal all_commands.extend(pane.terminal.take_commands()); } - + // Check for pending data - I/O thread may have written more let has_more_data = pane.shared_parser.has_pending_data(); - + // Handle commands outside the borrow for cmd in all_commands { self.handle_terminal_command(pane_id, cmd); } - + return (ever_processed, has_more_data); } } - + (false, false) } - + /// Handle a command from the terminal (triggered by OSC sequences). - fn handle_terminal_command(&mut self, pane_id: PaneId, cmd: TerminalCommand) { + fn handle_terminal_command( + &mut self, + pane_id: PaneId, + cmd: TerminalCommand, + ) { match cmd { TerminalCommand::NavigatePane(direction) => { - log::debug!("Terminal requested pane navigation: {:?}", direction); + log::debug!( + "Terminal requested pane navigation: {:?}", + direction + ); self.focus_pane(direction); } TerminalCommand::SetStatusline(statusline) => { - log::debug!("Pane {:?} set statusline: {:?}", pane_id, statusline.as_ref().map(|s| s.len())); + log::debug!( + "Pane {:?} set statusline: {:?}", + pane_id, + statusline.as_ref().map(|s| s.len()) + ); // Find the pane and set its custom statusline for tab in &mut self.tabs { if let Some(pane) = tab.get_pane_mut(pane_id) { @@ -1587,12 +1831,37 @@ impl App { } } } + TerminalCommand::SetClipboard(content) => { + log::debug!( + "OSC 52: Setting clipboard ({} bytes)", + content.len() + ); + // Use wl-copy to set the clipboard on Wayland + match std::process::Command::new("wl-copy") + .stdin(std::process::Stdio::piped()) + .spawn() + { + Ok(mut child) => { + if let Some(stdin) = child.stdin.as_mut() { + use std::io::Write; + if let Err(e) = stdin.write_all(content.as_bytes()) + { + log::warn!("Failed to write to wl-copy: {}", e); + } + } + // Don't wait for child to exit - let it run async + } + Err(e) => { + log::warn!("Failed to spawn wl-copy for OSC 52: {}", e); + } + } + } } } - + /// Render a frame directly. Called from Tick handler for async rendering. /// Returns true if animations are in progress and another render should be scheduled. - /// + /// /// This is the Kitty-style approach: parse all input, then render once, all in the /// same event handler. This avoids the overhead of bouncing between UserEvent::Tick /// and WindowEvent::RedrawRequested. @@ -1600,18 +1869,20 @@ impl App { #[cfg(feature = "render_timing")] let render_start = std::time::Instant::now(); let mut needs_another_frame = false; - + // Send any terminal responses back to PTY (for active pane) if let Some(tab) = self.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { if let Some(response) = pane.terminal.take_response() { pane.write_to_pty(&response); } - + // Track scrollback changes for selection adjustment let scrollback_len = pane.terminal.scrollback.len() as u32; if scrollback_len != pane.last_scrollback_len { - let lines_added = scrollback_len.saturating_sub(pane.last_scrollback_len) as isize; + let lines_added = scrollback_len + .saturating_sub(pane.last_scrollback_len) + as isize; if let Some(ref mut selection) = pane.selection { selection.start.row -= lines_added; selection.end.row -= lines_added; @@ -1620,66 +1891,84 @@ impl App { } } } - + // Render all panes let num_tabs = self.tabs.len(); let active_tab_idx = self.active_tab; let fade_duration_ms = self.config.inactive_pane_fade_ms; let inactive_dim = self.config.inactive_pane_dim; - + if let Some(renderer) = &mut self.renderer { if let Some(tab) = self.tabs.get_mut(active_tab_idx) { // Collect all pane geometries let geometries = tab.collect_pane_geometries(); let active_pane_id = tab.active_pane; - + // 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); + 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); } } - + // Clear custom statusline if the foreground process is no longer neovim/vim if let Some(pane) = tab.panes.get_mut(&active_pane_id) { if pane.custom_statusline.is_some() { - if let Some(proc_name) = pane.pty.foreground_process_name() { - let is_vim = proc_name == "nvim" || proc_name == "vim" || proc_name == "vi"; + if let Some(proc_name) = + pane.pty.foreground_process_name() + { + let is_vim = proc_name == "nvim" + || proc_name == "vim" + || proc_name == "vi"; if !is_vim { pane.custom_statusline = None; } } } } - + // Build render info for all panes - let mut pane_render_data: Vec<(&Terminal, PaneRenderInfo, Option<(usize, usize, usize, usize)>)> = Vec::new(); - + let mut pane_render_data: Vec<( + &Terminal, + PaneRenderInfo, + Option<(usize, usize, usize, usize)>, + )> = Vec::new(); + for (pane_id, geom) in &geometries { if let Some(pane) = tab.panes.get(pane_id) { let is_active = *pane_id == active_pane_id; let scroll_offset = pane.terminal.scroll_offset; - + // Get pre-calculated dim factor - let dim_factor = dim_factors.iter() + let dim_factor = dim_factors + .iter() .find(|(id, _)| id == pane_id) .map(|(_, f)| *f) - .unwrap_or(if is_active { 1.0 } else { inactive_dim }); - + .unwrap_or(if is_active { + 1.0 + } else { + inactive_dim + }); + // Convert selection to screen coords for this pane let selection = if is_active { - pane.selection.as_ref() - .and_then(|sel| sel.to_screen_coords(scroll_offset, geom.rows)) + pane.selection.as_ref().and_then(|sel| { + sel.to_screen_coords(scroll_offset, geom.rows) + }) } else { None }; - + let render_info = PaneRenderInfo { pane_id: pane_id.0, x: geom.x, @@ -1691,31 +1980,41 @@ impl App { is_active, dim_factor, }; - - pane_render_data.push((&pane.terminal, render_info, selection)); + + pane_render_data.push(( + &pane.terminal, + render_info, + selection, + )); } } - + // Check if any animation is in progress - let animation_in_progress = dim_factors.iter().any(|(id, factor)| { - let is_active = *id == active_pane_id; - if is_active { - *factor < 1.0 - } else { - *factor > inactive_dim - } - }); - + let animation_in_progress = + dim_factors.iter().any(|(id, factor)| { + let is_active = *id == active_pane_id; + if is_active { + *factor < 1.0 + } else { + *factor > inactive_dim + } + }); + let glow_in_progress = !self.edge_glows.is_empty(); - - let image_animation_in_progress = tab.panes.values().any(|pane| { - pane.terminal.image_storage.has_animations() - }); - - needs_another_frame = animation_in_progress || glow_in_progress || image_animation_in_progress; - + + let image_animation_in_progress = tab + .panes + .values() + .any(|pane| pane.terminal.image_storage.has_animations()); + + needs_another_frame = animation_in_progress + || glow_in_progress + || image_animation_in_progress; + // Get the statusline content for the active pane - let statusline_content: StatuslineContent = tab.panes.get(&active_pane_id) + let statusline_content: StatuslineContent = tab + .panes + .get(&active_pane_id) .map(|pane| { if let Some(ref custom) = pane.custom_statusline { StatuslineContent::Raw(custom.clone()) @@ -1730,8 +2029,15 @@ impl App { } }) .unwrap_or_default(); - - match renderer.render_panes(&pane_render_data, num_tabs, active_tab_idx, &self.edge_glows, self.config.edge_glow_intensity, &statusline_content) { + + match renderer.render_panes( + &pane_render_data, + num_tabs, + active_tab_idx, + &self.edge_glows, + self.config.edge_glow_intensity, + &statusline_content, + ) { Ok(_) => {} Err(wgpu::SurfaceError::Lost) => { renderer.resize(renderer.width, renderer.height); @@ -1746,10 +2052,10 @@ impl App { } } } - + // Clean up finished edge glow animations self.edge_glows.retain(|g| !g.is_finished()); - + // Update stats #[cfg(feature = "render_timing")] { @@ -1758,37 +2064,37 @@ impl App { self.render_count += 1; } self.last_render_at = std::time::Instant::now(); - + needs_another_frame } - + /// Send bytes to the active tab's PTY. fn write_to_pty(&mut self, data: &[u8]) { if let Some(tab) = self.tabs.get_mut(self.active_tab) { tab.write_to_pty(data); } } - + /// Get the active tab, if any. fn active_tab(&self) -> Option<&Tab> { self.tabs.get(self.active_tab) } - + /// Get the active tab mutably, if any. fn active_tab_mut(&mut self) -> Option<&mut Tab> { self.tabs.get_mut(self.active_tab) } - + /// Get the active pane of the active tab, if any. fn active_pane(&self) -> Option<&Pane> { self.active_tab().and_then(|t| t.active_pane()) } - + /// Get the active pane of the active tab mutably, if any. fn active_pane_mut(&mut self) -> Option<&mut Pane> { self.active_tab_mut().and_then(|t| t.active_pane_mut()) } - + fn resize(&mut self, new_size: PhysicalSize) { if new_size.width == 0 || new_size.height == 0 { return; @@ -1797,41 +2103,63 @@ impl App { if let Some(renderer) = &mut self.renderer { renderer.resize(new_size.width, new_size.height); } - + // Resize all panes self.resize_all_panes(); - + if let Some(renderer) = &self.renderer { let (cols, rows) = renderer.terminal_size(); log::debug!("Resized to {}x{} cells", cols, rows); } } - + fn get_scroll_offset(&self) -> usize { self.active_pane() .map(|p| p.terminal.scroll_offset) .unwrap_or(0) } - + fn has_mouse_tracking(&self) -> bool { self.active_pane() .map(|p| p.terminal.mouse_tracking != MouseTrackingMode::None) .unwrap_or(false) } - + fn get_mouse_modifiers(&self) -> u8 { let mod_state = self.modifiers.state(); let mut mods = 0u8; - if mod_state.shift_key() { mods |= 1; } - if mod_state.alt_key() { mods |= 2; } - if mod_state.control_key() { mods |= 4; } + if mod_state.shift_key() { + mods |= 1; + } + if mod_state.alt_key() { + mods |= 2; + } + if mod_state.control_key() { + mods |= 4; + } mods } - - fn send_mouse_event(&mut self, button: u8, col: u16, row: u16, pressed: bool, is_motion: bool) { + + fn send_mouse_event( + &mut self, + button: u8, + col: u16, + row: u16, + pressed: bool, + is_motion: bool, + ) { let seq = { - let Some(pane) = self.active_pane() else { return }; - pane.terminal.encode_mouse(button, col, row, pressed, is_motion, self.get_mouse_modifiers()) + let Some(pane) = self.active_pane() else { + return; + }; + pane.terminal.encode_mouse( + button, + col, + row, + pressed, + is_motion, + self.get_mouse_modifiers(), + ) }; if !seq.is_empty() { self.write_to_pty(&seq); @@ -1964,7 +2292,7 @@ impl App { } } } - + fn split_pane(&mut self, horizontal: bool) { // Get terminal dimensions let (cols, rows) = if let Some(renderer) = &self.renderer { @@ -1972,10 +2300,10 @@ impl App { } else { return; }; - + let scrollback_lines = self.config.scrollback_lines; let active_tab = self.active_tab; - + // Create the new pane and get its info for the I/O thread let new_pane_info = if let Some(tab) = self.tabs.get_mut(active_tab) { match tab.split(horizontal, cols, rows, scrollback_lines) { @@ -1993,21 +2321,29 @@ impl App { } else { None }; - + // Start I/O thread for the new pane (outside the tab borrow) if let Some((pane_id, pty_fd, shared_parser)) = new_pane_info { self.start_pane_io_thread_with_info(pane_id, pty_fd, shared_parser); // Recalculate layout self.resize_all_panes(); self.request_redraw(); - log::info!("Split pane (horizontal={}), new pane {}", horizontal, pane_id.0); + log::info!( + "Split pane (horizontal={}), new pane {}", + horizontal, + pane_id.0 + ); } } - + /// Focus neighbor pane or pass keys through to applications like Neovim. /// If the foreground process matches `pass_keys_to_programs`, send the Alt+Arrow /// escape sequence to the PTY. Otherwise, focus the neighboring pane. - fn focus_pane_or_pass_key(&mut self, direction: Direction, arrow_letter: u8) { + fn focus_pane_or_pass_key( + &mut self, + direction: Direction, + arrow_letter: u8, + ) { // Check if we should pass keys to the foreground process let should_pass = if let Some(tab) = self.tabs.get(self.active_tab) { if let Some(pane) = tab.active_pane() { @@ -2018,7 +2354,7 @@ impl App { } else { false }; - + if should_pass { // Send Alt+Arrow escape sequence: \x1b[1;3X where X is A/B/C/D let escape_seq = [0x1b, b'[', b'1', b';', b'3', arrow_letter]; @@ -2027,15 +2363,16 @@ impl App { self.focus_pane(direction); } } - + 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) { + 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); @@ -2043,14 +2380,21 @@ impl App { } else { false }; - + if !navigated { // No neighbor in that direction - trigger edge glow animation // Use renderer's helper to calculate proper screen-space glow bounds - if let (Some(geom), Some(renderer)) = (active_pane_geom, &self.renderer) { - let (glow_x, glow_y, glow_width, glow_height) = - renderer.calculate_edge_glow_bounds(geom.x, geom.y, geom.width, geom.height); - + if let (Some(geom), Some(renderer)) = + (active_pane_geom, &self.renderer) + { + let (glow_x, glow_y, glow_width, glow_height) = renderer + .calculate_edge_glow_bounds( + geom.x, + geom.y, + geom.width, + geom.height, + ); + self.edge_glows.push(EdgeGlow::new( direction, glow_x, @@ -2060,18 +2404,19 @@ impl App { )); } } - + self.request_redraw(); } - + fn close_active_pane(&mut self) { - let should_close_tab = if let Some(tab) = self.tabs.get_mut(self.active_tab) { - tab.close_active_pane(); - tab.panes.is_empty() - } else { - false - }; - + let should_close_tab = + if let Some(tab) = self.tabs.get_mut(self.active_tab) { + tab.close_active_pane(); + tab.panes.is_empty() + } else { + false + }; + if should_close_tab { self.tabs.remove(self.active_tab); if !self.tabs.is_empty() && self.active_tab >= self.tabs.len() { @@ -2081,10 +2426,10 @@ impl App { // Recalculate layout after removing pane self.resize_all_panes(); } - + self.request_redraw(); } - + fn switch_to_tab(&mut self, idx: usize) { if idx < self.tabs.len() { self.active_tab = idx; @@ -2093,7 +2438,7 @@ impl App { self.request_redraw(); } } - + /// Update the renderer's grid dimensions based on the active tab's stored dimensions. fn update_active_tab_grid_dimensions(&mut self) { if let Some(tab) = self.tabs.get(self.active_tab) { @@ -2103,11 +2448,9 @@ impl App { } } } - + fn paste_from_clipboard(&mut self) { - let output = match Command::new("wl-paste") - .arg("--no-newline") - .output() + let output = match Command::new("wl-paste").arg("--no-newline").output() { Ok(output) => output, Err(e) => { @@ -2115,44 +2458,69 @@ impl App { return; } }; - + if output.status.success() && !output.stdout.is_empty() { - self.write_to_pty(&output.stdout); + let bracketed_paste = self + .active_tab() + .and_then(|t| t.active_pane()) + .map(|p| p.terminal.bracketed_paste) + .unwrap_or(false); + + if bracketed_paste { + self.write_to_pty(b"\x1b[200~"); + self.write_to_pty(&output.stdout); + self.write_to_pty(b"\x1b[201~"); + } else { + self.write_to_pty(&output.stdout); + } } } - + fn copy_selection_to_clipboard(&mut self) { let Some(tab) = self.active_tab() else { return }; - let Some(pane) = tab.active_pane() else { return }; - let Some(selection) = &pane.selection else { return }; + let Some(pane) = tab.active_pane() else { + return; + }; + let Some(selection) = &pane.selection else { + return; + }; let terminal = &pane.terminal; - + let (start, end) = selection.normalized(); let mut text = String::new(); - + let scroll_offset = terminal.scroll_offset as isize; let rows = terminal.rows; - + let screen_start_row = (start.row + scroll_offset).max(0) as usize; - let screen_end_row = ((end.row + scroll_offset).max(0) as usize).min(rows.saturating_sub(1)); - + let screen_end_row = ((end.row + scroll_offset).max(0) as usize) + .min(rows.saturating_sub(1)); + let visible_rows = terminal.visible_rows(); - + for screen_row in screen_start_row..=screen_end_row { if screen_row >= visible_rows.len() { break; } - + let content_row = screen_row as isize - scroll_offset; if content_row < start.row || content_row > end.row { continue; } - + let row_cells = visible_rows[screen_row]; let cols = row_cells.len(); - let col_start = if content_row == start.row { start.col } else { 0 }; - let col_end = if content_row == end.row { end.col } else { cols.saturating_sub(1) }; - + let col_start = if content_row == start.row { + start.col + } else { + 0 + }; + let col_end = if content_row == end.row { + end.col + } else { + cols.saturating_sub(1) + }; + let mut line = String::new(); for col in col_start..=col_end.min(cols.saturating_sub(1)) { let c = row_cells[col].character; @@ -2160,21 +2528,18 @@ impl App { line.push(c); } } - + text.push_str(line.trim_end()); if content_row < end.row { text.push('\n'); } } - + if text.is_empty() { return; } - - match Command::new("wl-copy") - .stdin(Stdio::piped()) - .spawn() - { + + match Command::new("wl-copy").stdin(Stdio::piped()).spawn() { Ok(mut child) => { if let Some(mut stdin) = child.stdin.take() { let _ = stdin.write_all(text.as_bytes()); @@ -2186,22 +2551,33 @@ impl App { } } } - + fn handle_keyboard_input(&mut self, event: KeyEvent) { - log::debug!("KeyEvent: {:?} state={:?} repeat={}", event.logical_key, event.state, event.repeat); - + log::debug!( + "KeyEvent: {:?} state={:?} repeat={}", + event.logical_key, + event.state, + event.repeat + ); + if self.check_keybinding(&event) { return; } let event_type = match event.state { ElementState::Pressed => { - if event.repeat { KeyEventType::Repeat } else { KeyEventType::Press } + if event.repeat { + KeyEventType::Repeat + } else { + KeyEventType::Press + } } ElementState::Released => KeyEventType::Release, }; - if event_type == KeyEventType::Release && !self.keyboard_state.report_events() { + if event_type == KeyEventType::Release + && !self.keyboard_state.report_events() + { log::debug!("Ignoring release event (not in enhanced mode)"); return; } @@ -2218,7 +2594,17 @@ impl App { num_lock: false, }; - let encoder = KeyEncoder::new(&self.keyboard_state); + // Get the application cursor keys mode from the active terminal + let application_cursor_keys = self + .active_tab() + .and_then(|t| t.active_pane()) + .map(|p| p.terminal.application_cursor_keys) + .unwrap_or(false); + + let encoder = KeyEncoder::with_cursor_mode( + &self.keyboard_state, + application_cursor_keys, + ); let bytes: Option> = match &event.logical_key { Key::Named(named) => { @@ -2303,7 +2689,7 @@ impl ApplicationHandler for App { #[cfg(feature = "render_timing")] log::info!("App resumed (window creation): {:?}", start.elapsed()); } - + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { match event { UserEvent::ShowWindow => { @@ -2320,24 +2706,26 @@ impl ApplicationHandler for App { event_loop.exit(); return; } - + #[cfg(feature = "render_timing")] let tick_received_at = std::time::Instant::now(); #[cfg(feature = "render_timing")] let _ = tick_received_at; // silence unused warning for now - + // Like Kitty's process_global_state: parse panes (with time budget), then render #[cfg(feature = "render_timing")] let tick_start = std::time::Instant::now(); let mut any_input = false; let mut any_has_more = false; let mut any_not_synchronized = false; - + // Collect all pane IDs first to avoid borrow issues - let pane_ids: Vec = self.tabs.iter() + let pane_ids: Vec = self + .tabs + .iter() .flat_map(|tab| tab.panes.keys().copied()) .collect(); - + // Poll each pane - parses data up to time budget, returns if more pending for pane_id in &pane_ids { let (processed, has_more) = self.poll_pane(*pane_id); @@ -2348,7 +2736,7 @@ impl ApplicationHandler for App { any_has_more = true; } } - + // Log detailed parse stats if any tick was slow (only with render_timing feature) #[cfg(feature = "render_timing")] { @@ -2362,19 +2750,22 @@ impl ApplicationHandler for App { } } } - + // CRITICAL: Send any terminal responses back to PTY immediately after parsing. // This must happen regardless of rendering, or the application will hang // waiting for responses to queries like DSR (Device Status Report). for tab in &mut self.tabs { for pane in tab.panes.values_mut() { if let Some(response) = pane.terminal.take_response() { - log::debug!("[RESPONSE] Sending {} bytes to PTY", response.len()); + log::debug!( + "[RESPONSE] Sending {} bytes to PTY", + response.len() + ); pane.write_to_pty(&response); } } } - + // Check if any terminal is NOT in synchronized mode (needs render) for tab in &self.tabs { for pane in tab.panes.values() { @@ -2383,37 +2774,47 @@ impl ApplicationHandler for App { break; } } - if any_not_synchronized { break; } + if any_not_synchronized { + break; + } } - + // Render directly here (Kitty-style), throttled by repaint_delay // 6ms ≈ 166 FPS, ensures we hit 144 FPS on high refresh displays const REPAINT_DELAY: Duration = Duration::from_millis(6); let time_since_last_render = self.last_render_at.elapsed(); - - let should_render = any_input && any_not_synchronized && - time_since_last_render >= REPAINT_DELAY && - self.renderer.is_some(); - + + let should_render = any_input + && any_not_synchronized + && time_since_last_render >= REPAINT_DELAY + && self.renderer.is_some(); + #[cfg(feature = "render_timing")] let render_start = std::time::Instant::now(); - + if should_render { let needs_another_frame = self.do_render(); - + // If animations are in progress, schedule another render via redraw if needs_another_frame { self.request_redraw(); } - } else if any_input && any_not_synchronized && self.renderer.is_some() { + } else if any_input + && any_not_synchronized + && self.renderer.is_some() + { // We had input but skipped render due to throttling. // Request a redraw so we don't lose this frame. self.request_redraw(); } - + #[cfg(feature = "render_timing")] - let render_time = if should_render { render_start.elapsed() } else { Duration::ZERO }; - + let render_time = if should_render { + render_start.elapsed() + } else { + Duration::ZERO + }; + // If there's more data pending, send another Tick immediately // This ensures we keep processing without waiting for I/O thread wakeup if any_has_more { @@ -2421,7 +2822,7 @@ impl ApplicationHandler for App { let _ = proxy.send_event(UserEvent::Tick); } } - + // Log every tick during benchmark for analysis (only with render_timing feature) #[cfg(feature = "render_timing")] { @@ -2438,7 +2839,12 @@ impl ApplicationHandler for App { } } - fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + _id: WindowId, + event: WindowEvent, + ) { match event { WindowEvent::CloseRequested => { log::info!("Window close requested - hiding window"); @@ -2463,6 +2869,22 @@ impl ApplicationHandler for App { self.request_redraw(); } + WindowEvent::Focused(focused) => { + let focus_reporting = self + .active_tab() + .and_then(|t| t.active_pane()) + .map(|p| p.terminal.focus_reporting) + .unwrap_or(false); + + if focus_reporting { + if focused { + self.write_to_pty(b"\x1b[I"); + } else { + self.write_to_pty(b"\x1b[O"); + } + } + } + WindowEvent::ModifiersChanged(new_modifiers) => { self.modifiers = new_modifiers; } @@ -2472,18 +2894,21 @@ impl ApplicationHandler for App { MouseScrollDelta::LineDelta(_, y) => (y * 3.0) as i32, MouseScrollDelta::PixelDelta(pos) => (pos.y / 20.0) as i32, }; - + if lines != 0 { if self.has_mouse_tracking() { if let Some(renderer) = &self.renderer { if let Some((col, row)) = renderer.pixel_to_cell( self.cursor_position.x, - self.cursor_position.y + self.cursor_position.y, ) { let button = if lines > 0 { 64 } else { 65 }; let count = lines.abs().min(3); for _ in 0..count { - self.send_mouse_event(button, col as u16, row as u16, true, false); + self.send_mouse_event( + button, col as u16, row as u16, true, + false, + ); } } } @@ -2496,33 +2921,44 @@ impl ApplicationHandler for App { self.request_redraw(); } } - + WindowEvent::CursorMoved { position, .. } => { self.cursor_position = position; - - let is_selecting = self.active_pane() - .map(|p| p.is_selecting) - .unwrap_or(false); - + + let is_selecting = + self.active_pane().map(|p| p.is_selecting).unwrap_or(false); + if is_selecting && self.has_mouse_tracking() { // Send mouse drag/motion events to PTY for apps like Neovim if let Some(renderer) = &self.renderer { - if let Some((col, row)) = renderer.pixel_to_cell(position.x, position.y) { + if let Some((col, row)) = + renderer.pixel_to_cell(position.x, position.y) + { // Button 0 (left) with motion flag - self.send_mouse_event(0, col as u16, row as u16, true, true); + self.send_mouse_event( + 0, col as u16, row as u16, true, true, + ); } } } else if is_selecting && !self.has_mouse_tracking() { // Terminal-native selection if let Some(renderer) = &self.renderer { - if let Some((col, screen_row)) = renderer.pixel_to_cell(position.x, position.y) { + if let Some((col, screen_row)) = + renderer.pixel_to_cell(position.x, position.y) + { let scroll_offset = self.get_scroll_offset(); - let content_row = screen_row as isize - scroll_offset as isize; - + let content_row = + screen_row as isize - scroll_offset as isize; + if let Some(tab) = self.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { - if let Some(ref mut selection) = pane.selection { - selection.end = CellPosition { col, row: content_row }; + if let Some(ref mut selection) = + pane.selection + { + selection.end = CellPosition { + col, + row: content_row, + }; self.request_redraw(); } } @@ -2531,7 +2967,7 @@ impl ApplicationHandler for App { } } } - + WindowEvent::MouseInput { state, button, .. } => { let button_code = match button { MouseButton::Left => 0, @@ -2539,15 +2975,21 @@ impl ApplicationHandler for App { MouseButton::Right => 2, _ => return, }; - + if self.has_mouse_tracking() { if let Some(renderer) = &self.renderer { if let Some((col, row)) = renderer.pixel_to_cell( self.cursor_position.x, - self.cursor_position.y + self.cursor_position.y, ) { let pressed = state == ElementState::Pressed; - self.send_mouse_event(button_code, col as u16, row as u16, pressed, false); + self.send_mouse_event( + button_code, + col as u16, + row as u16, + pressed, + false, + ); if button == MouseButton::Left { if let Some(tab) = self.active_tab_mut() { if let Some(pane) = tab.active_pane_mut() { @@ -2566,17 +3008,29 @@ impl ApplicationHandler for App { match state { ElementState::Pressed => { if let Some(renderer) = &self.renderer { - if let Some((col, screen_row)) = renderer.pixel_to_cell( - self.cursor_position.x, - self.cursor_position.y - ) { - let scroll_offset = self.get_scroll_offset(); - let content_row = screen_row as isize - scroll_offset as isize; - let pos = CellPosition { col, row: content_row }; + if let Some((col, screen_row)) = renderer + .pixel_to_cell( + self.cursor_position.x, + self.cursor_position.y, + ) + { + let scroll_offset = + self.get_scroll_offset(); + let content_row = screen_row as isize + - scroll_offset as isize; + let pos = CellPosition { + col, + row: content_row, + }; log::debug!("Selection started at col={}, content_row={}, screen_row={}, scroll_offset={}", col, content_row, screen_row, scroll_offset); if let Some(tab) = self.active_tab_mut() { - if let Some(pane) = tab.active_pane_mut() { - pane.selection = Some(Selection { start: pos, end: pos }); + if let Some(pane) = + tab.active_pane_mut() + { + pane.selection = Some(Selection { + start: pos, + end: pos, + }); pane.is_selecting = true; } } @@ -2584,7 +3038,8 @@ impl ApplicationHandler for App { } } ElementState::Released => { - let was_selecting = self.active_pane() + let was_selecting = self + .active_pane() .map(|p| p.is_selecting) .unwrap_or(false); if was_selecting { @@ -2615,41 +3070,49 @@ impl ApplicationHandler for App { event_loop.exit(); return; } - + #[cfg(feature = "render_timing")] let frame_start = std::time::Instant::now(); self.frame_count += 1; - + if self.last_frame_log.elapsed() >= Duration::from_secs(1) { log::debug!("FPS: {}", self.frame_count); self.frame_count = 0; self.last_frame_log = std::time::Instant::now(); } - + // Use shared render logic let needs_another_frame = self.do_render(); - + // If animations are in progress, schedule another render if needs_another_frame { self.request_redraw(); } - + // Log cumulative stats every second (only with render_timing feature) #[cfg(feature = "render_timing")] if self.last_stats_log.elapsed() >= Duration::from_secs(1) { let parse_ms = self.total_parse_ns as f64 / 1_000_000.0; let render_ms = self.total_render_ns as f64 / 1_000_000.0; - log::info!("STATS: parse={:.1}ms/{} render={:.1}ms/{} ratio={:.2}", - parse_ms, self.parse_count, - render_ms, self.render_count, - if parse_ms > 0.0 { render_ms / parse_ms } else { 0.0 }); + log::info!( + "STATS: parse={:.1}ms/{} render={:.1}ms/{} ratio={:.2}", + parse_ms, + self.parse_count, + render_ms, + self.render_count, + if parse_ms > 0.0 { + render_ms / parse_ms + } else { + 0.0 + } + ); self.total_parse_ns = 0; self.total_render_ns = 0; self.parse_count = 0; self.render_count = 0; self.last_stats_log = std::time::Instant::now(); } - + #[cfg(feature = "render_timing")] { let frame_time = frame_start.elapsed(); @@ -2670,7 +3133,7 @@ impl ApplicationHandler for App { event_loop.exit(); return; } - + // Check for exited tabs and remove them let mut i = 0; let mut tabs_removed = false; @@ -2686,18 +3149,18 @@ impl ApplicationHandler for App { i += 1; } } - + // Update grid dimensions if tabs were removed if tabs_removed && !self.tabs.is_empty() { self.update_active_tab_grid_dimensions(); } - + if self.tabs.is_empty() { log::info!("All tabs closed, exiting"); event_loop.exit(); return; } - + // Batching is done in the I/O thread (Kitty-style). // We just wait for events here. event_loop.set_control_flow(ControlFlow::Wait); @@ -2713,15 +3176,19 @@ 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) -> Option { +fn setup_config_watcher( + proxy: EventLoopProxy, +) -> Option { let config_path = match Config::config_path() { Some(path) => path, None => { - log::warn!("Could not determine config path, config hot-reload disabled"); + 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(), @@ -2730,52 +3197,65 @@ fn setup_config_watcher(proxy: EventLoopProxy) -> Option| { - 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); + + let mut watcher = match notify::recommended_watcher( + move |res: Result| { + 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); } } - 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); + 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(); + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or("info"), + ) + .init(); log::info!("Starting ZTerm"); @@ -2806,7 +3286,7 @@ fn main() { let mut app = App::new(); let proxy = event_loop.create_proxy(); app.set_event_loop_proxy(proxy.clone()); - + // Store proxy for signal handler (uses the global static defined below) unsafe { EVENT_PROXY = Some(proxy.clone()); diff --git a/src/terminal.rs b/src/terminal.rs index ecfd4f9..bd031b7 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -16,6 +16,9 @@ pub enum TerminalCommand { /// Triggered by OSC 51;statusline; ST /// Empty content clears the statusline (restores default). SetStatusline(Option), + /// Set clipboard content via OSC 52. + /// Triggered by OSC 52;c; ST + SetClipboard(String), } /// Direction for pane navigation. @@ -130,44 +133,45 @@ pub struct ColorPalette { impl Default for ColorPalette { fn default() -> Self { let mut colors = [[0u8; 3]; 256]; - + // Standard ANSI colors (0-7) - colors[0] = [0, 0, 0]; // Black - colors[1] = [204, 0, 0]; // Red - colors[2] = [0, 204, 0]; // Green - colors[3] = [204, 204, 0]; // Yellow - colors[4] = [0, 0, 204]; // Blue - colors[5] = [204, 0, 204]; // Magenta - colors[6] = [0, 204, 204]; // Cyan + colors[0] = [0, 0, 0]; // Black + colors[1] = [204, 0, 0]; // Red + colors[2] = [0, 204, 0]; // Green + colors[3] = [204, 204, 0]; // Yellow + colors[4] = [0, 0, 204]; // Blue + colors[5] = [204, 0, 204]; // Magenta + colors[6] = [0, 204, 204]; // Cyan colors[7] = [204, 204, 204]; // White - + // Bright ANSI colors (8-15) - colors[8] = [102, 102, 102]; // Bright Black (Gray) - colors[9] = [255, 0, 0]; // Bright Red - colors[10] = [0, 255, 0]; // Bright Green - colors[11] = [255, 255, 0]; // Bright Yellow - colors[12] = [0, 0, 255]; // Bright Blue - colors[13] = [255, 0, 255]; // Bright Magenta - colors[14] = [0, 255, 255]; // Bright Cyan + colors[8] = [102, 102, 102]; // Bright Black (Gray) + colors[9] = [255, 0, 0]; // Bright Red + colors[10] = [0, 255, 0]; // Bright Green + colors[11] = [255, 255, 0]; // Bright Yellow + colors[12] = [0, 0, 255]; // Bright Blue + colors[13] = [255, 0, 255]; // Bright Magenta + colors[14] = [0, 255, 255]; // Bright Cyan colors[15] = [255, 255, 255]; // Bright White - + // 216 color cube (16-231) for r in 0..6 { for g in 0..6 { for b in 0..6 { let idx = 16 + r * 36 + g * 6 + b; - let to_val = |c: usize| if c == 0 { 0 } else { (55 + c * 40) as u8 }; + let to_val = + |c: usize| if c == 0 { 0 } else { (55 + c * 40) as u8 }; colors[idx] = [to_val(r), to_val(g), to_val(b)]; } } } - + // 24 grayscale colors (232-255) for i in 0..24 { let gray = (8 + i * 10) as u8; colors[232 + i] = [gray, gray, gray]; } - + Self { colors, default_fg: [230, 230, 230], // Light gray @@ -180,7 +184,7 @@ impl ColorPalette { /// Parse a color specification like "#RRGGBB" or "rgb:RR/GG/BB". pub fn parse_color_spec(spec: &str) -> Option<[u8; 3]> { let spec = spec.trim(); - + if let Some(hex) = spec.strip_prefix('#') { // #RRGGBB format if hex.len() == 6 { @@ -196,7 +200,11 @@ impl ColorPalette { let parse_component = |s: &str| -> Option { let val = u16::from_str_radix(s, 16).ok()?; // Scale to 8-bit if it's a 16-bit value - Some(if s.len() > 2 { (val >> 8) as u8 } else { val as u8 }) + Some(if s.len() > 2 { + (val >> 8) as u8 + } else { + val as u8 + }) }; let r = parse_component(parts[0])?; let g = parse_component(parts[1])?; @@ -204,10 +212,10 @@ impl ColorPalette { return Some([r, g, b]); } } - + None } - + /// Get RGBA for a color, using the palette for indexed colors. pub fn to_rgba(&self, color: &Color) -> [f32; 4] { match color { @@ -215,14 +223,16 @@ impl ColorPalette { let [r, g, b] = self.default_fg; [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] } - Color::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0], + Color::Rgb(r, g, b) => { + [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0] + } Color::Indexed(idx) => { let [r, g, b] = self.colors[*idx as usize]; [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] } } } - + /// Get RGBA for background, using palette default_bg for Color::Default. pub fn to_rgba_bg(&self, color: &Color) -> [f32; 4] { match color { @@ -230,7 +240,9 @@ impl ColorPalette { let [r, g, b] = self.default_bg; [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] } - Color::Rgb(r, g, b) => [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0], + Color::Rgb(r, g, b) => { + [*r as f32 / 255.0, *g as f32 / 255.0, *b as f32 / 255.0, 1.0] + } Color::Indexed(idx) => { let [r, g, b] = self.colors[*idx as usize]; [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0] @@ -314,15 +326,19 @@ impl ProcessingStats { pub fn reset(&mut self) { *self = Self::default(); } - + #[cfg(not(feature = "render_timing"))] pub fn reset(&mut self) {} - + #[cfg(feature = "render_timing")] pub fn log_if_slow(&self, threshold_ms: u64) { - let total_ms = (self.scroll_up_ns + self.text_handler_ns + self.csi_handler_ns) / 1_000_000; + let total_ms = + (self.scroll_up_ns + self.text_handler_ns + self.csi_handler_ns) + / 1_000_000; if total_ms >= threshold_ms { - let vt_only_ns = self.vt_parser_ns.saturating_sub(self.text_handler_ns + self.csi_handler_ns); + let vt_only_ns = self + .vt_parser_ns + .saturating_sub(self.text_handler_ns + self.csi_handler_ns); log::info!( "[PARSE_DETAIL] text={:.2}ms ({}chars) csi={:.2}ms ({}x) vt_only={:.2}ms ({}calls) scroll={:.2}ms ({}x)", self.text_handler_ns as f64 / 1_000_000.0, @@ -336,22 +352,22 @@ impl ProcessingStats { ); } } - + #[cfg(not(feature = "render_timing"))] pub fn log_if_slow(&self, _threshold_ms: u64) {} } /// Kitty-style ring buffer for scrollback history. -/// +/// /// Pre-allocates all lines upfront to avoid allocation during scrolling. /// Uses modulo arithmetic for O(1) operations with no memory allocation or /// pointer chasing - just simple index arithmetic like Kitty's historybuf. -/// +/// /// Key insight from Kitty: When the buffer is full, instead of pop_front + push_back /// (which involves linked-list-style pointer updates in VecDeque), we just: /// 1. Calculate the insertion slot with modulo arithmetic /// 2. Increment the start pointer (also with modulo) -/// +/// /// This eliminates all per-scroll overhead that was causing timing variance. pub struct ScrollbackBuffer { /// Pre-allocated line storage. All lines are allocated upfront. @@ -371,7 +387,7 @@ impl ScrollbackBuffer { // 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, start: 0, @@ -379,30 +395,30 @@ impl ScrollbackBuffer { capacity, } } - + /// Returns the number of lines currently stored. #[inline] pub fn len(&self) -> usize { self.count } - + /// Returns true if the buffer is empty. #[inline] pub fn is_empty(&self) -> bool { self.count == 0 } - + /// Returns true if the buffer is at capacity. #[inline] pub fn is_full(&self) -> bool { self.count == self.capacity } - + /// Push a line into the buffer, returning a mutable reference to write into. - /// + /// /// If the buffer is full, the oldest line is overwritten and its slot is returned /// for reuse (the caller can swap content into it). - /// + /// /// Lines are allocated lazily on first use to avoid slow startup. #[inline] pub fn push(&mut self, cols: usize) -> &mut Vec { @@ -410,11 +426,11 @@ impl ScrollbackBuffer { // Shouldn't happen in normal use, but handle gracefully panic!("Cannot push to zero-capacity scrollback buffer"); } - + // Calculate insertion index: (start + count) % capacity // This is where the new line goes let idx = (self.start + self.count) % self.capacity; - + if self.count == self.capacity { // Buffer is full - we're overwriting the oldest line // Advance start to point to the new oldest line @@ -428,10 +444,10 @@ impl ScrollbackBuffer { } self.count += 1; } - + &mut self.lines[idx] } - + /// Get a line by logical index (0 = oldest, count-1 = newest). /// Returns None if index is out of bounds. #[inline] @@ -443,7 +459,7 @@ impl ScrollbackBuffer { let physical_idx = (self.start + index) % self.capacity; Some(&self.lines[physical_idx]) } - + /// Clear all lines from the buffer. /// Note: This doesn't deallocate - lines stay allocated for reuse. #[inline] @@ -520,6 +536,8 @@ pub struct Terminal { pub application_cursor_keys: bool, /// Auto-wrap mode (DECAWM) - wrap at end of line. auto_wrap: bool, + /// Origin mode (DECOM) - cursor positioning relative to scroll region. + origin_mode: bool, /// Bracketed paste mode - wrap pasted text with escape sequences. pub bracketed_paste: bool, /// Focus event reporting mode. @@ -545,7 +563,12 @@ impl Terminal { /// Creates a new terminal with the given dimensions and scrollback limit. pub fn new(cols: usize, rows: usize, scrollback_limit: usize) -> Self { - log::info!("Terminal::new: cols={}, rows={}, scroll_bottom={}", cols, rows, rows.saturating_sub(1)); + log::info!( + "Terminal::new: cols={}, rows={}, scroll_bottom={}", + cols, + rows, + rows.saturating_sub(1) + ); let grid = vec![vec![Cell::default(); cols]; rows]; let line_map: Vec = (0..rows).collect(); @@ -579,18 +602,19 @@ impl Terminal { alternate_screen: None, using_alternate_screen: false, application_cursor_keys: false, - auto_wrap: true, // Auto-wrap is on by default + auto_wrap: true, + origin_mode: false, bracketed_paste: false, focus_reporting: false, synchronized_output: false, stats: ProcessingStats::default(), command_queue: Vec::new(), image_storage: ImageStorage::new(), - cell_width: 10.0, // Default, will be set by renderer + cell_width: 10.0, // Default, will be set by renderer cell_height: 20.0, // Default, will be set by renderer } } - + /// Mark a specific line as dirty (needs redrawing). #[inline] pub fn mark_line_dirty(&mut self, line: usize) { @@ -600,13 +624,13 @@ impl Terminal { self.dirty_lines[word] |= 1u64 << bit; } } - + /// Mark all lines as dirty. #[inline] pub fn mark_all_lines_dirty(&mut self) { self.dirty_lines = [!0u64; 4]; } - + /// Check if a line is dirty. #[inline] pub fn is_line_dirty(&self, line: usize) -> bool { @@ -618,34 +642,34 @@ impl Terminal { true // Lines beyond 256 are always considered dirty } } - + /// Clear all dirty line flags. #[inline] pub fn clear_dirty_lines(&mut self) { self.dirty_lines = [0u64; 4]; } - + /// Take all pending commands from the queue. /// Returns an empty Vec if no commands are pending. #[inline] pub fn take_commands(&mut self) -> Vec { std::mem::take(&mut self.command_queue) } - + /// Get the dirty lines bitmap (for passing to shm). #[inline] pub fn get_dirty_lines(&self) -> u64 { // Return first 64 lines worth of dirty bits (most common case) self.dirty_lines[0] } - + /// Check if synchronized output mode is active (rendering should be suppressed). /// This is set by CSI 2026 or DCS pending mode (=1s/=2s). #[inline] pub fn is_synchronized(&self) -> bool { self.synchronized_output } - + /// Advance cursor to next row, scrolling if necessary. /// This is the common pattern: increment row, scroll if past scroll_bottom. #[inline] @@ -656,7 +680,7 @@ impl Terminal { self.cursor_row = self.scroll_bottom; } } - + /// Create a cell with current text attributes. #[inline] fn make_cell(&self, character: char, wide_continuation: bool) -> Cell { @@ -671,26 +695,26 @@ impl Terminal { wide_continuation, } } - + /// Get the actual grid row index for a visual row. #[inline] pub fn grid_row(&self, visual_row: usize) -> usize { self.line_map[visual_row] } - + /// Get a reference to a row by visual index. #[inline] pub fn row(&self, visual_row: usize) -> &Vec { &self.grid[self.line_map[visual_row]] } - + /// Get a mutable reference to a row by visual index. #[inline] pub fn row_mut(&mut self, visual_row: usize) -> &mut Vec { let idx = self.line_map[visual_row]; &mut self.grid[idx] } - + /// Clear a row (by actual grid index, not visual). #[inline] fn clear_grid_row(&mut self, grid_row: usize) { @@ -735,8 +759,14 @@ impl Terminal { if cols == self.cols && rows == self.rows { return; } - - log::info!("Terminal::resize: {}x{} -> {}x{}", self.cols, self.rows, cols, rows); + + log::info!( + "Terminal::resize: {}x{} -> {}x{}", + self.cols, + self.rows, + cols, + rows + ); let old_cols = self.cols; let old_rows = self.rows; @@ -750,7 +780,8 @@ impl Terminal { // Use actual row length - may differ from self.cols after scrollback swap let old_row_len = self.grid[old_grid_row].len(); for col in 0..cols.min(old_row_len) { - new_grid[visual_row][col] = self.grid[old_grid_row][col].clone(); + new_grid[visual_row][col] = + self.grid[old_grid_row][col].clone(); } } @@ -772,11 +803,16 @@ impl Terminal { if let Some(ref mut saved) = self.alternate_screen { let mut new_saved_grid = vec![vec![Cell::default(); cols]; rows]; for visual_row in 0..rows.min(old_rows) { - let old_grid_row = saved.line_map.get(visual_row).copied().unwrap_or(visual_row); + let old_grid_row = saved + .line_map + .get(visual_row) + .copied() + .unwrap_or(visual_row); if old_grid_row < saved.grid.len() { for col in 0..cols.min(old_cols) { if col < saved.grid[old_grid_row].len() { - new_saved_grid[visual_row][col] = saved.grid[old_grid_row][col].clone(); + new_saved_grid[visual_row][col] = + saved.grid[old_grid_row][col].clone(); } } } @@ -857,18 +893,23 @@ impl Terminal { fn scroll_up(&mut self, n: usize) { let region_size = self.scroll_bottom - self.scroll_top + 1; let n = n.min(region_size); - + #[cfg(feature = "render_timing")] - { self.stats.scroll_up_count += n as u32; } - + { + self.stats.scroll_up_count += n as u32; + } + for _ in 0..n { // Save the top line's grid index before rotation let recycled_grid_row = self.line_map[self.scroll_top]; - + // Save to scrollback only if scrolling from the very top of the screen // AND not in alternate screen mode (alternate screen never uses scrollback) // AND scrollback is enabled (capacity > 0) - if self.scroll_top == 0 && !self.using_alternate_screen && self.scrollback.capacity > 0 { + 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 cols = self.cols; @@ -882,16 +923,19 @@ impl Terminal { // Not saving to scrollback - just clear the line self.clear_grid_row(recycled_grid_row); } - + // Rotate line_map: shift all indices up within scroll region using memmove - self.line_map.copy_within(self.scroll_top + 1..=self.scroll_bottom, self.scroll_top); + self.line_map.copy_within( + self.scroll_top + 1..=self.scroll_bottom, + self.scroll_top, + ); self.line_map[self.scroll_bottom] = recycled_grid_row; } - + // Mark all lines dirty with a single bitmask operation instead of loop self.mark_region_dirty(self.scroll_top, self.scroll_bottom); } - + /// Mark a range of lines as dirty efficiently using bitmask operations. #[inline] fn mark_region_dirty(&mut self, start: usize, end: usize) { @@ -899,19 +943,23 @@ impl Terminal { if start > end { return; } - + // Process each 64-bit word that overlaps with [start, end] let start_word = start / 64; let end_word = end / 64; - + for word_idx in start_word..=end_word.min(3) { let word_start = word_idx * 64; let word_end = word_start + 63; - + // Calculate bit range within this word - let bit_start = if start > word_start { start - word_start } else { 0 }; + let bit_start = if start > word_start { + start - word_start + } else { + 0 + }; let bit_end = if end < word_end { end - word_start } else { 63 }; - + // Create mask for bits [bit_start, bit_end] // mask = ((1 << (bit_end - bit_start + 1)) - 1) << bit_start let num_bits = bit_end - bit_start + 1; @@ -920,7 +968,7 @@ impl Terminal { } else { ((1u64 << num_bits) - 1) << bit_start }; - + self.dirty_lines[word_idx] |= mask; } } @@ -930,19 +978,22 @@ impl Terminal { fn scroll_down(&mut self, n: usize) { let region_size = self.scroll_bottom - self.scroll_top + 1; let n = n.min(region_size); - + for _ in 0..n { // Save the bottom line's grid index before rotation let recycled_grid_row = self.line_map[self.scroll_bottom]; - + // Rotate line_map: shift all indices down within scroll region using memmove - self.line_map.copy_within(self.scroll_top..self.scroll_bottom, self.scroll_top + 1); + self.line_map.copy_within( + self.scroll_top..self.scroll_bottom, + self.scroll_top + 1, + ); self.line_map[self.scroll_top] = recycled_grid_row; - + // Clear the recycled line (now at visual top of scroll region) self.clear_grid_row(recycled_grid_row); } - + // Mark all lines in the scroll region as dirty. self.mark_region_dirty(self.scroll_top, self.scroll_bottom); } @@ -990,7 +1041,7 @@ impl Terminal { self.mark_all_lines_dirty(); // All visible content changes when scrolling } } - + /// Scroll the viewport by the given number of lines. /// Positive values scroll up (into history), negative values scroll down (toward live). pub fn scroll(&mut self, lines: i32) { @@ -1000,7 +1051,7 @@ impl Terminal { self.scroll_viewport_down((-lines) as usize); } } - + /// Encode a mouse event based on current tracking mode and encoding. /// Returns the escape sequence to send to the application, or empty vec if no tracking. pub fn encode_mouse( @@ -1035,7 +1086,7 @@ impl Terminal { // Any-event reports all motion } } - + // Build the button code // Bits 0-1: button (0=left, 1=middle, 2=right, 3=release) // Bit 2: shift @@ -1043,7 +1094,7 @@ impl Terminal { // Bit 4: control // Bits 5-6: 00=press, 01=motion with button, 10=scroll let mut cb = button; - + // Handle release if !pressed && !is_motion { // For SGR encoding, we keep the button number @@ -1052,19 +1103,19 @@ impl Terminal { cb = 3; } } - + // Add modifiers cb |= modifiers << 2; - + // Add motion flag if is_motion { cb |= 32; } - + // Convert to 1-based coordinates let col = col.saturating_add(1); let row = row.saturating_add(1); - + match self.mouse_encoding { MouseEncoding::X10 => { // X10 encoding: ESC [ M Cb Cx Cy @@ -1089,7 +1140,8 @@ impl Terminal { // M for press, m for release // Most modern and recommended format let suffix = if pressed { b'M' } else { b'm' }; - format!("\x1b[<{};{};{}{}", cb, col, row, suffix as char).into_bytes() + format!("\x1b[<{};{};{}{}", cb, col, row, suffix as char) + .into_bytes() } MouseEncoding::Urxvt => { // URXVT encoding: ESC [ Cb ; Cx ; Cy M @@ -1104,7 +1156,7 @@ impl Terminal { /// This combines scrollback lines with the current grid. pub fn visible_rows(&self) -> Vec<&Vec> { let mut rows = Vec::with_capacity(self.rows); - + if self.scroll_offset == 0 { // No scrollback viewing, just return the grid via line_map for visual_row in 0..self.rows { @@ -1114,16 +1166,17 @@ impl Terminal { // We're viewing scrollback // scroll_offset = how many lines back we're looking let scrollback_len = self.scrollback.len(); - + for i in 0..self.rows { // Calculate which line to show // If scroll_offset = 5, we want to show 5 lines from scrollback at the top let lines_from_scrollback = self.scroll_offset.min(self.rows); - + if i < lines_from_scrollback { // This row comes from scrollback // Use ring buffer's get() method with logical index - let scrollback_idx = scrollback_len - self.scroll_offset + i; + let scrollback_idx = + scrollback_len - self.scroll_offset + i; if let Some(line) = self.scrollback.get(scrollback_idx) { rows.push(line); } else { @@ -1139,10 +1192,10 @@ impl Terminal { } } } - + rows } - + /// Get a single visible row by index without allocation. /// Returns None if row_idx is out of bounds. #[inline] @@ -1150,7 +1203,7 @@ impl Terminal { if row_idx >= self.rows { return None; } - + if self.scroll_offset == 0 { // No scrollback viewing, just return from grid via line_map Some(&self.grid[self.line_map[row_idx]]) @@ -1158,11 +1211,13 @@ impl Terminal { // We're viewing scrollback let scrollback_len = self.scrollback.len(); let lines_from_scrollback = self.scroll_offset.min(self.rows); - + if row_idx < lines_from_scrollback { // This row comes from scrollback - let scrollback_idx = scrollback_len - self.scroll_offset + row_idx; - self.scrollback.get(scrollback_idx) + let scrollback_idx = + scrollback_len - self.scroll_offset + row_idx; + self.scrollback + .get(scrollback_idx) .or_else(|| Some(&self.grid[self.line_map[row_idx]])) } else { // This row comes from the grid @@ -1179,26 +1234,28 @@ impl Terminal { /// Inserts n blank lines at the cursor position, scrolling lines below down. /// Uses line_map rotation for efficiency. fn insert_lines(&mut self, n: usize) { - if self.cursor_row < self.scroll_top || self.cursor_row > self.scroll_bottom { + if self.cursor_row < self.scroll_top + || self.cursor_row > self.scroll_bottom + { return; } let n = n.min(self.scroll_bottom - self.cursor_row + 1); - + for _ in 0..n { // Save the bottom line's grid index before rotation let recycled_grid_row = self.line_map[self.scroll_bottom]; - + // Rotate line_map: shift lines from cursor to bottom down by 1 // The bottom line becomes the new line at cursor position for i in (self.cursor_row + 1..=self.scroll_bottom).rev() { self.line_map[i] = self.line_map[i - 1]; } self.line_map[self.cursor_row] = recycled_grid_row; - + // Clear the recycled line (now at cursor position) self.clear_grid_row(recycled_grid_row); } - + // Mark affected lines dirty once after all rotations for line in self.cursor_row..=self.scroll_bottom { self.mark_line_dirty(line); @@ -1208,26 +1265,28 @@ impl Terminal { /// Deletes n lines at the cursor position, scrolling lines below up. /// Uses line_map rotation for efficiency. fn delete_lines(&mut self, n: usize) { - if self.cursor_row < self.scroll_top || self.cursor_row > self.scroll_bottom { + if self.cursor_row < self.scroll_top + || self.cursor_row > self.scroll_bottom + { return; } let n = n.min(self.scroll_bottom - self.cursor_row + 1); - + for _ in 0..n { // Save the line at cursor's grid index before rotation let recycled_grid_row = self.line_map[self.cursor_row]; - + // Rotate line_map: shift lines from cursor to bottom up by 1 // The cursor line becomes the new bottom line for i in self.cursor_row..self.scroll_bottom { self.line_map[i] = self.line_map[i + 1]; } self.line_map[self.scroll_bottom] = recycled_grid_row; - + // Clear the recycled line (now at bottom of scroll region) self.clear_grid_row(recycled_grid_row); } - + // Mark affected lines dirty once after all rotations for line in self.cursor_row..=self.scroll_bottom { self.mark_line_dirty(line); @@ -1243,7 +1302,10 @@ impl Terminal { // Truncate n characters from the end row.truncate(self.cols - n); // Insert n blank characters at cursor position (single O(cols) operation) - row.splice(self.cursor_col..self.cursor_col, std::iter::repeat(blank).take(n)); + row.splice( + self.cursor_col..self.cursor_col, + std::iter::repeat(blank).take(n), + ); self.mark_line_dirty(self.cursor_row); } @@ -1296,7 +1358,7 @@ impl Terminal { std::mem::swap(&mut self.grid[grid_row], dest); } } - + // Now clear the grid with BCE let blank = self.blank_cell(); for row in &mut self.grid { @@ -1314,14 +1376,14 @@ impl Handler for Terminal { fn text(&mut self, codepoints: &[u32]) { #[cfg(feature = "render_timing")] let start = std::time::Instant::now(); - + // Cache the current line to avoid repeated line_map lookups let mut cached_row = self.cursor_row; let mut grid_row = self.line_map[cached_row]; - + // Mark the initial line as dirty (like Kitty's init_text_loop_line) self.mark_line_dirty(cached_row); - + for &cp in codepoints { // Fast path for ASCII control characters and printable ASCII // These are the most common cases, so check them first using u32 directly @@ -1348,7 +1410,11 @@ impl Handler for Terminal { if self.cursor_row > self.scroll_bottom { self.scroll_up(1); self.cursor_row = self.scroll_bottom; - log::trace!("LF: scrolled at row {}, now at scroll_bottom {}", old_row, self.cursor_row); + log::trace!( + "LF: scrolled at row {}, now at scroll_bottom {}", + old_row, + self.cursor_row + ); } // Update cache after line change cached_row = self.cursor_row; @@ -1375,11 +1441,12 @@ impl Handler for Terminal { self.cursor_col = self.cols - 1; } } - + // Write character directly - no wide char handling needed for ASCII // SAFETY: cp is in 0x20..=0x7E which are valid ASCII chars let c = unsafe { char::from_u32_unchecked(cp) }; - self.grid[grid_row][self.cursor_col] = self.make_cell(c, false); + self.grid[grid_row][self.cursor_col] = + self.make_cell(c, false); self.cursor_col += 1; } // Slow path for non-ASCII printable characters (including all Unicode) @@ -1399,7 +1466,7 @@ impl Handler for Terminal { } } // Dirty lines are marked incrementally above - no need for mark_all_lines_dirty() - + #[cfg(feature = "render_timing")] { self.stats.text_handler_ns += start.elapsed().as_nanos() as u64; @@ -1453,10 +1520,18 @@ impl Handler for Terminal { if parts.len() >= 3 { if let Ok(index_str) = std::str::from_utf8(parts[1]) { if let Ok(index) = index_str.parse::() { - if let Ok(color_spec) = std::str::from_utf8(parts[2]) { - if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { + if let Ok(color_spec) = + std::str::from_utf8(parts[2]) + { + if let Some(rgb) = + ColorPalette::parse_color_spec(color_spec) + { self.palette.colors[index as usize] = rgb; - log::debug!("OSC 4: Set color {} to {:?}", index, rgb); + log::debug!( + "OSC 4: Set color {} to {:?}", + index, + rgb + ); } } } @@ -1467,9 +1542,14 @@ impl Handler for Terminal { 10 => { if parts.len() >= 2 { if let Ok(color_spec) = std::str::from_utf8(parts[1]) { - if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { + if let Some(rgb) = + ColorPalette::parse_color_spec(color_spec) + { self.palette.default_fg = rgb; - log::debug!("OSC 10: Set default foreground to {:?}", rgb); + log::debug!( + "OSC 10: Set default foreground to {:?}", + rgb + ); } } } @@ -1478,9 +1558,14 @@ impl Handler for Terminal { 11 => { if parts.len() >= 2 { if let Ok(color_spec) = std::str::from_utf8(parts[1]) { - if let Some(rgb) = ColorPalette::parse_color_spec(color_spec) { + if let Some(rgb) = + ColorPalette::parse_color_spec(color_spec) + { self.palette.default_bg = rgb; - log::debug!("OSC 11: Set default background to {:?}", rgb); + log::debug!( + "OSC 11: Set default background to {:?}", + rgb + ); } } } @@ -1496,7 +1581,9 @@ impl Handler for Terminal { match command { "navigate" => { if parts.len() >= 3 { - if let Ok(direction_str) = std::str::from_utf8(parts[2]) { + if let Ok(direction_str) = + std::str::from_utf8(parts[2]) + { let direction = match direction_str { "up" => Some(Direction::Up), "down" => Some(Direction::Down), @@ -1505,8 +1592,15 @@ impl Handler for Terminal { _ => None, }; if let Some(dir) = direction { - log::debug!("OSC 51: Navigate {:?}", dir); - self.command_queue.push(TerminalCommand::NavigatePane(dir)); + log::debug!( + "OSC 51: Navigate {:?}", + dir + ); + self.command_queue.push( + TerminalCommand::NavigatePane( + dir, + ), + ); } } } @@ -1517,14 +1611,20 @@ impl Handler for Terminal { // Content may be base64-encoded (prefixed with "b64:") to avoid // escape sequence interpretation issues in the terminal let prefix = b"51;statusline;"; - let raw_content = if data.len() > prefix.len() && data.starts_with(prefix) { - std::str::from_utf8(&data[prefix.len()..]).ok().map(|s| s.to_string()) + let raw_content = if data.len() > prefix.len() + && data.starts_with(prefix) + { + std::str::from_utf8(&data[prefix.len()..]) + .ok() + .map(|s| s.to_string()) } else if parts.len() >= 3 { - std::str::from_utf8(parts[2]).ok().map(|s| s.to_string()) + std::str::from_utf8(parts[2]) + .ok() + .map(|s| s.to_string()) } else { None }; - + // Decode base64 if prefixed with "b64:" let content = raw_content.and_then(|s| { if let Some(encoded) = s.strip_prefix("b64:") { @@ -1537,13 +1637,55 @@ impl Handler for Terminal { Some(s) } }); - - let statusline = content.filter(|s| !s.is_empty()); - log::info!("OSC 51: Set statusline: {:?}", statusline.as_ref().map(|s| format!("{} bytes", s.len()))); - self.command_queue.push(TerminalCommand::SetStatusline(statusline)); + + let statusline = + content.filter(|s| !s.is_empty()); + log::info!( + "OSC 51: Set statusline: {:?}", + statusline + .as_ref() + .map(|s| format!("{} bytes", s.len())) + ); + self.command_queue.push( + TerminalCommand::SetStatusline(statusline), + ); } _ => { - log::debug!("OSC 51: Unknown command '{}'", command); + log::debug!( + "OSC 51: Unknown command '{}'", + command + ); + } + } + } + } + } + // OSC 52 - Clipboard operations + // Format: OSC 52;Pc;Pd ST + // Pc = clipboard type ('c' for clipboard, 'p' for primary, 's' for selection) + // Pd = base64-encoded data to set, or '?' to query + 52 => { + if parts.len() >= 3 { + if let Ok(data_str) = std::str::from_utf8(parts[2]) { + if data_str == "?" { + log::debug!( + "OSC 52: Query clipboard (not implemented)" + ); + } else { + use base64::Engine; + if let Ok(decoded) = + base64::engine::general_purpose::STANDARD + .decode(data_str) + { + if let Ok(text) = String::from_utf8(decoded) { + log::debug!( + "OSC 52: Set clipboard ({} bytes)", + text.len() + ); + self.command_queue.push( + TerminalCommand::SetClipboard(text), + ); + } } } } @@ -1590,8 +1732,10 @@ impl Handler for Terminal { } } } else { - log::debug!("Unhandled DCS sequence: {:?}", - std::str::from_utf8(data).unwrap_or("")); + log::debug!( + "Unhandled DCS sequence: {:?}", + std::str::from_utf8(data).unwrap_or("") + ); } } @@ -1600,62 +1744,85 @@ impl Handler for Terminal { fn csi(&mut self, params: &CsiParams) { #[cfg(feature = "render_timing")] let start = std::time::Instant::now(); - + let action = params.final_char as char; let primary = params.primary; let secondary = params.secondary; match action { - // Cursor Up 'A' => { let n = params.get(0, 1).max(1) as usize; - let old_row = self.cursor_row; - self.cursor_row = self.cursor_row.saturating_sub(n); - log::trace!("CSI A: cursor up {} from row {} to {}", n, old_row, self.cursor_row); + let min_row = + if self.origin_mode { self.scroll_top } else { 0 }; + self.cursor_row = + self.cursor_row.saturating_sub(n).max(min_row); } - // Cursor Down 'B' => { let n = params.get(0, 1).max(1) as usize; - let old_row = self.cursor_row; - self.cursor_row = (self.cursor_row + n).min(self.rows - 1); - log::trace!("CSI B: cursor down {} from row {} to {}", n, old_row, self.cursor_row); + let max_row = if self.origin_mode { + self.scroll_bottom + } else { + self.rows - 1 + }; + self.cursor_row = (self.cursor_row + n).min(max_row); } // Cursor Forward 'C' => { let n = params.get(0, 1).max(1) as usize; let old_col = self.cursor_col; self.cursor_col = (self.cursor_col + n).min(self.cols - 1); - log::trace!("CSI C: cursor forward {} from col {} to {}", n, old_col, self.cursor_col); + log::trace!( + "CSI C: cursor forward {} from col {} to {}", + n, + old_col, + self.cursor_col + ); } // Cursor Back 'D' => { let n = params.get(0, 1).max(1) as usize; self.cursor_col = self.cursor_col.saturating_sub(n); } - // Cursor Next Line (CNL) 'E' => { let n = params.get(0, 1).max(1) as usize; + let max_row = if self.origin_mode { + self.scroll_bottom + } else { + self.rows - 1 + }; self.cursor_col = 0; - self.cursor_row = (self.cursor_row + n).min(self.rows - 1); + self.cursor_row = (self.cursor_row + n).min(max_row); } - // Cursor Previous Line (CPL) 'F' => { let n = params.get(0, 1).max(1) as usize; + let min_row = + if self.origin_mode { self.scroll_top } else { 0 }; self.cursor_col = 0; - self.cursor_row = self.cursor_row.saturating_sub(n); + self.cursor_row = + self.cursor_row.saturating_sub(n).max(min_row); } // Cursor Horizontal Absolute (CHA) 'G' => { let col = params.get(0, 1).max(1) as usize; let old_col = self.cursor_col; self.cursor_col = (col - 1).min(self.cols - 1); - log::trace!("CSI G: cursor to col {} (was {})", self.cursor_col, old_col); + log::trace!( + "CSI G: cursor to col {} (was {})", + self.cursor_col, + old_col + ); } // Cursor Position 'H' | 'f' => { let row = params.get(0, 1).max(1) as usize; let col = params.get(1, 1).max(1) as usize; - self.cursor_row = (row - 1).min(self.rows - 1); + if self.origin_mode { + let abs_row = + (self.scroll_top + row - 1).min(self.scroll_bottom); + self.cursor_row = abs_row; + } else { + self.cursor_row = (row - 1).min(self.rows - 1); + } self.cursor_col = (col - 1).min(self.cols - 1); } // Erase in Display @@ -1754,14 +1921,15 @@ impl Handler for Terminal { let n = (params.get(0, 1).max(1) as usize).min(65535); // Like Kitty's CSI_REP_MAX_REPETITIONS if self.cursor_col > 0 && n > 0 { let grid_row = self.line_map[self.cursor_row]; - let last_char = self.grid[grid_row][self.cursor_col - 1].character; + let last_char = + self.grid[grid_row][self.cursor_col - 1].character; let last_cp = last_char as u32; - + // Fast path for ASCII: direct grid write, no width lookup if last_cp >= 0x20 && last_cp <= 0x7E { let cell = self.make_cell(last_char, false); self.mark_line_dirty(self.cursor_row); - + for _ in 0..n { // Handle wrap if self.cursor_col >= self.cols { @@ -1799,7 +1967,13 @@ impl Handler for Terminal { // Vertical Position Absolute (VPA) 'd' => { let row = params.get(0, 1).max(1) as usize; - self.cursor_row = (row - 1).min(self.rows - 1); + if self.origin_mode { + let abs_row = + (self.scroll_top + row - 1).min(self.scroll_bottom); + self.cursor_row = abs_row; + } else { + self.cursor_row = (row - 1).min(self.rows - 1); + } } // SGR (Select Graphic Rendition) 'm' => { @@ -1815,8 +1989,13 @@ impl Handler for Terminal { } 6 => { // Cursor position report - let response = format!("\x1b[{};{}R", self.cursor_row + 1, self.cursor_col + 1); - self.response_queue.extend_from_slice(response.as_bytes()); + let response = format!( + "\x1b[{};{}R", + self.cursor_row + 1, + self.cursor_col + 1 + ); + self.response_queue + .extend_from_slice(response.as_bytes()); } _ => {} } @@ -1844,7 +2023,10 @@ impl Handler for Terminal { self.scroll_top = (top - 1).min(self.rows - 1); self.scroll_bottom = (bottom - 1).min(self.rows - 1); if self.scroll_top > self.scroll_bottom { - std::mem::swap(&mut self.scroll_top, &mut self.scroll_bottom); + std::mem::swap( + &mut self.scroll_top, + &mut self.scroll_bottom, + ); } // Move cursor to home position self.cursor_row = 0; @@ -1856,25 +2038,44 @@ impl Handler for Terminal { 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); + 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); + 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); + 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 @@ -1884,9 +2085,17 @@ impl Handler for Terminal { } } } - // Kitty keyboard protocol + // ANSI Save Cursor (CSI s) - DECSLRM uses CSI ? s which has primary='?' + 's' if primary == 0 => { + self.save_cursor(); + } + // CSI u: ANSI restore cursor (no params) vs Kitty keyboard protocol (with params) 'u' => { - self.handle_keyboard_protocol_csi(params); + if primary == 0 && params.num_params == 0 { + self.restore_cursor(); + } else { + self.handle_keyboard_protocol_csi(params); + } } // DEC Private Mode Set (CSI ? Ps h) 'h' if primary == b'?' => { @@ -1903,7 +2112,7 @@ impl Handler for Terminal { ); } } - + #[cfg(feature = "render_timing")] { self.stats.csi_handler_ns += start.elapsed().as_nanos() as u64; @@ -1922,19 +2131,29 @@ impl Handler for Terminal { underline_style: self.current_underline_style, strikethrough: self.current_strikethrough, }; - log::debug!("ESC 7: Cursor saved at ({}, {})", self.cursor_col, self.cursor_row); + log::debug!( + "ESC 7: Cursor saved at ({}, {})", + self.cursor_col, + self.cursor_row + ); } fn restore_cursor(&mut self) { - self.cursor_col = self.saved_cursor.col.min(self.cols.saturating_sub(1)); - self.cursor_row = self.saved_cursor.row.min(self.rows.saturating_sub(1)); + self.cursor_col = + self.saved_cursor.col.min(self.cols.saturating_sub(1)); + self.cursor_row = + self.saved_cursor.row.min(self.rows.saturating_sub(1)); self.current_fg = self.saved_cursor.fg; self.current_bg = self.saved_cursor.bg; self.current_bold = self.saved_cursor.bold; self.current_italic = self.saved_cursor.italic; self.current_underline_style = self.saved_cursor.underline_style; self.current_strikethrough = self.saved_cursor.strikethrough; - log::debug!("ESC 8: Cursor restored to ({}, {})", self.cursor_col, self.cursor_row); + log::debug!( + "ESC 8: Cursor restored to ({}, {})", + self.cursor_col, + self.cursor_row + ); } fn reset(&mut self) { @@ -1954,6 +2173,7 @@ impl Handler for Terminal { self.mouse_encoding = MouseEncoding::X10; self.application_cursor_keys = false; self.auto_wrap = true; + self.origin_mode = false; self.bracketed_paste = false; self.focus_reporting = false; self.synchronized_output = false; @@ -2014,7 +2234,7 @@ impl Handler for Terminal { for visual_row in 0..self.rows { let grid_row = self.line_map[visual_row]; for cell in &mut self.grid[grid_row] { - *cell = Cell { + *cell = Cell { character: 'E', fg_color: Color::Default, bg_color: Color::Default, @@ -2028,13 +2248,13 @@ impl Handler for Terminal { self.mark_line_dirty(visual_row); } } - + #[cfg(feature = "render_timing")] fn add_vt_parser_ns(&mut self, ns: u64) { self.stats.vt_parser_ns += ns; self.stats.consume_input_count += 1; } - + #[cfg(not(feature = "render_timing"))] fn add_vt_parser_ns(&mut self, _ns: u64) {} } @@ -2049,13 +2269,13 @@ impl Terminal { // Width 1 = normal width // Width 0 = combining/non-spacing marks (handled separately) let char_width = c.width().unwrap_or(1); - + // Skip zero-width characters (combining marks, etc.) if char_width == 0 { // TODO: Handle combining characters by attaching to previous cell return; } - + // Check if we need to wrap before printing if self.cursor_col >= self.cols { if self.auto_wrap { @@ -2065,7 +2285,7 @@ impl Terminal { self.cursor_col = self.cols - 1; } } - + // For double-width characters, check if there's room // If at the last column, we need to wrap first if char_width == 2 && self.cursor_col == self.cols - 1 { @@ -2082,34 +2302,39 @@ impl Terminal { } let grid_row = self.line_map[self.cursor_row]; - + // If we're overwriting a wide character's continuation cell, // we need to clear the first cell of that wide character - if self.grid[grid_row][self.cursor_col].wide_continuation && self.cursor_col > 0 { + if self.grid[grid_row][self.cursor_col].wide_continuation + && self.cursor_col > 0 + { self.grid[grid_row][self.cursor_col - 1] = Cell::default(); } - + // If we're overwriting the first cell of a wide character, // we need to clear its continuation cell - if char_width == 1 && self.cursor_col + 1 < self.cols - && self.grid[grid_row][self.cursor_col + 1].wide_continuation { + if char_width == 1 + && self.cursor_col + 1 < self.cols + && self.grid[grid_row][self.cursor_col + 1].wide_continuation + { self.grid[grid_row][self.cursor_col + 1] = Cell::default(); } - + // Write the character to the first cell self.grid[grid_row][self.cursor_col] = self.make_cell(c, false); self.mark_line_dirty(self.cursor_row); self.cursor_col += 1; - + // For double-width characters, write a continuation marker to the second cell if char_width == 2 && self.cursor_col < self.cols { // If the next cell is the first cell of another wide character, // clear its continuation cell - if self.cursor_col + 1 < self.cols - && self.grid[grid_row][self.cursor_col + 1].wide_continuation { + if self.cursor_col + 1 < self.cols + && self.grid[grid_row][self.cursor_col + 1].wide_continuation + { self.grid[grid_row][self.cursor_col + 1] = Cell::default(); } - + self.grid[grid_row][self.cursor_col] = self.make_cell(' ', true); self.cursor_col += 1; } @@ -2117,25 +2342,27 @@ impl Terminal { /// Parse extended color (SGR 38/48) and return the color and number of params consumed. /// Returns (Color, params_consumed) or None if parsing failed. - /// + /// /// SAFETY: Caller must ensure i < params.num_params #[inline(always)] - fn parse_extended_color(params: &CsiParams, i: usize) -> Option<(Color, usize)> { + fn parse_extended_color( + params: &CsiParams, + i: usize, + ) -> Option<(Color, usize)> { let num = params.num_params; let p = ¶ms.params; let is_sub = ¶ms.is_sub_param; - + // Check for sub-parameter format (38:2:r:g:b or 38:5:idx) if i + 1 < num && is_sub[i + 1] { let mode = p[i + 1]; if mode == 5 && i + 2 < num { return Some((Color::Indexed(p[i + 2] as u8), 2)); } else if mode == 2 && i + 4 < num { - return Some((Color::Rgb( - p[i + 2] as u8, - p[i + 3] as u8, - p[i + 4] as u8, - ), 4)); + return Some(( + Color::Rgb(p[i + 2] as u8, p[i + 3] as u8, p[i + 4] as u8), + 4, + )); } } else if i + 2 < num { // Regular format (38;2;r;g;b or 38;5;idx) @@ -2143,11 +2370,10 @@ impl Terminal { if mode == 5 { return Some((Color::Indexed(p[i + 2] as u8), 2)); } else if mode == 2 && i + 4 < num { - return Some((Color::Rgb( - p[i + 2] as u8, - p[i + 3] as u8, - p[i + 4] as u8, - ), 4)); + return Some(( + Color::Rgb(p[i + 2] as u8, p[i + 3] as u8, p[i + 4] as u8), + 4, + )); } } None @@ -2158,7 +2384,7 @@ impl Terminal { #[inline(always)] fn handle_sgr(&mut self, params: &CsiParams) { let num = params.num_params; - + // Fast path: SGR 0 (reset) with no params or explicit 0 if num == 0 { self.reset_sgr_attributes(); @@ -2168,7 +2394,7 @@ impl Terminal { let p = ¶ms.params; let is_sub = ¶ms.is_sub_param; let mut i = 0; - + while i < num { // SAFETY: i < num <= MAX_CSI_PARAMS, so index is always valid let code = p[i]; @@ -2195,13 +2421,17 @@ impl Terminal { 22 => self.current_bold = false, 23 => self.current_italic = false, 24 => self.current_underline_style = 0, - 27 => std::mem::swap(&mut self.current_fg, &mut self.current_bg), + 27 => { + std::mem::swap(&mut self.current_fg, &mut self.current_bg) + } 29 => self.current_strikethrough = false, // Standard foreground colors (30-37) 30..=37 => self.current_fg = Color::Indexed((code - 30) as u8), 38 => { // Extended foreground color - if let Some((color, consumed)) = Self::parse_extended_color(params, i) { + if let Some((color, consumed)) = + Self::parse_extended_color(params, i) + { self.current_fg = color; i += consumed; } @@ -2211,22 +2441,28 @@ impl Terminal { 40..=47 => self.current_bg = Color::Indexed((code - 40) as u8), 48 => { // Extended background color - if let Some((color, consumed)) = Self::parse_extended_color(params, i) { + if let Some((color, consumed)) = + Self::parse_extended_color(params, i) + { self.current_bg = color; i += consumed; } } 49 => self.current_bg = Color::Default, // Bright foreground colors (90-97) - 90..=97 => self.current_fg = Color::Indexed((code - 90 + 8) as u8), + 90..=97 => { + self.current_fg = Color::Indexed((code - 90 + 8) as u8) + } // Bright background colors (100-107) - 100..=107 => self.current_bg = Color::Indexed((code - 100 + 8) as u8), + 100..=107 => { + self.current_bg = Color::Indexed((code - 100 + 8) as u8) + } _ => {} } i += 1; } } - + /// Reset all SGR attributes to defaults. #[inline(always)] fn reset_sgr_attributes(&mut self) { @@ -2250,7 +2486,11 @@ impl Terminal { let flags = params.get(0, 0) as u8; let mode = params.get(1, 1) as u8; self.keyboard.set_flags(flags, mode); - log::debug!("Keyboard flags set to {:?} (mode {})", self.keyboard.flags(), mode); + log::debug!( + "Keyboard flags set to {:?} (mode {})", + self.keyboard.flags(), + mode + ); } b'>' => { let flags = if params.num_params == 0 { @@ -2259,12 +2499,18 @@ impl Terminal { Some(params.params[0] as u8) }; self.keyboard.push(flags); - log::debug!("Keyboard flags pushed: {:?}", self.keyboard.flags()); + log::debug!( + "Keyboard flags pushed: {:?}", + self.keyboard.flags() + ); } b'<' => { let count = params.get(0, 1) as usize; self.keyboard.pop(count); - log::debug!("Keyboard flags popped: {:?}", self.keyboard.flags()); + log::debug!( + "Keyboard flags popped: {:?}", + self.keyboard.flags() + ); } _ => {} } @@ -2279,6 +2525,16 @@ impl Terminal { self.application_cursor_keys = true; log::debug!("DECCKM: Application cursor keys enabled"); } + 6 => { + self.origin_mode = true; + self.cursor_row = self.scroll_top; + self.cursor_col = 0; + log::debug!( + "DECOM: Origin mode enabled, cursor at ({}, {})", + self.cursor_col, + self.cursor_row + ); + } 7 => { self.auto_wrap = true; log::debug!("DECAWM: Auto-wrap enabled"); @@ -2334,7 +2590,10 @@ impl Terminal { self.synchronized_output = true; log::trace!("Synchronized output enabled"); } - _ => log::debug!("Unhandled DEC private mode set: {}", params.params[i]), + _ => log::debug!( + "Unhandled DEC private mode set: {}", + params.params[i] + ), } } } @@ -2348,6 +2607,14 @@ impl Terminal { self.application_cursor_keys = false; log::debug!("DECCKM: Normal cursor keys enabled"); } + 6 => { + self.origin_mode = false; + self.cursor_row = 0; + self.cursor_col = 0; + log::debug!( + "DECOM: Origin mode disabled, cursor at (0, 0)" + ); + } 7 => { self.auto_wrap = false; log::debug!("DECAWM: Auto-wrap disabled"); @@ -2372,7 +2639,9 @@ impl Terminal { 1002 => { if self.mouse_tracking == MouseTrackingMode::ButtonEvent { self.mouse_tracking = MouseTrackingMode::None; - log::debug!("Mouse tracking: Button-event mode disabled"); + log::debug!( + "Mouse tracking: Button-event mode disabled" + ); } } 1003 => { @@ -2417,7 +2686,10 @@ impl Terminal { self.synchronized_output = false; log::trace!("Synchronized output disabled"); } - _ => log::debug!("Unhandled DEC private mode reset: {}", params.params[i]), + _ => log::debug!( + "Unhandled DEC private mode reset: {}", + params.params[i] + ), } } } @@ -2449,29 +2721,33 @@ impl Terminal { 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, - ); - + 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 + // "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 { + 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); + 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;