//! 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<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<()>,
}
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 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<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");
}
}