Improve terminal rendering performance (#33345)

Alisina Bahadori and Conrad Irwin created

Closes #18263

Improvements:

• **Batch text rendering** - Combine adjacent cells with identical
styling into single text runs to reduce draw calls
• **Throttle hyperlink searches** - Limit hyperlink detection to every
100ms or when mouse moves >5px to reduce CPU usage
• **Pre-allocate collections** - Use `Vec::with_capacity()` for cells,
runs, and regions to minimize reallocations
• **Optimize background regions** - Merge adjacent background rectangles
to reduce number of draw operations
• **Cache selection text** - Only compute terminal selection string when
selection exists

Release Notes:

- Improved terminal rendering performance.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/editor/src/display_map.rs             |   2 
crates/editor/src/element.rs                 |  29 
crates/gpui/examples/input.rs                |   2 
crates/gpui/src/text_system.rs               |  10 
crates/gpui/src/text_system/line_layout.rs   |  42 +
crates/repl/src/outputs/plain.rs             |  24 
crates/repl/src/outputs/table.rs             |   6 
crates/terminal/src/terminal.rs              |  63 +
crates/terminal_view/src/terminal_element.rs | 608 +++++++++++++++++----
9 files changed, 609 insertions(+), 177 deletions(-)

Detailed changes

crates/editor/src/display_map.rs 🔗

@@ -1066,7 +1066,7 @@ impl DisplaySnapshot {
         }
 
         let font_size = editor_style.text.font_size.to_pixels(*rem_size);
-        text_system.layout_line(&line, font_size, &runs)
+        text_system.layout_line(&line, font_size, &runs, None)
     }
 
     pub fn x_for_display_point(

crates/editor/src/element.rs 🔗

@@ -1611,6 +1611,7 @@ impl EditorElement {
                                         strikethrough: None,
                                         underline: None,
                                     }],
+                                    None,
                                 )
                             })
                     } else {
@@ -3263,10 +3264,12 @@ impl EditorElement {
                         underline: None,
                         strikethrough: None,
                     };
-                    let line =
-                        window
-                            .text_system()
-                            .shape_line(line.to_string().into(), font_size, &[run]);
+                    let line = window.text_system().shape_line(
+                        line.to_string().into(),
+                        font_size,
+                        &[run],
+                        None,
+                    );
                     LineWithInvisibles {
                         width: line.width,
                         len: line.len,
@@ -6888,6 +6891,7 @@ impl EditorElement {
                 underline: None,
                 strikethrough: None,
             }],
+            None,
         );
 
         layout.width
@@ -6916,6 +6920,7 @@ impl EditorElement {
             text,
             self.style.text.font_size.to_pixels(window.rem_size()),
             &[run],
+            None,
         )
     }
 
@@ -7184,10 +7189,12 @@ impl LineWithInvisibles {
         }]) {
             if let Some(replacement) = highlighted_chunk.replacement {
                 if !line.is_empty() {
-                    let shaped_line =
-                        window
-                            .text_system()
-                            .shape_line(line.clone().into(), font_size, &styles);
+                    let shaped_line = window.text_system().shape_line(
+                        line.clone().into(),
+                        font_size,
+                        &styles,
+                        None,
+                    );
                     width += shaped_line.width;
                     len += shaped_line.len;
                     fragments.push(LineFragment::Text(shaped_line));
@@ -7207,6 +7214,7 @@ impl LineWithInvisibles {
                                 chunk,
                                 font_size,
                                 &[text_style.to_run(highlighted_chunk.text.len())],
+                                None,
                             );
                             AvailableSpace::Definite(shaped_line.width)
                         } else {
@@ -7251,7 +7259,7 @@ impl LineWithInvisibles {
                         };
                         let line_layout = window
                             .text_system()
-                            .shape_line(x, font_size, &[run])
+                            .shape_line(x, font_size, &[run], None)
                             .with_len(highlighted_chunk.text.len());
 
                         width += line_layout.width;
@@ -7266,6 +7274,7 @@ impl LineWithInvisibles {
                             line.clone().into(),
                             font_size,
                             &styles,
+                            None,
                         );
                         width += shaped_line.width;
                         len += shaped_line.len;
@@ -8831,6 +8840,7 @@ impl Element for EditorElement {
                             underline: None,
                             strikethrough: None,
                         }],
+                        None
                     );
                     let space_invisible = window.text_system().shape_line(
                         "•".into(),
@@ -8843,6 +8853,7 @@ impl Element for EditorElement {
                             underline: None,
                             strikethrough: None,
                         }],
+                        None
                     );
 
                     let mode = snapshot.mode.clone();

crates/gpui/examples/input.rs 🔗

