diff --git a/src/attr.rs b/src/attr.rs index 71e29c6..1bf4aa4 100644 --- a/src/attr.rs +++ b/src/attr.rs @@ -1,4 +1,3 @@ -use crate::csi; use bitflags::bitflags; bitflags! { @@ -41,8 +40,16 @@ impl Attr { /// /// Returns a string containing the ANSI escape codes for the active attributes. pub fn to_ansi(&self) -> String { + let mut buf = String::with_capacity(24); + self.write_ansi(&mut buf); + buf + } + + /// Write ANSI escape codes directly into an existing buffer, avoiding allocation. + pub fn write_ansi(&self, buf: &mut String) { if self.is_empty() { - return csi!("0m"); + buf.push_str("\x1B[0m"); + return; } let attr_mappings = [ @@ -59,7 +66,6 @@ impl Attr { (Attr::Primary, "10"), ]; - let mut buf = String::with_capacity(24); buf.push_str("\x1B["); let mut first = true; @@ -74,7 +80,6 @@ impl Attr { } buf.push('m'); - buf } } diff --git a/src/color.rs b/src/color.rs index 008ff22..b750cf1 100644 --- a/src/color.rs +++ b/src/color.rs @@ -20,8 +20,14 @@ impl Color { /// /// Returns an ANSI escape code string for the color. pub fn to_ansi(&self, fg: bool) -> String { - use std::fmt::Write; let mut buf = String::with_capacity(20); + self.write_ansi(fg, &mut buf); + buf + } + + /// Write ANSI escape code directly into an existing buffer, avoiding allocation. + pub fn write_ansi(&self, fg: bool, buf: &mut String) { + use std::fmt::Write; buf.push_str("\x1B["); match self { @@ -59,7 +65,6 @@ impl Color { } Color::None => buf.push_str(if fg { "39m" } else { "49m" }), } - buf } } diff --git a/src/framebuffer.rs b/src/framebuffer.rs index 7e7a248..b183a2e 100644 --- a/src/framebuffer.rs +++ b/src/framebuffer.rs @@ -1,3 +1,4 @@ +use std::fmt::Write as FmtWrite; use std::io::{self, Write}; use crate::{Attr, Color}; @@ -254,13 +255,16 @@ impl Framebuffer { /// * `x_offset`: The x-coordinate offset to start combining. /// * `y_offset`: The y-coordinate offset to start combining. pub fn combine(&mut self, other: &Framebuffer, x_offset: usize, y_offset: usize) { - for y in 0..other.height { - for x in 0..other.width { - if x + x_offset < self.width && y + y_offset < self.height { - let idx = (y + y_offset) * self.width + (x + x_offset); - self.buffer[idx] = other.buffer[y * other.width + x]; - } - } + let max_y = other.height.min(self.height.saturating_sub(y_offset)); + let max_x = other.width.min(self.width.saturating_sub(x_offset)); + if max_x == 0 || max_y == 0 { + return; + } + for y in 0..max_y { + let dst_start = (y + y_offset) * self.width + x_offset; + let src_start = y * other.width; + self.buffer[dst_start..dst_start + max_x] + .copy_from_slice(&other.buffer[src_start..src_start + max_x]); } } @@ -295,50 +299,44 @@ impl Framebuffer { let mut prev_attrs = Attr::NORMAL; let mut prev_fg: Color = Color::default(); let mut prev_bg: Color = Color::default(); + let mut has_changes = false; - stdout_lock.write_all("\x1B[0m".as_bytes())?; // Reset all attributes - stdout_lock.flush()?; + let mut chunk = String::with_capacity(CHUNK_SIZE); - // Collect all changed cells first - let mut changes = Vec::new(); for y in 0..self.height { for x in 0..self.width { let idx = y * self.width + x; - let front = &self.buffer[idx]; - let back = &back_fb.buffer[idx]; - - if front != back { - changes.push((x, y, idx, back)); + let back = back_fb.buffer[idx]; // Cell is Copy; no heap allocation + + if self.buffer[idx] != back { + if !has_changes { + chunk.push_str("\x1B[0m"); // Reset all attributes before first change + has_changes = true; + } + write!(chunk, "\x1B[{};{}H", y + 1, x + 1).unwrap(); // Move to the target coordinates + if prev_attrs != back.attrs { + prev_attrs = back.attrs; + back.attrs.write_ansi(&mut chunk); + } + if prev_fg != back.fg { + prev_fg = back.fg; + back.fg.write_ansi(true, &mut chunk); + } + if prev_bg != back.bg { + prev_bg = back.bg; + back.bg.write_ansi(false, &mut chunk); + } + chunk.push(back.ch); // Add the character + self.buffer[idx] = back; // Copy the Cell to the front buffer + + if chunk.len() >= CHUNK_SIZE { + stdout_lock.write_all(chunk.as_bytes())?; + stdout_lock.flush()?; + chunk.clear(); + } } } } - - let mut chunk = String::with_capacity(CHUNK_SIZE); - - // Draw the output for each changed cell - for (x, y, idx, back) in changes { - chunk.push_str(&format!("\x1B[{};{}H", y + 1, x + 1)); // Move to the target coordinates - if prev_attrs != back.attrs { - prev_attrs = back.attrs; - chunk.push_str(&back.attrs.to_ansi()); - } - if prev_fg != back.fg { - prev_fg = back.fg; - chunk.push_str(&back.fg.to_ansi(true)); - } - if prev_bg != back.bg { - prev_bg = back.bg; - chunk.push_str(&back.bg.to_ansi(false)); - } - chunk.push(back.ch); // Add the character - self.buffer[idx] = *back; // Copy the Cell to the front buffer - - if chunk.len() >= CHUNK_SIZE { - stdout_lock.write_all(chunk.as_bytes())?; - stdout_lock.flush()?; - chunk.clear(); - } - } if !chunk.is_empty() { stdout_lock.write_all(chunk.as_bytes())?; stdout_lock.flush()?; @@ -505,4 +503,29 @@ mod tests { assert_eq!(fb1.buffer[7].ch, '─'); assert_eq!(fb1.buffer[8].ch, '╯'); } + + #[test] + fn test_fb_combine_out_of_bounds() { + let mut fb1 = Framebuffer::new(3, 2); + let mut fb2 = Framebuffer::new(2, 2); + fb2.set_char( + 0, + 0, + 'X', + Attr::default(), + Color::default(), + Color::default(), + ); + + // x_offset fully outside: must not panic + fb1.combine(&fb2, 7, 0); + // y_offset fully outside: must not panic + fb1.combine(&fb2, 0, 5); + // both offsets outside + fb1.combine(&fb2, 7, 5); + // fb1 should be unchanged (all spaces) + for cell in &fb1.buffer { + assert_eq!(cell.ch, ' '); + } + } } diff --git a/src/input.rs b/src/input.rs index f863f67..ae4eb52 100644 --- a/src/input.rs +++ b/src/input.rs @@ -318,6 +318,8 @@ impl InputListener { Err(mpsc::TrySendError::Full(_)) => {} // If the channel is full, we can drop the event Err(mpsc::TrySendError::Disconnected(_)) => break, // Stop the loop if the receiver is dropped } + // Skip sleep to immediately drain any buffered input (e.g. burst typing, mouse moves) + continue; } Ok(None) => {} // No input Err(_) => {} // Read error, continue diff --git a/src/render.rs b/src/render.rs index a8cab2e..f8837b4 100644 --- a/src/render.rs +++ b/src/render.rs @@ -49,7 +49,6 @@ impl RenderThread { if elapsed_since_frame < rendering_rate { thread::sleep(rendering_rate - elapsed_since_frame); } - last_frame_time = time::Instant::now(); // Rendering process match back_fb.try_lock() { @@ -58,12 +57,16 @@ impl RenderThread { if front_locked.refresh(&back_locked).is_err() { continue; } + // Update frame time only after a successful render so that + // a failed lock attempt does not consume the frame budget. + last_frame_time = time::Instant::now(); frame_count += 1; } } Err(TryLockError::WouldBlock) => { - // Skip if the back buffer is locked - thread::sleep(time::Duration::from_millis(1)); + // back_fb is locked by draw(); retry immediately without + // resetting the frame timer so the next iteration does not + // sleep a full rendering_rate before retrying. continue; } Err(_) => {