merge: clean resize redraw + display-width truncation (closes #65)

This commit is contained in:
Hunter Bown
2026-04-26 14:49:42 -05:00
3 changed files with 156 additions and 2 deletions
+45 -2
View File
@@ -973,8 +973,37 @@ async fn run_event_loop(
}
if let Event::Resize(width, height) = evt {
tracing::debug!(width, height, "Event::Resize received; clearing terminal");
// Coalesce queued resize events so we only act on the final
// size. Rapid drag-resizes can produce many intermediate
// events; processing each one with its own clear+redraw
// amplifies the artifact window. Drain the queue here, keep
// only the final dimensions, and issue a single clear+draw.
let mut final_w = width;
let mut final_h = height;
while event::poll(Duration::from_millis(0)).unwrap_or(false) {
match event::read() {
Ok(Event::Resize(w, h)) => {
final_w = w;
final_h = h;
}
Ok(other) => {
tracing::debug!(
?other,
"non-resize event during resize coalesce; dropping"
);
break;
}
Err(_) => break,
}
}
terminal.clear()?;
app.handle_resize(width, height);
app.handle_resize(final_w, final_h);
// Repaint immediately. Without this, the cleared screen can
// sit blank until the next event triggers another draw,
// which presents as a flicker or stale frame to the user.
terminal.draw(|f| render(f, app))?;
app.needs_redraw = false;
continue;
}
@@ -3631,8 +3660,22 @@ pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String {
if UnicodeWidthStr::width(text) <= max_width {
return text.to_string();
}
// For very small budgets, take chars until we exceed the *display* width.
// The previous implementation counted codepoints, which overran the
// budget for any double-width grapheme and contributed to mid-character
// sidebar artifacts on resize (issue #65).
if max_width <= 3 {
return text.chars().take(max_width).collect();
let mut out = String::new();
let mut width = 0usize;
for ch in text.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if width + ch_width > max_width {
break;
}
out.push(ch);
width += ch_width;
}
return out;
}
let mut out = String::new();
+27
View File
@@ -1887,3 +1887,30 @@ fn non_fanout_tool_does_not_populate_prompts() {
"non-fan-out tool must not populate prompts"
);
}
/// Regression for issue #65: `truncate_line_to_width` with a tiny budget
/// must respect display widths, not codepoint counts. The old branch counted
/// chars and overran the budget for any double-width grapheme, which
/// contributed to mid-character sidebar artifacts on resize.
#[test]
fn truncate_line_to_width_respects_display_width_for_tiny_budgets() {
use unicode_width::UnicodeWidthStr;
let trimmed = truncate_line_to_width("Agents", 3);
assert_eq!(trimmed, "Age");
assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3);
let trimmed_cjk = truncate_line_to_width("中文测试", 3);
assert!(
UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3,
"trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})",
UnicodeWidthStr::width(trimmed_cjk.as_str()),
);
assert_eq!(truncate_line_to_width("anything", 0), "");
assert_eq!(truncate_line_to_width("hi", 10), "hi");
let trimmed_long = truncate_line_to_width("a long sidebar label", 10);
assert!(trimmed_long.ends_with("..."));
assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10);
}
+84
View File
@@ -1687,4 +1687,88 @@ mod tests {
"scrollbar should be visible for a long history"
);
}
/// Regression for issue #65: after `App::handle_resize`, the chat widget
/// must produce a clean render at the new width — no panic, and a
/// populated history must yield non-empty rendered cells. Cycling
/// through several widths (shrinks and grows) flushes any cached layout
/// that fails to invalidate on resize.
#[test]
fn chat_widget_renders_cleanly_after_resize_cycle() {
let mut app = create_test_app();
for i in 0..40 {
app.add_message(HistoryCell::User {
content: format!("user message {i} with enough text to wrap at 30 columns easily"),
});
}
let widths_to_cycle = [120u16, 80, 40, 60, 100, 30];
let height: u16 = 20;
for width in widths_to_cycle {
app.handle_resize(width, height);
let area = Rect {
x: 0,
y: 0,
width,
height,
};
let mut buf = Buffer::empty(area);
let widget = ChatWidget::new(&mut app, area);
widget.render(area, &mut buf);
let mut non_empty = 0usize;
for y in 0..height {
for x in 0..width {
let sym = buf[(x, y)].symbol();
if sym != " " && !sym.is_empty() {
non_empty += 1;
}
}
}
assert!(
non_empty > 0,
"render at {width}x{height} produced an empty buffer after resize"
);
}
}
/// Regression for issue #65: the transcript view cache must invalidate
/// when width changes, so the same `App.history` re-wraps at the new
/// width on the very next `ChatWidget::new` call.
#[test]
fn transcript_cache_invalidates_on_width_change() {
let mut app = create_test_app();
for i in 0..10 {
app.add_message(HistoryCell::User {
content: format!("a fairly long user message number {i} that needs to wrap"),
});
}
let area_wide = Rect {
x: 0,
y: 0,
width: 120,
height: 20,
};
let area_narrow = Rect {
x: 0,
y: 0,
width: 30,
height: 20,
};
let mut buf_wide = Buffer::empty(area_wide);
let widget_wide = ChatWidget::new(&mut app, area_wide);
widget_wide.render(area_wide, &mut buf_wide);
let wide_total_lines = app.transcript_cache.total_lines();
let mut buf_narrow = Buffer::empty(area_narrow);
let widget_narrow = ChatWidget::new(&mut app, area_narrow);
widget_narrow.render(area_narrow, &mut buf_narrow);
let narrow_total_lines = app.transcript_cache.total_lines();
assert!(
narrow_total_lines > wide_total_lines,
"narrow render should produce more wrapped lines (got {narrow_total_lines}, wide={wide_total_lines})"
);
}
}