//! One terminal of the drawer: the shell's screen drawn cell by cell in the //! mono font, its cursor and selection, scrollback on the wheel, and T3 //! Code's right-click menu (Add to chat, Copy, Paste, Select all, Jump to //! latest). Keys go to the shell as xterm sends them; typed text comes //! through an input handler, so input methods work. use std::cell::Cell; use std::ops::Range; use std::path::Path; use std::rc::Rc; use alacritty_terminal::event::Event; use alacritty_terminal::grid::{Dimensions as _, Scroll}; use alacritty_terminal::index::{Column, Line, Point as GridPoint, Side}; use alacritty_terminal::selection::{Selection, SelectionType}; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::TermMode; use alacritty_terminal::vte::ansi::{Color as AnsiColor, CursorShape, NamedColor, Rgb}; use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::terminal::{GridSize, Terminal, key_bytes}; use crate::ui::metal::{self, Palette}; use crate::{TerminalCopy, TerminalPaste}; const FONT_SIZE: f32 = 12.; const LINE_HEIGHT: f32 = 17.; /// The space between the drawer's edges and the grid. const PAD_X: f32 = 10.; const PAD_Y: f32 = 6.; /// What a terminal tells the drawer. pub enum TerminalEvent { /// The shell exited, so the terminal closes. Exited, /// "Add to chat": the selection, formatted for the composer. AddToChat(String), } pub struct TerminalView { terminal: Option, /// Why the shell didn't start. error: Option, /// "Terminal 2", for what Add to chat says the text is from. label: SharedString, focus_handle: FocusHandle, exited: bool, /// Where the grid was last laid out, for turning the pointer into cells. grid_bounds: Rc>>, cell_width: Pixels, /// A left-button drag is extending the selection. selecting: bool, /// Wheel movement short of a whole line, kept for the next event. scroll_remainder: f32, _events: Task<()>, } impl EventEmitter for TerminalView {} impl Focusable for TerminalView { fn focus_handle(&self, _: &App) -> FocusHandle { self.focus_handle.clone() } } impl TerminalView { /// Starts a shell in `cwd`. pub fn new(cwd: &Path, label: impl Into, window: &mut Window, cx: &mut Context) -> Self { let cell_width = cell_width(window); let size = GridSize { columns: 80, lines: 24 }; let (terminal, error, events) = match Terminal::spawn(cwd, size, cell_pixels(cell_width)) { Ok((terminal, events)) => { let task = cx.spawn_in(window, async move |this, cx| { while let Ok(event) = events.recv().await { let mut batch = vec![event]; while let Ok(event) = events.try_recv() { batch.push(event); } if this.update_in(cx, |this, _, cx| this.handle_events(batch, cx)).is_err() { break; } } }); (Some(terminal), None, task) } Err(err) => (None, Some(format!("Couldn't start the shell: {err}").into()), Task::ready(())), }; Self { terminal, error, label: label.into(), focus_handle: cx.focus_handle(), exited: false, grid_bounds: Rc::default(), cell_width, selecting: false, scroll_remainder: 0., _events: events, } } fn handle_events(&mut self, events: Vec, cx: &mut Context) { let Some(terminal) = self.terminal.as_ref() else { return }; for event in events { match event { Event::PtyWrite(text) => terminal.write(text.into_bytes()), Event::ClipboardStore(_, text) => cx.write_to_clipboard(ClipboardItem::new_string(text)), Event::ClipboardLoad(_, format) => { let text = cx.read_from_clipboard().and_then(|item| item.text()).unwrap_or_default(); terminal.write(format(&text).into_bytes()); } Event::ColorRequest(index, format) => { let color = Rgba::from(ansi_index(index, metal::palette(cx))); let channel = |value: f32| (value * 255.).round() as u8; let rgb = Rgb { r: channel(color.r), g: channel(color.g), b: channel(color.b) }; terminal.write(format(rgb).into_bytes()); } Event::TextAreaSizeRequest(format) => { let size = terminal.size(); let (cell_width, cell_height) = cell_pixels(self.cell_width); terminal.write( format(alacritty_terminal::event::WindowSize { num_lines: size.lines as u16, num_cols: size.columns as u16, cell_width, cell_height, }) .into_bytes(), ); } Event::Exit | Event::ChildExit(_) => { if !self.exited { self.exited = true; cx.emit(TerminalEvent::Exited); } } Event::Wakeup | Event::MouseCursorDirty | Event::CursorBlinkingChange | Event::Title(_) | Event::ResetTitle | Event::Bell => {} } } cx.notify(); } /// Sends what the user typed, back at the bottom of the scrollback. fn send(&mut self, bytes: Vec, cx: &mut Context) { let Some(terminal) = self.terminal.as_ref() else { return }; terminal.with_term(|term| { term.selection = None; term.scroll_display(Scroll::Bottom); }); terminal.write(bytes); cx.notify(); } fn key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context) { let Some(terminal) = self.terminal.as_ref() else { return }; let app_cursor = terminal.with_term(|term| term.mode().contains(TermMode::APP_CURSOR)); if let Some(bytes) = key_bytes(&event.keystroke, app_cursor) { cx.stop_propagation(); self.send(bytes, cx); } } pub fn has_selection(&self) -> bool { self.selected_text().is_some_and(|text| !text.is_empty()) } fn selected_text(&self) -> Option { self.terminal.as_ref()?.with_term(|term| term.selection_to_string()) } pub fn copy(&mut self, cx: &mut Context) { if let Some(text) = self.selected_text().filter(|text| !text.is_empty()) { cx.write_to_clipboard(ClipboardItem::new_string(text)); } } pub fn paste(&mut self, cx: &mut Context) { let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else { return }; let Some(terminal) = self.terminal.as_ref() else { return }; terminal.with_term(|term| term.scroll_display(Scroll::Bottom)); terminal.paste(&text); cx.notify(); } pub fn select_all(&mut self, cx: &mut Context) { let Some(terminal) = self.terminal.as_ref() else { return }; terminal.with_term(|term| { let start = GridPoint::new(term.topmost_line(), Column(0)); let end = GridPoint::new(term.bottommost_line(), term.last_column()); let mut selection = Selection::new(SelectionType::Simple, start, Side::Left); selection.update(end, Side::Right); term.selection = Some(selection); }); cx.notify(); } /// T3's "Jump to latest". pub fn scroll_to_bottom(&mut self, cx: &mut Context) { if let Some(terminal) = self.terminal.as_ref() { terminal.with_term(|term| term.scroll_display(Scroll::Bottom)); } cx.notify(); } /// Hands the selection to the composer, as T3's Add to chat does: the /// terminal and lines it's from, then the text. fn add_selection_to_chat(&mut self, cx: &mut Context) { let Some(terminal) = self.terminal.as_ref() else { return }; let selection = terminal.with_term(|term| { let range = term.selection.as_ref()?.to_range(term)?; let text = term.selection_to_string()?; // Lines count from the top of the scrollback, from 1. let history = term.history_size() as i32; let start = (range.start.line.0 + history + 1).max(1) as usize; let end = ((range.end.line.0 + history + 1).max(1) as usize).max(start); term.selection = None; Some((text, start, end)) }); let Some((text, start, end)) = selection else { return }; let text = text.replace("\r\n", "\n"); let text = text.trim_matches('\n'); if text.is_empty() { return; } cx.emit(TerminalEvent::AddToChat(context_block(&self.label, start, end, text))); cx.notify(); } /// The cell under a point in the window, and which half of it. fn grid_point(&self, position: Point) -> Option<(GridPoint, Side)> { let terminal = self.terminal.as_ref()?; let bounds = self.grid_bounds.get(); let size = terminal.size(); let x = (position.x - bounds.origin.x).as_f32().max(0.); let y = (position.y - bounds.origin.y).as_f32().max(0.); let cell = self.cell_width.as_f32(); let column = ((x / cell) as usize).min(size.columns.saturating_sub(1)); let row = ((y / LINE_HEIGHT) as usize).min(size.lines.saturating_sub(1)); let side = if x - column as f32 * cell > cell / 2. { Side::Right } else { Side::Left }; let offset = terminal.with_term(|term| term.grid().display_offset()) as i32; Some((GridPoint::new(Line(row as i32 - offset), Column(column)), side)) } fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context) { self.focus_handle.focus(window, cx); let Some((point, side)) = self.grid_point(event.position) else { return }; let kind = match event.click_count { 2 => SelectionType::Semantic, 3.. => SelectionType::Lines, _ => SelectionType::Simple, }; if let Some(terminal) = self.terminal.as_ref() { terminal.with_term(|term| term.selection = Some(Selection::new(kind, point, side))); } self.selecting = true; cx.notify(); } fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context) { if !self.selecting || event.pressed_button != Some(MouseButton::Left) { return; } let Some((point, side)) = self.grid_point(event.position) else { return }; if let Some(terminal) = self.terminal.as_ref() { terminal.with_term(|term| { if let Some(selection) = term.selection.as_mut() { selection.update(point, side); } }); } cx.notify(); } fn mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, cx: &mut Context) { self.selecting = false; if let Some(terminal) = self.terminal.as_ref() { terminal.with_term(|term| { if term.selection.as_ref().is_some_and(|selection| selection.is_empty()) { term.selection = None; } }); } cx.notify(); } /// The wheel scrolls the scrollback; in a full-screen program it sends /// what that program asked for: mouse wheel reports, or arrow keys. fn scroll_wheel(&mut self, event: &ScrollWheelEvent, _: &mut Window, cx: &mut Context) { let Some(terminal) = self.terminal.as_ref() else { return }; let delta = event.delta.pixel_delta(px(LINE_HEIGHT)).y.as_f32() + self.scroll_remainder; let lines = (delta / LINE_HEIGHT) as i32; self.scroll_remainder = delta - lines as f32 * LINE_HEIGHT; if lines == 0 { return; } let mode = terminal.with_term(|term| *term.mode()); if mode.intersects(TermMode::MOUSE_MODE) && mode.contains(TermMode::SGR_MOUSE) { let Some((point, _)) = self.grid_point(event.position) else { return }; let offset = terminal.with_term(|term| term.grid().display_offset()) as i32; let (column, row) = (point.column.0 + 1, point.line.0 + offset + 1); let button = if lines > 0 { 64 } else { 65 }; let report = format!("\x1b[<{button};{column};{row}M").repeat(lines.unsigned_abs() as usize); terminal.write(report.into_bytes()); } else if mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) { let arrow: &[u8] = match (lines > 0, mode.contains(TermMode::APP_CURSOR)) { (true, true) => b"\x1bOA", (true, false) => b"\x1b[A", (false, true) => b"\x1bOB", (false, false) => b"\x1b[B", }; terminal.write(arrow.repeat(lines.unsigned_abs() as usize)); } else { terminal.with_term(|term| term.scroll_display(Scroll::Delta(lines))); cx.notify(); } cx.stop_propagation(); } /// Fits the shell's screen to the space the drawer gives it. fn fit(&mut self, bounds: Bounds, cx: &mut Context) { let Some(terminal) = self.terminal.as_mut() else { return }; let size = GridSize { columns: ((bounds.size.width.as_f32() / self.cell_width.as_f32()) as usize).max(2), lines: ((bounds.size.height.as_f32() / LINE_HEIGHT) as usize).max(1), }; if size != terminal.size() { terminal.resize(size, cell_pixels(self.cell_width)); cx.notify(); } } fn context_menu(view: WeakEntity, menu: PopupMenu, cx: &App) -> PopupMenu { let selected = view.upgrade().is_some_and(|view| view.read(cx).has_selection()); let item = |label: &'static str, disabled: bool, run: fn(&mut Self, &mut Context)| { let view = view.clone(); PopupMenuItem::new(label).disabled(disabled).on_click(move |_, window, cx| { let _ = view.update(cx, |this, cx| { run(this, cx); this.focus_handle.focus(window, cx); }); }) }; menu.item(item("Add to chat", !selected, Self::add_selection_to_chat)) .item(item("Copy", !selected, Self::copy)) .item(item("Paste", false, Self::paste)) .item(item("Select all", false, Self::select_all)) .item(item("Jump to latest", false, Self::scroll_to_bottom)) } } /// What Add to chat puts in the composer. fn context_block(label: &str, start: usize, end: usize, text: &str) -> String { let range = if start == end { format!("line {start}") } else { format!("lines {start}-{end}") }; format!("{label} {range}:\n```\n{text}\n```\n") } /// The width of one cell of the mono font. fn cell_width(window: &mut Window) -> Pixels { let text_system = window.text_system(); let font_id = text_system.resolve_font(&font(metal::MONO)); text_system.advance(font_id, px(FONT_SIZE), 'm').map(|size| size.width).unwrap_or(px(FONT_SIZE * 0.6)) } fn cell_pixels(cell_width: Pixels) -> (u16, u16) { (cell_width.as_f32().round() as u16, LINE_HEIGHT as u16) } /// The 16 ANSI colors, tuned to sit on the theme's sheet: the dark set /// borrows the palette's inks, the light set is deep enough to read on bone. fn ansi16(index: usize, p: &Palette) -> Hsla { const DARK: [&str; 16] = [ "#4a4a4f", "#ff7a66", "#5fcb93", "#e7c98a", "#8fb0ff", "#ce76dc", "#25c2c3", "#d6d6d8", // "#77777c", "#ff9a8a", "#7fdcaa", "#f5d58f", "#aac4ff", "#e19aec", "#5fd7d8", "#f2f2f0", ]; const LIGHT: [&str; 16] = [ "#1b1b1d", "#c4372c", "#1d8152", "#9a6700", "#2f5fcf", "#9a3fae", "#0e7f80", "#6c6c6f", // "#57575b", "#d9473b", "#26995f", "#b07a00", "#4a74e0", "#b052c4", "#139596", "#8e8e93", ]; metal::hex(if p.dark { DARK[index] } else { LIGHT[index] }) } /// A 256-color index: the 16 ANSI colors, the 6×6×6 cube, then the grays. fn ansi_index(index: usize, p: &Palette) -> Hsla { match index { 0..16 => ansi16(index, p), 16..232 => { let index = index - 16; let level = |value: usize| if value == 0 { 0. } else { (55 + value * 40) as f32 / 255. }; Rgba { r: level(index / 36), g: level(index / 6 % 6), b: level(index % 6), a: 1. }.into() } 232..256 => { let gray = (8 + (index - 232) * 10) as f32 / 255.; Rgba { r: gray, g: gray, b: gray, a: 1. }.into() } 256 => p.ink, _ => p.s, } } fn from_rgb(rgb: Rgb) -> Hsla { Rgba { r: rgb.r as f32 / 255., g: rgb.g as f32 / 255., b: rgb.b as f32 / 255., a: 1. }.into() } /// The colors the screen is drawn with. struct Colors { foreground: Hsla, background: Hsla, cursor: Hsla, selection: Hsla, } impl Colors { fn new(p: &Palette) -> Self { Self { foreground: p.ink, background: p.s, cursor: p.ink, selection: p.blue_ink.opacity(0.3) } } /// A cell color; `None` is the default background, left unpainted. fn resolve(&self, color: AnsiColor, overrides: &alacritty_terminal::term::color::Colors, p: &Palette) -> Option { let index = match color { AnsiColor::Spec(rgb) => return Some(from_rgb(rgb)), AnsiColor::Indexed(index) => index as usize, AnsiColor::Named(NamedColor::Background) => return None, AnsiColor::Named(NamedColor::Foreground | NamedColor::BrightForeground) => return Some(self.foreground), AnsiColor::Named(NamedColor::DimForeground) => return Some(self.foreground.opacity(0.7)), AnsiColor::Named(NamedColor::Cursor) => return Some(self.cursor), AnsiColor::Named(named) => { let named = named as usize; // The dim colors follow the 16, from black up. if named >= NamedColor::DimBlack as usize { return Some(ansi16(named - NamedColor::DimBlack as usize, p).opacity(0.7)); } named } }; Some(overrides[index].map_or_else(|| ansi_index(index, p), from_rgb)) } } /// A stretch of a row drawn in one style. struct Run { column: usize, /// How many cells it covers. cells: usize, /// Whether the next cell may join it: a wide glyph ends its run, so what /// follows lines up with the grid whatever width the font gives it. open: bool, text: String, color: Hsla, bold: bool, italic: bool, underline: bool, strike: bool, } /// A stretch of a row painted in one background color. struct Fill { column: usize, width: usize, color: Hsla, } #[derive(Default)] struct Row { runs: Vec, fills: Vec, } /// The visible screen as rows of styled runs, with the cursor's place and /// shape when it shows. fn layout_screen(terminal: &Terminal, focused: bool, p: &Palette) -> (Vec, Option<(usize, usize, CursorShape)>) { let colors = Colors::new(p); terminal.with_term(|term| { let content = term.renderable_content(); let lines = term.screen_lines(); let offset = content.display_offset as i32; let mut rows: Vec = (0..lines).map(|_| Row::default()).collect(); let cursor_row = content.cursor.point.line.0 + offset; let cursor = (content.cursor.shape != CursorShape::Hidden && (0..lines as i32).contains(&cursor_row)) .then(|| (cursor_row as usize, content.cursor.point.column.0, content.cursor.shape)); let block_cursor = cursor.filter(|_| focused).filter(|(_, _, shape)| *shape == CursorShape::Block); let selection = content.selection; let overrides = content.colors; for indexed in content.display_iter { let row_ix = indexed.point.line.0 + offset; if row_ix < 0 || row_ix as usize >= lines { continue; } let cell = indexed.cell; let flags = cell.flags; if flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { continue; } let column = indexed.point.column.0; let mut foreground = colors.resolve(cell.fg, overrides, p).unwrap_or(colors.foreground); let mut background = colors.resolve(cell.bg, overrides, p); if flags.contains(Flags::BOLD) { // Bold ANSI colors brighten, as most terminals draw them. if let AnsiColor::Named(named) = cell.fg { if (named as usize) < 8 { foreground = ansi16(named as usize + 8, p); } } } if flags.contains(Flags::DIM) { foreground = foreground.opacity(0.7); } if flags.contains(Flags::INVERSE) { let back = background.unwrap_or(colors.background); background = Some(foreground); foreground = back; } if selection.is_some_and(|range| range.contains(indexed.point)) { background = Some(colors.selection); } if block_cursor.is_some_and(|(row, col, _)| row == row_ix as usize && col == column) { foreground = background.unwrap_or(colors.background); background = Some(colors.cursor); } let width = if flags.contains(Flags::WIDE_CHAR) { 2 } else { 1 }; let row = &mut rows[row_ix as usize]; if let Some(color) = background { match row.fills.last_mut() { Some(fill) if fill.color == color && fill.column + fill.width == column => fill.width += width, _ => row.fills.push(Fill { column, width, color }), } } let mut c = if flags.contains(Flags::HIDDEN) { ' ' } else { cell.c }; if c == '\t' { c = ' '; } let bold = flags.contains(Flags::BOLD); let italic = flags.contains(Flags::ITALIC); let underline = flags.intersects(Flags::ALL_UNDERLINES); let strike = flags.contains(Flags::STRIKEOUT); let continues = row.runs.last().is_some_and(|run| { run.open && run.color == foreground && run.bold == bold && run.italic == italic && run.underline == underline && run.strike == strike && run.column + run.cells == column }); let mut text = String::from(c); if let Some(zerowidth) = cell.zerowidth() { text.extend(zerowidth); } match row.runs.last_mut() { Some(run) if continues && width == 1 => { run.text.push_str(&text); run.cells += 1; } _ => row.runs.push(Run { column, cells: width, open: width == 1, text, color: foreground, bold, italic, underline, strike, }), } } for row in &mut rows { // Trailing blanks draw nothing. if let Some(run) = row.runs.last_mut().filter(|run| !run.underline && !run.strike) { let trimmed = run.text.trim_end_matches(' ').len(); run.text.truncate(trimmed); } row.runs.retain(|run| !run.text.is_empty()); } (rows, cursor.filter(|_| !focused || block_cursor.is_none())) }) } impl EntityInputHandler for TerminalView { fn text_for_range(&mut self, _: Range, _: &mut Option>, _: &mut Window, _: &mut Context) -> Option { None } fn selected_text_range(&mut self, _: bool, _: &mut Window, _: &mut Context) -> Option { Some(UTF16Selection { range: 0..0, reversed: false }) } fn marked_text_range(&self, _: &mut Window, _: &mut Context) -> Option> { None } fn unmark_text(&mut self, _: &mut Window, _: &mut Context) {} /// Typed text, and what an input method commits. fn replace_text_in_range(&mut self, _: Option>, text: &str, _: &mut Window, cx: &mut Context) { if !text.is_empty() { self.send(text.as_bytes().to_vec(), cx); } } fn replace_and_mark_text_in_range( &mut self, _: Option>, _: &str, _: Option>, _: &mut Window, _: &mut Context, ) { } /// Input method popups open at the cursor. fn bounds_for_range(&mut self, _: Range, _: Bounds, _: &mut Window, _: &mut Context) -> Option> { let terminal = self.terminal.as_ref()?; let (line, column) = terminal.with_term(|term| { let point = term.grid().cursor.point; (point.line.0 + term.grid().display_offset() as i32, point.column.0) }); let origin = self.grid_bounds.get().origin; Some(Bounds::new( point(origin.x + self.cell_width * column as f32, origin.y + px(LINE_HEIGHT * line as f32)), size(self.cell_width, px(LINE_HEIGHT)), )) } fn character_index_for_point(&mut self, _: Point, _: &mut Window, _: &mut Context) -> Option { None } } impl Render for TerminalView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let p = metal::palette(cx); let focused = self.focus_handle.is_focused(window); let cell = self.cell_width; let line = px(LINE_HEIGHT); let view = cx.entity(); let weak = cx.weak_entity(); let grid_bounds = self.grid_bounds.clone(); let focus_handle = self.focus_handle.clone(); let screen = self.terminal.as_ref().map(|terminal| layout_screen(terminal, focused, p)); let grid = screen.map(|(rows, cursor)| { let colors = Colors::new(p); let cursor = cursor.map(|(row, column, shape)| { let base = div().absolute().left(cell * column as f32).top(line * row as f32); match (focused, shape) { (true, CursorShape::Beam) => base.w(px(2.)).h(line).bg(colors.cursor), (true, CursorShape::Underline) => base.mt(line - px(2.)).w(cell).h(px(2.)).bg(colors.cursor), _ => base.w(cell).h(line).border_1().border_color(colors.cursor.opacity(0.7)), } }); div() .relative() .size_full() .children(rows.into_iter().enumerate().map(|(ix, row)| { div() .absolute() .left_0() .right_0() .top(line * ix as f32) .h(line) .children(row.fills.into_iter().map(|fill| { div() .absolute() .top_0() .h(line) .left(cell * fill.column as f32) .w(cell * fill.width as f32) .bg(fill.color) })) .children(row.runs.into_iter().map(|run| { div() .absolute() .top_0() .left(cell * run.column as f32) .whitespace_nowrap() .text_color(run.color) .when(run.bold, |this| this.font_weight(FontWeight::BOLD)) .when(run.italic, |this| this.italic()) .when(run.underline, |this| this.underline()) .when(run.strike, |this| this.line_through()) .child(run.text) })) })) .children(cursor) }); div() .id("terminal") .key_context("Terminal") .track_focus(&self.focus_handle) .size_full() .overflow_hidden() .px(px(PAD_X)) .py(px(PAD_Y)) .cursor_text() .on_key_down(cx.listener(Self::key_down)) .on_action(cx.listener(|this, _: &TerminalCopy, _, cx| this.copy(cx))) .on_action(cx.listener(|this, _: &TerminalPaste, _, cx| this.paste(cx))) .on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down)) .on_mouse_move(cx.listener(Self::mouse_move)) .on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up)) .on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up)) .on_scroll_wheel(cx.listener(Self::scroll_wheel)) .child( // The text style stays in here, out of the right-click menu's way. div() .relative() .size_full() .font_family(metal::MONO) .text_size(px(FONT_SIZE)) .line_height(line) .text_color(p.ink) .child( canvas( move |bounds, _, cx| { grid_bounds.set(bounds); view.update(cx, |this, cx| this.fit(bounds, cx)); }, move |bounds, _, window, cx| { if let Some(view) = weak.upgrade() { window.handle_input(&focus_handle, ElementInputHandler::new(bounds, view), cx); } }, ) .absolute() .size_full(), ) .children(grid) .when_some(self.error.clone(), |this, error| this.child(div().text_color(p.red).child(error))), ) .context_menu({ let view = cx.weak_entity(); move |menu, _, cx| Self::context_menu(view.clone(), menu, cx) }) } } #[cfg(test)] mod tests { use super::context_block; #[test] fn add_to_chat_names_the_lines() { assert_eq!(context_block("Terminal 1", 3, 3, "ls"), "Terminal 1 line 3:\n```\nls\n```\n"); assert_eq!(context_block("Terminal 2", 4, 6, "a\nb"), "Terminal 2 lines 4-6:\n```\na\nb\n```\n"); } }