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
2 changes: 1 addition & 1 deletion src/sig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ pub async fn run(
let size = crossterm::terminal::size()?;

let pane = text_editor.create_pane(size.0, size.1);
let mut term = Terminal::new(size, &pane)?;
let mut term = Terminal::try_new(size, &pane)?;
term.draw_pane(&pane)?;

let shared_term = Arc::new(RwLock::new(term));
Expand Down
74 changes: 50 additions & 24 deletions src/terminal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,19 @@ pub struct Terminal {
pane_rows: u16,
}

/// Reset the scroll region to the entire terminal.
fn reset_scroll_region_sequence() -> &'static str {
crossterm::csi!("r")
}

/// Set the scroll region to [top, bottom], where both are 1-based.
fn set_scroll_region_sequence(top_1based: u16, bottom_1based: u16) -> String {
format!(crossterm::csi!("{};{}r"), top_1based, bottom_1based)
}

impl Terminal {
pub fn new(size: (u16, u16), pane: &Pane) -> anyhow::Result<Self> {
/// Create a new Terminal instance and apply the initial scroll region.
pub fn try_new(size: (u16, u16), pane: &Pane) -> anyhow::Result<Self> {
let term = Self {
Comment on lines +25 to 27
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

The PR title indicates this is a documentation-only change, but this hunk also renames the constructor from new to try_new (and updates call sites), which is a functional/API change. Consider updating the PR title (and/or description) to reflect that it includes a small refactor, so release notes/changelogs and reviewer expectations stay accurate.

Copilot uses AI. Check for mistakes.
size,
pane_rows: Self::pane_rows(size, pane),
Expand All @@ -22,6 +33,7 @@ impl Terminal {
Ok(term)
}

/// Draw the stream content, which is displayed below the pane.
pub fn draw_stream(&mut self, items: &[StyledGraphemes]) -> anyhow::Result<()> {
let stream_height = self.stream_height();
if items.is_empty() || stream_height == 0 {
Expand All @@ -31,6 +43,14 @@ impl Terminal {

let visible_rows = items.len().min(stream_height as usize);
let start = items.len().saturating_sub(visible_rows);
// Note: This view intentionally keeps only the tail of `items` that fits in the stream area.
// The trade-off is that older rows are dropped from the current frame
// when incoming data exceeds the stream height.
// In this realtime UI, we accept that loss because such overflow already exceeds
// what a human can read at once
// and tail-first rendering keeps behavior predictable under high throughput.
//
// If users need to re-check past matches, guide them to Archived mode (Ctrl+F).
let rows = &items[start..];
let scroll_rows = rows.len() as u16;
let write_from = self.size.1.saturating_sub(scroll_rows);
Expand All @@ -53,12 +73,31 @@ impl Terminal {
Ok(())
}

/// Draw the pane content.
/// This should be called after syncing the layout to ensure the pane area is correctly sized.
pub fn draw_pane(&mut self, pane: &Pane) -> anyhow::Result<()> {
self.redraw_pane_rows(pane)?;
for y in 0..self.pane_rows {
crossterm::queue!(
io::stdout(),
cursor::MoveTo(0, y),
terminal::Clear(terminal::ClearType::CurrentLine),
)?;
}

for (y, row) in pane.extract(self.pane_rows as usize).iter().enumerate() {
crossterm::queue!(
io::stdout(),
cursor::MoveTo(0, y as u16),
style::Print(row.styled_display()),
)?;
}

io::stdout().flush()?;
Ok(())
}

/// Sync the terminal layout with the given size and pane rows.
/// Returns true if the layout was changed and the pane needs to be redrawn.
pub fn sync_layout(&mut self, size: (u16, u16), pane_rows: u16) -> anyhow::Result<bool> {
let pane_rows = pane_rows.min(size.1);
if self.size == size && self.pane_rows == pane_rows {
Expand All @@ -84,25 +123,6 @@ impl Terminal {
self.size.1.saturating_sub(self.pane_rows)
}

fn redraw_pane_rows(&self, pane: &Pane) -> anyhow::Result<()> {
for y in 0..self.pane_rows {
crossterm::queue!(
io::stdout(),
cursor::MoveTo(0, y),
terminal::Clear(terminal::ClearType::CurrentLine),
)?;
}

for (y, row) in pane.extract(self.pane_rows as usize).iter().enumerate() {
crossterm::queue!(
io::stdout(),
cursor::MoveTo(0, y as u16),
style::Print(row.styled_display()),
)?;
}
Ok(())
}

fn clear_stream_area(&self) -> anyhow::Result<()> {
for y in self.stream_top()..self.size.1 {
crossterm::queue!(
Expand All @@ -114,22 +134,28 @@ impl Terminal {
Ok(())
}

/// Apply the scroll region to the stream area, excluding the pane area.
fn apply_scroll_region(&self) -> anyhow::Result<()> {
if self.stream_height() == 0 {
crossterm::queue!(io::stdout(), style::Print("\x1b[r"))?;
crossterm::queue!(io::stdout(), style::Print(reset_scroll_region_sequence()),)?;
return Ok(());
}

let top = self.stream_top() + 1;
let bottom = self.size.1;
crossterm::queue!(io::stdout(), style::Print(format!("\x1b[{top};{bottom}r")))?;
// Exclude the pane area from the scroll region,
// so that only the stream area is scrolled when new lines are added.
crossterm::queue!(
io::stdout(),
style::Print(set_scroll_region_sequence(top, bottom)),
)?;
Ok(())
}
}

impl Drop for Terminal {
fn drop(&mut self) {
let _ = crossterm::queue!(io::stdout(), style::Print("\x1b[r"));
let _ = crossterm::queue!(io::stdout(), style::Print(reset_scroll_region_sequence()));
let _ = io::stdout().flush();
}
}
Loading