terminal: Reduce flicker on resize (#47195)

Skip PTY resizes for pixel-only changes and coalesce pending resize
events.
Snap standalone terminal layout to whole device pixels to avoid subpixel
jitter.

before:


https://github.com/user-attachments/assets/0ad0db83-0099-44c8-b8d1-3dc8146b25ef


after:


https://github.com/user-attachments/assets/86278014-1c87-4263-a9e5-b58bcc1fa2ea




Release Notes:

- Fixed: Reduce terminal flicker on resize

---------

Signed-off-by: pigletfly <wangbing.adam@gmail.com>
Co-authored-by: Ben Kunkle <ben@zed.dev>
This commit is contained in:
Bing WangandBen Kunkle authored and GitHub committed 2026-04-27 07:13:44 +00:00
1 parent 53e1473bea
commit 930c6f08b6
2 files changed
+118 -13

No files matched your search

+66 -2
View File
@@ -1440,8 +1440,27 @@ impl Terminal {
///Resize the terminal and the PTY.
pub fn set_size(&mut self, new_bounds: TerminalBounds) {
if self.last_content.terminal_bounds != new_bounds {
self.events.push_back(InternalEvent::Resize(new_bounds))
let mut new_bounds = new_bounds;
new_bounds.bounds.size.height = cmp::max(new_bounds.line_height, new_bounds.height());
new_bounds.bounds.size.width = cmp::max(new_bounds.cell_width, new_bounds.width());
let old_bounds = self.last_content.terminal_bounds;
self.last_content.terminal_bounds = new_bounds;
// Avoid spamming PTY resizes on pixel-level size changes (e.g. while dragging edges),
// since those can generate excessive SIGWINCH/reflows and cause visible flicker.
let requires_resize = old_bounds.num_lines() != new_bounds.num_lines()
|| old_bounds.num_columns() != new_bounds.num_columns()
|| old_bounds.cell_width != new_bounds.cell_width
|| old_bounds.line_height != new_bounds.line_height;
if !requires_resize {
return;
}
match self.events.back_mut() {
Some(InternalEvent::Resize(pending_bounds)) => *pending_bounds = new_bounds,
_ => self.events.push_back(InternalEvent::Resize(new_bounds)),
}
}
@@ -3036,6 +3055,51 @@ mod tests {
);
}
#[gpui::test]
async fn test_set_size_coalesces_pixel_only_changes(cx: &mut TestAppContext) {
let builder = cx.update(|cx| {
TerminalBuilder::new_display_only(
CursorShape::Block,
AlternateScroll::On,
None,
0,
cx.background_executor(),
PathStyle::local(),
)
.unwrap()
});
let mut terminal = builder.terminal;
let base_bounds = TerminalBounds {
cell_width: Pixels::from(10.),
line_height: Pixels::from(10.),
bounds: bounds(
Point::default(),
size(Pixels::from(100.), Pixels::from(100.)),
),
};
terminal.set_size(base_bounds);
terminal.events.clear();
assert_eq!(terminal.last_content.terminal_bounds, base_bounds);
// Pixel-only change: height grows by 1px but still the same number of rows/cols.
let mut pixel_changed = base_bounds;
pixel_changed.bounds.size.height = Pixels::from(101.);
terminal.set_size(pixel_changed);
assert!(terminal.events.is_empty());
assert_eq!(terminal.last_content.terminal_bounds, pixel_changed);
// Grid change: height increases enough to add a row.
let mut grid_changed = base_bounds;
grid_changed.bounds.size.height = Pixels::from(110.);
terminal.set_size(grid_changed);
assert!(matches!(
terminal.events.back(),
Some(InternalEvent::Resize(_))
));
}
fn get_cells(size: TerminalBounds, rng: &mut StdRng) -> Vec<Vec<char>> {
let mut cells = Vec::new();
+52 -11
View File
@@ -50,7 +50,6 @@ pub struct LayoutState {
mode: TermMode,
display_offset: usize,
hyperlink_tooltip: Option<AnyElement>,
gutter: Pixels,
block_below_cursor_element: Option<AnyElement>,
base_text_style: TextStyle,
content_mode: ContentMode,
@@ -970,6 +969,7 @@ impl Element for TerminalElement {
let mut size = bounds.size;
size.width -= gutter;
let available_height = size.height;
// https://github.com/zed-industries/zed/issues/2750
// if the terminal is one column wide, rendering 🦀
@@ -981,6 +981,43 @@ impl Element for TerminalElement {
let mut origin = bounds.origin;
origin.x += gutter;
if matches!(self.terminal_view.read(cx).mode, TerminalMode::Standalone) {
let scale_factor = window.scale_factor();
let line_height_pixels = px(line_height);
let line_height_device_px = (f32::from(line_height_pixels) * scale_factor)
.round()
.max(1.0) as i32;
let available_height_device_px =
(f32::from(available_height) * scale_factor)
.floor()
.max(0.0) as i32;
let rows =
((available_height_device_px / line_height_device_px) as usize).max(1);
let snapped_height_device_px = (rows as i32) * line_height_device_px;
let padding_device_px =
(available_height_device_px - snapped_height_device_px).max(0);
let snapped_height =
px(snapped_height_device_px as f32 / scale_factor.max(1.0));
let padding = px(padding_device_px as f32 / scale_factor.max(1.0));
size.height = snapped_height;
if self.terminal.read(cx).scrolled_to_bottom() {
origin.y += padding;
}
}
// Snap to device pixels to avoid subpixel jitter while resizing.
// Terminal rendering is grid-based; allowing fractional origins can cause the
// glyph rasterization to shift between frames, which looks like flicker.
let scale_factor = window.scale_factor();
let snap_px = |value: Pixels| {
Pixels::from((f32::from(value) * scale_factor).floor() / scale_factor)
};
origin.x = snap_px(origin.x);
origin.y = snap_px(origin.y);
(
TerminalBounds::new(px(line_height), cell_width, Bounds { origin, size }),
line_height,
@@ -1018,7 +1055,7 @@ impl Element for TerminalElement {
let scroll_top = self.terminal_view.read(cx).scroll_top;
let hyperlink_tooltip = hover_tooltip.map(|hover_tooltip| {
let offset = bounds.origin + point(gutter, px(0.)) - point(px(0.), scroll_top);
let offset = dimensions.bounds.origin - point(px(0.), scroll_top);
let mut element = div()
.size_full()
.id("terminal-element")
@@ -1062,8 +1099,9 @@ impl Element for TerminalElement {
//
// This optimization is analogous to the editor optimization in PR #45077
// which fixed performance issues with large AutoHeight editors inside Lists.
let content_bounds = dimensions.bounds;
let visible_bounds = window.content_mask().bounds;
let intersection = visible_bounds.intersect(&bounds);
let intersection = visible_bounds.intersect(&content_bounds);
// If the terminal is entirely outside the viewport, skip all cell processing.
// This handles the case where the terminal has been scrolled past (above or
@@ -1073,7 +1111,7 @@ impl Element for TerminalElement {
|| intersection.size.width <= px(0.)
{
(Vec::new(), Vec::new())
} else if intersection == bounds {
} else if intersection == content_bounds {
// Fast path: terminal fully visible, no clipping needed.
// Avoid grouping/allocation overhead by streaming cells directly.
TerminalElement::layout_grid(
@@ -1092,9 +1130,9 @@ impl Element for TerminalElement {
// by screen position (enumerated line group index), not by the cell's
// internal line number (which can be negative in Scrollable mode for
// scrollback history).
let rows_above_viewport =
f32::from((intersection.top() - bounds.top()).max(px(0.)) / line_height_px)
as usize;
let rows_above_viewport = f32::from(
(intersection.top() - content_bounds.top()).max(px(0.)) / line_height_px,
) as usize;
let visible_row_count =
f32::from((intersection.size.height / line_height_px).ceil()) as usize + 1;
@@ -1199,7 +1237,7 @@ impl Element for TerminalElement {
block.height as f32 * dimensions.line_height(),
),
);
let origin = bounds.origin
let origin = Point::new(bounds.origin.x, dimensions.bounds.origin.y)
+ point(px(0.), target_line as f32 * dimensions.line_height())
- point(px(0.), scroll_top);
window.with_rem_size(rem_size, |window| {
@@ -1225,7 +1263,6 @@ impl Element for TerminalElement {
mode,
display_offset,
hyperlink_tooltip,
gutter,
block_below_cursor_element,
base_text_style: text_style,
content_mode,
@@ -1249,8 +1286,12 @@ impl Element for TerminalElement {
let scroll_top = self.terminal_view.read(cx).scroll_top;
window.paint_quad(fill(bounds, layout.background_color));
let origin =
bounds.origin + Point::new(layout.gutter, px(0.)) - Point::new(px(0.), scroll_top);
let origin = layout.dimensions.bounds.origin - Point::new(px(0.), scroll_top);
let scale_factor = window.scale_factor();
let snap_px = |value: Pixels| {
Pixels::from((f32::from(value) * scale_factor).floor() / scale_factor)
};
let origin = point(snap_px(origin.x), snap_px(origin.y));
let marked_text_cloned: Option<String> = {
let ime_state = &self.terminal_view.read(cx).ime_state;