Files
shell-rs/src/terminal/terminal_view.rs
T
XuzandClaude Opus 5.5 5ca6e53366 设置 › 终端新增「终端交互」:选中复制、右键行为
「选中复制」打开后,鼠标选中的文字在松开时进入系统剪贴板,单击没选中东西时剪贴板不变;
默认关闭。「右键行为」可选无动作、粘贴、显示菜单,默认显示菜单;右键从不交给要求鼠标的
程序。设置存在 settings.json 的 terminal_interaction,终端经全局量 TerminalInteraction
读取,改动立即作用于已打开的终端。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:24:04 +08:00

2218 lines
76 KiB
Rust

use std::ops::Range;
use std::rc::Rc;
use std::time::Duration;
use alacritty_terminal::index::{Column, Line, Point as TerminalPoint, Side};
use alacritty_terminal::selection::SelectionType;
use alacritty_terminal::term::TermMode;
use alacritty_terminal::term::cell::Flags;
use alacritty_terminal::term::color::COUNT;
use alacritty_terminal::vte::ansi::{Color, CursorShape, NamedColor, Rgb};
use gpui_kit::component::{
ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex,
input::{Input, InputEvent, InputState},
menu::PopupMenu,
scroll::Scrollbar,
tooltip::Tooltip,
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use unicode_width::UnicodeWidthChar as _;
use crate::app::{
CatalogIcon, ClearTerminal, CopyTerminal, DismissTerminalFind, FindInTerminal,
FindNextInTerminal, FindPreviousInTerminal, PasteTerminal,
};
use crate::connection::{ConnectionPromptReply, Latency};
use crate::i18n::{UiLocale, t};
use super::mouse::{self, MouseReport, ReportButton, ReportKind};
use super::scrollbar::ScrollbackHandle;
use super::search::SearchMark;
use super::{
RightClick, SearchDirection, SharedTerminalTransportFactory, TerminalColors, TerminalEngine,
TerminalEvent, TerminalFont, TerminalInteraction, TerminalLifecycle, TerminalSize,
TerminalSnapshot, TerminalStatus, TerminalTheme,
};
pub const TERMINAL_KEY_CONTEXT: &str = "Terminal";
/// Key context of a terminal's find bar.
pub const TERMINAL_FIND_KEY_CONTEXT: &str = "TerminalFind";
/// How long typing in the find bar pauses before the scrollback is searched.
/// A full search of a long scrollback takes a frame or more, so it waits for
/// the query to settle rather than running on every keystroke.
const FIND_DEBOUNCE: Duration = Duration::from_millis(80);
/// How long the pointer rests on a link before its address shows.
const LINK_TOOLTIP_DELAY: Duration = Duration::from_millis(500);
/// The key held to open a link with a click, as the tooltip names it.
#[cfg(target_os = "macos")]
const OPEN_LINK_KEY: &str = "⌘";
#[cfg(not(target_os = "macos"))]
const OPEN_LINK_KEY: &str = "Ctrl";
/// Whether `modifiers` hold the key that opens a link: ⌘, or Ctrl where
/// there is no ⌘. A plain click selects text, as it always has.
fn opens_links(modifiers: &Modifiers) -> bool {
if cfg!(target_os = "macos") {
modifiers.platform
} else {
modifiers.control
}
}
/// What a terminal is called for screen readers. Asked for each time it is
/// drawn, so it follows the interface language.
type TerminalLabel = Rc<dyn Fn(&App) -> SharedString>;
/// The commands a terminal's owner adds to the bottom of its context menu,
/// built from the terminal's lifecycle when the menu opens.
pub type TerminalMenuItems = Rc<dyn Fn(PopupMenu, &TerminalLifecycle) -> PopupMenu>;
/// The open find bar of one terminal.
struct FindBar {
input: Entity<InputState>,
/// The search waiting for typing to pause, if any.
pending: Option<Task<()>>,
_subscription: Subscription,
}
gpui_kit::actions!(shellrs_terminal, [SendTab, SendBackTab]);
pub(crate) fn terminal_key_bindings() -> [KeyBinding; 2] {
[
KeyBinding::new("tab", SendTab, Some(TERMINAL_KEY_CONTEXT)),
KeyBinding::new("shift-tab", SendBackTab, Some(TERMINAL_KEY_CONTEXT)),
]
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ScrollTarget {
History,
AlternateScreen,
/// The program asked for the mouse: the wheel is reported to it.
Report,
}
#[derive(Clone, Copy, Debug, Default)]
struct ScrollAccumulator {
fractional_lines: f32,
target: Option<ScrollTarget>,
}
impl ScrollAccumulator {
fn reset(&mut self) {
self.fractional_lines = 0.;
self.target = None;
}
fn consume(&mut self, delta: ScrollDelta, line_height: Pixels, target: ScrollTarget) -> i32 {
if self.target != Some(target) {
self.fractional_lines = 0.;
self.target = Some(target);
}
match delta {
ScrollDelta::Lines(delta) => {
self.fractional_lines = 0.;
delta.y.round() as i32
}
ScrollDelta::Pixels(delta) => {
self.fractional_lines += delta.y.as_f32() / line_height.as_f32().max(1.);
let complete_lines = self.fractional_lines.trunc() as i32;
self.fractional_lines -= complete_lines as f32;
complete_lines.clamp(-12, 12)
}
}
}
}
/// Where the grid sits on screen and how large one cell of it is.
#[derive(Clone, Copy, Debug)]
struct TerminalGeometry {
bounds: Bounds<Pixels>,
cell_width: Pixels,
line_height: Pixels,
}
impl TerminalGeometry {
fn cell_bounds(&self, column: usize, row: usize, width: usize) -> Bounds<Pixels> {
Bounds::new(
point(
self.bounds.left() + self.cell_width * column as f32,
self.bounds.top() + self.line_height * row as f32,
),
size(self.cell_width * width as f32, self.line_height),
)
}
}
#[derive(Clone, Copy, Debug)]
struct TerminalViewport {
geometry: TerminalGeometry,
columns: usize,
rows: usize,
display_offset: usize,
cursor_bounds: Bounds<Pixels>,
}
impl TerminalViewport {
/// The cell on screen under `position`, if it is over the grid.
fn cell_at(&self, position: gpui_kit::Point<Pixels>) -> Option<(usize, usize)> {
screen_cell(&self.geometry, self.columns, self.rows, position)
}
/// The cell on screen nearest `position`, which may be outside: where a
/// drag that left the terminal is reported.
fn nearest_cell(&self, position: gpui_kit::Point<Pixels>) -> (usize, usize) {
let (point, _) = self.grid_point(position);
(
point.column.0,
(point.line.0 + self.display_offset as i32).max(0) as usize,
)
}
fn grid_point(&self, position: gpui_kit::Point<Pixels>) -> (TerminalPoint, Side) {
grid_point(
position,
self.geometry.bounds,
self.geometry.cell_width,
self.geometry.line_height,
self.display_offset,
self.columns,
self.rows,
)
}
}
/// The link under the pointer, and where the pointer is.
#[derive(Clone, Debug, PartialEq)]
struct HoveredLink {
/// Which link of the snapshot, as the cells number them.
index: usize,
uri: SharedString,
position: gpui_kit::Point<Pixels>,
}
pub struct TerminalView {
engine: Entity<TerminalEngine>,
element_id: ElementId,
label: TerminalLabel,
focus_handle: FocusHandle,
marked_text: String,
marked_selection: Range<usize>,
viewport: Option<TerminalViewport>,
ime_cursor_bounds: Option<Bounds<Pixels>>,
requested_size: TerminalSize,
/// A selection drag is under way.
selecting: bool,
scroll_accumulator: ScrollAccumulator,
context_menu: Option<(Entity<PopupMenu>, gpui_kit::Point<Pixels>)>,
context_menu_subscription: Option<Subscription>,
menu_items: Option<TerminalMenuItems>,
find: Option<FindBar>,
cursor_visible: bool,
focused: bool,
hovered_link: Option<HoveredLink>,
/// The hovered link's address shows, the pointer having rested on it.
link_tooltip: bool,
link_tooltip_task: Option<Task<()>>,
/// The button the program was told went down, until it hears it go up.
reported_button: Option<ReportButton>,
/// The cell the program last heard of: motion is reported once a cell.
reported_cell: Option<(usize, usize)>,
_subscriptions: Vec<Subscription>,
_blink_task: Task<()>,
}
impl TerminalView {
pub fn new(
element_id: impl Into<ElementId>,
label: impl Fn(&App) -> SharedString + 'static,
factory: SharedTerminalTransportFactory,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let engine = cx.new(|cx| TerminalEngine::new(factory, cx));
let focus_handle = cx.focus_handle();
let subscriptions = vec![
cx.observe(&engine, |_, _, cx| cx.notify()),
// The panel hears the engine's events through the view.
cx.subscribe(&engine, |_, _, event: &TerminalEvent, cx| {
cx.emit(event.clone())
}),
cx.on_focus(&focus_handle, window, |this, _, cx| {
this.focused = true;
this.cursor_visible = true;
this.engine.read(cx).set_focused(true);
cx.notify();
}),
cx.on_blur(&focus_handle, window, |this, _, cx| {
this.focused = false;
this.cursor_visible = false;
this.selecting = false;
this.engine.read(cx).set_focused(false);
cx.notify();
}),
// An open find bar's placeholder, in the new language.
cx.observe_global_in::<UiLocale>(window, |this, window, cx| {
if let Some(find) = &this.find {
find.input.update(cx, |input, cx| {
input.set_placeholder(t!("terminal.find.placeholder"), window, cx)
});
}
}),
];
let blink_task = cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(Duration::from_millis(500))
.await;
if this
.update(cx, |this, cx| {
if this.focused && this.engine.read(cx).cursor_blinking() {
this.cursor_visible = !this.cursor_visible;
cx.notify();
} else if this.focused && !this.cursor_visible {
this.cursor_visible = true;
cx.notify();
}
})
.is_err()
{
break;
}
}
});
Self {
engine,
element_id: element_id.into(),
label: Rc::new(label),
focus_handle,
marked_text: String::new(),
marked_selection: 0..0,
viewport: None,
ime_cursor_bounds: None,
requested_size: TerminalSize::DEFAULT,
selecting: false,
scroll_accumulator: ScrollAccumulator::default(),
context_menu: None,
context_menu_subscription: None,
menu_items: None,
find: None,
cursor_visible: true,
focused: false,
hovered_link: None,
link_tooltip: false,
link_tooltip_task: None,
reported_button: None,
reported_cell: None,
_subscriptions: subscriptions,
_blink_task: blink_task,
}
}
pub fn focus(&self, window: &mut Window, cx: &mut App) {
window.focus(&self.focus_handle, cx);
}
pub fn focus_handle(&self) -> FocusHandle {
self.focus_handle.clone()
}
pub fn restart(&mut self, cx: &mut Context<Self>) {
self.reset_interaction();
self.engine.update(cx, |engine, cx| engine.restart(cx));
}
pub fn restart_with_factory(
&mut self,
factory: SharedTerminalTransportFactory,
cx: &mut Context<Self>,
) {
self.reset_interaction();
self.engine
.update(cx, |engine, cx| engine.restart_with_factory(factory, cx));
}
pub fn reply_to_prompt(&self, request_id: u64, reply: ConnectionPromptReply, cx: &App) {
self.engine.read(cx).reply_to_prompt(request_id, reply);
}
/// Forget everything in progress on the screen that is going away: IME
/// composition, a selection drag, the context menu and the find bar.
fn reset_interaction(&mut self) {
self.marked_text.clear();
self.marked_selection = 0..0;
self.ime_cursor_bounds = None;
self.selecting = false;
self.scroll_accumulator.reset();
self.context_menu = None;
self.context_menu_subscription = None;
self.find = None;
self.cursor_visible = true;
self.hovered_link = None;
self.link_tooltip = false;
self.link_tooltip_task = None;
self.reported_button = None;
self.reported_cell = None;
}
pub fn shutdown(&mut self, cx: &mut Context<Self>) {
self.reset_interaction();
self.engine.update(cx, |engine, cx| engine.shutdown(cx));
}
pub fn stop(&mut self, message: &str, cx: &mut Context<Self>) {
self.engine
.update(cx, |engine, cx| engine.stop(message, cx));
}
pub fn status(&self, cx: &App) -> TerminalStatus {
self.engine.read(cx).status()
}
pub fn lifecycle(&self, cx: &App) -> TerminalLifecycle {
self.engine.read(cx).lifecycle().clone()
}
pub fn title(&self, cx: &App) -> Option<String> {
self.engine.read(cx).title().map(str::to_owned)
}
pub fn latency(&self, cx: &App) -> Option<Latency> {
self.engine.read(cx).latency()
}
/// Which connection the terminal is on: counts up with every reconnect.
pub fn generation(&self, cx: &App) -> u64 {
self.engine.read(cx).generation()
}
/// Run `command` beside the shell, on this terminal's own connection;
/// see [`super::ExecRequest`]. `None` while the shell is not running.
pub fn exec(
&self,
command: String,
cx: &App,
) -> Option<std::sync::mpsc::Receiver<super::ExecResult>> {
self.engine.read(cx).exec(command)
}
/// Take up an `exec --terminal` run; see [`TerminalEngine::run_command`].
pub fn run_command(&self, run: super::RunHandle, cx: &App) -> Result<(), super::RunFailure> {
self.engine.read(cx).run_command(run)
}
pub fn screen_text(&self, cx: &App) -> String {
self.engine.read(cx).snapshot().visible_text()
}
pub fn copy_selection(&self, cx: &mut App) {
if let Some(text) = self
.engine
.read(cx)
.selection_text()
.filter(|text| !text.is_empty())
{
cx.write_to_clipboard(ClipboardItem::new_string(text));
}
}
pub fn paste_clipboard(&mut self, cx: &mut Context<Self>) {
if !self.engine.read(cx).lifecycle().accepts_input() {
return;
}
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
return;
};
self.engine.read(cx).paste(&text);
self.selecting = false;
self.scroll_accumulator.reset();
self.cursor_visible = true;
cx.notify();
}
/// Put `command` on the shell's input line, with `replace` in place of
/// what is typed there, and with `run` run it; see
/// [`TerminalEngine::enter_command`].
pub fn enter_command(
&mut self,
command: &str,
run: bool,
replace: bool,
cx: &mut Context<Self>,
) -> Result<(), SharedString> {
self.engine.read(cx).enter_command(command, run, replace)?;
self.selecting = false;
self.scroll_accumulator.reset();
self.cursor_visible = true;
cx.notify();
Ok(())
}
/// Add the owner's commands below the terminal's own in the context menu.
pub fn set_menu_items(&mut self, items: TerminalMenuItems) {
self.menu_items = Some(items);
}
/// Clear the screen and the scrollback, keeping the prompt line.
pub fn clear(&mut self, cx: &mut Context<Self>) {
self.selecting = false;
self.scroll_accumulator.reset();
self.engine
.update(cx, |engine, cx| engine.clear_keeping_prompt(cx));
}
/// Open the find bar and put the cursor in it. An open bar selects its
/// query again. A selection, when there is one, becomes the query.
pub fn open_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let selection = self
.engine
.read(cx)
.selection_text()
.filter(|text| !text.is_empty() && !text.contains('\n'));
let input = match &self.find {
Some(find) => find.input.clone(),
None => {
let input = cx.new(|cx| {
InputState::new(window, cx).placeholder(t!("terminal.find.placeholder"))
});
let subscription = cx.subscribe_in(
&input,
window,
|this, _, event: &InputEvent, _, cx| match event {
InputEvent::Change => this.schedule_find(cx),
InputEvent::PressEnter { shift, .. } => {
let direction = if *shift {
SearchDirection::Up
} else {
SearchDirection::Down
};
this.step_find(direction, cx);
}
_ => {}
},
);
self.find = Some(FindBar {
input: input.clone(),
pending: None,
_subscription: subscription,
});
input
}
};
if let Some(query) = selection {
input.update(cx, |input, cx| input.set_value(query, window, cx));
self.run_find(cx);
}
input.update(cx, |input, cx| {
input.focus(window, cx);
input.select_all(window, cx);
});
cx.notify();
}
/// Focus the next match up or down. A query still being typed is searched
/// first, and that search picks the match to focus.
pub fn step_find(&mut self, direction: SearchDirection, cx: &mut Context<Self>) {
let Some(find) = &mut self.find else {
return;
};
if find.pending.take().is_some() {
self.run_find(cx);
return;
}
self.engine
.update(cx, |engine, cx| engine.step_search(direction, cx));
}
/// Close the find bar, drop its highlights and hand the keyboard back to
/// the terminal.
pub fn dismiss_find(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.find.take().is_none() {
return;
}
self.engine.update(cx, |engine, cx| engine.clear_search(cx));
self.focus(window, cx);
cx.notify();
}
fn schedule_find(&mut self, cx: &mut Context<Self>) {
let Some(find) = &mut self.find else {
return;
};
find.pending = Some(cx.spawn(async move |this, cx| {
cx.background_executor().timer(FIND_DEBOUNCE).await;
_ = this.update(cx, |this, cx| this.run_find(cx));
}));
}
fn run_find(&mut self, cx: &mut Context<Self>) {
let Some(find) = &mut self.find else {
return;
};
find.pending = None;
let query = find.input.read(cx).value();
self.engine
.update(cx, |engine, cx| engine.set_search_query(&query, cx));
}
fn on_key_down(&mut self, event: &KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) {
let mode = self.engine.read(cx).mode();
if let Some(bytes) = encode_key(&event.keystroke, mode) {
self.send_user_input(bytes, cx);
cx.stop_propagation();
}
}
fn resize(&mut self, size: TerminalSize, cx: &mut Context<Self>) {
if size == self.requested_size {
return;
}
self.requested_size = size;
self.scroll_accumulator.reset();
self.engine.update(cx, |engine, cx| engine.resize(size, cx));
}
fn scroll(
&mut self,
delta: ScrollDelta,
line_height: Pixels,
cell: (usize, usize),
modifiers: Modifiers,
cx: &mut Context<Self>,
) {
let mode = self.engine.read(cx).mode();
// Shift scrolls the history even while the program has the mouse.
let target = if mode.intersects(TermMode::MOUSE_MODE) && !modifiers.shift {
ScrollTarget::Report
} else if mode.contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL) {
ScrollTarget::AlternateScreen
} else {
ScrollTarget::History
};
let lines = self.scroll_accumulator.consume(delta, line_height, target);
if lines == 0 {
return;
}
// The text under the pointer moves on.
self.hover_link(None, cx);
if target == ScrollTarget::Report {
let button = if lines > 0 {
ReportButton::WheelUp
} else {
ReportButton::WheelDown
};
for _ in 0..lines.unsigned_abs().min(12) {
self.report_mouse(button, ReportKind::Press, cell, modifiers, cx);
}
} else if target == ScrollTarget::AlternateScreen {
let sequence = if lines > 0 { b"\x1bOA" } else { b"\x1bOB" };
for _ in 0..lines.unsigned_abs().min(12) {
self.engine.read(cx).write(sequence.to_vec());
}
} else {
self.engine
.update(cx, |engine, cx| engine.scroll(lines, cx));
}
}
fn start_selection(
&mut self,
point: TerminalPoint,
side: Side,
selection_type: SelectionType,
cx: &mut Context<Self>,
) {
self.selecting = true;
self.engine.update(cx, |engine, cx| {
engine.start_selection(point, side, selection_type, cx)
});
}
fn update_selection(&mut self, point: TerminalPoint, side: Side, cx: &mut Context<Self>) {
self.engine
.update(cx, |engine, cx| engine.update_selection(point, side, cx));
}
/// The mouse let go of a selection. With 选中复制 on, what it took in
/// goes to the clipboard; a click that selected nothing leaves it be.
fn finish_selection(&mut self, cx: &mut Context<Self>) {
self.selecting = false;
if TerminalInteraction::current(cx).copy_on_select {
self.copy_selection(cx);
}
}
/// Tell the program of the mouse, if it asked to hear of it. Shift keeps
/// the mouse for selecting text, unless a reported button is down.
/// Whether the program was told.
fn report_mouse(
&mut self,
button: ReportButton,
kind: ReportKind,
cell: (usize, usize),
modifiers: Modifiers,
cx: &mut Context<Self>,
) -> bool {
let mode = self.engine.read(cx).mode();
let held = self.reported_button.is_some();
if (modifiers.shift && !held) || !mouse::wants(mode, kind, held) {
return false;
}
let button = match kind {
ReportKind::Motion if self.reported_cell == Some(cell) => return true,
ReportKind::Motion => self.reported_button.unwrap_or(ReportButton::None),
_ => button,
};
let report = MouseReport {
button,
kind,
column: cell.0,
row: cell.1,
alt: modifiers.alt,
control: modifiers.control,
};
if let Some(bytes) = mouse::encode(report, mode) {
self.engine.read(cx).write(bytes);
}
self.reported_cell = Some(cell);
if kind == ReportKind::Press && matches!(button, ReportButton::Left | ReportButton::Middle)
{
self.reported_button = Some(button);
}
true
}
/// A button went up: the program hears of it if it heard it go down.
fn release_mouse(
&mut self,
button: ReportButton,
cell: (usize, usize),
modifiers: Modifiers,
cx: &mut Context<Self>,
) {
if self.reported_button == Some(button) {
self.report_mouse(button, ReportKind::Release, cell, modifiers, cx);
self.reported_button = None;
}
}
/// The pointer moved onto a link or off one. Its address shows once the
/// pointer rests there.
fn hover_link(&mut self, link: Option<HoveredLink>, cx: &mut Context<Self>) {
let same = match (&self.hovered_link, &link) {
(Some(hovered), Some(link)) => hovered.index == link.index && hovered.uri == link.uri,
(None, None) => true,
_ => false,
};
if same {
return;
}
self.link_tooltip = false;
self.link_tooltip_task = link.as_ref().map(|_| {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(LINK_TOOLTIP_DELAY).await;
let _ = this.update(cx, |this, cx| {
this.link_tooltip = true;
cx.notify();
});
})
});
self.hovered_link = link;
cx.notify();
}
fn send_user_input(&mut self, bytes: impl Into<Vec<u8>>, cx: &mut Context<Self>) {
self.hover_link(None, cx);
self.selecting = false;
self.scroll_accumulator.reset();
self.cursor_visible = true;
self.engine.read(cx).send_user_input(bytes);
cx.notify();
}
fn prepare_for_user_input(&mut self, cx: &mut Context<Self>) {
self.selecting = false;
self.scroll_accumulator.reset();
self.cursor_visible = true;
self.engine.read(cx).prepare_for_user_input();
}
/// Right-clicking does what 右键行为 says.
fn right_click(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
match TerminalInteraction::current(cx).right_click {
RightClick::Nothing => {}
RightClick::Paste => {
self.focus(window, cx);
self.paste_clipboard(cx);
}
RightClick::Menu => self.open_context_menu(event, window, cx),
}
}
fn open_context_menu(
&mut self,
event: &MouseDownEvent,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.focus(window, cx);
let engine = self.engine.read(cx);
let has_selection = engine.has_selection();
let lifecycle = engine.lifecycle().clone();
let can_clear = engine.accepts_clear();
let can_paste = lifecycle.accepts_input()
&& cx
.read_from_clipboard()
.and_then(|item| item.text())
.is_some();
let action_context = self.focus_handle.clone();
let owner_items = self.menu_items.clone();
let menu = PopupMenu::build(window, cx, move |menu, _, _| {
let menu = menu
.action_context(action_context)
.menu_with_icon_and_disabled(
t!("terminal.menu.copy"),
Icon::new(IconName::Copy),
Box::new(CopyTerminal),
!has_selection,
)
.menu_with_icon_and_disabled(
t!("terminal.menu.paste"),
Icon::new(CatalogIcon::ClipboardPaste),
Box::new(PasteTerminal),
!can_paste,
)
.separator()
.menu_with_icon(
t!("terminal.menu.find"),
Icon::new(IconName::Search),
Box::new(FindInTerminal),
)
.menu_with_icon_and_disabled(
t!("terminal.menu.clear"),
Icon::new(CatalogIcon::Eraser),
Box::new(ClearTerminal),
!can_clear,
);
match &owner_items {
Some(items) => items(menu.separator(), &lifecycle),
None => menu,
}
});
let terminal = cx.weak_entity();
let subscription = window.subscribe(&menu, cx, move |_, _: &DismissEvent, _, cx| {
_ = terminal.update(cx, |terminal, cx| {
terminal.context_menu = None;
terminal.context_menu_subscription = None;
cx.notify();
});
});
menu.focus_handle(cx).focus(window, cx);
self.context_menu = Some((menu, event.position));
self.context_menu_subscription = Some(subscription);
cx.notify();
}
fn set_viewport(&mut self, viewport: TerminalViewport, ime_cursor_bounds: Bounds<Pixels>) {
self.viewport = Some(viewport);
self.ime_cursor_bounds = Some(ime_cursor_bounds);
}
}
impl Focusable for TerminalView {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl EventEmitter<TerminalEvent> for TerminalView {}
impl EntityInputHandler for TerminalView {
fn text_for_range(
&mut self,
_: Range<usize>,
adjusted_range: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
let length = self.marked_text.encode_utf16().count();
adjusted_range.replace(0..length);
Some(self.marked_text.clone())
}
fn selected_text_range(
&mut self,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: self.marked_selection.clone(),
reversed: false,
})
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
(!self.marked_text.is_empty()).then(|| 0..self.marked_text.encode_utf16().count())
}
fn unmark_text(&mut self, _: &mut Window, cx: &mut Context<Self>) {
self.marked_text.clear();
self.marked_selection = 0..0;
cx.notify();
}
fn paste(&mut self, item: ClipboardItem, _: &mut Window, cx: &mut Context<Self>) {
if let Some(text) = item.text() {
self.engine.read(cx).paste(&text);
self.selecting = false;
self.scroll_accumulator.reset();
cx.notify();
}
}
fn replace_text_in_range(
&mut self,
_: Option<Range<usize>>,
text: &str,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.marked_text.clear();
self.marked_selection = 0..0;
self.send_user_input(text.as_bytes().to_vec(), cx);
}
fn replace_and_mark_text_in_range(
&mut self,
_: Option<Range<usize>>,
new_text: &str,
new_selected_range: Option<Range<usize>>,
_: &mut Window,
cx: &mut Context<Self>,
) {
self.prepare_for_user_input(cx);
self.marked_text = new_text.to_owned();
let length = new_text.encode_utf16().count();
self.marked_selection = new_selected_range.unwrap_or(length..length);
cx.notify();
}
fn bounds_for_range(
&mut self,
_: Range<usize>,
_: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
self.ime_cursor_bounds
.or_else(|| self.viewport.map(|viewport| viewport.cursor_bounds))
}
fn character_index_for_point(
&mut self,
_: gpui_kit::Point<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
Some(self.marked_selection.end)
}
fn text_length_utf16(&mut self, _: &mut Window, _: &mut Context<Self>) -> Option<usize> {
Some(self.marked_text.encode_utf16().count())
}
fn accepts_text_input(&self, _: &mut Window, cx: &mut Context<Self>) -> bool {
self.engine.read(cx).lifecycle().accepts_input()
}
}
impl Render for TerminalView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let snapshot = self.engine.read(cx).snapshot();
let font = TerminalFont::current(cx);
let scrollback = ScrollbackHandle::new(
self.engine.read(cx).scrollback(),
&snapshot,
font.row_height(window),
);
let view = cx.entity();
let context_menu = self.context_menu.clone();
let element = TerminalElement {
view: view.clone(),
snapshot,
focus_handle: self.focus_handle.clone(),
cursor_visible: self.cursor_visible,
marked_text: self.marked_text.clone(),
marked_selection: self.marked_selection.clone(),
requested_size: self.requested_size,
};
let terminal = div()
.id(self.element_id.clone())
.test_support()
.key_context(TERMINAL_KEY_CONTEXT)
.track_focus(&self.focus_handle)
.size_full()
.font_family(font.family(cx))
.text_size(font.size)
.aria_label((self.label)(cx))
.on_action(cx.listener(|this, _: &SendTab, _, cx| {
this.send_user_input(vec![b'\t'], cx);
}))
.on_action(cx.listener(|this, _: &SendBackTab, _, cx| {
this.send_user_input(b"\x1b[Z".to_vec(), cx);
}))
.on_key_down(cx.listener(Self::on_key_down))
.on_mouse_down(MouseButton::Right, cx.listener(Self::right_click))
.child(element)
.when_some(context_menu, |terminal, (menu, position)| {
terminal.child(
deferred(
anchored()
.position(position)
.snap_to_window_with_margin(px(8.))
.child(menu),
)
.with_priority(gpui_kit::base::POPUP_PRIORITY),
)
})
// Below the pointer, so the pointer stays on the link.
.when_some(
self.hovered_link.clone().filter(|_| self.link_tooltip),
|terminal, link| {
terminal.child(
deferred(
anchored()
.position(link.position + point(px(0.), px(20.)))
.snap_to_window_with_margin(px(8.))
.child(link_tooltip(link.uri, window, cx)),
)
.with_priority(1),
)
},
);
// The find bar is the terminal's sibling, not its child: keys typed
// into it must not bubble to the terminal's key handler, which would
// send them to the shell (and Tab would hit `SendTab`).
div()
.relative()
.size_full()
.p_2()
// The margin and the part of a cell the grid does not fill are
// the terminal's too.
.bg(TerminalColors::current(cx).background())
.child(terminal)
// Over the whole margin, so the thumb lies mostly beside the text.
.child(
div()
.absolute()
.inset_0()
.child(
canvas(
{
let scrollback = scrollback.clone();
move |bounds, _, _| scrollback.set_viewport(bounds)
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.child(Scrollbar::vertical(&scrollback).id("terminal-scrollbar")),
)
.when_some(self.find.as_ref(), |container, find| {
container.child(self.render_find_bar(find, cx))
})
}
}
/// Where a link leads and how to open it: the address may not be its text.
fn link_tooltip(uri: SharedString, window: &mut Window, cx: &mut App) -> AnyView {
Tooltip::element(move |_, cx| {
v_flex()
.id("terminal-link-tooltip")
.test_support()
.aria_label(uri.clone())
.max_w(rems(32.))
.child(uri.clone())
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(t!("terminal.link.open_hint", key = OPEN_LINK_KEY)),
)
})
.build(window, cx)
}
impl TerminalView {
fn render_find_bar(&self, find: &FindBar, cx: &App) -> impl IntoElement {
let has_query = !find.input.read(cx).value().is_empty();
let position = self
.engine
.read(cx)
.search_position()
.filter(|_| has_query && find.pending.is_none());
let navigate = position.is_some_and(|position| position.total() > 0);
h_flex()
.key_context(TERMINAL_FIND_KEY_CONTEXT)
// Keep the terminal underneath from taking clicks on the bar,
// which would move focus and start a selection there.
.occlude()
.absolute()
.top_2()
.right_4()
.w_80()
.gap_1()
.p_1()
.bg(cx.theme().popover)
.border_1()
.border_color(cx.theme().border)
.rounded(cx.theme().radius)
.shadow_md()
.child(
Input::new(&find.input)
.id("terminal-find")
.small()
.appearance(false)
.prefix(Icon::new(IconName::Search).small()),
)
.when_some(position, |bar, position| {
let label = position.label();
bar.child(
div()
.id("terminal-find-count")
.test_support()
.aria_label(label.clone())
.flex_none()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(label),
)
})
.child(
Button::new("terminal-find-previous")
.ghost()
.xsmall()
.icon(IconName::ChevronUp)
.tooltip(t!("terminal.find.previous"))
.disabled(!navigate)
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(FindPreviousInTerminal), cx)
}),
)
.child(
Button::new("terminal-find-next")
.ghost()
.xsmall()
.icon(IconName::ChevronDown)
.tooltip(t!("terminal.find.next"))
.disabled(!navigate)
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(FindNextInTerminal), cx)
}),
)
.child(
Button::new("terminal-find-close")
.ghost()
.xsmall()
.icon(IconName::Close)
.tooltip(t!("terminal.find.close"))
.on_click(|_, window, cx| {
window.dispatch_action(Box::new(DismissTerminalFind), cx)
}),
)
}
}
struct TerminalElement {
view: Entity<TerminalView>,
snapshot: TerminalSnapshot,
focus_handle: FocusHandle,
cursor_visible: bool,
marked_text: String,
marked_selection: Range<usize>,
requested_size: TerminalSize,
}
struct TerminalPaintState {
hitbox: Hitbox,
viewport: TerminalViewport,
lines: Vec<ShapedLine>,
backgrounds: Vec<PaintQuad>,
preedit: Vec<PreeditPaintCell>,
cursor: Option<PaintQuad>,
ime_cursor_bounds: Bounds<Pixels>,
}
struct PreeditPaintCell {
bounds: Bounds<Pixels>,
line: ShapedLine,
background: Option<PaintQuad>,
}
impl IntoElement for TerminalElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
impl Element for TerminalElement {
type RequestLayoutState = ();
type PrepaintState = TerminalPaintState;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let mut style = Style::default();
style.size.width = relative(1.).into();
style.size.height = relative(1.).into();
(window.request_layout(style, [], cx), ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
window: &mut Window,
cx: &mut App,
) -> Self::PrepaintState {
let bounds = window.pixel_snap_bounds(bounds);
let text_style = window.text_style();
let font_size = text_style.font_size.to_pixels(window.rem_size());
let measure_run = TextRun {
len: 1,
font: text_style.font(),
color: text_style.color,
background_color: None,
underline: None,
strikethrough: None,
};
let cell_width = window
.text_system()
.shape_line("M".into(), font_size, &[measure_run], None)
.width()
.max(px(1.));
let line_height = TerminalFont::current(cx).row_height(window);
let columns = ((bounds.size.width / cell_width).floor() as usize).max(2);
let rows = ((bounds.size.height / line_height).floor() as usize).max(1);
let requested_size = TerminalSize::new(
columns,
rows,
cell_width.as_f32().round().clamp(1., u16::MAX as f32) as u16,
line_height.as_f32().round().clamp(1., u16::MAX as f32) as u16,
);
if requested_size != self.requested_size {
let terminal = self.view.clone();
cx.defer(move |cx| {
terminal.update(cx, |terminal, cx| terminal.resize(requested_size, cx));
});
}
let palette = TerminalPalette::new(cx);
let geometry = TerminalGeometry {
bounds,
cell_width,
line_height,
};
// The link under the pointer, lit a little brighter.
let hovered_link = screen_cell(
&geometry,
self.snapshot.columns,
self.snapshot.rows,
window.mouse_position(),
)
.and_then(|(column, row)| {
self.snapshot
.cells
.get(row * self.snapshot.columns + column)?
.link
});
let mut lines = Vec::with_capacity(self.snapshot.rows);
let mut backgrounds = Vec::new();
for (row_index, row) in self
.snapshot
.cells
.chunks(self.snapshot.columns)
.enumerate()
{
let mut text = String::new();
let mut runs = Vec::new();
let mut background_start = 0;
let mut background_color = None;
for (column, cell) in row.iter().enumerate() {
let (foreground, background) = palette.cell_colors(
cell.foreground,
cell.background,
cell.flags,
&self.snapshot.colors,
);
let cell_background = if cell.selected {
palette.selection
} else {
match cell.search {
SearchMark::Focused => cx.theme().warning,
SearchMark::Match => cx.theme().warning.opacity(0.35),
SearchMark::None => background,
}
};
if column == 0 {
background_color = Some(cell_background);
} else if background_color != Some(cell_background) {
push_background(
&mut backgrounds,
geometry,
background_start..column,
row_index,
background_color.unwrap_or(palette.background),
);
background_start = column;
background_color = Some(cell_background);
}
let character = painted_cell_text(&cell.character, cell.flags);
let font = terminal_font(text_style.font(), cell.flags);
let len = character.len();
text.push_str(character);
// A rule's color, dimmed as the program dimmed the text.
let highlight = cell.highlight.map(|highlight| {
let color = highlight.hsla();
if cell.flags.contains(Flags::DIM) {
color.opacity(0.66)
} else {
color
}
});
// Links always show as links, blue whatever color the
// program gave them.
let link = cell
.link
.map(|link| palette.link(hovered_link == Some(link)));
// A rule colors a link's text too; the underline still says
// it is one.
let text_color = highlight.or(link).unwrap_or(foreground);
runs.push(TextRun {
len,
font,
color: if cell.selected {
palette.foreground
} else if cell.search == SearchMark::Focused {
cx.theme().warning_foreground
} else {
text_color
},
background_color: None,
underline: match link {
Some(color) => Some(UnderlineStyle {
thickness: px(1.),
color: Some(color),
wavy: false,
}),
None => {
cell.flags
.intersects(Flags::ALL_UNDERLINES)
.then_some(UnderlineStyle {
thickness: px(1.),
color: Some(text_color),
wavy: cell.flags.contains(Flags::UNDERCURL),
})
}
},
strikethrough: cell.flags.contains(Flags::STRIKEOUT).then_some(
StrikethroughStyle {
thickness: px(1.),
color: Some(text_color),
},
),
});
}
push_background(
&mut backgrounds,
geometry,
background_start..row.len(),
row_index,
background_color.unwrap_or(palette.background),
);
let line = window.text_system().shape_line(
text.into(),
font_size,
&runs,
// `force_width` is the width of one glyph cell, not the maximum
// width of the complete line. Passing the row width here sends
// every character after the first one towards the right edge.
Some(cell_width),
);
lines.push(line);
}
let cursor_bounds = geometry.cell_bounds(
self.snapshot
.cursor_column
.min(self.snapshot.columns.saturating_sub(1)),
self.snapshot
.cursor_row
.unwrap_or_default()
.min(self.snapshot.rows.saturating_sub(1)),
1,
);
let viewport = TerminalViewport {
geometry,
columns: self.snapshot.columns,
rows: self.snapshot.rows,
display_offset: self.snapshot.display_offset,
cursor_bounds,
};
let (preedit, ime_cursor_bounds) = build_preedit(
&self.marked_text,
self.marked_selection.clone(),
self.snapshot.cursor_column,
self.snapshot.cursor_row,
viewport,
text_style.font(),
font_size,
&palette,
palette.selection,
window,
);
let cursor = self
.snapshot
.cursor_row
.filter(|_| {
self.marked_text.is_empty()
&& self.cursor_visible
&& self.focus_handle.is_focused(window)
})
.and_then(|_| cursor_quad(cursor_bounds, self.snapshot.cursor_shape, &palette));
TerminalPaintState {
hitbox: window.insert_hitbox(bounds, HitboxBehavior::Normal),
viewport,
lines,
backgrounds,
preedit,
cursor,
ime_cursor_bounds,
}
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut Self::RequestLayoutState,
state: &mut Self::PrepaintState,
window: &mut Window,
cx: &mut App,
) {
window.handle_input(
&self.focus_handle,
ElementInputHandler::new(bounds, self.view.clone()),
cx,
);
let view = self.view.clone();
let viewport = state.viewport;
let ime_cursor_bounds = state.ime_cursor_bounds;
view.update(cx, |view, _| view.set_viewport(viewport, ime_cursor_bounds));
let mask = ContentMask { bounds };
window.with_content_mask(Some(mask), |window| {
for background in state.backgrounds.drain(..) {
window.paint_quad(background);
}
for (row, line) in state.lines.iter().enumerate() {
let _ = line.paint(
point(
bounds.left(),
bounds.top() + state.viewport.geometry.line_height * row as f32,
),
state.viewport.geometry.line_height,
TextAlign::Left,
Some(bounds.size.width),
window,
cx,
);
}
for cell in state.preedit.iter_mut() {
if let Some(background) = cell.background.take() {
window.paint_quad(background);
}
let _ = cell.line.paint(
cell.bounds.origin,
cell.bounds.size.height,
TextAlign::Left,
Some(cell.bounds.size.width),
window,
cx,
);
}
if let Some(cursor) = state.cursor.take() {
window.paint_quad(cursor);
}
});
let hitbox = state.hitbox.clone();
let view = self.view.clone();
let viewport = state.viewport;
let links = Rc::new(ScreenLinks::new(&self.snapshot));
// A hand over a link while the key that opens it is held; pressing
// or letting go of the key changes it at once.
let pointer_on_link = hitbox.is_hovered(window)
&& viewport
.cell_at(window.mouse_position())
.and_then(|cell| links.at(cell))
.is_some();
if pointer_on_link {
if opens_links(&window.modifiers()) {
window.set_cursor_style(CursorStyle::PointingHand, &hitbox);
}
let view = view.clone();
window.on_modifiers_changed(move |_, _, cx| view.update(cx, |_, cx| cx.notify()));
}
// A click opens a link with ⌘ held; otherwise it goes to the program
// that asked for the mouse, or selects text.
window.on_mouse_event({
let (hitbox, view, links) = (hitbox.clone(), view.clone(), links.clone());
move |event: &MouseDownEvent, phase, window, cx| {
if !phase.bubble() || !hitbox.is_hovered(window) {
return;
}
let Some(cell) = viewport.cell_at(event.position) else {
return;
};
let button = match event.button {
MouseButton::Left => ReportButton::Left,
MouseButton::Middle => ReportButton::Middle,
_ => return,
};
if button == ReportButton::Left
&& opens_links(&event.modifiers)
&& let Some((_, uri)) = links.at(cell)
{
cx.open_url(uri);
view.update(cx, |view, cx| view.hover_link(None, cx));
cx.stop_propagation();
return;
}
let reported = view.update(cx, |view, cx| {
view.focus(window, cx);
view.report_mouse(button, ReportKind::Press, cell, event.modifiers, cx)
});
if reported {
cx.stop_propagation();
} else if button == ReportButton::Left {
let (point, side) = viewport.grid_point(event.position);
let selection_type = match event.click_count {
2 => SelectionType::Semantic,
count if count >= 3 => SelectionType::Lines,
_ => SelectionType::Simple,
};
view.update(cx, |view, cx| {
view.start_selection(point, side, selection_type, cx);
});
cx.stop_propagation();
}
}
});
// The link under the pointer, for its tooltip; and motion, for a
// program that asked for it. A drag that leaves the terminal is
// reported at its edge.
window.on_mouse_event({
let (hitbox, view, links) = (hitbox.clone(), view.clone(), links.clone());
move |event: &MouseMoveEvent, phase, window, cx| {
if !phase.bubble() {
return;
}
let cell = hitbox
.is_hovered(window)
.then(|| viewport.cell_at(event.position))
.flatten();
let link = cell
.and_then(|cell| links.at(cell))
.map(|(index, uri)| HoveredLink {
index,
uri: uri.clone(),
position: event.position,
});
view.update(cx, |view, cx| {
view.hover_link(link, cx);
let cell = match (cell, view.reported_button) {
(_, Some(_)) => Some(viewport.nearest_cell(event.position)),
(cell, None) => cell,
};
if let Some(cell) = cell {
view.report_mouse(
ReportButton::None,
ReportKind::Motion,
cell,
event.modifiers,
cx,
);
}
});
}
});
// A reported button going up is reported wherever it is let go.
window.on_mouse_event({
let view = view.clone();
move |event: &MouseUpEvent, phase, _, cx| {
if !phase.capture() {
return;
}
let button = match event.button {
MouseButton::Left => ReportButton::Left,
MouseButton::Middle => ReportButton::Middle,
_ => return,
};
let cell = viewport.nearest_cell(event.position);
view.update(cx, |view, cx| {
view.release_mouse(button, cell, event.modifiers, cx)
});
}
});
let view = self.view.clone();
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
if !phase.capture() || !view.read(cx).selecting {
return;
}
if event.dragging() {
let (point, side) = viewport.grid_point(event.position);
view.update(cx, |view, cx| view.update_selection(point, side, cx));
} else {
// Let go where the window did not hear it.
view.update(cx, |view, cx| view.finish_selection(cx));
}
});
let view = self.view.clone();
window.on_mouse_event(move |event: &MouseUpEvent, phase, _, cx| {
if event.button != MouseButton::Left || !phase.capture() || !view.read(cx).selecting {
return;
}
let (point, side) = viewport.grid_point(event.position);
view.update(cx, |view, cx| {
view.update_selection(point, side, cx);
view.finish_selection(cx);
});
});
let hitbox = state.hitbox.clone();
let view = self.view.clone();
window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| {
if !phase.bubble() || !hitbox.should_handle_scroll(window) {
return;
}
let cell = viewport.nearest_cell(event.position);
view.update(cx, |view, cx| {
view.scroll(
event.delta,
viewport.geometry.line_height,
cell,
event.modifiers,
cx,
)
});
cx.stop_propagation();
});
}
}
fn painted_cell_text(character: &str, flags: Flags) -> &str {
if flags.intersects(Flags::HIDDEN | Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) {
// Keep one shaped glyph for every emulator grid cell. In particular, a
// wide-character spacer must remain present so the following glyph does
// not move one column to the left.
" "
} else {
character
}
}
fn terminal_font(mut font: Font, flags: Flags) -> Font {
// BOLD_ITALIC and DIM_BOLD are aliases composed from the basic flag bits.
// Testing them with `intersects` would make every bold glyph italic and
// every dim glyph bold, which is both semantically wrong and visibly soft.
if flags.contains(Flags::BOLD) {
font = font.bold();
}
if flags.contains(Flags::ITALIC) {
font = font.italic();
}
font
}
#[derive(Debug, PartialEq, Eq)]
struct PreeditCellLayout {
text: String,
width: usize,
utf16_range: Range<usize>,
}
fn preedit_cell_layouts(text: &str) -> Vec<PreeditCellLayout> {
let mut layouts: Vec<PreeditCellLayout> = Vec::new();
let mut utf16_offset = 0;
for character in text.chars() {
let utf16_len = character.len_utf16();
let width = character.width().unwrap_or(1);
if width == 0
&& let Some(previous) = layouts.last_mut()
{
previous.text.push(character);
previous.utf16_range.end += utf16_len;
} else {
layouts.push(PreeditCellLayout {
text: character.to_string(),
width: width.max(1),
utf16_range: utf16_offset..utf16_offset + utf16_len,
});
}
utf16_offset += utf16_len;
}
layouts
}
fn advance_grid_position(column: &mut usize, row: &mut usize, width: usize, columns: usize) {
if *column + width > columns {
*column = 0;
*row = row.saturating_add(1);
}
*column += width;
if *column >= columns {
*column = 0;
*row = row.saturating_add(1);
}
}
fn preedit_caret_position(
layouts: &[PreeditCellLayout],
utf16_index: usize,
cursor_column: usize,
cursor_row: usize,
columns: usize,
) -> (usize, usize) {
let mut column = cursor_column.min(columns.saturating_sub(1));
let mut row = cursor_row;
if utf16_index == 0 {
return (column, row);
}
for layout in layouts {
let width = layout.width.min(columns).max(1);
if column + width > columns {
column = 0;
row = row.saturating_add(1);
}
if utf16_index <= layout.utf16_range.start {
return (column, row);
}
advance_grid_position(&mut column, &mut row, width, columns);
if utf16_index <= layout.utf16_range.end {
return (column, row);
}
}
(column, row)
}
#[allow(clippy::too_many_arguments)]
fn build_preedit(
text: &str,
selection: Range<usize>,
cursor_column: usize,
cursor_row: Option<usize>,
viewport: TerminalViewport,
font: Font,
font_size: Pixels,
palette: &TerminalPalette,
selection_color: Hsla,
window: &mut Window,
) -> (Vec<PreeditPaintCell>, Bounds<Pixels>) {
let Some(cursor_row) = cursor_row else {
return (Vec::new(), viewport.cursor_bounds);
};
if text.is_empty() {
return (Vec::new(), viewport.cursor_bounds);
}
let layouts = preedit_cell_layouts(text);
let text_utf16_len = text.encode_utf16().count();
let caret_index = selection.end.min(text_utf16_len);
let selection = selection.start.min(text_utf16_len)..selection.end.min(text_utf16_len);
let mut column = cursor_column.min(viewport.columns.saturating_sub(1));
let mut row = cursor_row.min(viewport.rows.saturating_sub(1));
let caret = preedit_caret_position(&layouts, caret_index, column, row, viewport.columns);
let mut painted = Vec::with_capacity(layouts.len());
for layout in layouts {
let width = layout.width.min(viewport.columns).max(1);
if column + width > viewport.columns {
column = 0;
row = row.saturating_add(1);
}
let cell_bounds = viewport.geometry.cell_bounds(column, row, width);
if row < viewport.rows {
let selected = !selection.is_empty()
&& selection.start < layout.utf16_range.end
&& selection.end > layout.utf16_range.start;
let run = TextRun {
len: layout.text.len(),
font: font.clone(),
color: palette.foreground,
background_color: None,
underline: Some(UnderlineStyle {
thickness: px(1.),
color: Some(palette.cursor),
wavy: false,
}),
strikethrough: None,
};
let line = window.text_system().shape_line(
layout.text.into(),
font_size,
&[run],
Some(viewport.geometry.cell_width * width as f32),
);
painted.push(PreeditPaintCell {
bounds: cell_bounds,
line,
background: selected.then(|| fill(cell_bounds, selection_color)),
});
}
advance_grid_position(&mut column, &mut row, width, viewport.columns);
}
let caret_column = caret.0.min(viewport.columns.saturating_sub(1));
let caret_row = caret.1.min(viewport.rows.saturating_sub(1));
(
painted,
viewport.geometry.cell_bounds(caret_column, caret_row, 1),
)
}
fn push_background(
quads: &mut Vec<PaintQuad>,
geometry: TerminalGeometry,
columns: Range<usize>,
row: usize,
color: Hsla,
) {
if columns.is_empty() {
return;
}
quads.push(fill(
geometry.cell_bounds(columns.start, row, columns.len()),
color,
));
}
fn cursor_quad(
bounds: Bounds<Pixels>,
shape: CursorShape,
palette: &TerminalPalette,
) -> Option<PaintQuad> {
let cursor = match shape {
CursorShape::Hidden => return None,
CursorShape::Block => bounds,
CursorShape::Underline => Bounds::new(
point(bounds.left(), bounds.bottom() - px(2.)),
size(bounds.size.width, px(2.)),
),
CursorShape::Beam => Bounds::new(bounds.origin, size(px(2.), bounds.size.height)),
CursorShape::HollowBlock => {
return Some(outline(bounds, palette.cursor, BorderStyle::Solid));
}
};
Some(fill(cursor, palette.cursor.opacity(0.75)))
}
/// The cell on screen under `position`, if it is over the grid.
fn screen_cell(
geometry: &TerminalGeometry,
columns: usize,
rows: usize,
position: gpui_kit::Point<Pixels>,
) -> Option<(usize, usize)> {
let local = position - geometry.bounds.origin;
if local.x < px(0.) || local.y < px(0.) || columns == 0 || rows == 0 {
return None;
}
let column = (local.x / geometry.cell_width).floor() as usize;
let row = (local.y / geometry.line_height).floor() as usize;
(column < columns && row < rows).then_some((column, row))
}
/// Which link each cell on screen belongs to, for the pointer's handlers.
struct ScreenLinks {
columns: usize,
cells: Vec<Option<usize>>,
links: Vec<SharedString>,
}
impl ScreenLinks {
fn new(snapshot: &TerminalSnapshot) -> Self {
Self {
columns: snapshot.columns,
cells: snapshot.cells.iter().map(|cell| cell.link).collect(),
links: snapshot.links.clone(),
}
}
fn at(&self, (column, row): (usize, usize)) -> Option<(usize, &SharedString)> {
let link = (*self.cells.get(row * self.columns + column)?)?;
Some((link, self.links.get(link)?))
}
}
fn grid_point(
position: gpui_kit::Point<Pixels>,
bounds: Bounds<Pixels>,
cell_width: Pixels,
line_height: Pixels,
display_offset: usize,
columns: usize,
rows: usize,
) -> (TerminalPoint, Side) {
let grid_width = cell_width.as_f32() * columns as f32;
let local_x = (position.x - bounds.left()).as_f32();
let local_y = (position.y - bounds.top()).as_f32();
let clamped_x = local_x.clamp(0., grid_width.max(0.));
let clamped_y = local_y.clamp(0., line_height.as_f32() * rows as f32);
let column =
((clamped_x / cell_width.as_f32()).floor() as usize).min(columns.saturating_sub(1));
let row =
((clamped_y / line_height.as_f32()).floor() as usize).min(rows.saturating_sub(1)) as i32;
let side = if local_x <= 0. {
Side::Left
} else if local_x >= grid_width {
Side::Right
} else if (clamped_x % cell_width.as_f32()) / cell_width.as_f32() < 0.5 {
Side::Left
} else {
Side::Right
};
(
TerminalPoint::new(Line(row - display_offset as i32), Column(column)),
side,
)
}
/// The colors of the terminal theme in effect, for one frame.
struct TerminalPalette {
theme: &'static TerminalTheme,
foreground: Hsla,
background: Hsla,
cursor: Hsla,
selection: Hsla,
}
impl TerminalPalette {
fn new(cx: &App) -> Self {
let theme = TerminalColors::current(cx);
Self {
theme,
foreground: theme.foreground(),
background: theme.background(),
cursor: theme.cursor(),
selection: theme.selection(),
}
}
/// A link's color, and a hovered link's: the theme's blue and bright
/// blue, so they read on its background.
fn link(&self, hovered: bool) -> Hsla {
self.theme.indexed(if hovered { 12 } else { 4 })
}
fn cell_colors(
&self,
foreground: Color,
background: Color,
flags: Flags,
overrides: &[Option<Rgb>; COUNT],
) -> (Hsla, Hsla) {
let mut foreground = self.resolve(foreground, overrides);
let mut background = self.resolve(background, overrides);
if flags.contains(Flags::INVERSE) {
std::mem::swap(&mut foreground, &mut background);
}
if flags.contains(Flags::DIM) {
foreground = foreground.opacity(0.66);
}
(foreground, background)
}
fn resolve(&self, color: Color, overrides: &[Option<Rgb>; COUNT]) -> Hsla {
let index = match color {
Color::Spec(color) => return rgb_to_hsla(color),
Color::Indexed(index) => index as usize,
Color::Named(color) => color as usize,
};
if let Some(color) = overrides.get(index).copied().flatten() {
return rgb_to_hsla(color);
}
let dim = |index: u8| self.theme.indexed(index).opacity(0.66);
match color {
Color::Named(NamedColor::Foreground | NamedColor::BrightForeground) => self.foreground,
Color::Named(NamedColor::DimForeground) => self.foreground.opacity(0.66),
Color::Named(NamedColor::Background) => self.background,
Color::Named(NamedColor::Cursor) => self.cursor,
Color::Named(NamedColor::DimBlack) => dim(0),
Color::Named(NamedColor::DimRed) => dim(1),
Color::Named(NamedColor::DimGreen) => dim(2),
Color::Named(NamedColor::DimYellow) => dim(3),
Color::Named(NamedColor::DimBlue) => dim(4),
Color::Named(NamedColor::DimMagenta) => dim(5),
Color::Named(NamedColor::DimCyan) => dim(6),
Color::Named(NamedColor::DimWhite) => dim(7),
Color::Named(named) => self.theme.indexed(named as u8),
Color::Indexed(index) => self.theme.indexed(index),
Color::Spec(_) => unreachable!(),
}
}
}
fn rgb_to_hsla(color: Rgb) -> Hsla {
rgb(((color.r as u32) << 16) | ((color.g as u32) << 8) | color.b as u32).into()
}
pub(crate) fn encode_key(keystroke: &Keystroke, mode: TermMode) -> Option<Vec<u8>> {
if keystroke.modifiers.platform {
return None;
}
let modifiers = keystroke.modifiers;
let key = keystroke.key.as_str();
let app_cursor = mode.contains(TermMode::APP_CURSOR);
let modifier = 1
+ u8::from(modifiers.shift)
+ u8::from(modifiers.alt) * 2
+ u8::from(modifiers.control) * 4;
let modified_csi = |final_byte: char| format!("\x1b[1;{modifier}{final_byte}").into_bytes();
let mut sequence = match key {
"enter" => vec![b'\r'],
"tab" if modifiers.shift => b"\x1b[Z".to_vec(),
"tab" => vec![b'\t'],
"backspace" => vec![0x7f],
"escape" => vec![0x1b],
"up" if modifier > 1 => modified_csi('A'),
"down" if modifier > 1 => modified_csi('B'),
"right" if modifier > 1 => modified_csi('C'),
"left" if modifier > 1 => modified_csi('D'),
"up" => if app_cursor { b"\x1bOA" } else { b"\x1b[A" }.to_vec(),
"down" => if app_cursor { b"\x1bOB" } else { b"\x1b[B" }.to_vec(),
"right" => if app_cursor { b"\x1bOC" } else { b"\x1b[C" }.to_vec(),
"left" => if app_cursor { b"\x1bOD" } else { b"\x1b[D" }.to_vec(),
"home" if modifier > 1 => modified_csi('H'),
"end" if modifier > 1 => modified_csi('F'),
"home" => b"\x1b[H".to_vec(),
"end" => b"\x1b[F".to_vec(),
"insert" => b"\x1b[2~".to_vec(),
"delete" => b"\x1b[3~".to_vec(),
"pageup" => b"\x1b[5~".to_vec(),
"pagedown" => b"\x1b[6~".to_vec(),
"f1" => b"\x1bOP".to_vec(),
"f2" => b"\x1bOQ".to_vec(),
"f3" => b"\x1bOR".to_vec(),
"f4" => b"\x1bOS".to_vec(),
"f5" => b"\x1b[15~".to_vec(),
"f6" => b"\x1b[17~".to_vec(),
"f7" => b"\x1b[18~".to_vec(),
"f8" => b"\x1b[19~".to_vec(),
"f9" => b"\x1b[20~".to_vec(),
"f10" => b"\x1b[21~".to_vec(),
"f11" => b"\x1b[23~".to_vec(),
"f12" => b"\x1b[24~".to_vec(),
_ if modifiers.control => {
let character = keystroke.key_char.as_deref().unwrap_or(key);
let byte = character.as_bytes().first().copied()?;
match byte.to_ascii_lowercase() {
b'a'..=b'z' => vec![byte.to_ascii_lowercase() - b'a' + 1],
b' ' | b'@' => vec![0],
b'[' => vec![0x1b],
b'\\' => vec![0x1c],
b']' => vec![0x1d],
b'^' => vec![0x1e],
b'_' => vec![0x1f],
b'?' => vec![0x7f],
_ => return None,
}
}
_ if modifiers.alt => keystroke.key_char.as_ref()?.as_bytes().to_vec(),
_ => return None,
};
if modifiers.alt && !matches!(key, "up" | "down" | "right" | "left" | "home" | "end") {
sequence.insert(0, 0x1b);
}
Some(sequence)
}
#[cfg(test)]
mod tests {
use alacritty_terminal::term::TermMode;
use alacritty_terminal::term::cell::Flags;
use gpui_kit::{Bounds, FontStyle, FontWeight, Keystroke, ScrollDelta, font, point, px, size};
use super::{
ScrollAccumulator, ScrollTarget, encode_key, grid_point, painted_cell_text,
preedit_caret_position, preedit_cell_layouts, terminal_font,
};
fn key(source: &str) -> Keystroke {
Keystroke::parse(source).unwrap()
}
#[test]
fn encodes_control_and_cursor_keys() {
assert_eq!(
encode_key(&key("ctrl-c"), TermMode::default()),
Some(vec![3])
);
assert_eq!(
encode_key(&key("up"), TermMode::APP_CURSOR),
Some(b"\x1bOA".to_vec())
);
assert_eq!(
encode_key(&key("shift-up"), TermMode::default()),
Some(b"\x1b[1;2A".to_vec())
);
}
#[test]
fn leaves_platform_shortcuts_for_actions() {
assert_eq!(encode_key(&key("cmd-t"), TermMode::default()), None);
}
#[test]
fn wide_character_spacers_keep_their_grid_column() {
assert_eq!(painted_cell_text("你", Flags::WIDE_CHAR), "你");
assert_eq!(painted_cell_text("", Flags::WIDE_CHAR_SPACER), " ");
assert_eq!(painted_cell_text("", Flags::LEADING_WIDE_CHAR_SPACER), " ");
assert_eq!(painted_cell_text("secret", Flags::HIDDEN), " ");
}
#[test]
fn ansi_bold_does_not_accidentally_make_text_italic() {
let bold = terminal_font(font("Menlo"), Flags::BOLD);
assert_eq!(bold.weight, FontWeight::BOLD);
assert_eq!(bold.style, FontStyle::Normal);
let italic = terminal_font(font("Menlo"), Flags::ITALIC);
assert_eq!(italic.weight, FontWeight::NORMAL);
assert_eq!(italic.style, FontStyle::Italic);
}
#[test]
fn grid_hit_testing_clamps_to_viewport_and_applies_scrollback_offset() {
let bounds = Bounds::new(point(px(10.), px(20.)), size(px(80.), px(40.)));
let (top_left, left_side) =
grid_point(point(px(0.), px(0.)), bounds, px(10.), px(20.), 3, 8, 2);
assert_eq!(top_left.line.0, -3);
assert_eq!(top_left.column.0, 0);
assert_eq!(left_side, alacritty_terminal::index::Side::Left);
let (bottom_right, right_side) =
grid_point(point(px(200.), px(200.)), bounds, px(10.), px(20.), 3, 8, 2);
assert_eq!(bottom_right.line.0, -2);
assert_eq!(bottom_right.column.0, 7);
assert_eq!(right_side, alacritty_terminal::index::Side::Right);
}
#[test]
fn pixel_scrolling_accumulates_full_lines_and_resets_between_modes() {
let mut accumulator = ScrollAccumulator::default();
assert_eq!(
accumulator.consume(
ScrollDelta::Pixels(point(px(0.), px(4.))),
px(10.),
ScrollTarget::History,
),
0
);
assert_eq!(
accumulator.consume(
ScrollDelta::Pixels(point(px(0.), px(7.))),
px(10.),
ScrollTarget::History,
),
1
);
assert_eq!(
accumulator.consume(
ScrollDelta::Pixels(point(px(0.), px(-12.))),
px(10.),
ScrollTarget::History,
),
-1
);
assert_eq!(
accumulator.consume(
ScrollDelta::Pixels(point(px(0.), px(9.))),
px(10.),
ScrollTarget::AlternateScreen,
),
0
);
assert_eq!(
accumulator.consume(
ScrollDelta::Pixels(point(px(0.), px(2.))),
px(10.),
ScrollTarget::AlternateScreen,
),
1
);
}
#[test]
fn preedit_layout_uses_utf16_ranges_and_terminal_cell_widths() {
let layouts = preedit_cell_layouts("a你e\u{301}😀");
assert_eq!(layouts.len(), 4);
assert_eq!(layouts[0].width, 1);
assert_eq!(layouts[1].width, 2);
assert_eq!(layouts[1].utf16_range, 1..2);
assert_eq!(layouts[2].text, "e\u{301}");
assert_eq!(layouts[2].utf16_range, 2..4);
assert_eq!(layouts[3].width, 2);
assert_eq!(layouts[3].utf16_range, 4..6);
assert_eq!(preedit_caret_position(&layouts, 2, 2, 3, 5), (0, 4));
assert_eq!(preedit_caret_position(&layouts, 6, 2, 3, 5), (3, 4));
}
}