From 930c6f08b6cf96ce9cd52df3da46942e19e6b2ea Mon Sep 17 00:00:00 2001 From: Bing Wang Date: Mon, 27 Apr 2026 15:13:44 +0800 Subject: [PATCH] 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 Co-authored-by: Ben Kunkle --- crates/terminal/src/terminal.rs | 68 +++++++++++++++++++- crates/terminal_view/src/terminal_element.rs | 63 ++++++++++++++---- 2 files changed, 118 insertions(+), 13 deletions(-) diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index e47cc2bb8d..3be023e826 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -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> { let mut cells = Vec::new(); diff --git a/crates/terminal_view/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs index 1e07e1c49d..8f46e8f9b2 100644 --- a/crates/terminal_view/src/terminal_element.rs +++ b/crates/terminal_view/src/terminal_element.rs @@ -50,7 +50,6 @@ pub struct LayoutState { mode: TermMode, display_offset: usize, hyperlink_tooltip: Option, - gutter: Pixels, block_below_cursor_element: Option, 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 = { let ime_state = &self.terminal_view.read(cx).ime_state;