Skip to repository content2503 lines · 95.8 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:53:48.053Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
terminal_element.rs
1use editor::{CursorLayout, EditorSettings, HighlightedRange, HighlightedRangeLine};
2use gpui::{
3 AbsoluteLength, AnyElement, App, AvailableSpace, Bounds, ContentMask, Context, DispatchPhase,
4 Element, ElementId, Entity, FocusHandle, Font, FontFeatures, FontStyle, FontWeight,
5 GlobalElementId, HighlightStyle, Hitbox, Hsla, InputHandler, InteractiveElement, Interactivity,
6 IntoElement, LayoutId, Length, ModifiersChangedEvent, MouseButton, MouseMoveEvent, Pixels,
7 Point as GpuiPoint, StatefulInteractiveElement, StrikethroughStyle, Styled, TextRun, TextStyle,
8 UTF16Selection, UnderlineStyle, WeakEntity, WhiteSpace, Window, div, fill, point, px, relative,
9 size,
10};
11use itertools::Itertools;
12use language::CursorShape as EditorCursorShape;
13use settings::Settings;
14use std::time::Instant;
15use terminal::{
16 Cell, Color, Content, CursorShape, IndexedCell, Modes, NamedColor, Point, Range, Terminal,
17 TerminalBounds, is_app_chosen_exact_color as terminal_is_app_chosen_exact_color,
18 is_default_background_color, terminal_settings::TerminalSettings,
19};
20use theme::{ActiveTheme, Theme};
21use theme_settings::ThemeSettings;
22use ui::utils::ensure_minimum_contrast;
23use ui::{ParentElement, Tooltip};
24use util::ResultExt;
25use workspace::Workspace;
26
27use std::mem;
28use std::{fmt::Debug, rc::Rc};
29
30use crate::{BlockContext, BlockProperties, ContentMode, TerminalMode, TerminalView};
31
32/// The information generated during layout that is necessary for painting.
33pub struct LayoutState {
34 hitbox: Hitbox,
35 batched_text_runs: Vec<BatchedTextRun>,
36 rects: Vec<LayoutRect>,
37 relative_highlighted_ranges: Vec<(Range, Hsla)>,
38 cursor: Option<CursorLayout>,
39 ime_cursor_bounds: Option<Bounds<Pixels>>,
40 background_color: Hsla,
41 dimensions: TerminalBounds,
42 mode: Modes,
43 display_offset: usize,
44 hyperlink_tooltip: Option<AnyElement>,
45 block_below_cursor_element: Option<AnyElement>,
46 base_text_style: TextStyle,
47 content_mode: ContentMode,
48}
49
50/// Helper struct for converting terminal cursor points to displayed cursor points.
51#[derive(Copy, Clone)]
52struct DisplayCursor {
53 line: i32,
54 col: usize,
55}
56
57impl DisplayCursor {
58 fn from(cursor_point: Point, display_offset: usize) -> Self {
59 Self {
60 line: cursor_point.line + display_offset as i32,
61 col: cursor_point.column,
62 }
63 }
64
65 pub fn line(&self) -> i32 {
66 self.line
67 }
68
69 pub fn col(&self) -> usize {
70 self.col
71 }
72}
73
74#[derive(Copy, Clone, Debug, Default)]
75pub struct LayoutPoint {
76 line: i32,
77 column: i32,
78}
79
80impl LayoutPoint {
81 fn new(line: i32, column: i32) -> Self {
82 Self { line, column }
83 }
84
85 pub fn line(&self) -> i32 {
86 self.line
87 }
88
89 pub fn column(&self) -> i32 {
90 self.column
91 }
92}
93
94/// A batched text run that combines multiple adjacent cells with the same style
95#[derive(Debug)]
96pub struct BatchedTextRun {
97 pub start_point: LayoutPoint,
98 pub text: String,
99 pub cell_count: usize,
100 pub style: TextRun,
101 pub font_size: AbsoluteLength,
102}
103
104impl BatchedTextRun {
105 fn new_from_char(
106 start_point: LayoutPoint,
107 c: char,
108 style: TextRun,
109 font_size: AbsoluteLength,
110 ) -> Self {
111 let mut text = String::with_capacity(100); // Pre-allocate for typical line length
112 text.push(c);
113 BatchedTextRun {
114 start_point,
115 text,
116 cell_count: 1,
117 style,
118 font_size,
119 }
120 }
121
122 fn can_append(&self, other_style: &TextRun) -> bool {
123 self.style.font == other_style.font
124 && self.style.color == other_style.color
125 && self.style.background_color == other_style.background_color
126 && self.style.underline == other_style.underline
127 && self.style.strikethrough == other_style.strikethrough
128 }
129
130 fn append_char(&mut self, c: char) {
131 self.append_char_internal(c, true);
132 }
133
134 fn append_zero_width_chars(&mut self, chars: &[char]) {
135 for &c in chars {
136 self.append_char_internal(c, false);
137 }
138 }
139
140 fn append_char_internal(&mut self, c: char, counts_cell: bool) {
141 self.text.push(c);
142 if counts_cell {
143 self.cell_count += 1;
144 }
145 self.style.len += c.len_utf8();
146 }
147
148 pub fn paint(
149 &self,
150 origin: GpuiPoint<Pixels>,
151 dimensions: &TerminalBounds,
152 window: &mut Window,
153 cx: &mut App,
154 ) {
155 let pos = GpuiPoint::new(
156 origin.x + self.start_point.column as f32 * dimensions.cell_width,
157 origin.y + self.start_point.line as f32 * dimensions.line_height,
158 );
159
160 window
161 .text_system()
162 .shape_line(
163 self.text.clone().into(),
164 self.font_size.to_pixels(window.rem_size()),
165 std::slice::from_ref(&self.style),
166 Some(dimensions.cell_width),
167 )
168 .paint(
169 pos,
170 dimensions.line_height,
171 gpui::TextAlign::Left,
172 None,
173 window,
174 cx,
175 )
176 .log_err();
177 }
178}
179
180#[derive(Clone, Debug, Default)]
181pub struct LayoutRect {
182 point: LayoutPoint,
183 num_of_cells: usize,
184 color: Hsla,
185}
186
187impl LayoutRect {
188 fn new(point: LayoutPoint, num_of_cells: usize, color: Hsla) -> LayoutRect {
189 LayoutRect {
190 point,
191 num_of_cells,
192 color,
193 }
194 }
195
196 pub fn paint(
197 &self,
198 origin: GpuiPoint<Pixels>,
199 dimensions: &TerminalBounds,
200 window: &mut Window,
201 ) {
202 let position = {
203 let layout_point = self.point;
204 point(
205 (origin.x + layout_point.column as f32 * dimensions.cell_width).floor(),
206 origin.y + layout_point.line as f32 * dimensions.line_height,
207 )
208 };
209 let size = point(
210 (dimensions.cell_width * self.num_of_cells as f32).ceil(),
211 dimensions.line_height,
212 )
213 .into();
214
215 window.paint_quad(fill(Bounds::new(position, size), self.color));
216 }
217}
218
219/// Represents a rectangular region with a specific background color
220#[derive(Debug, Clone)]
221struct BackgroundRegion {
222 start_line: i32,
223 start_col: i32,
224 end_line: i32,
225 end_col: i32,
226 color: Hsla,
227}
228
229impl BackgroundRegion {
230 fn new(line: i32, col: i32, color: Hsla) -> Self {
231 BackgroundRegion {
232 start_line: line,
233 start_col: col,
234 end_line: line,
235 end_col: col,
236 color,
237 }
238 }
239
240 /// Check if this region can be merged with another region
241 fn can_merge_with(&self, other: &BackgroundRegion) -> bool {
242 if self.color != other.color {
243 return false;
244 }
245
246 // Check if regions are adjacent horizontally
247 if self.start_line == other.start_line && self.end_line == other.end_line {
248 return self.end_col + 1 == other.start_col || other.end_col + 1 == self.start_col;
249 }
250
251 // Check if regions are adjacent vertically with same column span
252 if self.start_col == other.start_col && self.end_col == other.end_col {
253 return self.end_line + 1 == other.start_line || other.end_line + 1 == self.start_line;
254 }
255
256 false
257 }
258
259 /// Merge this region with another region
260 fn merge_with(&mut self, other: &BackgroundRegion) {
261 self.start_line = self.start_line.min(other.start_line);
262 self.start_col = self.start_col.min(other.start_col);
263 self.end_line = self.end_line.max(other.end_line);
264 self.end_col = self.end_col.max(other.end_col);
265 }
266}
267
268pub trait TerminalLayoutCell {
269 fn point(&self) -> Point;
270 fn cell(&self) -> &Cell;
271}
272
273impl TerminalLayoutCell for IndexedCell {
274 fn point(&self) -> Point {
275 self.point
276 }
277
278 fn cell(&self) -> &Cell {
279 &self.cell
280 }
281}
282
283impl TerminalLayoutCell for &IndexedCell {
284 fn point(&self) -> Point {
285 self.point
286 }
287
288 fn cell(&self) -> &Cell {
289 &self.cell
290 }
291}
292
293/// Merge background regions to minimize the number of rectangles
294fn merge_background_regions(regions: Vec<BackgroundRegion>) -> Vec<BackgroundRegion> {
295 if regions.is_empty() {
296 return regions;
297 }
298
299 let mut merged = regions;
300 let mut changed = true;
301
302 // Keep merging until no more merges are possible
303 while changed {
304 changed = false;
305 let mut i = 0;
306
307 while i < merged.len() {
308 let mut j = i + 1;
309 while j < merged.len() {
310 if merged[i].can_merge_with(&merged[j]) {
311 let other = merged.remove(j);
312 merged[i].merge_with(&other);
313 changed = true;
314 } else {
315 j += 1;
316 }
317 }
318 i += 1;
319 }
320 }
321
322 merged
323}
324
325/// The GPUI element that paints the terminal.
326/// 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?
327pub struct TerminalElement {
328 terminal: Entity<Terminal>,
329 terminal_view: Entity<TerminalView>,
330 workspace: WeakEntity<Workspace>,
331 focus: FocusHandle,
332 focused: bool,
333 cursor_visible: bool,
334 interactivity: Interactivity,
335 mode: TerminalMode,
336 block_below_cursor: Option<Rc<BlockProperties>>,
337}
338
339impl InteractiveElement for TerminalElement {
340 fn interactivity(&mut self) -> &mut Interactivity {
341 &mut self.interactivity
342 }
343}
344
345impl StatefulInteractiveElement for TerminalElement {}
346
347impl TerminalElement {
348 pub fn new(
349 terminal: Entity<Terminal>,
350 terminal_view: Entity<TerminalView>,
351 workspace: WeakEntity<Workspace>,
352 focus: FocusHandle,
353 focused: bool,
354 cursor_visible: bool,
355 block_below_cursor: Option<Rc<BlockProperties>>,
356 mode: TerminalMode,
357 ) -> TerminalElement {
358 TerminalElement {
359 terminal,
360 terminal_view,
361 workspace,
362 focused,
363 focus: focus.clone(),
364 cursor_visible,
365 block_below_cursor,
366 mode,
367 interactivity: Default::default(),
368 }
369 .track_focus(&focus)
370 }
371
372 pub fn layout_grid<T: TerminalLayoutCell>(
373 grid: impl Iterator<Item = T>,
374 start_line_offset: i32,
375 text_style: &TextStyle,
376 hyperlink: Option<(HighlightStyle, &Range)>,
377 minimum_contrast: f32,
378 cx: &App,
379 ) -> (Vec<LayoutRect>, Vec<BatchedTextRun>) {
380 let start_time = Instant::now();
381 let theme = cx.theme();
382
383 // Pre-allocate with estimated capacity to reduce reallocations
384 let estimated_cells = grid.size_hint().0;
385 let estimated_runs = estimated_cells / 10; // Estimate ~10 cells per run
386 let estimated_regions = estimated_cells / 20; // Estimate ~20 cells per background region
387
388 let mut batched_runs = Vec::with_capacity(estimated_runs);
389 let mut cell_count = 0;
390
391 // Collect background regions for efficient merging
392 let mut background_regions: Vec<BackgroundRegion> = Vec::with_capacity(estimated_regions);
393 let mut current_batch: Option<BatchedTextRun> = None;
394
395 // First pass: collect all cells and their backgrounds
396 let linegroups = grid.into_iter().chunk_by(|cell| cell.point().line);
397 for (line_index, (_, line)) in linegroups.into_iter().enumerate() {
398 let display_line = start_line_offset + line_index as i32;
399
400 // Flush any existing batch at line boundaries
401 if let Some(batch) = current_batch.take() {
402 batched_runs.push(batch);
403 }
404
405 let mut previous_cell_had_extras = false;
406
407 for cell in line {
408 let point = cell.point();
409 let cell = cell.cell();
410 let mut fg = cell.foreground();
411 let mut bg = cell.background();
412 if cell.is_inverse() {
413 mem::swap(&mut fg, &mut bg);
414 }
415
416 // Collect background regions (skip default background)
417 if !is_default_background_color(bg) {
418 let color = convert_color(&bg, theme);
419 let col = point.column as i32;
420
421 // Try to extend the last region if it's on the same line with the same color
422 if let Some(last_region) = background_regions.last_mut()
423 && last_region.color == color
424 && last_region.start_line == display_line
425 && last_region.end_line == display_line
426 && last_region.end_col + 1 == col
427 {
428 last_region.end_col = col;
429 } else {
430 background_regions.push(BackgroundRegion::new(display_line, col, color));
431 }
432 }
433 // Skip wide character spacers - they're just placeholders for the second cell of wide characters
434 if cell.is_wide_char_spacer() {
435 continue;
436 }
437
438 // Skip spaces that follow cells with extras (emoji variation sequences)
439 if cell.character() == ' ' && previous_cell_had_extras {
440 previous_cell_had_extras = false;
441 continue;
442 }
443 // Update tracking for next iteration
444 previous_cell_had_extras =
445 matches!(cell.zerowidth(), Some(chars) if !chars.is_empty());
446
447 //Layout current cell text
448 {
449 if !is_blank(cell) {
450 cell_count += 1;
451 let cell_style = TerminalElement::cell_style(
452 point,
453 cell,
454 fg,
455 bg,
456 theme,
457 text_style,
458 hyperlink,
459 minimum_contrast,
460 );
461
462 let cell_point = LayoutPoint::new(display_line, point.column as i32);
463 let zero_width_chars = cell.zerowidth();
464
465 // Try to batch with existing run
466 if let Some(ref mut batch) = current_batch {
467 if batch.can_append(&cell_style)
468 && batch.start_point.line == cell_point.line
469 && batch.start_point.column + batch.cell_count as i32
470 == cell_point.column
471 {
472 batch.append_char(cell.character());
473 if let Some(chars) = zero_width_chars {
474 batch.append_zero_width_chars(chars);
475 }
476 } else {
477 // Flush current batch and start new one
478 let old_batch = current_batch.take().unwrap();
479 batched_runs.push(old_batch);
480 let mut new_batch = BatchedTextRun::new_from_char(
481 cell_point,
482 cell.character(),
483 cell_style,
484 text_style.font_size,
485 );
486 if let Some(chars) = zero_width_chars {
487 new_batch.append_zero_width_chars(chars);
488 }
489 current_batch = Some(new_batch);
490 }
491 } else {
492 // Start new batch
493 let mut new_batch = BatchedTextRun::new_from_char(
494 cell_point,
495 cell.character(),
496 cell_style,
497 text_style.font_size,
498 );
499 if let Some(chars) = zero_width_chars {
500 new_batch.append_zero_width_chars(chars);
501 }
502 current_batch = Some(new_batch);
503 }
504 };
505 }
506 }
507 }
508
509 // Flush any remaining batch
510 if let Some(batch) = current_batch {
511 batched_runs.push(batch);
512 }
513
514 // Second pass: merge background regions and convert to layout rects
515 let region_count = background_regions.len();
516 let merged_regions = merge_background_regions(background_regions);
517 let mut rects = Vec::with_capacity(merged_regions.len() * 2); // Estimate 2 rects per merged region
518
519 // Convert merged regions to layout rects
520 // Since LayoutRect only supports single-line rectangles, we need to split multi-line regions
521 for region in merged_regions {
522 for line in region.start_line..=region.end_line {
523 rects.push(LayoutRect::new(
524 LayoutPoint::new(line, region.start_col),
525 (region.end_col - region.start_col + 1) as usize,
526 region.color,
527 ));
528 }
529 }
530
531 let layout_time = start_time.elapsed();
532
533 log::debug!(
534 "Terminal layout_grid: {} cells processed, \
535 {} batched runs created, {} rects (from {} merged regions), \
536 layout took {:?}",
537 cell_count,
538 batched_runs.len(),
539 rects.len(),
540 region_count,
541 layout_time
542 );
543
544 (rects, batched_runs)
545 }
546
547 /// Computes the cursor position based on the cursor point and terminal dimensions.
548 fn cursor_position(
549 cursor_point: DisplayCursor,
550 size: TerminalBounds,
551 ) -> Option<GpuiPoint<Pixels>> {
552 if cursor_point.line() < size.num_lines() as i32 {
553 // When on pixel boundaries round the origin down
554 Some(point(
555 (cursor_point.col() as f32 * size.cell_width()).floor(),
556 (cursor_point.line() as f32 * size.line_height()).floor(),
557 ))
558 } else {
559 None
560 }
561 }
562
563 /// Checks if a character is a decorative block/box-like character that should
564 /// preserve its exact colors without contrast adjustment.
565 ///
566 /// This specifically targets characters used as visual connectors, separators,
567 /// and borders where color matching with adjacent backgrounds is critical.
568 /// Regular icons (git, folders, etc.) are excluded as they need to remain readable.
569 ///
570 /// Fixes https://github.com/zed-industries/zed/issues/34234
571 fn is_decorative_character(ch: char) -> bool {
572 matches!(
573 ch as u32,
574 // Unicode Box Drawing and Block Elements
575 0x2500..=0x257F // Box Drawing (└ ┐ ─ │ etc.)
576 | 0x2580..=0x259F // Block Elements (▀ ▄ █ ░ ▒ ▓ etc.)
577 | 0x25A0..=0x25FF // Geometric Shapes (■ ▶ ● etc. - includes triangular/circular separators)
578
579 // Private Use Area - Powerline separator symbols only
580 | 0xE0B0..=0xE0B7 // Powerline separators: triangles (E0B0-E0B3) and half circles (E0B4-E0B7)
581 | 0xE0B8..=0xE0BF // Powerline separators: corner triangles
582 | 0xE0C0..=0xE0CA // Powerline separators: flames (E0C0-E0C3), pixelated (E0C4-E0C7), and ice (E0C8 & E0CA)
583 | 0xE0CC..=0xE0D1 // Powerline separators: honeycombs (E0CC-E0CD) and lego (E0CE-E0D1)
584 | 0xE0D2..=0xE0D7 // Powerline separators: trapezoid (E0D2 & E0D4) and inverted triangles (E0D6-E0D7)
585 )
586 }
587
588 /// Whether the application explicitly picked this foreground color and does not
589 /// want it adjusted for contrast: 24-bit true color (`\e[38;2;R;G;Bm`) or a
590 /// specific entry in the 256-color palette (`\e[38;5;Nm`) where N >= 16 (the
591 /// 6x6x6 cube at 16..=231 and the 24-step grayscale ramp at 232..=255).
592 /// Indices 0..=15 still go through contrast adjustment since those map to
593 /// theme-defined ANSI colors that can clash with the theme background.
594 fn is_app_chosen_exact_color(fg: &Color) -> bool {
595 terminal_is_app_chosen_exact_color(*fg)
596 }
597
598 /// Converts terminal cell styles to GPUI text styles and background color.
599 fn cell_style(
600 point: Point,
601 cell: &Cell,
602 fg: Color,
603 bg: Color,
604 colors: &Theme,
605 text_style: &TextStyle,
606 hyperlink: Option<(HighlightStyle, &Range)>,
607 minimum_contrast: f32,
608 ) -> TextRun {
609 let skip_contrast = Self::is_app_chosen_exact_color(&fg);
610 let mut fg = convert_color(&fg, colors);
611 let bg = convert_color(&bg, colors);
612
613 if !skip_contrast && !Self::is_decorative_character(cell.character()) {
614 fg = ensure_minimum_contrast(fg, bg, minimum_contrast);
615 }
616
617 // Use a dim multiplier that stays close to the existing Alacritty look.
618 if cell.is_dim() {
619 fg.a *= 0.7;
620 }
621
622 let underline =
623 (cell.has_underline() || cell.hyperlink().is_some()).then(|| UnderlineStyle {
624 color: Some(fg),
625 thickness: Pixels::from(1.0),
626 wavy: cell.has_undercurl(),
627 });
628
629 let strikethrough = cell.has_strikeout().then(|| StrikethroughStyle {
630 color: Some(fg),
631 thickness: Pixels::from(1.0),
632 });
633
634 let weight = if cell.is_bold() {
635 FontWeight::BOLD
636 } else {
637 text_style.font_weight
638 };
639
640 let style = if cell.is_italic() {
641 FontStyle::Italic
642 } else {
643 FontStyle::Normal
644 };
645
646 let mut result = TextRun {
647 len: cell.character().len_utf8(),
648 color: fg,
649 background_color: None,
650 font: Font {
651 weight,
652 style,
653 ..text_style.font()
654 },
655 underline,
656 strikethrough,
657 };
658
659 if let Some((style, range)) = hyperlink
660 && range.contains(point)
661 {
662 if let Some(underline) = style.underline {
663 result.underline = Some(underline);
664 }
665
666 if let Some(color) = style.color {
667 result.color = color;
668 }
669 }
670
671 result
672 }
673
674 fn generic_button_handler<E>(
675 connection: Entity<Terminal>,
676 focus_handle: FocusHandle,
677 steal_focus: bool,
678 f: impl Fn(&mut Terminal, &E, &mut Context<Terminal>),
679 ) -> impl Fn(&E, &mut Window, &mut App) {
680 move |event, window, cx| {
681 if steal_focus {
682 window.focus(&focus_handle, cx);
683 } else if !focus_handle.is_focused(window) {
684 return;
685 }
686 connection.update(cx, |terminal, cx| {
687 f(terminal, event, cx);
688
689 cx.notify();
690 })
691 }
692 }
693
694 fn register_mouse_listeners(
695 &mut self,
696 mode: Modes,
697 hitbox: &Hitbox,
698 content_mode: &ContentMode,
699 window: &mut Window,
700 ) {
701 let focus = self.focus.clone();
702 let terminal = self.terminal.clone();
703 let terminal_view = self.terminal_view.clone();
704
705 self.interactivity.on_mouse_down(MouseButton::Left, {
706 let terminal = terminal.clone();
707 let focus = focus.clone();
708 let terminal_view = terminal_view.clone();
709
710 move |e, window, cx| {
711 window.focus(&focus, cx);
712
713 let scroll_top = terminal_view.read(cx).scroll_top;
714 terminal.update(cx, |terminal, cx| {
715 let mut adjusted_event = e.clone();
716 if scroll_top > Pixels::ZERO {
717 adjusted_event.position.y += scroll_top;
718 }
719 terminal.mouse_down(&adjusted_event, cx);
720 cx.notify();
721 })
722 }
723 });
724
725 window.on_mouse_event({
726 let terminal = self.terminal.clone();
727 let hitbox = hitbox.clone();
728 let focus = focus.clone();
729 let terminal_view = terminal_view;
730 move |e: &MouseMoveEvent, phase, window, cx| {
731 if phase != DispatchPhase::Bubble {
732 return;
733 }
734
735 if e.pressed_button.is_some() && !cx.has_active_drag() && focus.is_focused(window) {
736 let hovered = hitbox.is_hovered(window);
737
738 let scroll_top = terminal_view.read(cx).scroll_top;
739 terminal.update(cx, |terminal, cx| {
740 if terminal.selection_started() || hovered {
741 let mut adjusted_event = e.clone();
742 if scroll_top > Pixels::ZERO {
743 adjusted_event.position.y += scroll_top;
744 }
745 terminal.mouse_drag(&adjusted_event, hitbox.bounds, cx);
746 cx.notify();
747 }
748 })
749 }
750
751 if hitbox.is_hovered(window) {
752 terminal.update(cx, |terminal, cx| {
753 terminal.mouse_move(e, cx);
754 })
755 }
756 }
757 });
758
759 self.interactivity.on_mouse_up(
760 MouseButton::Left,
761 TerminalElement::generic_button_handler(
762 terminal.clone(),
763 focus.clone(),
764 false,
765 move |terminal, e, cx| {
766 terminal.mouse_up(e, cx);
767 },
768 ),
769 );
770 self.interactivity.on_mouse_down(
771 MouseButton::Middle,
772 TerminalElement::generic_button_handler(
773 terminal.clone(),
774 focus.clone(),
775 true,
776 move |terminal, e, cx| {
777 terminal.mouse_down(e, cx);
778 },
779 ),
780 );
781
782 if content_mode.is_scrollable() {
783 self.interactivity.on_scroll_wheel({
784 let terminal_view = self.terminal_view.downgrade();
785 move |e, window, cx| {
786 terminal_view
787 .update(cx, |terminal_view, cx| {
788 if matches!(terminal_view.mode, TerminalMode::Standalone)
789 || terminal_view.focus_handle.is_focused(window)
790 {
791 terminal_view.scroll_wheel(e, cx);
792 cx.notify();
793 }
794 })
795 .ok();
796 }
797 });
798 }
799
800 // Mouse mode handlers:
801 // All mouse modes need the extra click handlers
802 if mode.intersects(Modes::MOUSE_MODE) {
803 self.interactivity.on_mouse_down(
804 MouseButton::Right,
805 TerminalElement::generic_button_handler(
806 terminal.clone(),
807 focus.clone(),
808 true,
809 move |terminal, e, cx| {
810 terminal.mouse_down(e, cx);
811 },
812 ),
813 );
814 self.interactivity.on_mouse_up(
815 MouseButton::Right,
816 TerminalElement::generic_button_handler(
817 terminal.clone(),
818 focus.clone(),
819 false,
820 move |terminal, e, cx| {
821 terminal.mouse_up(e, cx);
822 },
823 ),
824 );
825 self.interactivity.on_mouse_up(
826 MouseButton::Middle,
827 TerminalElement::generic_button_handler(
828 terminal,
829 focus,
830 false,
831 move |terminal, e, cx| {
832 terminal.mouse_up(e, cx);
833 },
834 ),
835 );
836 }
837 }
838
839 fn rem_size(&self, cx: &mut App) -> Option<Pixels> {
840 let settings = ThemeSettings::get_global(cx).clone();
841 let buffer_font_size = settings.buffer_font_size(cx);
842 let rem_size_scale = {
843 // Our default UI font size is 14px on a 16px base scale.
844 // This means the default UI font size is 0.875rems.
845 let default_font_size_scale = 14. / ui::BASE_REM_SIZE_IN_PX;
846
847 // We then determine the delta between a single rem and the default font
848 // size scale.
849 let default_font_size_delta = 1. - default_font_size_scale;
850
851 // Finally, we add this delta to 1rem to get the scale factor that
852 // should be used to scale up the UI.
853 1. + default_font_size_delta
854 };
855
856 Some(buffer_font_size * rem_size_scale)
857 }
858}
859
860impl Element for TerminalElement {
861 type RequestLayoutState = ();
862 type PrepaintState = LayoutState;
863
864 fn id(&self) -> Option<ElementId> {
865 self.interactivity.element_id.clone()
866 }
867
868 fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
869 None
870 }
871
872 fn request_layout(
873 &mut self,
874 global_id: Option<&GlobalElementId>,
875 inspector_id: Option<&gpui::InspectorElementId>,
876 window: &mut Window,
877 cx: &mut App,
878 ) -> (LayoutId, Self::RequestLayoutState) {
879 let height: Length = match self.terminal_view.read(cx).content_mode(window, cx) {
880 ContentMode::Inline {
881 displayed_lines,
882 total_lines: _,
883 } => {
884 let rem_size = window.rem_size();
885 let line_height = f32::from(window.text_style().font_size.to_pixels(rem_size))
886 * TerminalSettings::get_global(cx).line_height.value();
887 px(displayed_lines as f32 * line_height).into()
888 }
889 ContentMode::Scrollable => {
890 if let TerminalMode::Embedded { .. } = &self.mode {
891 let term = self.terminal.read(cx);
892 if !term.scrolled_to_top() && !term.scrolled_to_bottom() && self.focused {
893 self.interactivity.occlude_mouse();
894 }
895 }
896
897 relative(1.).into()
898 }
899 };
900
901 let layout_id = self.interactivity.request_layout(
902 global_id,
903 inspector_id,
904 window,
905 cx,
906 |mut style, window, cx| {
907 style.size.width = relative(1.).into();
908 style.size.height = height;
909
910 window.request_layout(style, None, cx)
911 },
912 );
913 (layout_id, ())
914 }
915
916 fn prepaint(
917 &mut self,
918 global_id: Option<&GlobalElementId>,
919 inspector_id: Option<&gpui::InspectorElementId>,
920 bounds: Bounds<Pixels>,
921 _: &mut Self::RequestLayoutState,
922 window: &mut Window,
923 cx: &mut App,
924 ) -> Self::PrepaintState {
925 let rem_size = self.rem_size(cx);
926 self.interactivity.prepaint(
927 global_id,
928 inspector_id,
929 bounds,
930 bounds.size,
931 window,
932 cx,
933 |_, _, hitbox, window, cx| {
934 let hitbox = hitbox.unwrap();
935 let settings = ThemeSettings::get_global(cx).clone();
936
937 let buffer_font_size = settings.buffer_font_size(cx);
938
939 let terminal_settings = TerminalSettings::get_global(cx);
940 let minimum_contrast = terminal_settings.minimum_contrast;
941
942 let font_family = terminal_settings.font_family.as_ref().map_or_else(
943 || settings.buffer_font.family.clone(),
944 |font_family| font_family.0.clone().into(),
945 );
946
947 let font_fallbacks = terminal_settings
948 .font_fallbacks
949 .as_ref()
950 .or(settings.buffer_font.fallbacks.as_ref())
951 .cloned();
952
953 let font_features = terminal_settings
954 .font_features
955 .as_ref()
956 .unwrap_or(&FontFeatures::disable_ligatures())
957 .clone();
958
959 let font_weight = terminal_settings.font_weight.unwrap_or_default();
960
961 let line_height = terminal_settings.line_height.value();
962
963 let font_size = match &self.mode {
964 TerminalMode::Embedded { .. } => {
965 window.text_style().font_size.to_pixels(window.rem_size())
966 }
967 TerminalMode::Standalone => terminal_settings
968 .font_size
969 .map_or(buffer_font_size, |size| {
970 theme_settings::adjusted_font_size(size, cx)
971 }),
972 };
973
974 let theme = cx.theme().clone();
975
976 let link_style = HighlightStyle {
977 color: Some(theme.colors().link_text_hover),
978 font_weight: Some(font_weight),
979 font_style: None,
980 background_color: None,
981 underline: Some(UnderlineStyle {
982 thickness: px(1.0),
983 color: Some(theme.colors().link_text_hover),
984 wavy: false,
985 }),
986 strikethrough: None,
987 fade_out: None,
988 };
989
990 let text_style = TextStyle {
991 font_family,
992 font_features,
993 font_weight,
994 font_fallbacks,
995 font_size: font_size.into(),
996 font_style: FontStyle::Normal,
997 line_height: px(line_height).into(),
998 background_color: Some(theme.colors().terminal_ansi_background),
999 white_space: WhiteSpace::Normal,
1000 // These are going to be overridden per-cell
1001 color: theme.colors().terminal_foreground,
1002 ..Default::default()
1003 };
1004
1005 let text_system = cx.text_system();
1006 let player_color = theme.players().local();
1007 let match_color = theme.colors().search_match_background;
1008 let gutter;
1009 let (dimensions, line_height_px) = {
1010 let rem_size = window.rem_size();
1011 let font_pixels = text_style.font_size.to_pixels(rem_size);
1012 let line_height = f32::from(font_pixels) * line_height;
1013 let font_id = cx.text_system().resolve_font(&text_style.font());
1014
1015 let cell_width = text_system
1016 .advance(font_id, font_pixels, 'm')
1017 .unwrap()
1018 .width;
1019 gutter = cell_width;
1020
1021 let mut size = bounds.size;
1022 size.width -= gutter;
1023 let available_height = size.height;
1024
1025 // https://github.com/zed-industries/zed/issues/2750
1026 // if the terminal is one column wide, rendering 🦀
1027 // causes alacritty to misbehave.
1028 if size.width < cell_width * 2.0 {
1029 size.width = cell_width * 2.0;
1030 }
1031
1032 let mut origin = bounds.origin;
1033 origin.x += gutter;
1034
1035 if matches!(self.terminal_view.read(cx).mode, TerminalMode::Standalone) {
1036 let should_anchor_to_bottom = {
1037 let content = self.terminal.read(cx).last_content();
1038 content.mode.contains(Modes::ALT_SCREEN)
1039 || (content.scrolled_to_bottom && content.bottom_row_occupied)
1040 };
1041 let scale_factor = window.scale_factor();
1042 let line_height_pixels = px(line_height);
1043 let line_height_device_px = (f32::from(line_height_pixels) * scale_factor)
1044 .round()
1045 .max(1.0) as i32;
1046 let available_height_device_px =
1047 (f32::from(available_height) * scale_factor)
1048 .floor()
1049 .max(0.0) as i32;
1050
1051 let rows =
1052 ((available_height_device_px / line_height_device_px) as usize).max(1);
1053 let snapped_height_device_px = (rows as i32) * line_height_device_px;
1054 let padding_device_px =
1055 (available_height_device_px - snapped_height_device_px).max(0);
1056
1057 let snapped_height =
1058 px(snapped_height_device_px as f32 / scale_factor.max(1.0));
1059 let padding = px(padding_device_px as f32 / scale_factor.max(1.0));
1060
1061 size.height = snapped_height;
1062 if should_anchor_to_bottom {
1063 origin.y += padding;
1064 }
1065 }
1066
1067 // Snap to device pixels to avoid subpixel jitter while resizing.
1068 // Terminal rendering is grid-based; allowing fractional origins can cause the
1069 // glyph rasterization to shift between frames, which looks like flicker.
1070 let scale_factor = window.scale_factor();
1071 let snap_px = |value: Pixels| {
1072 Pixels::from((f32::from(value) * scale_factor).floor() / scale_factor)
1073 };
1074 origin.x = snap_px(origin.x);
1075 origin.y = snap_px(origin.y);
1076
1077 (
1078 TerminalBounds::new(px(line_height), cell_width, Bounds { origin, size }),
1079 line_height,
1080 )
1081 };
1082
1083 let search_matches = self.terminal.read(cx).matches.clone();
1084
1085 let background_color = theme.colors().terminal_background;
1086
1087 let (last_hovered_word, hover_tooltip) =
1088 self.terminal.update(cx, |terminal, cx| {
1089 terminal.set_size(dimensions);
1090 terminal.sync(window, cx);
1091
1092 if window.modifiers().secondary()
1093 && bounds.contains(&window.mouse_position())
1094 && self.terminal_view.read(cx).hover.is_some()
1095 {
1096 let registered_hover = self.terminal_view.read(cx).hover.as_ref();
1097 if terminal.last_content.last_hovered_word.as_ref()
1098 == registered_hover.map(|hover| &hover.hovered_word)
1099 {
1100 (
1101 terminal.last_content.last_hovered_word.clone(),
1102 registered_hover.map(|hover| hover.tooltip.clone()),
1103 )
1104 } else {
1105 (None, None)
1106 }
1107 } else {
1108 (None, None)
1109 }
1110 });
1111
1112 let scroll_top = self.terminal_view.read(cx).scroll_top;
1113 let hyperlink_tooltip = hover_tooltip.map(|hover_tooltip| {
1114 let offset = dimensions.bounds.origin - point(px(0.), scroll_top);
1115 let mut element = div()
1116 .size_full()
1117 .id("terminal-element")
1118 .tooltip(Tooltip::text(hover_tooltip))
1119 .into_any_element();
1120 element.prepaint_as_root(offset, bounds.size.into(), window, cx);
1121 element
1122 });
1123
1124 let Content {
1125 cells,
1126 mode,
1127 display_offset,
1128 cursor_char,
1129 selection,
1130 cursor,
1131 ..
1132 } = &self.terminal.read(cx).last_content;
1133 let mode = *mode;
1134 let display_offset = *display_offset;
1135
1136 // searches, highlights to a single range representations
1137 let mut relative_highlighted_ranges = Vec::new();
1138 for search_match in search_matches {
1139 relative_highlighted_ranges.push((search_match, match_color))
1140 }
1141 if let Some(selection) = selection {
1142 relative_highlighted_ranges
1143 .push((selection.point_range(), player_color.selection));
1144 }
1145
1146 // then have that representation be converted to the appropriate highlight data structure
1147
1148 let content_mode = self.terminal_view.read(cx).content_mode(window, cx);
1149
1150 // Calculate the intersection of the terminal's bounds with the current
1151 // content mask (the visible viewport after all parent clipping).
1152 // This allows us to only render cells that are actually visible, which is
1153 // critical for performance when terminals are inside scrollable containers
1154 // like the Agent Panel thread view.
1155 //
1156 // This optimization is analogous to the editor optimization in PR #45077
1157 // which fixed performance issues with large AutoHeight editors inside Lists.
1158 let content_bounds = dimensions.bounds;
1159 let visible_bounds = window.content_mask().bounds;
1160 let intersection = visible_bounds.intersect(&content_bounds);
1161
1162 // If the terminal is entirely outside the viewport, skip all cell processing.
1163 // This handles the case where the terminal has been scrolled past (above or
1164 // below the viewport), similar to the editor fix in PR #45077 where start_row
1165 // could exceed max_row when the editor was positioned above the viewport.
1166 let (rects, batched_text_runs) = if intersection.size.height <= px(0.)
1167 || intersection.size.width <= px(0.)
1168 {
1169 (Vec::new(), Vec::new())
1170 } else if intersection == content_bounds {
1171 // Fast path: terminal fully visible, no clipping needed.
1172 // Avoid grouping/allocation overhead by streaming cells directly.
1173 TerminalElement::layout_grid(
1174 cells.iter(),
1175 0,
1176 &text_style,
1177 last_hovered_word
1178 .as_ref()
1179 .map(|last_hovered_word| (link_style, &last_hovered_word.word_match)),
1180 minimum_contrast,
1181 cx,
1182 )
1183 } else {
1184 // Calculate which screen rows are visible based on pixel positions.
1185 // This works for both Scrollable and Inline modes because we filter
1186 // by screen position (enumerated line group index), not by the cell's
1187 // internal line number (which can be negative in Scrollable mode for
1188 // scrollback history).
1189 let rows_above_viewport = f32::from(
1190 (intersection.top() - content_bounds.top()).max(px(0.)) / line_height_px,
1191 ) as usize;
1192 let visible_row_count =
1193 f32::from((intersection.size.height / line_height_px).ceil()) as usize + 1;
1194
1195 TerminalElement::layout_grid(
1196 // Group cells by line and filter to only the visible screen rows.
1197 // skip() and take() work on enumerated line groups (screen position),
1198 // making this work regardless of the actual cell.point.line values.
1199 cells
1200 .iter()
1201 .chunk_by(|c| c.point.line)
1202 .into_iter()
1203 .skip(rows_above_viewport)
1204 .take(visible_row_count)
1205 .flat_map(|(_, line_cells)| line_cells),
1206 rows_above_viewport as i32,
1207 &text_style,
1208 last_hovered_word
1209 .as_ref()
1210 .map(|last_hovered_word| (link_style, &last_hovered_word.word_match)),
1211 minimum_contrast,
1212 cx,
1213 )
1214 };
1215
1216 // Layout cursor. Rectangle is used for IME, so we should lay it out even
1217 // if we don't end up showing it.
1218 let cursor_point = DisplayCursor::from(cursor.point, display_offset);
1219 let cursor_text = {
1220 let str_trxt = cursor_char.to_string();
1221 let len = str_trxt.len();
1222 window.text_system().shape_line(
1223 str_trxt.into(),
1224 text_style.font_size.to_pixels(window.rem_size()),
1225 &[TextRun {
1226 len,
1227 font: text_style.font(),
1228 color: theme.colors().terminal_ansi_background,
1229 ..Default::default()
1230 }],
1231 None,
1232 )
1233 };
1234
1235 // For whitespace, use cell width to avoid cursor stretching.
1236 // For other characters, use the larger of shaped width and cell width
1237 // to properly cover wide characters like emojis.
1238 let cursor_width = if cursor_char.is_whitespace() {
1239 dimensions.cell_width()
1240 } else {
1241 cursor_text.width.max(dimensions.cell_width())
1242 };
1243
1244 let ime_cursor_bounds = TerminalElement::cursor_position(cursor_point, dimensions)
1245 .map(|cursor_position| Bounds {
1246 origin: cursor_position,
1247 size: size(cursor_width.ceil(), dimensions.line_height),
1248 });
1249
1250 let cursor = if let CursorShape::Hidden = cursor.shape {
1251 None
1252 } else {
1253 let focused = self.focused;
1254 ime_cursor_bounds.map(move |bounds| {
1255 let (shape, text) = match cursor.shape {
1256 CursorShape::Block if !focused => (EditorCursorShape::Hollow, None),
1257 CursorShape::Block => (EditorCursorShape::Block, Some(cursor_text)),
1258 CursorShape::Underline if !focused => (EditorCursorShape::Hollow, None),
1259 CursorShape::Underline => (EditorCursorShape::Underline, None),
1260 CursorShape::Bar if !focused => (EditorCursorShape::Hollow, None),
1261 CursorShape::Bar => (EditorCursorShape::Bar, None),
1262 CursorShape::HollowBlock => (EditorCursorShape::Hollow, None),
1263 CursorShape::Hidden => unreachable!(),
1264 };
1265
1266 CursorLayout::new(
1267 bounds.origin,
1268 bounds.size.width,
1269 bounds.size.height,
1270 theme.players().local().cursor,
1271 shape,
1272 text,
1273 )
1274 })
1275 };
1276
1277 let block_below_cursor_element = if let Some(block) = &self.block_below_cursor {
1278 let terminal = self.terminal.read(cx);
1279 if terminal.last_content.display_offset == 0 {
1280 let target_line = terminal.last_content.cursor.point.line + 1;
1281 let render = &block.render;
1282 let mut block_cx = BlockContext {
1283 window,
1284 context: cx,
1285 dimensions,
1286 };
1287 let element = render(&mut block_cx);
1288 let mut element = div().occlude().child(element).into_any_element();
1289 let available_space = size(
1290 AvailableSpace::Definite(dimensions.width() + gutter),
1291 AvailableSpace::Definite(
1292 block.height as f32 * dimensions.line_height(),
1293 ),
1294 );
1295 let origin = GpuiPoint::new(bounds.origin.x, dimensions.bounds.origin.y)
1296 + point(px(0.), target_line as f32 * dimensions.line_height())
1297 - point(px(0.), scroll_top);
1298 window.with_rem_size(rem_size, |window| {
1299 element.prepaint_as_root(origin, available_space, window, cx);
1300 });
1301 Some(element)
1302 } else {
1303 None
1304 }
1305 } else {
1306 None
1307 };
1308
1309 LayoutState {
1310 hitbox,
1311 batched_text_runs,
1312 cursor,
1313 ime_cursor_bounds,
1314 background_color,
1315 dimensions,
1316 rects,
1317 relative_highlighted_ranges,
1318 mode,
1319 display_offset,
1320 hyperlink_tooltip,
1321 block_below_cursor_element,
1322 base_text_style: text_style,
1323 content_mode,
1324 }
1325 },
1326 )
1327 }
1328
1329 fn paint(
1330 &mut self,
1331 global_id: Option<&GlobalElementId>,
1332 inspector_id: Option<&gpui::InspectorElementId>,
1333 bounds: Bounds<Pixels>,
1334 _: &mut Self::RequestLayoutState,
1335 layout: &mut Self::PrepaintState,
1336 window: &mut Window,
1337 cx: &mut App,
1338 ) {
1339 let paint_start = Instant::now();
1340 window.with_content_mask(Some(ContentMask { bounds }), |window| {
1341 let scroll_top = self.terminal_view.read(cx).scroll_top;
1342
1343 window.paint_quad(fill(bounds, layout.background_color));
1344 let origin = layout.dimensions.bounds.origin - GpuiPoint::new(px(0.), scroll_top);
1345 let scale_factor = window.scale_factor();
1346 let snap_px = |value: Pixels| {
1347 Pixels::from((f32::from(value) * scale_factor).floor() / scale_factor)
1348 };
1349 let origin = point(snap_px(origin.x), snap_px(origin.y));
1350
1351 let marked_text_cloned: Option<String> = {
1352 let ime_state = &self.terminal_view.read(cx).ime_state;
1353 ime_state.as_ref().map(|state| state.marked_text.clone())
1354 };
1355
1356 let terminal_input_handler = TerminalInputHandler {
1357 terminal_view: self.terminal_view.clone(),
1358 cursor_bounds: layout.ime_cursor_bounds.map(|bounds| bounds + origin),
1359 workspace: self.workspace.clone(),
1360 };
1361
1362 self.register_mouse_listeners(
1363 layout.mode,
1364 &layout.hitbox,
1365 &layout.content_mode,
1366 window,
1367 );
1368 if window.modifiers().secondary()
1369 && bounds.contains(&window.mouse_position())
1370 && self.terminal_view.read(cx).hover.is_some()
1371 {
1372 window.set_cursor_style(gpui::CursorStyle::PointingHand, &layout.hitbox);
1373 } else {
1374 window.set_cursor_style(gpui::CursorStyle::IBeam, &layout.hitbox);
1375 }
1376
1377 let original_cursor = layout.cursor.take();
1378 let hyperlink_tooltip = layout.hyperlink_tooltip.take();
1379 let block_below_cursor_element = layout.block_below_cursor_element.take();
1380 self.interactivity.paint(
1381 global_id,
1382 inspector_id,
1383 bounds,
1384 Some(&layout.hitbox),
1385 window,
1386 cx,
1387 |_, window, cx| {
1388 window.handle_input(&self.focus, terminal_input_handler, cx);
1389
1390 window.on_key_event({
1391 let this = self.terminal.clone();
1392 move |event: &ModifiersChangedEvent, phase, window, cx| {
1393 if phase != DispatchPhase::Bubble {
1394 return;
1395 }
1396
1397 this.update(cx, |term, cx| {
1398 term.try_modifiers_change(&event.modifiers, window, cx)
1399 });
1400 }
1401 });
1402
1403 for rect in &layout.rects {
1404 rect.paint(origin, &layout.dimensions, window);
1405 }
1406
1407 for (relative_highlighted_range, color) in &layout.relative_highlighted_ranges {
1408 if let Some((start_y, highlighted_range_lines)) =
1409 to_highlighted_range_lines(relative_highlighted_range, layout, origin)
1410 {
1411 let corner_radius = if EditorSettings::get_global(cx).rounded_selection
1412 {
1413 0.15 * layout.dimensions.line_height
1414 } else {
1415 Pixels::ZERO
1416 };
1417 let hr = HighlightedRange {
1418 start_y,
1419 line_height: layout.dimensions.line_height,
1420 lines: highlighted_range_lines,
1421 color: *color,
1422 corner_radius: corner_radius,
1423 };
1424 hr.paint(true, bounds, window);
1425 }
1426 }
1427
1428 // Paint batched text runs instead of individual cells
1429 let text_paint_start = Instant::now();
1430 for batch in &layout.batched_text_runs {
1431 batch.paint(origin, &layout.dimensions, window, cx);
1432 }
1433 let text_paint_time = text_paint_start.elapsed();
1434
1435 if let Some(text_to_mark) = &marked_text_cloned
1436 && !text_to_mark.is_empty()
1437 && let Some(ime_bounds) = layout.ime_cursor_bounds
1438 {
1439 let ime_position = (ime_bounds + origin).origin;
1440 let mut ime_style = layout.base_text_style.clone();
1441 ime_style.underline = Some(UnderlineStyle {
1442 color: Some(ime_style.color),
1443 thickness: px(1.0),
1444 wavy: false,
1445 });
1446
1447 let shaped_line = window.text_system().shape_line(
1448 text_to_mark.clone().into(),
1449 ime_style.font_size.to_pixels(window.rem_size()),
1450 &[TextRun {
1451 len: text_to_mark.len(),
1452 font: ime_style.font(),
1453 color: ime_style.color,
1454 underline: ime_style.underline,
1455 ..Default::default()
1456 }],
1457 None,
1458 );
1459
1460 // Paint background to cover terminal text behind marked text
1461 let ime_background_bounds = Bounds::new(
1462 ime_position,
1463 size(shaped_line.width, layout.dimensions.line_height),
1464 );
1465 window.paint_quad(fill(ime_background_bounds, layout.background_color));
1466
1467 shaped_line
1468 .paint(
1469 ime_position,
1470 layout.dimensions.line_height,
1471 gpui::TextAlign::Left,
1472 None,
1473 window,
1474 cx,
1475 )
1476 .log_err();
1477 }
1478
1479 if self.cursor_visible
1480 && marked_text_cloned.is_none()
1481 && let Some(mut cursor) = original_cursor
1482 {
1483 cursor.paint(origin, window, cx);
1484 }
1485
1486 if let Some(mut element) = block_below_cursor_element {
1487 element.paint(window, cx);
1488 }
1489
1490 if let Some(mut element) = hyperlink_tooltip {
1491 element.paint(window, cx);
1492 }
1493
1494 log::debug!(
1495 "Terminal paint: {} text runs, {} rects, \
1496 text paint took {:?}, total paint took {total_paint_time:?}",
1497 layout.batched_text_runs.len(),
1498 layout.rects.len(),
1499 text_paint_time,
1500 total_paint_time = paint_start.elapsed()
1501 );
1502 },
1503 );
1504 });
1505 }
1506}
1507
1508impl IntoElement for TerminalElement {
1509 type Element = Self;
1510
1511 fn into_element(self) -> Self::Element {
1512 self
1513 }
1514}
1515
1516struct TerminalInputHandler {
1517 terminal_view: Entity<TerminalView>,
1518 workspace: WeakEntity<Workspace>,
1519 cursor_bounds: Option<Bounds<Pixels>>,
1520}
1521
1522impl InputHandler for TerminalInputHandler {
1523 fn selected_text_range(
1524 &mut self,
1525 _ignore_disabled_input: bool,
1526 _: &mut Window,
1527 _cx: &mut App,
1528 ) -> Option<UTF16Selection> {
1529 // Always return a valid selection for IME positioning,
1530 // even in ALT_SCREEN mode (fullscreen TUI apps like opencode, vim, etc.)
1531 // The terminal still has a cursor position that should be used for IME candidate window placement.
1532 Some(UTF16Selection {
1533 range: 0..0,
1534 reversed: false,
1535 })
1536 }
1537
1538 fn marked_text_range(
1539 &mut self,
1540 _window: &mut Window,
1541 cx: &mut App,
1542 ) -> Option<std::ops::Range<usize>> {
1543 self.terminal_view.read(cx).marked_text_range()
1544 }
1545
1546 fn text_for_range(
1547 &mut self,
1548 _: std::ops::Range<usize>,
1549 _: &mut Option<std::ops::Range<usize>>,
1550 _: &mut Window,
1551 _: &mut App,
1552 ) -> Option<String> {
1553 None
1554 }
1555
1556 fn replace_text_in_range(
1557 &mut self,
1558 _replacement_range: Option<std::ops::Range<usize>>,
1559 text: &str,
1560 window: &mut Window,
1561 cx: &mut App,
1562 ) {
1563 self.terminal_view.update(cx, |view, view_cx| {
1564 view.clear_marked_text(view_cx);
1565 view.commit_text(text, view_cx);
1566 });
1567
1568 self.workspace
1569 .update(cx, |this, cx| {
1570 window.invalidate_character_coordinates();
1571 let project = this.project().read(cx);
1572 let telemetry = project.client().telemetry().clone();
1573 telemetry.log_edit_event("terminal", project.is_via_remote_server());
1574 })
1575 .ok();
1576 }
1577
1578 fn replace_and_mark_text_in_range(
1579 &mut self,
1580 _range_utf16: Option<std::ops::Range<usize>>,
1581 new_text: &str,
1582 _new_marked_range: Option<std::ops::Range<usize>>,
1583 _window: &mut Window,
1584 cx: &mut App,
1585 ) {
1586 self.terminal_view.update(cx, |view, view_cx| {
1587 view.set_marked_text(new_text.to_string(), view_cx);
1588 });
1589 }
1590
1591 fn unmark_text(&mut self, _window: &mut Window, cx: &mut App) {
1592 self.terminal_view.update(cx, |view, view_cx| {
1593 view.clear_marked_text(view_cx);
1594 });
1595 }
1596
1597 fn bounds_for_range(
1598 &mut self,
1599 range_utf16: std::ops::Range<usize>,
1600 _window: &mut Window,
1601 cx: &mut App,
1602 ) -> Option<Bounds<Pixels>> {
1603 let term_bounds = self.terminal_view.read(cx).terminal_bounds(cx);
1604
1605 let mut bounds = self.cursor_bounds?;
1606 let offset_x = term_bounds.cell_width * range_utf16.start as f32;
1607 bounds.origin.x += offset_x;
1608
1609 Some(bounds)
1610 }
1611
1612 fn apple_press_and_hold_enabled(&mut self) -> bool {
1613 false
1614 }
1615
1616 fn character_index_for_point(
1617 &mut self,
1618 _point: GpuiPoint<Pixels>,
1619 _window: &mut Window,
1620 _cx: &mut App,
1621 ) -> Option<usize> {
1622 None
1623 }
1624}
1625
1626pub fn is_blank(cell: &Cell) -> bool {
1627 if cell.character() != ' ' {
1628 return false;
1629 }
1630
1631 if !is_default_background_color(cell.background()) {
1632 return false;
1633 }
1634
1635 if cell.hyperlink().is_some() {
1636 return false;
1637 }
1638
1639 if cell.has_visible_style_modifier() {
1640 return false;
1641 }
1642
1643 true
1644}
1645
1646fn to_highlighted_range_lines(
1647 range: &Range,
1648 layout: &LayoutState,
1649 origin: GpuiPoint<Pixels>,
1650) -> Option<(Pixels, Vec<HighlightedRangeLine>)> {
1651 // Step 1. Normalize the points to be viewport relative.
1652 // When display_offset = 1, here's how the grid is arranged:
1653 //-2,0 -2,1...
1654 //--- Viewport top
1655 //-1,0 -1,1...
1656 //--------- Terminal Top
1657 // 0,0 0,1...
1658 // 1,0 1,1...
1659 //--- Viewport Bottom
1660 // 2,0 2,1...
1661 //--------- Terminal Bottom
1662
1663 // Normalize to viewport relative, from terminal relative.
1664 // lines are i32s, which are negative above the top left corner of the terminal
1665 // If the user has scrolled, we use the display_offset to tell us which offset
1666 // of the grid data we should be looking at. But for the rendering step, we don't
1667 // want negatives. We want things relative to the 'viewport' (the area of the grid
1668 // which is currently shown according to the display offset)
1669 let display_offset = i32::try_from(layout.display_offset).unwrap_or(i32::MAX);
1670 let unclamped_start_line = range.start().line.saturating_add(display_offset);
1671 let unclamped_start_column = range.start().column;
1672 let unclamped_end_line = range.end().line.saturating_add(display_offset);
1673 let unclamped_end_column = range.end().column;
1674
1675 // Step 2. Clamp range to viewport, and return None if it doesn't overlap
1676 if unclamped_end_line < 0 || unclamped_start_line > layout.dimensions.num_lines() as i32 {
1677 return None;
1678 }
1679
1680 let clamped_start_line = unclamped_start_line.max(0) as usize;
1681
1682 let clamped_end_line = unclamped_end_line.min(layout.dimensions.num_lines() as i32) as usize;
1683
1684 // Convert the start of the range to pixels
1685 let start_y = origin.y + clamped_start_line as f32 * layout.dimensions.line_height;
1686
1687 // Step 3. Expand ranges that cross lines into a collection of single-line ranges.
1688 // (also convert to pixels)
1689 let mut highlighted_range_lines = Vec::new();
1690 for line in clamped_start_line..=clamped_end_line {
1691 let mut line_start = 0;
1692 let mut line_end = layout.dimensions.num_columns();
1693
1694 if line == clamped_start_line && unclamped_start_line >= 0 {
1695 line_start = unclamped_start_column;
1696 }
1697 if line == clamped_end_line && unclamped_end_line <= layout.dimensions.num_lines() as i32 {
1698 line_end = unclamped_end_column + 1; // +1 for inclusive
1699 }
1700
1701 highlighted_range_lines.push(HighlightedRangeLine {
1702 start_x: origin.x + line_start as f32 * layout.dimensions.cell_width,
1703 end_x: origin.x + line_end as f32 * layout.dimensions.cell_width,
1704 });
1705 }
1706
1707 Some((start_y, highlighted_range_lines))
1708}
1709
1710/// Converts a 2, 8, or 24 bit color ANSI color to the GPUI equivalent.
1711pub fn convert_color(fg: &Color, theme: &Theme) -> Hsla {
1712 let colors = theme.colors();
1713 match fg {
1714 // Named and theme defined colors
1715 Color::Named(color) => match color {
1716 NamedColor::Black => colors.terminal_ansi_black,
1717 NamedColor::Red => colors.terminal_ansi_red,
1718 NamedColor::Green => colors.terminal_ansi_green,
1719 NamedColor::Yellow => colors.terminal_ansi_yellow,
1720 NamedColor::Blue => colors.terminal_ansi_blue,
1721 NamedColor::Magenta => colors.terminal_ansi_magenta,
1722 NamedColor::Cyan => colors.terminal_ansi_cyan,
1723 NamedColor::White => colors.terminal_ansi_white,
1724 NamedColor::BrightBlack => colors.terminal_ansi_bright_black,
1725 NamedColor::BrightRed => colors.terminal_ansi_bright_red,
1726 NamedColor::BrightGreen => colors.terminal_ansi_bright_green,
1727 NamedColor::BrightYellow => colors.terminal_ansi_bright_yellow,
1728 NamedColor::BrightBlue => colors.terminal_ansi_bright_blue,
1729 NamedColor::BrightMagenta => colors.terminal_ansi_bright_magenta,
1730 NamedColor::BrightCyan => colors.terminal_ansi_bright_cyan,
1731 NamedColor::BrightWhite => colors.terminal_ansi_bright_white,
1732 NamedColor::Foreground => colors.terminal_foreground,
1733 NamedColor::Background => colors.terminal_ansi_background,
1734 NamedColor::Cursor => theme.players().local().cursor,
1735 NamedColor::DimBlack => colors.terminal_ansi_dim_black,
1736 NamedColor::DimRed => colors.terminal_ansi_dim_red,
1737 NamedColor::DimGreen => colors.terminal_ansi_dim_green,
1738 NamedColor::DimYellow => colors.terminal_ansi_dim_yellow,
1739 NamedColor::DimBlue => colors.terminal_ansi_dim_blue,
1740 NamedColor::DimMagenta => colors.terminal_ansi_dim_magenta,
1741 NamedColor::DimCyan => colors.terminal_ansi_dim_cyan,
1742 NamedColor::DimWhite => colors.terminal_ansi_dim_white,
1743 NamedColor::BrightForeground => colors.terminal_bright_foreground,
1744 NamedColor::DimForeground => colors.terminal_dim_foreground,
1745 },
1746 // 'True' colors
1747 Color::Spec(rgb) => terminal::rgba_color(rgb.r, rgb.g, rgb.b),
1748 // 8 bit, indexed colors
1749 Color::Indexed(i) => terminal::get_color_at_index(*i as usize, theme),
1750 }
1751}
1752
1753#[cfg(test)]
1754mod tests {
1755 use super::*;
1756 use gpui::{AbsoluteLength, Hsla, font};
1757 use ui::utils::apca_contrast;
1758
1759 #[test]
1760 fn test_is_decorative_character() {
1761 // Box Drawing characters (U+2500 to U+257F)
1762 assert!(TerminalElement::is_decorative_character('─')); // U+2500
1763 assert!(TerminalElement::is_decorative_character('│')); // U+2502
1764 assert!(TerminalElement::is_decorative_character('┌')); // U+250C
1765 assert!(TerminalElement::is_decorative_character('┐')); // U+2510
1766 assert!(TerminalElement::is_decorative_character('└')); // U+2514
1767 assert!(TerminalElement::is_decorative_character('┘')); // U+2518
1768 assert!(TerminalElement::is_decorative_character('┼')); // U+253C
1769
1770 // Block Elements (U+2580 to U+259F)
1771 assert!(TerminalElement::is_decorative_character('▀')); // U+2580
1772 assert!(TerminalElement::is_decorative_character('▄')); // U+2584
1773 assert!(TerminalElement::is_decorative_character('█')); // U+2588
1774 assert!(TerminalElement::is_decorative_character('░')); // U+2591
1775 assert!(TerminalElement::is_decorative_character('▒')); // U+2592
1776 assert!(TerminalElement::is_decorative_character('▓')); // U+2593
1777
1778 // Geometric Shapes - block/box-like subset (U+25A0 to U+25D7)
1779 assert!(TerminalElement::is_decorative_character('■')); // U+25A0
1780 assert!(TerminalElement::is_decorative_character('□')); // U+25A1
1781 assert!(TerminalElement::is_decorative_character('▲')); // U+25B2
1782 assert!(TerminalElement::is_decorative_character('▼')); // U+25BC
1783 assert!(TerminalElement::is_decorative_character('◆')); // U+25C6
1784 assert!(TerminalElement::is_decorative_character('●')); // U+25CF
1785
1786 // The specific character from the issue
1787 assert!(TerminalElement::is_decorative_character('◗')); // U+25D7
1788 assert!(TerminalElement::is_decorative_character('◘')); // U+25D8 (now included in Geometric Shapes)
1789 assert!(TerminalElement::is_decorative_character('◙')); // U+25D9 (now included in Geometric Shapes)
1790
1791 // Powerline symbols (Private Use Area)
1792 assert!(TerminalElement::is_decorative_character('\u{E0B0}')); // Powerline right triangle
1793 assert!(TerminalElement::is_decorative_character('\u{E0B2}')); // Powerline left triangle
1794 assert!(TerminalElement::is_decorative_character('\u{E0B4}')); // Powerline right half circle (the actual issue!)
1795 assert!(TerminalElement::is_decorative_character('\u{E0B6}')); // Powerline left half circle
1796 assert!(TerminalElement::is_decorative_character('\u{E0CA}')); // Powerline mirrored ice waveform
1797 assert!(TerminalElement::is_decorative_character('\u{E0D7}')); // Powerline left triangle inverted
1798
1799 // Characters that should NOT be considered decorative
1800 assert!(!TerminalElement::is_decorative_character('A')); // Regular letter
1801 assert!(!TerminalElement::is_decorative_character('$')); // Symbol
1802 assert!(!TerminalElement::is_decorative_character(' ')); // Space
1803 assert!(!TerminalElement::is_decorative_character('←')); // U+2190 (Arrow, not in our ranges)
1804 assert!(!TerminalElement::is_decorative_character('→')); // U+2192 (Arrow, not in our ranges)
1805 assert!(!TerminalElement::is_decorative_character('\u{F00C}')); // Font Awesome check (icon, needs contrast)
1806 assert!(!TerminalElement::is_decorative_character('\u{E711}')); // Devicons (icon, needs contrast)
1807 assert!(!TerminalElement::is_decorative_character('\u{EA71}')); // Codicons folder (icon, needs contrast)
1808 assert!(!TerminalElement::is_decorative_character('\u{F401}')); // Octicons (icon, needs contrast)
1809 assert!(!TerminalElement::is_decorative_character('\u{1F600}')); // Emoji (not in our ranges)
1810 }
1811
1812 #[test]
1813 fn test_decorative_character_boundary_cases() {
1814 // Test exact boundaries of our ranges
1815 // Box Drawing range boundaries
1816 assert!(TerminalElement::is_decorative_character('\u{2500}')); // First char
1817 assert!(TerminalElement::is_decorative_character('\u{257F}')); // Last char
1818 assert!(!TerminalElement::is_decorative_character('\u{24FF}')); // Just before
1819
1820 // Block Elements range boundaries
1821 assert!(TerminalElement::is_decorative_character('\u{2580}')); // First char
1822 assert!(TerminalElement::is_decorative_character('\u{259F}')); // Last char
1823
1824 // Geometric Shapes subset boundaries
1825 assert!(TerminalElement::is_decorative_character('\u{25A0}')); // First char
1826 assert!(TerminalElement::is_decorative_character('\u{25FF}')); // Last char
1827 assert!(!TerminalElement::is_decorative_character('\u{2600}')); // Just after
1828 }
1829
1830 #[test]
1831 fn test_decorative_characters_bypass_contrast_adjustment() {
1832 // Decorative characters should not be affected by contrast adjustment
1833
1834 // The specific character from issue #34234
1835 let problematic_char = '◗'; // U+25D7
1836 assert!(
1837 TerminalElement::is_decorative_character(problematic_char),
1838 "Character ◗ (U+25D7) should be recognized as decorative"
1839 );
1840
1841 // Verify some other commonly used decorative characters
1842 assert!(TerminalElement::is_decorative_character('│')); // Vertical line
1843 assert!(TerminalElement::is_decorative_character('─')); // Horizontal line
1844 assert!(TerminalElement::is_decorative_character('█')); // Full block
1845 assert!(TerminalElement::is_decorative_character('▓')); // Dark shade
1846 assert!(TerminalElement::is_decorative_character('■')); // Black square
1847 assert!(TerminalElement::is_decorative_character('●')); // Black circle
1848
1849 // Verify normal text characters are NOT decorative
1850 assert!(!TerminalElement::is_decorative_character('A'));
1851 assert!(!TerminalElement::is_decorative_character('1'));
1852 assert!(!TerminalElement::is_decorative_character('$'));
1853 assert!(!TerminalElement::is_decorative_character(' '));
1854 }
1855
1856 #[test]
1857 fn test_is_app_chosen_exact_color() {
1858 use terminal::{Color, NamedColor, Rgb};
1859
1860 // Indices 0..=15 are theme-overridable ANSI colors; contrast adjustment must still apply.
1861 assert!(!TerminalElement::is_app_chosen_exact_color(
1862 &Color::Indexed(0)
1863 ));
1864 assert!(!TerminalElement::is_app_chosen_exact_color(
1865 &Color::Indexed(15)
1866 ));
1867
1868 // Boundary: index 16 is the first entry of the 6x6x6 cube — application-chosen.
1869 assert!(TerminalElement::is_app_chosen_exact_color(&Color::Indexed(
1870 16
1871 )));
1872 // Interior of the cube.
1873 assert!(TerminalElement::is_app_chosen_exact_color(&Color::Indexed(
1874 17
1875 )));
1876 assert!(TerminalElement::is_app_chosen_exact_color(&Color::Indexed(
1877 231
1878 )));
1879 // Grayscale ramp boundaries.
1880 assert!(TerminalElement::is_app_chosen_exact_color(&Color::Indexed(
1881 232
1882 )));
1883 assert!(TerminalElement::is_app_chosen_exact_color(&Color::Indexed(
1884 255
1885 )));
1886
1887 // 24-bit true color is always application-chosen.
1888 assert!(TerminalElement::is_app_chosen_exact_color(&Color::Spec(
1889 Rgb {
1890 r: 10,
1891 g: 20,
1892 b: 30
1893 }
1894 )));
1895
1896 // Named colors are theme-defined and must go through contrast adjustment.
1897 assert!(!TerminalElement::is_app_chosen_exact_color(&Color::Named(
1898 NamedColor::Red
1899 )));
1900 assert!(!TerminalElement::is_app_chosen_exact_color(&Color::Named(
1901 NamedColor::Foreground
1902 )));
1903 }
1904
1905 #[test]
1906 fn test_contrast_adjustment_logic() {
1907 // Test the core contrast adjustment logic without needing full app context
1908
1909 // Test case 1: Light colors (poor contrast)
1910 let white_fg = gpui::Hsla {
1911 h: 0.0,
1912 s: 0.0,
1913 l: 1.0,
1914 a: 1.0,
1915 };
1916 let light_gray_bg = gpui::Hsla {
1917 h: 0.0,
1918 s: 0.0,
1919 l: 0.95,
1920 a: 1.0,
1921 };
1922
1923 // Should have poor contrast
1924 let actual_contrast = apca_contrast(white_fg, light_gray_bg).abs();
1925 assert!(
1926 actual_contrast < 30.0,
1927 "White on light gray should have poor APCA contrast: {}",
1928 actual_contrast
1929 );
1930
1931 // After adjustment with minimum APCA contrast of 45, should be darker
1932 let adjusted = ensure_minimum_contrast(white_fg, light_gray_bg, 45.0);
1933 assert!(
1934 adjusted.l < white_fg.l,
1935 "Adjusted color should be darker than original"
1936 );
1937 let adjusted_contrast = apca_contrast(adjusted, light_gray_bg).abs();
1938 assert!(adjusted_contrast >= 45.0, "Should meet minimum contrast");
1939
1940 // Test case 2: Dark colors (poor contrast)
1941 let black_fg = gpui::Hsla {
1942 h: 0.0,
1943 s: 0.0,
1944 l: 0.0,
1945 a: 1.0,
1946 };
1947 let dark_gray_bg = gpui::Hsla {
1948 h: 0.0,
1949 s: 0.0,
1950 l: 0.05,
1951 a: 1.0,
1952 };
1953
1954 // Should have poor contrast
1955 let actual_contrast = apca_contrast(black_fg, dark_gray_bg).abs();
1956 assert!(
1957 actual_contrast < 30.0,
1958 "Black on dark gray should have poor APCA contrast: {}",
1959 actual_contrast
1960 );
1961
1962 // After adjustment with minimum APCA contrast of 45, should be lighter
1963 let adjusted = ensure_minimum_contrast(black_fg, dark_gray_bg, 45.0);
1964 assert!(
1965 adjusted.l > black_fg.l,
1966 "Adjusted color should be lighter than original"
1967 );
1968 let adjusted_contrast = apca_contrast(adjusted, dark_gray_bg).abs();
1969 assert!(adjusted_contrast >= 45.0, "Should meet minimum contrast");
1970
1971 // Test case 3: Already good contrast
1972 let good_contrast = ensure_minimum_contrast(black_fg, white_fg, 45.0);
1973 assert_eq!(
1974 good_contrast, black_fg,
1975 "Good contrast should not be adjusted"
1976 );
1977 }
1978
1979 #[test]
1980 fn test_true_color_red_blue_not_washed_out_on_dark_bg() {
1981 // Red and blue have inherently low perceptual luminance in APCA.
1982 // Pure #ff0000 only achieves Lc ~35 against #1e1e1e — below the
1983 // default Lc 45 threshold. ensure_minimum_contrast would lighten
1984 // them, washing out the color. This is why cell_style skips the
1985 // adjustment for Color::Spec (24-bit true color).
1986 let dark_bg = gpui::Hsla {
1987 h: 0.0,
1988 s: 0.0,
1989 l: 0.05,
1990 a: 1.0,
1991 };
1992
1993 for (name, r, g, b) in [
1994 ("red", 225, 80, 80),
1995 ("blue", 80, 80, 225),
1996 ("pure red", 255, 0, 0),
1997 ] {
1998 let color = terminal::rgba_color(r, g, b);
1999 let contrast = apca_contrast(color, dark_bg).abs();
2000 assert!(
2001 contrast < 45.0,
2002 "{name} should have APCA < 45 on dark bg, got {contrast}",
2003 );
2004
2005 let adjusted = ensure_minimum_contrast(color, dark_bg, 45.0);
2006 assert!(
2007 adjusted.l > color.l,
2008 "{name} would be lightened by contrast adjustment (l: {} -> {})",
2009 color.l,
2010 adjusted.l,
2011 );
2012 }
2013 }
2014
2015 #[test]
2016 fn test_white_on_white_contrast_issue() {
2017 // This test reproduces the exact issue from the bug report
2018 // where white ANSI text on white background should be adjusted
2019
2020 // Simulate One Light theme colors
2021 let white_fg = gpui::Hsla {
2022 h: 0.0,
2023 s: 0.0,
2024 l: 0.98, // #fafafaff is approximately 98% lightness
2025 a: 1.0,
2026 };
2027 let white_bg = gpui::Hsla {
2028 h: 0.0,
2029 s: 0.0,
2030 l: 0.98, // Same as foreground - this is the problem!
2031 a: 1.0,
2032 };
2033
2034 // With minimum contrast of 0.0, no adjustment should happen
2035 let no_adjust = ensure_minimum_contrast(white_fg, white_bg, 0.0);
2036 assert_eq!(no_adjust, white_fg, "No adjustment with min_contrast 0.0");
2037
2038 // With minimum APCA contrast of 15, it should adjust to a darker color
2039 let adjusted = ensure_minimum_contrast(white_fg, white_bg, 15.0);
2040 assert!(
2041 adjusted.l < white_fg.l,
2042 "White on white should become darker, got l={}",
2043 adjusted.l
2044 );
2045
2046 // Verify the contrast is now acceptable
2047 let new_contrast = apca_contrast(adjusted, white_bg).abs();
2048 assert!(
2049 new_contrast >= 15.0,
2050 "Adjusted APCA contrast {} should be >= 15.0",
2051 new_contrast
2052 );
2053 }
2054
2055 #[test]
2056 fn test_batched_text_run_can_append() {
2057 let style1 = TextRun {
2058 len: 1,
2059 font: font("Helvetica"),
2060 color: Hsla::red(),
2061 ..Default::default()
2062 };
2063
2064 let style2 = TextRun {
2065 len: 1,
2066 font: font("Helvetica"),
2067 color: Hsla::red(),
2068 ..Default::default()
2069 };
2070
2071 let style3 = TextRun {
2072 len: 1,
2073 font: font("Helvetica"),
2074 color: Hsla::blue(), // Different color
2075 ..Default::default()
2076 };
2077
2078 let font_size = AbsoluteLength::Pixels(px(12.0));
2079 let batch = BatchedTextRun::new_from_char(LayoutPoint::new(0, 0), 'a', style1, font_size);
2080
2081 // Should be able to append same style
2082 assert!(batch.can_append(&style2));
2083
2084 // Should not be able to append different style
2085 assert!(!batch.can_append(&style3));
2086 }
2087
2088 #[test]
2089 fn test_batched_text_run_append() {
2090 let style = TextRun {
2091 len: 1,
2092 font: font("Helvetica"),
2093 color: Hsla::red(),
2094 ..Default::default()
2095 };
2096
2097 let font_size = AbsoluteLength::Pixels(px(12.0));
2098 let mut batch =
2099 BatchedTextRun::new_from_char(LayoutPoint::new(0, 0), 'a', style, font_size);
2100
2101 assert_eq!(batch.text, "a");
2102 assert_eq!(batch.cell_count, 1);
2103 assert_eq!(batch.style.len, 1);
2104
2105 batch.append_char('b');
2106
2107 assert_eq!(batch.text, "ab");
2108 assert_eq!(batch.cell_count, 2);
2109 assert_eq!(batch.style.len, 2);
2110
2111 batch.append_char('c');
2112
2113 assert_eq!(batch.text, "abc");
2114 assert_eq!(batch.cell_count, 3);
2115 assert_eq!(batch.style.len, 3);
2116 }
2117
2118 #[test]
2119 fn test_batched_text_run_append_char() {
2120 let style = TextRun {
2121 len: 1,
2122 font: font("Helvetica"),
2123 color: Hsla::red(),
2124 ..Default::default()
2125 };
2126
2127 let font_size = AbsoluteLength::Pixels(px(12.0));
2128 let mut batch =
2129 BatchedTextRun::new_from_char(LayoutPoint::new(0, 0), 'x', style, font_size);
2130
2131 assert_eq!(batch.text, "x");
2132 assert_eq!(batch.cell_count, 1);
2133 assert_eq!(batch.style.len, 1);
2134
2135 batch.append_char('y');
2136
2137 assert_eq!(batch.text, "xy");
2138 assert_eq!(batch.cell_count, 2);
2139 assert_eq!(batch.style.len, 2);
2140
2141 // Test with multi-byte character
2142 batch.append_char('😀');
2143
2144 assert_eq!(batch.text, "xy😀");
2145 assert_eq!(batch.cell_count, 3);
2146 assert_eq!(batch.style.len, 6); // 1 + 1 + 4 bytes for emoji
2147 }
2148
2149 #[test]
2150 fn test_batched_text_run_append_zero_width_char() {
2151 let style = TextRun {
2152 len: 1,
2153 font: font("Helvetica"),
2154 color: Hsla::red(),
2155 ..Default::default()
2156 };
2157
2158 let font_size = AbsoluteLength::Pixels(px(12.0));
2159 let mut batch =
2160 BatchedTextRun::new_from_char(LayoutPoint::new(0, 0), 'x', style, font_size);
2161
2162 let combining = '\u{0301}';
2163 batch.append_zero_width_chars(&[combining]);
2164
2165 assert_eq!(batch.text, format!("x{}", combining));
2166 assert_eq!(batch.cell_count, 1);
2167 assert_eq!(batch.style.len, 1 + combining.len_utf8());
2168 }
2169
2170 #[test]
2171 fn test_background_region_can_merge() {
2172 let color1 = Hsla::red();
2173 let color2 = Hsla::blue();
2174
2175 // Test horizontal merging
2176 let mut region1 = BackgroundRegion::new(0, 0, color1);
2177 region1.end_col = 5;
2178 let region2 = BackgroundRegion::new(0, 6, color1);
2179 assert!(region1.can_merge_with(®ion2));
2180
2181 // Test vertical merging with same column span
2182 let mut region3 = BackgroundRegion::new(0, 0, color1);
2183 region3.end_col = 5;
2184 let mut region4 = BackgroundRegion::new(1, 0, color1);
2185 region4.end_col = 5;
2186 assert!(region3.can_merge_with(®ion4));
2187
2188 // Test cannot merge different colors
2189 let region5 = BackgroundRegion::new(0, 0, color1);
2190 let region6 = BackgroundRegion::new(0, 1, color2);
2191 assert!(!region5.can_merge_with(®ion6));
2192
2193 // Test cannot merge non-adjacent regions
2194 let region7 = BackgroundRegion::new(0, 0, color1);
2195 let region8 = BackgroundRegion::new(0, 2, color1);
2196 assert!(!region7.can_merge_with(®ion8));
2197
2198 // Test cannot merge vertical regions with different column spans
2199 let mut region9 = BackgroundRegion::new(0, 0, color1);
2200 region9.end_col = 5;
2201 let mut region10 = BackgroundRegion::new(1, 0, color1);
2202 region10.end_col = 6;
2203 assert!(!region9.can_merge_with(®ion10));
2204 }
2205
2206 #[test]
2207 fn test_background_region_merge() {
2208 let color = Hsla::red();
2209
2210 // Test horizontal merge
2211 let mut region1 = BackgroundRegion::new(0, 0, color);
2212 region1.end_col = 5;
2213 let mut region2 = BackgroundRegion::new(0, 6, color);
2214 region2.end_col = 10;
2215 region1.merge_with(®ion2);
2216 assert_eq!(region1.start_col, 0);
2217 assert_eq!(region1.end_col, 10);
2218 assert_eq!(region1.start_line, 0);
2219 assert_eq!(region1.end_line, 0);
2220
2221 // Test vertical merge
2222 let mut region3 = BackgroundRegion::new(0, 0, color);
2223 region3.end_col = 5;
2224 let mut region4 = BackgroundRegion::new(1, 0, color);
2225 region4.end_col = 5;
2226 region3.merge_with(®ion4);
2227 assert_eq!(region3.start_col, 0);
2228 assert_eq!(region3.end_col, 5);
2229 assert_eq!(region3.start_line, 0);
2230 assert_eq!(region3.end_line, 1);
2231 }
2232
2233 #[test]
2234 fn test_merge_background_regions() {
2235 let color = Hsla::red();
2236
2237 // Test merging multiple adjacent regions
2238 let regions = vec![
2239 BackgroundRegion::new(0, 0, color),
2240 BackgroundRegion::new(0, 1, color),
2241 BackgroundRegion::new(0, 2, color),
2242 BackgroundRegion::new(1, 0, color),
2243 BackgroundRegion::new(1, 1, color),
2244 BackgroundRegion::new(1, 2, color),
2245 ];
2246
2247 let merged = merge_background_regions(regions);
2248 assert_eq!(merged.len(), 1);
2249 assert_eq!(merged[0].start_line, 0);
2250 assert_eq!(merged[0].end_line, 1);
2251 assert_eq!(merged[0].start_col, 0);
2252 assert_eq!(merged[0].end_col, 2);
2253
2254 // Test with non-mergeable regions
2255 let color2 = Hsla::blue();
2256 let regions2 = vec![
2257 BackgroundRegion::new(0, 0, color),
2258 BackgroundRegion::new(0, 2, color), // Gap at column 1
2259 BackgroundRegion::new(1, 0, color2), // Different color
2260 ];
2261
2262 let merged2 = merge_background_regions(regions2);
2263 assert_eq!(merged2.len(), 3);
2264 }
2265
2266 #[test]
2267 fn test_screen_position_filtering_with_positive_lines() {
2268 // Test the unified screen-position-based filtering approach.
2269 // This works for both Scrollable and Inline modes because we filter
2270 // by enumerated line group index, not by cell.point.line values.
2271 use itertools::Itertools;
2272 use terminal::{Cell, IndexedCell, Point};
2273
2274 // Create mock cells for lines 0-23 (typical terminal with 24 visible lines)
2275 let mut cells = Vec::new();
2276 for line in 0..24i32 {
2277 for col in 0..3i32 {
2278 cells.push(IndexedCell {
2279 point: Point::new(line, col as usize),
2280 cell: Cell::default(),
2281 });
2282 }
2283 }
2284
2285 // Scenario: Terminal partially scrolled above viewport
2286 // First 5 lines (0-4) are clipped, lines 5-15 should be visible
2287 let rows_above_viewport = 5usize;
2288 let visible_row_count = 11usize;
2289
2290 // Apply the same filtering logic as in the render code
2291 let filtered: Vec<_> = cells
2292 .iter()
2293 .chunk_by(|c| c.point.line)
2294 .into_iter()
2295 .skip(rows_above_viewport)
2296 .take(visible_row_count)
2297 .flat_map(|(_, line_cells)| line_cells)
2298 .collect();
2299
2300 // Should have lines 5-15 (11 lines * 3 cells each = 33 cells)
2301 assert_eq!(filtered.len(), 11 * 3, "Should have 33 cells for 11 lines");
2302
2303 // First filtered cell should be line 5
2304 assert_eq!(
2305 filtered.first().unwrap().point.line,
2306 5,
2307 "First cell should be on line 5"
2308 );
2309
2310 // Last filtered cell should be line 15
2311 assert_eq!(
2312 filtered.last().unwrap().point.line,
2313 15,
2314 "Last cell should be on line 15"
2315 );
2316 }
2317
2318 #[test]
2319 fn test_screen_position_filtering_with_negative_lines() {
2320 // This is the key test! In Scrollable mode, cells have NEGATIVE line numbers
2321 // for scrollback history. The screen-position filtering approach works because
2322 // we filter by enumerated line group index, not by cell.point.line values.
2323 use itertools::Itertools;
2324 use terminal::{Cell, IndexedCell, Point};
2325
2326 // Simulate cells from a scrolled terminal with scrollback
2327 // These have negative line numbers representing scrollback history
2328 let mut scrollback_cells = Vec::new();
2329 for line in -588i32..=-578i32 {
2330 for col in 0..80i32 {
2331 scrollback_cells.push(IndexedCell {
2332 point: Point::new(line, col as usize),
2333 cell: Cell::default(),
2334 });
2335 }
2336 }
2337
2338 // Scenario: First 3 screen rows clipped, show next 5 rows
2339 let rows_above_viewport = 3usize;
2340 let visible_row_count = 5usize;
2341
2342 // Apply the same filtering logic as in the render code
2343 let filtered: Vec<_> = scrollback_cells
2344 .iter()
2345 .chunk_by(|c| c.point.line)
2346 .into_iter()
2347 .skip(rows_above_viewport)
2348 .take(visible_row_count)
2349 .flat_map(|(_, line_cells)| line_cells)
2350 .collect();
2351
2352 // Should have 5 lines * 80 cells = 400 cells
2353 assert_eq!(filtered.len(), 5 * 80, "Should have 400 cells for 5 lines");
2354
2355 // First filtered cell should be line -585 (skipped 3 lines from -588)
2356 assert_eq!(
2357 filtered.first().unwrap().point.line,
2358 -585,
2359 "First cell should be on line -585"
2360 );
2361
2362 // Last filtered cell should be line -581 (5 lines: -585, -584, -583, -582, -581)
2363 assert_eq!(
2364 filtered.last().unwrap().point.line,
2365 -581,
2366 "Last cell should be on line -581"
2367 );
2368 }
2369
2370 #[test]
2371 fn test_screen_position_filtering_skip_all() {
2372 // Test what happens when we skip more rows than exist
2373 use itertools::Itertools;
2374 use terminal::{Cell, IndexedCell, Point};
2375
2376 let mut cells = Vec::new();
2377 for line in 0..10i32 {
2378 cells.push(IndexedCell {
2379 point: Point::new(line, 0),
2380 cell: Cell::default(),
2381 });
2382 }
2383
2384 // Skip more rows than exist
2385 let rows_above_viewport = 100usize;
2386 let visible_row_count = 5usize;
2387
2388 let filtered: Vec<_> = cells
2389 .iter()
2390 .chunk_by(|c| c.point.line)
2391 .into_iter()
2392 .skip(rows_above_viewport)
2393 .take(visible_row_count)
2394 .flat_map(|(_, line_cells)| line_cells)
2395 .collect();
2396
2397 assert_eq!(
2398 filtered.len(),
2399 0,
2400 "Should have no cells when all are skipped"
2401 );
2402 }
2403
2404 #[test]
2405 fn test_layout_grid_positioning_math() {
2406 // Test the math that layout_grid uses for positioning.
2407 // When we skip N rows, we pass N as start_line_offset to layout_grid,
2408 // which positions the first visible line at screen row N.
2409
2410 // Scenario: Terminal at y=-100px, line_height=20px
2411 // First 5 screen rows are above viewport (clipped)
2412 // So we skip 5 rows and pass offset=5 to layout_grid
2413
2414 let terminal_origin_y = -100.0f32;
2415 let line_height = 20.0f32;
2416 let rows_skipped = 5;
2417
2418 // The first visible line (at offset 5) renders at:
2419 // y = terminal_origin + offset * line_height = -100 + 5*20 = 0
2420 let first_visible_y = terminal_origin_y + rows_skipped as f32 * line_height;
2421 assert_eq!(
2422 first_visible_y, 0.0,
2423 "First visible line should be at viewport top (y=0)"
2424 );
2425
2426 // The 6th visible line (at offset 10) renders at:
2427 let sixth_visible_y = terminal_origin_y + (rows_skipped + 5) as f32 * line_height;
2428 assert_eq!(
2429 sixth_visible_y, 100.0,
2430 "6th visible line should be at y=100"
2431 );
2432 }
2433
2434 #[test]
2435 fn test_unified_filtering_works_for_both_modes() {
2436 // This test proves that the unified screen-position filtering approach
2437 // works for BOTH positive line numbers (Inline mode) and negative line
2438 // numbers (Scrollable mode with scrollback).
2439 //
2440 // The key insight: we filter by enumerated line group index (screen position),
2441 // not by cell.point.line values. This makes the filtering agnostic to the
2442 // actual line numbers in the cells.
2443 use itertools::Itertools;
2444 use terminal::Point;
2445 use terminal::{Cell, IndexedCell};
2446
2447 // Test with positive line numbers (Inline mode style)
2448 let positive_cells: Vec<_> = (0..10i32)
2449 .flat_map(|line| {
2450 (0..3i32).map(move |col| IndexedCell {
2451 point: Point::new(line, col as usize),
2452 cell: Cell::default(),
2453 })
2454 })
2455 .collect();
2456
2457 // Test with negative line numbers (Scrollable mode with scrollback)
2458 let negative_cells: Vec<_> = (-10i32..0i32)
2459 .flat_map(|line| {
2460 (0..3i32).map(move |col| IndexedCell {
2461 point: Point::new(line, col as usize),
2462 cell: Cell::default(),
2463 })
2464 })
2465 .collect();
2466
2467 let rows_to_skip = 3usize;
2468 let rows_to_take = 4usize;
2469
2470 // Filter positive cells
2471 let positive_filtered: Vec<_> = positive_cells
2472 .iter()
2473 .chunk_by(|c| c.point.line)
2474 .into_iter()
2475 .skip(rows_to_skip)
2476 .take(rows_to_take)
2477 .flat_map(|(_, cells)| cells)
2478 .collect();
2479
2480 // Filter negative cells
2481 let negative_filtered: Vec<_> = negative_cells
2482 .iter()
2483 .chunk_by(|c| c.point.line)
2484 .into_iter()
2485 .skip(rows_to_skip)
2486 .take(rows_to_take)
2487 .flat_map(|(_, cells)| cells)
2488 .collect();
2489
2490 // Both should have same count: 4 lines * 3 cells = 12
2491 assert_eq!(positive_filtered.len(), 12);
2492 assert_eq!(negative_filtered.len(), 12);
2493
2494 // Positive: lines 3, 4, 5, 6
2495 assert_eq!(positive_filtered.first().unwrap().point.line, 3);
2496 assert_eq!(positive_filtered.last().unwrap().point.line, 6);
2497
2498 // Negative: lines -7, -6, -5, -4
2499 assert_eq!(negative_filtered.first().unwrap().point.line, -7);
2500 assert_eq!(negative_filtered.last().unwrap().point.line, -4);
2501 }
2502}
2503