Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/attr.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use crate::csi;
use bitflags::bitflags;

bitflags! {
Expand Down Expand Up @@ -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 = [
Expand All @@ -59,7 +66,6 @@ impl Attr {
(Attr::Primary, "10"),
];

let mut buf = String::with_capacity(24);
buf.push_str("\x1B[");

let mut first = true;
Expand All @@ -74,7 +80,6 @@ impl Attr {
}

buf.push('m');
buf
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -59,7 +65,6 @@ impl Color {
}
Color::None => buf.push_str(if fg { "39m" } else { "49m" }),
}
buf
}
}

Expand Down
109 changes: 66 additions & 43 deletions src/framebuffer.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::fmt::Write as FmtWrite;
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use std::fmt::Write as FmtWrite; appears to be unused in this file (the write! macro doesn’t require the trait to be imported, and the alias isn’t referenced). If your CI denies warnings, this will fail builds; consider removing it or using it explicitly.

Suggested change
use std::fmt::Write as FmtWrite;

Copilot uses AI. Check for mistakes.
use std::io::{self, Write};

use crate::{Attr, Color};
Expand Down Expand Up @@ -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]);
Comment on lines +258 to +267
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

combine() can panic when x_offset > self.width: max_x becomes 0, but dst_start is still computed with the large x_offset, and slicing self.buffer[dst_start..dst_start + max_x] will panic if dst_start > self.buffer.len(). Add an early return (e.g., when x_offset >= self.width || y_offset >= self.height || max_x == 0 || max_y == 0) or otherwise clamp/guard indices before slicing to preserve the previous non-panicking behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +266 to +267
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The combine method uses copy_from_slice with a destination range starting at dst_start = (y + y_offset) * self.width + x_offset. If x_offset >= self.width, dst_start can exceed the buffer length even if max_x is 0, causing a panic. The previous implementation handled this gracefully with an if check. This regression introduces a potential crash (DoS) when out-of-bounds offsets are provided.

}
}

Expand Down Expand Up @@ -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();
}
}
}
}
Comment on lines 306 to 339
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The rendering logic has been significantly optimized by removing the intermediate Vec of changes. To further improve performance, you could avoid emitting cursor-positioning ANSI codes for horizontally adjacent cells. When multiple adjacent cells in the same row are updated, you only need to position the cursor for the first cell in the sequence. The cursor will then automatically advance to the next column after printing a character.

This can be achieved by tracking whether the previously processed cell in the same row was also updated.

Here's a conceptual example of how this could be implemented:

for y in 0..self.height {
    let mut last_cell_was_dirty = false;
    for x in 0..self.width {
        let idx = y * self.width + x;
        let back = back_fb.buffer[idx];

        if self.buffer[idx] != back {
            if !last_cell_was_dirty {
                // This is the first cell in a sequence of dirty cells, move cursor.
                write!(chunk, "\x1B[{};{}H", y + 1, x + 1).unwrap();
            }
            // ... (rest of the logic to set attributes and write character) ...
            self.buffer[idx] = back;
            last_cell_was_dirty = true;
        } else {
            last_cell_was_dirty = false;
        }
    }
}

This change would reduce the number of ANSI escape sequences sent to the terminal, especially for large contiguous updates, making rendering even more efficient.


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()?;
Expand Down Expand Up @@ -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, ' ');
}
}
}
2 changes: 2 additions & 0 deletions src/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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.
Comment on lines +67 to +69
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On TryLockError::WouldBlock, the loop continues without any yielding/backoff. Once elapsed_since_frame >= rendering_rate, this can devolve into a tight spin while draw() holds the back buffer lock, consuming a full CPU core. Consider adding thread::yield_now() (or a small backoff sleep) on WouldBlock while still avoiding updating last_frame_time.

Suggested change
// 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.
// back_fb is locked by draw(); yield to avoid busy-waiting,
// but do not reset the frame timer so the next iteration does
// not sleep a full rendering_rate before retrying.
thread::yield_now();

Copilot uses AI. Check for mistakes.
continue;
}
Comment on lines 66 to 71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The RenderThread now busy-waits when the back_fb lock is held by another thread. The thread::sleep(time::Duration::from_millis(1)) was removed in the TryLockError::WouldBlock case, causing the thread to spin at 100% CPU usage until the lock becomes available. This uncontrolled resource consumption can lead to a denial of service, especially in multi-tenant environments or on battery-powered devices.

Err(_) => {
Expand Down