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 self.modal,
325 focused,
326 self.should_show_cursor(focused, cx),
327 )
328 .contained()
329 .boxed(),
330 )
331 .with_child(ChildView::new(&self.context_menu, cx).boxed())
332 .boxed()
333 }
334
335 fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
336 self.has_new_content = false;
337 self.terminal.read(cx).focus_in();
338 self.blink_cursors(self.blink_epoch, cx);
339 cx.notify();
340 }
341
342 fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
343 self.terminal.update(cx, |terminal, _| {
344 terminal.focus_out();
345 });
346 cx.notify();
347 }
348
349 fn key_down(&mut self, event: &gpui::KeyDownEvent, cx: &mut ViewContext<Self>) -> bool {
350 self.clear_bel(cx);
351 self.pause_cursor_blinking(cx);
352
353 self.terminal.update(cx, |term, cx| {
354 term.try_keystroke(
355 &event.keystroke,
356 cx.global::<Settings>()
357 .terminal_overrides
358 .option_as_meta
359 .unwrap_or(false),
360 )
361 })
362 }
363
364 //IME stuff
365 fn selected_text_range(&self, cx: &AppContext) -> Option<std::ops::Range<usize>> {
366 if self
367 .terminal
368 .read(cx)
369 .last_content
370 .mode
371 .contains(TermMode::ALT_SCREEN)
372 {
373 None
374 } else {
375 Some(0..0)
376 }
377 }
378
379 fn replace_text_in_range(
380 &mut self,
381 _: Option<std::ops::Range<usize>>,
382 text: &str,
383 cx: &mut ViewContext<Self>,
384 ) {
385 self.terminal.update(cx, |terminal, _| {
386 terminal.input(text.into());
387 });
388 }
389
390 fn keymap_context(&self, cx: &gpui::AppContext) -> gpui::keymap::Context {
391 let mut context = Self::default_keymap_context();
392 if self.modal {
393 context.set.insert("ModalTerminal".into());
394 }
395 let mode = self.terminal.read(cx).last_content.mode;
396 context.map.insert(
397 "screen".to_string(),
398 (if mode.contains(TermMode::ALT_SCREEN) {
399 "alt"
400 } else {
401 "normal"
402 })
403 .to_string(),
404 );
405
406 if mode.contains(TermMode::APP_CURSOR) {
407 context.set.insert("DECCKM".to_string());
408 }
409 if mode.contains(TermMode::APP_KEYPAD) {
410 context.set.insert("DECPAM".to_string());
411 }
412 //Note the ! here
413 if !mode.contains(TermMode::APP_KEYPAD) {
414 context.set.insert("DECPNM".to_string());
415 }
416 if mode.contains(TermMode::SHOW_CURSOR) {
417 context.set.insert("DECTCEM".to_string());
418 }
419 if mode.contains(TermMode::LINE_WRAP) {
420 context.set.insert("DECAWM".to_string());
421 }
422 if mode.contains(TermMode::ORIGIN) {
423 context.set.insert("DECOM".to_string());
424 }
425 if mode.contains(TermMode::INSERT) {
426 context.set.insert("IRM".to_string());
427 }
428 //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
429 if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
430 context.set.insert("LNM".to_string());
431 }
432 if mode.contains(TermMode::FOCUS_IN_OUT) {
433 context.set.insert("report_focus".to_string());
434 }
435 if mode.contains(TermMode::ALTERNATE_SCROLL) {
436 context.set.insert("alternate_scroll".to_string());
437 }
438 if mode.contains(TermMode::BRACKETED_PASTE) {
439 context.set.insert("bracketed_paste".to_string());
440 }
441 if mode.intersects(TermMode::MOUSE_MODE) {
442 context.set.insert("any_mouse_reporting".to_string());
443 }
444 {
445 let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
446 "click"
447 } else if mode.contains(TermMode::MOUSE_DRAG) {
448 "drag"
449 } else if mode.contains(TermMode::MOUSE_MOTION) {
450 "motion"
451 } else {
452 "off"
453 };
454 context
455 .map
456 .insert("mouse_reporting".to_string(), mouse_reporting.to_string());
457 }
458 {
459 let format = if mode.contains(TermMode::SGR_MOUSE) {
460 "sgr"
461 } else if mode.contains(TermMode::UTF8_MOUSE) {
462 "utf8"
463 } else {
464 "normal"
465 };
466 context
467 .map
468 .insert("mouse_format".to_string(), format.to_string());
469 }
470 context
471 }
472}