fix(tui): force clean redraw on resize / bound sidebar labels (closes #65)
After v0.6.1's light-theme removal exposed it more visibly, rapid resizes left stale glyphs in the right column (sidebar fragments, mid-character title truncation, duplicated transcript timestamps). Three small fixes: - Coalesce queued `Event::Resize` events, run a single `terminal.clear()`, and immediately draw the new frame instead of waiting for the next event loop iteration. Previously the cleared screen could sit blank between the resize handler's `continue` and the next draw, so any other event arriving in that window would be processed before the repaint. - `truncate_line_to_width` for budgets `<= 3` was counting codepoints instead of display widths, overrunning the cell budget for any double-width grapheme. Fix by accumulating display widths consistently. - Add a `tracing::debug!` log to the resize handler so users hitting this in the wild can confirm whether crossterm is delivering the event. Adds two regression tests in `tui/widgets` (resize cycle + cache invalidation on width change) and one in `tui/ui` (truncate semantics). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -931,8 +931,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;
|
||||
}
|
||||
|
||||
@@ -3569,8 +3598,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();
|
||||
|
||||
@@ -1790,3 +1790,30 @@ fn second_thinking_block_appends_new_entry_in_same_active_cell() {
|
||||
"the group still hasn't flushed — no prose yet"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
@@ -1653,4 +1653,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})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user