@@ -487,7 +487,7 @@ impl Element for TextElement {
         let font_size = style.font_size.to_pixels(window.rem_size());
         let line = window
             .text_system()
-            .shape_line(display_text, font_size, &runs);
+            .shape_line(display_text, font_size, &runs, None);
 
         let cursor_pos = line.x_for_index(cursor);
         let (selection, cursor) = if selected_range.is_empty() {

crates/gpui/src/text_system.rs 🔗

@@ -357,6 +357,7 @@ impl WindowTextSystem {
         text: SharedString,
         font_size: Pixels,
         runs: &[TextRun],
+        force_width: Option<Pixels>,
     ) -> ShapedLine {
         debug_assert!(
             text.find('\n').is_none(),
@@ -384,7 +385,7 @@ impl WindowTextSystem {
             });
         }
 
-        let layout = self.layout_line(&text, font_size, runs);
+        let layout = self.layout_line(&text, font_size, runs, force_width);
 
         ShapedLine {
             layout,
@@ -524,6 +525,7 @@ impl WindowTextSystem {
         text: Text,
         font_size: Pixels,
         runs: &[TextRun],
+        force_width: Option<Pixels>,
     ) -> Arc<LineLayout>
     where
         Text: AsRef<str>,
@@ -544,9 +546,9 @@ impl WindowTextSystem {
             });
         }
 
-        let layout = self
-            .line_layout_cache
-            .layout_line(text, font_size, &font_runs);
+        let layout =
+            self.line_layout_cache
+                .layout_line_internal(text, font_size, &font_runs, force_width);
 
         font_runs.clear();
         self.font_runs_pool.lock().push(font_runs);

crates/gpui/src/text_system/line_layout.rs 🔗

@@ -482,6 +482,7 @@ impl LineLayoutCache {
             font_size,
             runs,
             wrap_width,
+            force_width: None,
         } as &dyn AsCacheKeyRef;
 
         let current_frame = self.current_frame.upgradable_read();
@@ -516,6 +517,7 @@ impl LineLayoutCache {
                 font_size,
                 runs: SmallVec::from(runs),
                 wrap_width,
+                force_width: None,
             });
 
             let mut current_frame = self.current_frame.write();
@@ -534,6 +536,20 @@ impl LineLayoutCache {
         font_size: Pixels,
         runs: &[FontRun],
     ) -> Arc<LineLayout>
+    where
+        Text: AsRef<str>,
+        SharedString: From<Text>,
+    {
+        self.layout_line_internal(text, font_size, runs, None)
+    }
+
+    pub fn layout_line_internal<Text>(
+        &self,
+        text: Text,
+        font_size: Pixels,
+        runs: &[FontRun],
+        force_width: Option<Pixels>,
+    ) -> Arc<LineLayout>
     where
         Text: AsRef<str>,
         SharedString: From<Text>,
@@ -543,6 +559,7 @@ impl LineLayoutCache {
             font_size,
             runs,
             wrap_width: None,
+            force_width,
         } as &dyn AsCacheKeyRef;
 
         let current_frame = self.current_frame.upgradable_read();
@@ -557,16 +574,30 @@ impl LineLayoutCache {
             layout
         } else {
             let text = SharedString::from(text);
-            let layout = Arc::new(
-                self.platform_text_system
-                    .layout_line(&text, font_size, runs),
-            );
+            let mut layout = self
+                .platform_text_system
+                .layout_line(&text, font_size, runs);
+
+            if let Some(force_width) = force_width {
+                let mut glyph_pos = 0;
+                for run in layout.runs.iter_mut() {
+                    for glyph in run.glyphs.iter_mut() {
+                        if (glyph.position.x - glyph_pos * force_width).abs() > px(1.) {
+                            glyph.position.x = glyph_pos * force_width;
+                        }
+                        glyph_pos += 1;
+                    }
+                }
+            }
+
             let key = Arc::new(CacheKey {
                 text,
                 font_size,
                 runs: SmallVec::from(runs),
                 wrap_width: None,
+                force_width,
             });
+            let layout = Arc::new(layout);
             current_frame.lines.insert(key.clone(), layout.clone());
             current_frame.used_lines.push(key);
             layout
@@ -591,6 +622,7 @@ struct CacheKey {
     font_size: Pixels,
     runs: SmallVec<[FontRun; 1]>,
     wrap_width: Option<Pixels>,
+    force_width: Option<Pixels>,
 }
 
 #[derive(Copy, Clone, PartialEq, Eq, Hash)]
@@ -599,6 +631,7 @@ struct CacheKeyRef<'a> {
     font_size: Pixels,
     runs: &'a [FontRun],
     wrap_width: Option<Pixels>,
+    force_width: Option<Pixels>,
 }
 
 impl PartialEq for (dyn AsCacheKeyRef + '_) {
@@ -622,6 +655,7 @@ impl AsCacheKeyRef for CacheKey {
             font_size: self.font_size,
             runs: self.runs.as_slice(),
             wrap_width: self.wrap_width,
+            force_width: self.force_width,
         }
     }
 }

crates/repl/src/outputs/plain.rs 🔗

@@ -259,20 +259,17 @@ impl Render for TerminalOutput {
                 cell: ic.cell.clone(),
             });
         let minimum_contrast = TerminalSettings::get_global(cx).minimum_contrast;
-        let (cells, rects) = TerminalElement::layout_grid(
-            grid,
-            0,
-            &text_style,
-            text_system,
-            None,
-            minimum_contrast,
-            window,
-            cx,
-        );
+        let (rects, batched_text_runs) =
+            TerminalElement::layout_grid(grid, 0, &text_style, None, minimum_contrast, cx);
 
         // lines are 0-indexed, so we must add 1 to get the number of lines
         let text_line_height = text_style.line_height_in_pixels(window.rem_size());
-        let num_lines = cells.iter().map(|c| c.point.line).max().unwrap_or(0) + 1;
+        let num_lines = batched_text_runs
+            .iter()
+            .map(|b| b.start_point.line)
+            .max()
+            .unwrap_or(0)
+            + 1;
         let height = num_lines as f32 * text_line_height;
 
         let font_pixels = text_style.font_size.to_pixels(window.rem_size());
