1use std::{ops::RangeInclusive, time::Duration};
2
3use alacritty_terminal::{index::Point, term::TermMode};
4use context_menu::{ContextMenu, ContextMenuItem};
5use gpui::{
6 actions,
7 elements::{AnchorCorner, ChildView, ParentElement, Stack},
8 geometry::vector::Vector2F,
9 impl_actions, impl_internal_actions,
10 keymap::Keystroke,
11 AnyViewHandle, AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task,
12 View, ViewContext, ViewHandle,
13};
14use serde::Deserialize;
15use settings::{Settings, TerminalBlink};
16use smol::Timer;
17use util::ResultExt;
18use workspace::pane;
19
20use crate::{terminal_element::TerminalElement, Event, Terminal};
21
22const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
23
24///Event to transmit the scroll from the element to the view
25#[derive(Clone, Debug, PartialEq)]
26pub struct ScrollTerminal(pub i32);
27
28#[derive(Clone, PartialEq)]
29pub struct DeployContextMenu {
30 pub position: Vector2F,
31}
32
33#[derive(Clone, Default, Deserialize, PartialEq)]
34pub struct SendText(String);
35
36#[derive(Clone, Default, Deserialize, PartialEq)]
37pub struct SendKeystroke(String);
38
39actions!(
40 terminal,
41 [Clear, Copy, Paste, ShowCharacterPalette, SearchTest]
42);
43
44impl_actions!(terminal, [SendText, SendKeystroke]);
45
46impl_internal_actions!(project_panel, [DeployContextMenu]);
47
48pub fn init(cx: &mut MutableAppContext) {
49 //Useful terminal views
50 cx.add_action(TerminalView::send_text);
51 cx.add_action(TerminalView::send_keystroke);
52 cx.add_action(TerminalView::deploy_context_menu);
53 cx.add_action(TerminalView::copy);
54 cx.add_action(TerminalView::paste);
55 cx.add_action(TerminalView::clear);
56 cx.add_action(TerminalView::show_character_palette);
57}
58
59///A terminal view, maintains the PTY's file handles and communicates with the terminal
60pub struct TerminalView {
61 terminal: ModelHandle<Terminal>,
62 has_new_content: bool,
63 //Currently using iTerm bell, show bell emoji in tab until input is received
64 has_bell: bool,
65 // Only for styling purposes. Doesn't effect behavior
66 modal: bool,
67 context_menu: ViewHandle<ContextMenu>,
68 blink_state: bool,
69 blinking_on: bool,
70 blinking_paused: bool,
71 blink_epoch: usize,
72}
73
74impl Entity for TerminalView {
75 type Event = Event;
76}
77
78impl TerminalView {
79 pub fn from_terminal(
80 terminal: ModelHandle<Terminal>,
81 modal: bool,
82 cx: &mut ViewContext<Self>,
83 ) -> Self {
84 cx.observe(&terminal, |_, _, cx| cx.notify()).detach();
85 cx.subscribe(&terminal, |this, _, event, cx| match event {
86 Event::Wakeup => {
87 if !cx.is_self_focused() {
88 this.has_new_content = true;
89 cx.notify();
90 }
91 cx.emit(Event::Wakeup);
92 }
93 Event::Bell => {
94 this.has_bell = true;
95 cx.emit(Event::Wakeup);
96 }
97 Event::BlinkChanged => this.blinking_on = !this.blinking_on,
98 _ => cx.emit(*event),
99 })
100 .detach();
101
102 Self {
103 terminal,
104 has_new_content: true,
105 has_bell: false,
106 modal,
107 context_menu: cx.add_view(ContextMenu::new),
108 blink_state: true,
109 blinking_on: false,
110 blinking_paused: false,
111 blink_epoch: 0,
112 }
113 }
114
115 pub fn handle(&self) -> ModelHandle<Terminal> {
116 self.terminal.clone()
117 }
118
119 pub fn has_new_content(&self) -> bool {
120 self.has_new_content
121 }
122
123 pub fn has_bell(&self) -> bool {
124 self.has_bell
125 }
126
127 pub fn clear_bel(&mut self, cx: &mut ViewContext<TerminalView>) {
128 self.has_bell = false;
129 cx.emit(Event::Wakeup);
130 }
131
132 pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
133 let menu_entries = vec![
134 ContextMenuItem::item("Clear", Clear),
135 ContextMenuItem::item("Close", pane::CloseActiveItem),
136 ];
137
138 self.context_menu.update(cx, |menu, cx| {
139 menu.show(action.position, AnchorCorner::TopLeft, menu_entries, cx)
140 });
141
142 cx.notify();
143 }
144
145 fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext<Self>) {
146 if !self
147 .terminal
148 .read(cx)
149 .last_content
150 .mode
151 .contains(TermMode::ALT_SCREEN)
152 {
153 cx.show_character_palette();
154 } else {
155 self.terminal.update(cx, |term, cx| {
156 term.try_keystroke(
157 &Keystroke::parse("ctrl-cmd-space").unwrap(),
158 cx.global::<Settings>()
159 .terminal_overrides
160 .option_as_meta
161 .unwrap_or(false),
162 )
163 });
164 }
165 }
166
167 fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
168 self.terminal.update(cx, |term, _| term.clear());
169 cx.notify();
170 }
171
172 pub fn should_show_cursor(
173 &self,
174 focused: bool,
175 cx: &mut gpui::RenderContext<'_, Self>,
176 ) -> bool {
177 //Don't blink the cursor when not focused, blinking is disabled, or paused
178 if !focused
179 || !self.blinking_on
180 || self.blinking_paused
181 || self
182 .terminal
183 .read(cx)
184 .last_content
185 .mode
186 .contains(TermMode::ALT_SCREEN)
187 {
188 return true;
189 }
190
191 let setting = {
192 let settings = cx.global::<Settings>();
193 settings
194 .terminal_overrides
195 .blinking
196 .clone()
197 .unwrap_or(TerminalBlink::TerminalControlled)
198 };
199
200 match setting {
201 //If the user requested to never blink, don't blink it.
202 TerminalBlink::Off => true,
203 //If the terminal is controlling it, check terminal mode
204 TerminalBlink::TerminalControlled | TerminalBlink::On => self.blink_state,
205 }
206 }
207
208 fn blink_cursors(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
209 if epoch == self.blink_epoch && !self.blinking_paused {
210 self.blink_state = !self.blink_state;
211 cx.notify();
212
213 let epoch = self.next_blink_epoch();
214 cx.spawn(|this, mut cx| {
215 let this = this.downgrade();
216 async move {
217 Timer::after(CURSOR_BLINK_INTERVAL).await;
218 if let Some(this) = this.upgrade(&cx) {
219 this.update(&mut cx, |this, cx| this.blink_cursors(epoch, cx));
220 }
221 }
222 })
223 .detach();
224 }
225 }
226
227 pub fn pause_cursor_blinking(&mut self, cx: &mut ViewContext<Self>) {
228 self.blink_state = true;
229 cx.notify();
230
231 let epoch = self.next_blink_epoch();
232 cx.spawn(|this, mut cx| {
233 let this = this.downgrade();
234 async move {
235 Timer::after(CURSOR_BLINK_INTERVAL).await;
236 if let Some(this) = this.upgrade(&cx) {
237 this.update(&mut cx, |this, cx| this.resume_cursor_blinking(epoch, cx))
238 }
239 }
240 })
241 .detach();
242 }
243
244 pub fn find_matches(
245 &mut self,
246 query: project::search::SearchQuery,
247 cx: &mut ViewContext<Self>,
248 ) -> Task<Vec<RangeInclusive<Point>>> {
249 self.terminal
250 .update(cx, |term, cx| term.find_matches(query, cx))
251 }
252
253 pub fn terminal(&self) -> &ModelHandle<Terminal> {
254 &self.terminal
255 }
256
257 fn next_blink_epoch(&mut self) -> usize {
258 self.blink_epoch += 1;
259 self.blink_epoch
260 }
261
262 fn resume_cursor_blinking(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
263 if epoch == self.blink_epoch {
264 self.blinking_paused = false;
265 self.blink_cursors(epoch, cx);
266 }
267 }
268
269 ///Attempt to paste the clipboard into the terminal
270 fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
271 self.terminal.update(cx, |term, _| term.copy())
272 }
273
274 ///Attempt to paste the clipboard into the terminal
275 fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
276 if let Some(item) = cx.read_from_clipboard() {
277 self.terminal
278 .update(cx, |terminal, _cx| terminal.paste(item.text()));
279 }
280 }
281
282 fn send_text(&mut self, text: &SendText, cx: &mut ViewContext<Self>) {
283 self.clear_bel(cx);
284 self.terminal.update(cx, |term, _| {
285 term.input(text.0.to_string());
286 });
287 }
288
289 fn send_keystroke(&mut self, text: &SendKeystroke, cx: &mut ViewContext<Self>) {
290 if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
291 self.clear_bel(cx);
292 self.terminal.update(cx, |term, cx| {
293 term.try_keystroke(
294 &keystroke,
295 cx.global::<Settings>()
296 .terminal_overrides
297 .option_as_meta
298 .unwrap_or(false),
299 );
300 });
301 }
302 }
303}
304
305impl View for TerminalView {
306 fn ui_name() -> &'static str {
307 "Terminal"
308 }
309
310 fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
311 let terminal_handle = self.terminal.clone().downgrade();
312
313 let self_id = cx.view_id();
314 let focused = cx
315 .focused_view_id(cx.window_id())
316 .filter(|view_id| *view_id == self_id)
317 .is_some();
318
319 Stack::new()
320 .with_child(
321 TerminalElement::new(
322 cx.handle(),
323 terminal_handle,
324 focused,
325 self.should_show_cursor(focused, cx),
326 )
327 .contained()
328 .boxed(),
329 )
330 .with_child(ChildView::new(&self.context_menu, cx).boxed())
331 .boxed()
332 }
333
334 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
335 self.has_new_content = false;
336 self.terminal.read(cx).focus_in();
337 self.blink_cursors(self.blink_epoch, cx);
338 cx.notify();
339 }
340
341 fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
342 self.terminal.update(cx, |terminal, _| {
343 terminal.focus_out();
344 });
345 cx.notify();
346 }
347
348 fn key_down(&mut self, event: &gpui::KeyDownEvent, cx: &mut ViewContext<Self>) -> bool {
349 self.clear_bel(cx);
350 self.pause_cursor_blinking(cx);
351
352 self.terminal.update(cx, |term, cx| {
353 term.try_keystroke(
354 &event.keystroke,
355 cx.global::<Settings>()
356 .terminal_overrides
357 .option_as_meta
358 .unwrap_or(false),
359 )
360 })
361 }
362
363 //IME stuff
364 fn selected_text_range(&self, cx: &AppContext) -> Option<std::ops::Range<usize>> {
365 if self
366 .terminal
367 .read(cx)
368 .last_content
369 .mode
370 .contains(TermMode::ALT_SCREEN)
371 {
372 None
373 } else {
374 Some(0..0)
375 }
376 }
377
378 fn replace_text_in_range(
379 &mut self,
380 _: Option<std::ops::Range<usize>>,
381 text: &str,
382 cx: &mut ViewContext<Self>,
383 ) {
384 self.terminal.update(cx, |terminal, _| {
385 terminal.input(text.into());
386 });
387 }
388
389 fn keymap_context(&self, cx: &gpui::AppContext) -> gpui::keymap::Context {
390 let mut context = Self::default_keymap_context();
391 if self.modal {
392 context.set.insert("ModalTerminal".into());
393 }
394 let mode = self.terminal.read(cx).last_content.mode;
395 context.map.insert(
396 "screen".to_string(),
397 (if mode.contains(TermMode::ALT_SCREEN) {
398 "alt"
399 } else {
400 "normal"
401 })
402 .to_string(),
403 );
404
405 if mode.contains(TermMode::APP_CURSOR) {
406 context.set.insert("DECCKM".to_string());
407 }
408 if mode.contains(TermMode::APP_KEYPAD) {
409 context.set.insert("DECPAM".to_string());
410 }
411 //Note the ! here
412 if !mode.contains(TermMode::APP_KEYPAD) {
413 context.set.insert("DECPNM".to_string());
414 }
415 if mode.contains(TermMode::SHOW_CURSOR) {
416 context.set.insert("DECTCEM".to_string());
417 }
418 if mode.contains(TermMode::LINE_WRAP) {
419 context.set.insert("DECAWM".to_string());
420 }
421 if mode.contains(TermMode::ORIGIN) {
422 context.set.insert("DECOM".to_string());
423 }
424 if mode.contains(TermMode::INSERT) {
425 context.set.insert("IRM".to_string());
426 }
427 //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
428 if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
429 context.set.insert("LNM".to_string());
430 }
431 if mode.contains(TermMode::FOCUS_IN_OUT) {
432 context.set.insert("report_focus".to_string());
433 }
434 if mode.contains(TermMode::ALTERNATE_SCROLL) {
435 context.set.insert("alternate_scroll".to_string());
436 }
437 if mode.contains(TermMode::BRACKETED_PASTE) {
438 context.set.insert("bracketed_paste".to_string());
439 }
440 if mode.intersects(TermMode::MOUSE_MODE) {
441 context.set.insert("any_mouse_reporting".to_string());
442 }
443 {
444 let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
445 "click"
446 } else if mode.contains(TermMode::MOUSE_DRAG) {
447 "drag"
448 } else if mode.contains(TermMode::MOUSE_MOTION) {
449 "motion"
450 } else {
451 "off"
452 };
453 context
454 .map
455 .insert("mouse_reporting".to_string(), mouse_reporting.to_string());
456 }
457 {
458 let format = if mode.contains(TermMode::SGR_MOUSE) {
459 "sgr"
460 } else if mode.contains(TermMode::UTF8_MOUSE) {
461 "utf8"
462 } else {
463 "normal"
464 };
465 context
466 .map
467 .insert("mouse_format".to_string(), format.to_string());
468 }
469 context
470 }
471}