Vectrum
Sign In
851 lines · 34.9 KB · Rust
//! 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 std::time::{Duration, Instant};

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, RemoteRequest, 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.;
/// The screen a shell starts with, until the drawer lays it out.
pub const INITIAL_SIZE: GridSize = GridSize { columns: 80, lines: 24 };
/// How long a remote shell has to say something before it's given up on.
const REMOTE_ANSWER_TIMEOUT: Duration = Duration::from_secs(15);

/// 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<Terminal>,
    /// Why the shell didn't start.
    error: Option<SharedString>,
    /// "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<Bounds<Pixels>>>,
    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<()>,
    /// Waits out a remote shell's synchronized update.
    sync_timer: Option<Task<()>>,
    /// Gives up on a remote shell that never says anything.
    answer_timer: Option<Task<()>>,
}

impl EventEmitter<TerminalEvent> 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<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
        let cell_width = cell_width(window);
        let started = Terminal::spawn(cwd, INITIAL_SIZE, cell_pixels(cell_width))
            .map_err(|err| SharedString::from(format!("Couldn't start the shell: {err}")));
        Self::with_terminal(started, label.into(), cell_width, window, cx)
    }

    /// A terminal whose shell runs on another device, reached through
    /// `link`. The shell was asked for at [`INITIAL_SIZE`]; its output
    /// arrives in [`Self::receive`].
    pub fn remote(
        label: impl Into<SharedString>,
        link: impl Fn(RemoteRequest) + 'static,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> Self {
        let cell_width = cell_width(window);
        let started = Ok(Terminal::remote(INITIAL_SIZE, link));
        let mut view = Self::with_terminal(started, label.into(), cell_width, window, cx);
        // A shell prints its prompt at once; a device too old to start one
        // never answers.
        view.answer_timer = Some(cx.spawn(async move |this, cx| {
            cx.background_executor().timer(REMOTE_ANSWER_TIMEOUT).await;
            let _ = this.update(cx, |this, cx| {
                this.answer_timer = None;
                this.stop("The other device didn't start a shell. Update Vectrum Code there, then try again.", cx);
            });
        }));
        view
    }

    fn with_terminal(
        started: Result<(Terminal, smol::channel::Receiver<Event>), SharedString>,
        label: SharedString,
        cell_width: Pixels,
        window: &mut Window,
        cx: &mut Context<Self>,
    ) -> Self {
        let (terminal, error, events) = match started {
            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(error) => (None, Some(error), Task::ready(())),
        };
        Self {
            terminal,
            error,
            label,
            focus_handle: cx.focus_handle(),
            exited: false,
            grid_bounds: Rc::default(),
            cell_width,
            selecting: false,
            scroll_remainder: 0.,
            _events: events,
            sync_timer: None,
            answer_timer: None,
        }
    }

    /// Output from a shell on another device.
    pub fn receive(&mut self, bytes: &[u8], cx: &mut Context<Self>) {
        let Some(terminal) = self.terminal.as_mut() else { return };
        self.answer_timer = None;
        terminal.feed(bytes);
        self.arm_sync_timer(cx);
        cx.notify();
    }

    /// A synchronized update the remote shell doesn't end in time shows
    /// anyway, as a local one's does.
    fn arm_sync_timer(&mut self, cx: &mut Context<Self>) {
        if self.sync_timer.is_some() {
            return;
        }
        let Some(deadline) = self.terminal.as_ref().and_then(Terminal::sync_deadline) else { return };
        self.sync_timer = Some(cx.spawn(async move |this, cx| {
            cx.background_executor().timer(deadline.saturating_duration_since(Instant::now())).await;
            let _ = this.update(cx, |this, cx| {
                this.sync_timer = None;
                if let Some(terminal) = this.terminal.as_mut() {
                    terminal.end_sync();
                }
                // It may have been extended meanwhile.
                this.arm_sync_timer(cx);
                cx.notify();
            });
        }));
    }

    /// The shell on another device exited; without an `error`, the terminal
    /// closes as a local one would.
    pub fn remote_exited(&mut self, error: Option<String>, cx: &mut Context<Self>) {
        match error {
            Some(error) => self.stop(error, cx),
            None if !self.exited => {
                self.exited = true;
                cx.emit(TerminalEvent::Exited);
            }
            None => {}
        }
    }

    /// Stops showing the shell, saying why.
    pub fn stop(&mut self, error: impl Into<SharedString>, cx: &mut Context<Self>) {
        self.terminal = None;
        self.sync_timer = None;
        self.answer_timer = None;
        self.error = Some(error.into());
        cx.notify();
    }

    fn handle_events(&mut self, events: Vec<Event>, cx: &mut Context<Self>) {
        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<u8>, cx: &mut Context<Self>) {
        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<Self>) {
        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<String> {
        self.terminal.as_ref()?.with_term(|term| term.selection_to_string())
    }

    pub fn copy(&mut self, cx: &mut Context<Self>) {
        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<Self>) {
        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<Self>) {
        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<Self>) {
        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<Self>) {
        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<Pixels>) -> 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>) {
        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<Self>) {
        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>) {
        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<Self>) {
        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<Pixels>, cx: &mut Context<Self>) {
        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<Self>, 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<Self>)| {
            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<Hsla> {
        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<Run>,
    fills: Vec<Fill>,
}

/// 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<Row>, 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<Row> = (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<usize>, _: &mut Option<Range<usize>>, _: &mut Window, _: &mut Context<Self>) -> Option<String> {
        None
    }

    fn selected_text_range(&mut self, _: bool, _: &mut Window, _: &mut Context<Self>) -> Option<UTF16Selection> {
        Some(UTF16Selection { range: 0..0, reversed: false })
    }

    fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
        None
    }

    fn unmark_text(&mut self, _: &mut Window, _: &mut Context<Self>) {}

    /// Typed text, and what an input method commits.
    fn replace_text_in_range(&mut self, _: Option<Range<usize>>, text: &str, _: &mut Window, cx: &mut Context<Self>) {
        if !text.is_empty() {
            self.send(text.as_bytes().to_vec(), cx);
        }
    }

    fn replace_and_mark_text_in_range(
        &mut self,
        _: Option<Range<usize>>,
        _: &str,
        _: Option<Range<usize>>,
        _: &mut Window,
        _: &mut Context<Self>,
    ) {
    }

    /// Input method popups open at the cursor.
    fn bounds_for_range(&mut self, _: Range<usize>, _: Bounds<Pixels>, _: &mut Window, _: &mut Context<Self>) -> Option<Bounds<Pixels>> {
        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<Pixels>, _: &mut Window, _: &mut Context<Self>) -> Option<usize> {
        None
    }
}

impl Render for TerminalView {
    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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");
    }
}