@@ -300,15 +297,14 @@ impl Render for TerminalOutput {
                     );
                 }
 
-                for cell in cells {
-                    cell.paint(
+                for batch in batched_text_runs {
+                    batch.paint(
                         bounds.origin,
                         &terminal::TerminalBounds {
                             cell_width,
                             line_height: text_line_height,
                             bounds,
                         },
-                        bounds,
                         window,
                         cx,
                     );

crates/repl/src/outputs/table.rs 🔗

@@ -106,7 +106,9 @@ impl TableView {
 
         for field in table.schema.fields.iter() {
             runs[0].len = field.name.len();
-            let mut width = text_system.layout_line(&field.name, font_size, &runs).width;
+            let mut width = text_system
+                .layout_line(&field.name, font_size, &runs, None)
+                .width;
 
             let Some(data) = table.data.as_ref() else {
                 widths.push(width);
@@ -118,7 +120,7 @@ impl TableView {
                 runs[0].len = content.len();
                 let cell_width = window
                     .text_system()
-                    .layout_line(&content, font_size, &runs)
+                    .layout_line(&content, font_size, &runs, None)
                     .width;
 
                 width = width.max(cell_width)

crates/terminal/src/terminal.rs 🔗

@@ -58,7 +58,7 @@ use std::{
     path::PathBuf,
     process::ExitStatus,
     sync::Arc,
-    time::Duration,
+    time::{Duration, Instant},
 };
 use thiserror::Error;
 
@@ -501,6 +501,8 @@ impl TerminalBuilder {
             vi_mode_enabled: false,
             is_ssh_terminal,
             python_venv_directory,
+            last_mouse_move_time: Instant::now(),
+            last_hyperlink_search_position: None,
         };
 
         Ok(TerminalBuilder {
@@ -659,6 +661,8 @@ pub struct Terminal {
     task: Option<TaskState>,
     vi_mode_enabled: bool,
     is_ssh_terminal: bool,
+    last_mouse_move_time: Instant,
+    last_hyperlink_search_position: Option<Point<Pixels>>,
 }
 
 pub struct TaskState {
@@ -1307,24 +1311,27 @@ impl Terminal {
 
     fn make_content(term: &Term<ZedListener>, last_content: &TerminalContent) -> TerminalContent {
         let content = term.renderable_content();
+
+        // Pre-allocate with estimated size to reduce reallocations
+        let estimated_size = content.display_iter.size_hint().0;
+        let mut cells = Vec::with_capacity(estimated_size);
+
+        cells.extend(content.display_iter.map(|ic| IndexedCell {
+            point: ic.point,
+            cell: ic.cell.clone(),
+        }));
+
+        let selection_text = if content.selection.is_some() {
+            term.selection_to_string()
+        } else {
+            None
+        };
+
         TerminalContent {
-            cells: content
-                .display_iter
-                //TODO: Add this once there's a way to retain empty lines
-                // .filter(|ic| {
-                //     !ic.flags.contains(Flags::HIDDEN)
-                //         && !(ic.bg == Named(NamedColor::Background)
-                //             && ic.c == ' '
-                //             && !ic.flags.contains(Flags::INVERSE))
-                // })
-                .map(|ic| IndexedCell {
-                    point: ic.point,
-                    cell: ic.cell.clone(),
-                })
-                .collect::<Vec<IndexedCell>>(),
+            cells,
             mode: content.mode,
             display_offset: content.display_offset,
-            selection_text: term.selection_to_string(),
+            selection_text,
             selection: content.selection,
             cursor: content.cursor,
             cursor_char: term.grid()[content.cursor.point].c,
@@ -1457,10 +1464,26 @@ impl Terminal {
         if self.selection_phase == SelectionPhase::Selecting {
             self.last_content.last_hovered_word = None;
         } else if self.last_content.terminal_bounds.bounds.contains(&position) {
-            self.events.push_back(InternalEvent::FindHyperlink(
-                position - self.last_content.terminal_bounds.bounds.origin,
-                false,
-            ));
+            // Throttle hyperlink searches to avoid excessive processing
+            let now = Instant::now();
+            let should_search = if let Some(last_pos) = self.last_hyperlink_search_position {
+                // Only search if mouse moved significantly or enough time passed
+                let distance_moved =
+                    ((position.x - last_pos.x).abs() + (position.y - last_pos.y).abs()) > px(5.0);
+                let time_elapsed = now.duration_since(self.last_mouse_move_time).as_millis() > 100;
+                distance_moved || time_elapsed
+            } else {
+                true
+            };
+
+            if should_search {
+                self.last_mouse_move_time = now;
+                self.last_hyperlink_search_position = Some(position);
+                self.events.push_back(InternalEvent::FindHyperlink(
+                    position - self.last_content.terminal_bounds.bounds.origin,
+                    false,
+                ));
+            }
         } else {
             self.last_content.last_hovered_word = None;
         }

crates/terminal_view/src/terminal_element.rs 🔗

@@ -1,17 +1,18 @@
 use crate::color_contrast;
 use editor::{CursorLayout, HighlightedRange, HighlightedRangeLine};
 use gpui::{
-    AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase, Element,
-    ElementId, Entity, FocusHandle, Font, FontStyle, FontWeight, GlobalElementId, HighlightStyle,
-    Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity, IntoElement, LayoutId, Length,
-    ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels, Point, ShapedLine,
-    StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle, UTF16Selection,
-    UnderlineStyle, WeakEntity, WhiteSpace, Window, WindowTextSystem, div, fill, point, px,
-    relative, size,
+    AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase,
+    Element, ElementId, Entity, FocusHandle, Font, FontFeatures, FontStyle, FontWeight,
+    GlobalElementId, HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity,
+    IntoElement, LayoutId, Length, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels,
+    Point, ShapedLine, StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle,
+    UTF16Selection, UnderlineStyle, WeakEntity, WhiteSpace, Window, div, fill, point, px, relative,
+    size,
 };
 use itertools::Itertools;
 use language::CursorShape;
 use settings::Settings;
+use std::time::Instant;
 use terminal::{
     IndexedCell, Terminal, TerminalBounds, TerminalContent,
     alacritty_terminal::{
@@ -38,7 +39,7 @@ use crate::{BlockContext, BlockProperties, ContentMode, TerminalMode, TerminalVi
 /// The information generated during layout that is necessary for painting.
 pub struct LayoutState {
     hitbox: Hitbox,
-    cells: Vec<LayoutCell>,
+    batched_text_runs: Vec<BatchedTextRun>,
     rects: Vec<LayoutRect>,
     relative_highlighted_ranges: Vec<(RangeInclusive<AlacPoint>, Hsla)>,
     cursor: Option<CursorLayout>,
@@ -76,37 +77,69 @@ impl DisplayCursor {
     }
 }
 
-#[derive(Debug, Default)]
-pub struct LayoutCell {
-    pub point: AlacPoint<i32, i32>,
-    text: gpui::ShapedLine,
+/// A batched text run that combines multiple adjacent cells with the same style
+#[derive(Debug)]
+pub struct BatchedTextRun {
+    pub start_point: AlacPoint<i32, i32>,
+    pub text: String,
+    pub cell_count: usize,
+    pub style: TextRun,
+    pub font_size: AbsoluteLength,
 }
 
-impl LayoutCell {
-    fn new(point: AlacPoint<i32, i32>, text: gpui::ShapedLine) -> LayoutCell {
-        LayoutCell { point, text }
+impl BatchedTextRun {
+    fn new_from_char(
+        start_point: AlacPoint<i32, i32>,
+        c: char,
+        style: TextRun,
+        font_size: AbsoluteLength,
+    ) -> Self {
+        let mut text = String::with_capacity(100); // Pre-allocate for typical line length
+        text.push(c);
+        BatchedTextRun {
+            start_point,
+            text,
+            cell_count: 1,
+            style,
+            font_size,
+        }
+    }
+
+    fn can_append(&self, other_style: &TextRun) -> bool {
+        self.style.font == other_style.font
+            && self.style.color == other_style.color
+            && self.style.background_color == other_style.background_color
+            && self.style.underline == other_style.underline
+            && self.style.strikethrough == other_style.strikethrough
+    }
+
+    fn append_char(&mut self, c: char) {
+        self.text.push(c);
+        self.cell_count += 1;
+        self.style.len += c.len_utf8();
     }
 
     pub fn paint(
         &self,
         origin: Point<Pixels>,
         dimensions: &TerminalBounds,
-        _visible_bounds: Bounds<Pixels>,
         window: &mut Window,
         cx: &mut App,
     ) {
-        let pos = {
-            let point = self.point;
+        let pos = Point::new(
+            (origin.x + self.start_point.column as f32 * dimensions.cell_width).floor(),
+            origin.y + self.start_point.line as f32 * dimensions.line_height,
+        );
 
-            Point::new(
-                (origin.x + point.column as f32 * dimensions.cell_width).floor(),
-                origin.y + point.line as f32 * dimensions.line_height,
+        let _ = window
+            .text_system()
+            .shape_line(
+                self.text.clone().into(),
+                self.font_size.to_pixels(window.rem_size()),
+                &[self.style.clone()],
+                Some(dimensions.cell_width),
             )
-        };
-
-        self.text
-            .paint(pos, dimensions.line_height, window, cx)
-            .ok();
+            .paint(pos, dimensions.line_height, window, cx);
     }
 }
 
@@ -126,14 +159,6 @@ impl LayoutRect {
         }
     }
 
-    fn extend(&self) -> Self {
-        LayoutRect {
-            point: self.point,
-            num_of_cells: self.num_of_cells + 1,
-            color: self.color,
-        }
-    }
-
     pub fn paint(&self, origin: Point<Pixels>, dimensions: &TerminalBounds, window: &mut Window) {
         let position = {
             let alac_point = self.point;
@@ -152,6 +177,87 @@ impl LayoutRect {
     }
 }
 
+/// Represents a rectangular region with a specific background color
+#[derive(Debug, Clone)]
+struct BackgroundRegion {
+    start_line: i32,
+    start_col: i32,
+    end_line: i32,
+    end_col: i32,
+    color: Hsla,
+}
+
+impl BackgroundRegion {
+    fn new(line: i32, col: i32, color: Hsla) -> Self {
+        BackgroundRegion {
+            start_line: line,
+            start_col: col,
+            end_line: line,
+            end_col: col,
+            color,
+        }
+    }
+
+    /// Check if this region can be merged with another region
+    fn can_merge_with(&self, other: &BackgroundRegion) -> bool {
+        if self.color != other.color {
+            return false;
+        }
+
+        // Check if regions are adjacent horizontally
+        if self.start_line == other.start_line && self.end_line == other.end_line {
+            return self.end_col + 1 == other.start_col || other.end_col + 1 == self.start_col;
+        }
+
+        // Check if regions are adjacent vertically with same column span
+        if self.start_col == other.start_col && self.end_col == other.end_col {
+            return self.end_line + 1 == other.start_line || other.end_line + 1 == self.start_line;
+        }
+
+        false
+    }
+
+    /// Merge this region with another region
+    fn merge_with(&mut self, other: &BackgroundRegion) {
+        self.start_line = self.start_line.min(other.start_line);
+        self.start_col = self.start_col.min(other.start_col);
+        self.end_line = self.end_line.max(other.end_line);
+        self.end_col = self.end_col.max(other.end_col);
+    }
+}
+
+/// Merge background regions to minimize the number of rectangles
+fn merge_background_regions(regions: Vec<BackgroundRegion>) -> Vec<BackgroundRegion> {
+    if regions.is_empty() {
+        return regions;
+    }
+
+    let mut merged = regions;
+    let mut changed = true;
+
+    // Keep merging until no more merges are possible
+    while changed {
+        changed = false;
+        let mut i = 0;
+
+        while i < merged.len() {
+            let mut j = i + 1;
+            while j < merged.len() {
+                if merged[i].can_merge_with(&merged[j]) {
+                    let other = merged.remove(j);
+                    merged[i].merge_with(&other);
+                    changed = true;
+                } else {
+                    j += 1;
+                }
+            }
+            i += 1;
+        }
+    }
+
+    merged
+}
+
 /// The GPUI element that paints the terminal.
 /// We need to keep a reference to the model for mouse events, do we need it for any other terminal stuff, or can we move that to connection?
 pub struct TerminalElement {
@@ -205,23 +311,37 @@ impl TerminalElement {
         grid: impl Iterator<Item = IndexedCell>,
         start_line_offset: i32,
         text_style: &TextStyle,
-        text_system: &WindowTextSystem,
         hyperlink: Option<(HighlightStyle, &RangeInclusive<AlacPoint>)>,
         minimum_contrast: f32,
-        window: &Window,
         cx: &App,
-    ) -> (Vec<LayoutCell>, Vec<LayoutRect>) {
+    ) -> (Vec<LayoutRect>, Vec<BatchedTextRun>) {
+        let start_time = Instant::now();
         let theme = cx.theme();
-        let mut cells = vec![];
-        let mut rects = vec![];
 
-        let mut cur_rect: Option<LayoutRect> = None;
-        let mut cur_alac_color = None;
+        // Pre-allocate with estimated capacity to reduce reallocations
+        let estimated_cells = grid.size_hint().0;
+        let estimated_runs = estimated_cells / 10; // Estimate ~10 cells per run
+        let estimated_regions = estimated_cells / 20; // Estimate ~20 cells per background region
+
+        let mut batched_runs = Vec::with_capacity(estimated_runs);
+        let mut cell_count = 0;
+
+        // Collect background regions for efficient merging
+        let mut background_regions: Vec<BackgroundRegion> = Vec::with_capacity(estimated_regions);
+        let mut current_batch: Option<BatchedTextRun> = None;
 
+        // First pass: collect all cells and their backgrounds
         let linegroups = grid.into_iter().chunk_by(|i| i.point.line);
         for (line_index, (_, line)) in linegroups.into_iter().enumerate() {
             let alac_line = start_line_offset + line_index as i32;
 
+            // Flush any existing batch at line boundaries
+            if let Some(batch) = current_batch.take() {
+                batched_runs.push(batch);
+            }
+
+            let mut previous_cell_had_extras = false;
+
             for cell in line {
                 let mut fg = cell.fg;
                 let mut bg = cell.bg;
@@ -229,63 +349,43 @@ impl TerminalElement {
                     mem::swap(&mut fg, &mut bg);
                 }
 
-                //Expand background rect range
-                {
-                    if matches!(bg, Named(NamedColor::Background)) {
-                        //Continue to next cell, resetting variables if necessary
-                        cur_alac_color = None;
-                        if let Some(rect) = cur_rect {
-                            rects.push(rect);
-                            cur_rect = None
+                // Collect background regions (skip default background)
+                if !matches!(bg, Named(NamedColor::Background)) {
+                    let color = convert_color(&bg, theme);
+                    let col = cell.point.column.0 as i32;
+
+                    // Try to extend the last region if it's on the same line with the same color
+                    if let Some(last_region) = background_regions.last_mut() {
+                        if last_region.color == color
+                            && last_region.start_line == alac_line
+                            && last_region.end_line == alac_line
+                            && last_region.end_col + 1 == col
+                        {
+                            last_region.end_col = col;
+                        } else {
+                            background_regions.push(BackgroundRegion::new(alac_line, col, color));
                         }
                     } else {
-                        match cur_alac_color {
-                            Some(cur_color) => {
-                                if bg == cur_color {
-                                    // `cur_rect` can be None if it was moved to the `rects` vec after wrapping around
-                                    // from one line to the next. The variables are all set correctly but there is no current
-                                    // rect, so we create one if necessary.
-                                    cur_rect = cur_rect.map_or_else(
-                                        || {
-                                            Some(LayoutRect::new(
-                                                AlacPoint::new(
-                                                    alac_line,
-                                                    cell.point.column.0 as i32,
-                                                ),
-                                                1,
-                                                convert_color(&bg, theme),
-                                            ))
-                                        },
-                                        |rect| Some(rect.extend()),
-                                    );
-                                } else {
-                                    cur_alac_color = Some(bg);
-                                    if cur_rect.is_some() {
-                                        rects.push(cur_rect.take().unwrap());
-                                    }
-                                    cur_rect = Some(LayoutRect::new(
-                                        AlacPoint::new(alac_line, cell.point.column.0 as i32),
-                                        1,
-                                        convert_color(&bg, theme),
-                                    ));
-                                }
-                            }
-                            None => {
-                                cur_alac_color = Some(bg);
-                                cur_rect = Some(LayoutRect::new(
-                                    AlacPoint::new(alac_line, cell.point.column.0 as i32),
-                                    1,
-                                    convert_color(&bg, theme),
-                                ));
-                            }
-                        }
+                        background_regions.push(BackgroundRegion::new(alac_line, col, color));
                     }
                 }
+                // Skip wide character spacers - they're just placeholders for the second cell of wide characters
+                if cell.flags.contains(Flags::WIDE_CHAR_SPACER) {
+                    continue;
+                }
+
+                // Skip spaces that follow cells with extras (emoji variation sequences)
+                if cell.c == ' ' && previous_cell_had_extras {
+                    previous_cell_had_extras = false;
+                    continue;
+                }
+                // Update tracking for next iteration
+                previous_cell_had_extras = cell.extra.is_some();
 
                 //Layout current cell text
                 {
                     if !is_blank(&cell) {
-                        let cell_text = cell.c.to_string();
+                        cell_count += 1;
                         let cell_style = TerminalElement::cell_style(
                             &cell,
                             fg,
@@ -296,25 +396,74 @@ impl TerminalElement {
                             minimum_contrast,
                         );
 
-                        let layout_cell = text_system.shape_line(
-                            cell_text.into(),
-                            text_style.font_size.to_pixels(window.rem_size()),
-                            &[cell_style],
-                        );
+                        let cell_point = AlacPoint::new(alac_line, cell.point.column.0 as i32);
 
-                        cells.push(LayoutCell::new(
-                            AlacPoint::new(alac_line, cell.point.column.0 as i32),
-                            layout_cell,
-                        ))
+                        // Try to batch with existing run
+                        if let Some(ref mut batch) = current_batch {
+                            if batch.can_append(&cell_style)
+                                && batch.start_point.line == cell_point.line
+                                && batch.start_point.column + batch.cell_count as i32
+                                    == cell_point.column
+                            {
+                                batch.append_char(cell.c);
+                            } else {
+                                // Flush current batch and start new one
+                                let old_batch = current_batch.take().unwrap();
+                                batched_runs.push(old_batch);
+                                current_batch = Some(BatchedTextRun::new_from_char(
+                                    cell_point,
+                                    cell.c,
+                                    cell_style,
+                                    text_style.font_size,
+                                ));
+                            }
+                        } else {
+                            // Start new batch
+                            current_batch = Some(BatchedTextRun::new_from_char(
+                                cell_point,
+                                cell.c,
+                                cell_style,
+                                text_style.font_size,
+                            ));
+                        }
                     };
                 }
             }
+        }
+
+        // Flush any remaining batch
+        if let Some(batch) = current_batch {
+            batched_runs.push(batch);
+        }
 
-            if cur_rect.is_some() {
-                rects.push(cur_rect.take().unwrap());
+        // Second pass: merge background regions and convert to layout rects
+        let region_count = background_regions.len();
+        let merged_regions = merge_background_regions(background_regions);
+        let mut rects = Vec::with_capacity(merged_regions.len() * 2); // Estimate 2 rects per merged region
+
+        // Convert merged regions to layout rects
+        // Since LayoutRect only supports single-line rectangles, we need to split multi-line regions
+        for region in merged_regions {
+            for line in region.start_line..=region.end_line {
+                rects.push(LayoutRect::new(
+                    AlacPoint::new(line, region.start_col),
+                    (region.end_col - region.start_col + 1) as usize,
+                    region.color,
+                ));
             }
         }
-        (cells, rects)
+
+        let layout_time = start_time.elapsed();
+        log::debug!(
+            "Terminal layout_grid: {} cells processed, {} batched runs created, {} rects (from {} merged regions), layout took {:?}",
+            cell_count,
+            batched_runs.len(),
+            rects.len(),
+            region_count,
+            layout_time
+        );
+
+        (rects, batched_runs)
     }
 
     /// Computes the cursor position and expected block width, may return a zero width if x_for_index returns
@@ -708,7 +857,7 @@ impl Element for TerminalElement {
                 let font_features = terminal_settings
                     .font_features
                     .as_ref()
-                    .unwrap_or(&settings.buffer_font.features)
+                    .unwrap_or(&FontFeatures::disable_ligatures())
                     .clone();
 
                 let font_weight = terminal_settings.font_weight.unwrap_or_default();
@@ -857,19 +1006,22 @@ impl Element for TerminalElement {
                 // then have that representation be converted to the appropriate highlight data structure
 
                 let content_mode = self.terminal_view.read(cx).content_mode(window, cx);
-                let (cells, rects) = match content_mode {
-                    ContentMode::Scrollable => TerminalElement::layout_grid(
-                        cells.iter().cloned(),
-                        0,
-                        &text_style,
-                        window.text_system(),
-                        last_hovered_word
-                            .as_ref()
-                            .map(|last_hovered_word| (link_style, &last_hovered_word.word_match)),
-                        minimum_contrast,
-                        window,
-                        cx,
-                    ),
+                let (rects, batched_text_runs) = match content_mode {
+                    ContentMode::Scrollable => {
+                        // In scrollable mode, the terminal already provides cells
+                        // that are correctly positioned for the current viewport
+                        // based on its display_offset. We don't need additional filtering.
+                        TerminalElement::layout_grid(
+                            cells.iter().cloned(),
+                            0,
+                            &text_style,
+                            last_hovered_word.as_ref().map(|last_hovered_word| {
+                                (link_style, &last_hovered_word.word_match)
+                            }),
+                            minimum_contrast,
+                            cx,
+                        )
+                    }
                     ContentMode::Inline { .. } => {
                         let intersection = window.content_mask().bounds.intersect(&bounds);
                         let start_row = (intersection.top() - bounds.top()) / line_height_px;
@@ -884,12 +1036,10 @@ impl Element for TerminalElement {
                                 .cloned(),
                             *line_range.start(),
                             &text_style,
-                            window.text_system(),
                             last_hovered_word.as_ref().map(|last_hovered_word| {
                                 (link_style, &last_hovered_word.word_match)
                             }),
                             minimum_contrast,
-                            window,
                             cx,
                         )
                     }
@@ -915,6 +1065,7 @@ impl Element for TerminalElement {
                                 underline: Default::default(),
                                 strikethrough: None,
                             }],
+                            None,
                         )
                     };
 
@@ -977,7 +1128,7 @@ impl Element for TerminalElement {
 
                 LayoutState {
                     hitbox,
-                    cells,
+                    batched_text_runs,
                     cursor,
                     background_color,
                     dimensions,
@@ -1005,6 +1156,7 @@ impl Element for TerminalElement {
         window: &mut Window,
         cx: &mut App,
     ) {
+        let paint_start = Instant::now();
         window.with_content_mask(Some(ContentMask { bounds }), |window| {
             let scroll_top = self.terminal_view.read(cx).scroll_top;
 
@@ -1089,9 +1241,12 @@ impl Element for TerminalElement {
                         }
                     }
 
-                    for cell in &layout.cells {
-                        cell.paint(origin, &layout.dimensions, bounds, window, cx);
+                    // Paint batched text runs instead of individual cells
+                    let text_paint_start = Instant::now();
+                    for batch in &layout.batched_text_runs {
+                        batch.paint(origin, &layout.dimensions, window, cx);
                     }
+                    let text_paint_time = text_paint_start.elapsed();
 
                     if let Some(text_to_mark) = &marked_text_cloned {
                         if !text_to_mark.is_empty() {
@@ -1115,6 +1270,7 @@ impl Element for TerminalElement {
                                         underline: ime_style.underline,
                                         strikethrough: None,
                                     }],
+                                    None
                                 );
                                 shaped_line
                                     .paint(ime_position, layout.dimensions.line_height, window, cx)
@@ -1136,6 +1292,14 @@ impl Element for TerminalElement {
                     if let Some(mut element) = hyperlink_tooltip {
                         element.paint(window, cx);
                     }
+                    let total_paint_time = paint_start.elapsed();
+                    log::debug!(
+                        "Terminal paint: {} text runs, {} rects, text paint took {:?}, total paint took {:?}",
+                        layout.batched_text_runs.len(),
+                        layout.rects.len(),
+                        text_paint_time,
+                        total_paint_time
+                    );
                 },
             );
         });
@@ -1290,7 +1454,7 @@ pub fn is_blank(cell: &IndexedCell) -> bool {
         return false;
     }
 
-    true
+    return true;
 }
 
 fn to_highlighted_range_lines(
@@ -1409,6 +1573,7 @@ pub fn convert_color(fg: &terminal::alacritty_terminal::vte::ansi::Color, theme:
 #[cfg(test)]
 mod tests {
     use super::*;
+    use gpui::{AbsoluteLength, Hsla, font};
 
     #[test]
     fn test_contrast_adjustment_logic() {
@@ -1523,4 +1688,203 @@ mod tests {
             new_contrast
         );
     }
+
+    #[test]
+    fn test_batched_text_run_can_append() {
+        let style1 = TextRun {
+            len: 1,
+            font: font("Helvetica"),
+            color: Hsla::red(),
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+        };
+
+        let style2 = TextRun {
+            len: 1,
+            font: font("Helvetica"),
+            color: Hsla::red(),
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+        };
+
+        let style3 = TextRun {
+            len: 1,
+            font: font("Helvetica"),
+            color: Hsla::blue(), // Different color
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+        };
+
+        let font_size = AbsoluteLength::Pixels(px(12.0));
+        let batch =
+            BatchedTextRun::new_from_char(AlacPoint::new(0, 0), 'a', style1.clone(), font_size);
+
+        // Should be able to append same style
+        assert!(batch.can_append(&style2));
+
+        // Should not be able to append different style
+        assert!(!batch.can_append(&style3));
+    }
+
+    #[test]
+    fn test_batched_text_run_append() {
+        let style = TextRun {
+            len: 1,
+            font: font("Helvetica"),
+            color: Hsla::red(),
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+        };
+
+        let font_size = AbsoluteLength::Pixels(px(12.0));
+        let mut batch = BatchedTextRun::new_from_char(AlacPoint::new(0, 0), 'a', style, font_size);
+
+        assert_eq!(batch.text, "a");
+        assert_eq!(batch.cell_count, 1);
+        assert_eq!(batch.style.len, 1);
+
+        batch.append_char('b');
+
+        assert_eq!(batch.text, "ab");
+        assert_eq!(batch.cell_count, 2);
+        assert_eq!(batch.style.len, 2);
+
+        batch.append_char('c');
+
+        assert_eq!(batch.text, "abc");
+        assert_eq!(batch.cell_count, 3);
+        assert_eq!(batch.style.len, 3);
+    }
+
+    #[test]
+    fn test_batched_text_run_append_char() {
+        let style = TextRun {
+            len: 1,
+            font: font("Helvetica"),
+            color: Hsla::red(),
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+        };
+
+        let font_size = AbsoluteLength::Pixels(px(12.0));
+        let mut batch = BatchedTextRun::new_from_char(AlacPoint::new(0, 0), 'x', style, font_size);
+
+        assert_eq!(batch.text, "x");
+        assert_eq!(batch.cell_count, 1);
+        assert_eq!(batch.style.len, 1);
+
+        batch.append_char('y');
+
+        assert_eq!(batch.text, "xy");
+        assert_eq!(batch.cell_count, 2);
+        assert_eq!(batch.style.len, 2);
+
+        // Test with multi-byte character
+        batch.append_char('😀');
+
+        assert_eq!(batch.text, "xy😀");
+        assert_eq!(batch.cell_count, 3);
+        assert_eq!(batch.style.len, 6); // 1 + 1 + 4 bytes for emoji
+    }
+
+    #[test]
+    fn test_background_region_can_merge() {
+        let color1 = Hsla::red();
+        let color2 = Hsla::blue();
+
+        // Test horizontal merging
+        let mut region1 = BackgroundRegion::new(0, 0, color1);
+        region1.end_col = 5;
+        let region2 = BackgroundRegion::new(0, 6, color1);
+        assert!(region1.can_merge_with(&region2));
+
+        // Test vertical merging with same column span
+        let mut region3 = BackgroundRegion::new(0, 0, color1);
+        region3.end_col = 5;
+        let mut region4 = BackgroundRegion::new(1, 0, color1);
+        region4.end_col = 5;
+        assert!(region3.can_merge_with(&region4));
+
+        // Test cannot merge different colors
+        let region5 = BackgroundRegion::new(0, 0, color1);
+        let region6 = BackgroundRegion::new(0, 1, color2);
+        assert!(!region5.can_merge_with(&region6));
+
+        // Test cannot merge non-adjacent regions
+        let region7 = BackgroundRegion::new(0, 0, color1);
+        let region8 = BackgroundRegion::new(0, 2, color1);
+        assert!(!region7.can_merge_with(&region8));
+
+        // Test cannot merge vertical regions with different column spans
+        let mut region9 = BackgroundRegion::new(0, 0, color1);
+        region9.end_col = 5;
+        let mut region10 = BackgroundRegion::new(1, 0, color1);
+        region10.end_col = 6;
+        assert!(!region9.can_merge_with(&region10));
+    }
+
+    #[test]
+    fn test_background_region_merge() {
+        let color = Hsla::red();
+
+        // Test horizontal merge
+        let mut region1 = BackgroundRegion::new(0, 0, color);
+        region1.end_col = 5;
+        let mut region2 = BackgroundRegion::new(0, 6, color);
+        region2.end_col = 10;
+        region1.merge_with(&region2);
+        assert_eq!(region1.start_col, 0);
+        assert_eq!(region1.end_col, 10);
+        assert_eq!(region1.start_line, 0);
+        assert_eq!(region1.end_line, 0);
+
+        // Test vertical merge
+        let mut region3 = BackgroundRegion::new(0, 0, color);
+        region3.end_col = 5;
+        let mut region4 = BackgroundRegion::new(1, 0, color);
+        region4.end_col = 5;
+        region3.merge_with(&region4);
+        assert_eq!(region3.start_col, 0);
+        assert_eq!(region3.end_col, 5);
+        assert_eq!(region3.start_line, 0);
+        assert_eq!(region3.end_line, 1);
+    }
+
+    #[test]
+    fn test_merge_background_regions() {
+        let color = Hsla::red();
+
+        // Test merging multiple adjacent regions
+        let regions = vec![
+            BackgroundRegion::new(0, 0, color),
+            BackgroundRegion::new(0, 1, color),
+            BackgroundRegion::new(0, 2, color),
+            BackgroundRegion::new(1, 0, color),
+            BackgroundRegion::new(1, 1, color),
+            BackgroundRegion::new(1, 2, color),
+        ];
+
+        let merged = merge_background_regions(regions);
+        assert_eq!(merged.len(), 1);
+        assert_eq!(merged[0].start_line, 0);
+        assert_eq!(merged[0].end_line, 1);
+        assert_eq!(merged[0].start_col, 0);
+        assert_eq!(merged[0].end_col, 2);
+
+        // Test with non-mergeable regions
+        let color2 = Hsla::blue();
+        let regions2 = vec![
+            BackgroundRegion::new(0, 0, color),
+            BackgroundRegion::new(0, 2, color),  // Gap at column 1
+            BackgroundRegion::new(1, 0, color2), // Different color
+        ];
+
+        let merged2 = merge_background_regions(regions2);
+        assert_eq!(merged2.len(), 3);
+    }
 }