1pub mod mappings;
2pub use alacritty_terminal;
3pub mod terminal_settings;
4
5use alacritty_terminal::{
6 ansi::{ClearMode, Handler},
7 config::{Config, Program, PtyConfig, Scrolling},
8 event::{Event as AlacTermEvent, EventListener, Notify, WindowSize},
9 event_loop::{EventLoop, Msg, Notifier},
10 grid::{Dimensions, Scroll as AlacScroll},
11 index::{Boundary, Column, Direction as AlacDirection, Line, Point as AlacPoint},
12 selection::{Selection, SelectionRange, SelectionType},
13 sync::FairMutex,
14 term::{
15 cell::Cell,
16 color::Rgb,
17 search::{Match, RegexIter, RegexSearch},
18 RenderableCursor, TermMode,
19 },
20 tty::{self, setup_env},
21 Term,
22};
23use anyhow::{bail, Result};
24
25use futures::{
26 channel::mpsc::{unbounded, UnboundedReceiver, UnboundedSender},
27 FutureExt,
28};
29
30use mappings::mouse::{
31 alt_scroll, grid_point, mouse_button_report, mouse_moved_report, mouse_side, scroll_report,
32};
33
34use procinfo::LocalProcessInfo;
35use serde::{Deserialize, Serialize};
36use terminal_settings::{AlternateScroll, Shell, TerminalBlink, TerminalSettings};
37use util::truncate_and_trailoff;
38
39use std::{
40 cmp::{self, min},
41 collections::{HashMap, VecDeque},
42 fmt::Display,
43 ops::{Deref, Index, RangeInclusive},
44 os::unix::prelude::AsRawFd,
45 path::PathBuf,
46 sync::Arc,
47 time::{Duration, Instant},
48};
49use thiserror::Error;
50
51use gpui2::{
52 px, AnyWindowHandle, AppContext, Bounds, ClipboardItem, EventEmitter, Hsla, Keystroke,
53 MainThread, ModelContext, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
54 Pixels, Point, ScrollWheelEvent, Size, Task, TouchPhase,
55};
56
57use crate::mappings::{colors::to_alac_rgb, keys::to_esc_str};
58use lazy_static::lazy_static;
59
60///Scrolling is unbearably sluggish by default. Alacritty supports a configurable
61///Scroll multiplier that is set to 3 by default. This will be removed when I
62///Implement scroll bars.
63const SCROLL_MULTIPLIER: f32 = 4.;
64const MAX_SEARCH_LINES: usize = 100;
65const DEBUG_TERMINAL_WIDTH: Pixels = px(500.);
66const DEBUG_TERMINAL_HEIGHT: Pixels = px(30.);
67const DEBUG_CELL_WIDTH: Pixels = px(5.);
68const DEBUG_LINE_HEIGHT: Pixels = px(5.);
69
70lazy_static! {
71 // Regex Copied from alacritty's ui_config.rs and modified its declaration slightly:
72 // * avoid Rust-specific escaping.
73 // * use more strict regex for `file://` protocol matching: original regex has `file:` inside, but we want to avoid matching `some::file::module` strings.
74 static ref URL_REGEX: RegexSearch = RegexSearch::new(r#"(ipfs:|ipns:|magnet:|mailto:|gemini://|gopher://|https://|http://|news:|file://|git://|ssh:|ftp://)[^\u{0000}-\u{001F}\u{007F}-\u{009F}<>"\s{-}\^⟨⟩`]+"#).unwrap();
75
76 static ref WORD_REGEX: RegexSearch = RegexSearch::new(r#"[\w.\[\]:/@\-~]+"#).unwrap();
77}
78
79///Upward flowing events, for changing the title and such
80#[derive(Clone, Debug)]
81pub enum Event {
82 TitleChanged,
83 BreadcrumbsChanged,
84 CloseTerminal,
85 Bell,
86 Wakeup,
87 BlinkChanged,
88 SelectionsChanged,
89 NewNavigationTarget(Option<MaybeNavigationTarget>),
90 Open(MaybeNavigationTarget),
91}
92
93/// A string inside terminal, potentially useful as a URI that can be opened.
94#[derive(Clone, Debug)]
95pub enum MaybeNavigationTarget {
96 /// HTTP, git, etc. string determined by the [`URL_REGEX`] regex.
97 Url(String),
98 /// File system path, absolute or relative, existing or not.
99 /// Might have line and column number(s) attached as `file.rs:1:23`
100 PathLike(String),
101}
102
103#[derive(Clone)]
104enum InternalEvent {
105 ColorRequest(usize, Arc<dyn Fn(Rgb) -> String + Sync + Send + 'static>),
106 Resize(TerminalSize),
107 Clear,
108 // FocusNextMatch,
109 Scroll(AlacScroll),
110 ScrollToAlacPoint(AlacPoint),
111 SetSelection(Option<(Selection, AlacPoint)>),
112 UpdateSelection(Point<Pixels>),
113 // Adjusted mouse position, should open
114 FindHyperlink(Point<Pixels>, bool),
115 Copy,
116}
117
118///A translation struct for Alacritty to communicate with us from their event loop
119#[derive(Clone)]
120pub struct ZedListener(UnboundedSender<AlacTermEvent>);
121
122impl EventListener for ZedListener {
123 fn send_event(&self, event: AlacTermEvent) {
124 self.0.unbounded_send(event).ok();
125 }
126}
127
128pub fn init(cx: &mut AppContext) {
129 settings2::register::<TerminalSettings>(cx);
130}
131
132#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
133pub struct TerminalSize {
134 pub cell_width: Pixels,
135 pub line_height: Pixels,
136 pub size: Size<Pixels>,
137}
138
139impl TerminalSize {
140 pub fn new(line_height: Pixels, cell_width: Pixels, size: Size<Pixels>) -> Self {
141 TerminalSize {
142 cell_width,
143 line_height,
144 size,
145 }
146 }
147
148 pub fn num_lines(&self) -> usize {
149 f32::from((self.size.height / self.line_height).floor()) as usize
150 }
151
152 pub fn num_columns(&self) -> usize {
153 f32::from((self.size.width / self.cell_width).floor()) as usize
154 }
155
156 pub fn height(&self) -> Pixels {
157 self.size.height
158 }
159
160 pub fn width(&self) -> Pixels {
161 self.size.width
162 }
163
164 pub fn cell_width(&self) -> Pixels {
165 self.cell_width
166 }
167
168 pub fn line_height(&self) -> Pixels {
169 self.line_height
170 }
171}
172impl Default for TerminalSize {
173 fn default() -> Self {
174 TerminalSize::new(
175 DEBUG_LINE_HEIGHT,
176 DEBUG_CELL_WIDTH,
177 Size {
178 width: DEBUG_TERMINAL_WIDTH,
179 height: DEBUG_TERMINAL_HEIGHT,
180 },
181 )
182 }
183}
184
185impl From<TerminalSize> for WindowSize {
186 fn from(val: TerminalSize) -> Self {
187 WindowSize {
188 num_lines: val.num_lines() as u16,
189 num_cols: val.num_columns() as u16,
190 cell_width: f32::from(val.cell_width()) as u16,
191 cell_height: f32::from(val.line_height()) as u16,
192 }
193 }
194}
195
196impl Dimensions for TerminalSize {
197 /// Note: this is supposed to be for the back buffer's length,
198 /// but we exclusively use it to resize the terminal, which does not
199 /// use this method. We still have to implement it for the trait though,
200 /// hence, this comment.
201 fn total_lines(&self) -> usize {
202 self.screen_lines()
203 }
204
205 fn screen_lines(&self) -> usize {
206 self.num_lines()
207 }
208
209 fn columns(&self) -> usize {
210 self.num_columns()
211 }
212}
213
214#[derive(Error, Debug)]
215pub struct TerminalError {
216 pub directory: Option<PathBuf>,
217 pub shell: Shell,
218 pub source: std::io::Error,
219}
220
221impl TerminalError {
222 pub fn fmt_directory(&self) -> String {
223 self.directory
224 .clone()
225 .map(|path| {
226 match path
227 .into_os_string()
228 .into_string()
229 .map_err(|os_str| format!("<non-utf8 path> {}", os_str.to_string_lossy()))
230 {
231 Ok(s) => s,
232 Err(s) => s,
233 }
234 })
235 .unwrap_or_else(|| {
236 let default_dir =
237 dirs::home_dir().map(|buf| buf.into_os_string().to_string_lossy().to_string());
238 match default_dir {
239 Some(dir) => format!("<none specified, using home directory> {}", dir),
240 None => "<none specified, could not find home directory>".to_string(),
241 }
242 })
243 }
244
245 pub fn shell_to_string(&self) -> String {
246 match &self.shell {
247 Shell::System => "<system shell>".to_string(),
248 Shell::Program(p) => p.to_string(),
249 Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
250 }
251 }
252
253 pub fn fmt_shell(&self) -> String {
254 match &self.shell {
255 Shell::System => "<system defined shell>".to_string(),
256 Shell::Program(s) => s.to_string(),
257 Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
258 }
259 }
260}
261
262impl Display for TerminalError {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 let dir_string: String = self.fmt_directory();
265 let shell = self.fmt_shell();
266
267 write!(
268 f,
269 "Working directory: {} Shell command: `{}`, IOError: {}",
270 dir_string, shell, self.source
271 )
272 }
273}
274
275pub struct TerminalBuilder {
276 terminal: Terminal,
277 events_rx: UnboundedReceiver<AlacTermEvent>,
278}
279
280impl TerminalBuilder {
281 pub fn new(
282 working_directory: Option<PathBuf>,
283 shell: Shell,
284 mut env: HashMap<String, String>,
285 blink_settings: Option<TerminalBlink>,
286 alternate_scroll: AlternateScroll,
287 window: AnyWindowHandle,
288 color_for_index: impl Fn(usize, &mut AppContext) -> Hsla + Send + Sync + 'static,
289 ) -> Result<TerminalBuilder> {
290 let pty_config = {
291 let alac_shell = match shell.clone() {
292 Shell::System => None,
293 Shell::Program(program) => Some(Program::Just(program)),
294 Shell::WithArguments { program, args } => Some(Program::WithArgs { program, args }),
295 };
296
297 PtyConfig {
298 shell: alac_shell,
299 working_directory: working_directory.clone(),
300 hold: false,
301 }
302 };
303
304 //TODO: Properly set the current locale,
305 env.insert("LC_ALL".to_string(), "en_US.UTF-8".to_string());
306 env.insert("ZED_TERM".to_string(), true.to_string());
307
308 let alac_scrolling = Scrolling::default();
309 // alac_scrolling.set_history((BACK_BUFFER_SIZE * 2) as u32);
310
311 let config = Config {
312 pty_config: pty_config.clone(),
313 env,
314 scrolling: alac_scrolling,
315 ..Default::default()
316 };
317
318 setup_env(&config);
319
320 //Spawn a task so the Alacritty EventLoop can communicate with us in a view context
321 //TODO: Remove with a bounded sender which can be dispatched on &self
322 let (events_tx, events_rx) = unbounded();
323 //Set up the terminal...
324 let mut term = Term::new(
325 &config,
326 &TerminalSize::default(),
327 ZedListener(events_tx.clone()),
328 );
329
330 //Start off blinking if we need to
331 if let Some(TerminalBlink::On) = blink_settings {
332 term.set_mode(alacritty_terminal::ansi::Mode::BlinkingCursor)
333 }
334
335 //Alacritty defaults to alternate scrolling being on, so we just need to turn it off.
336 if let AlternateScroll::Off = alternate_scroll {
337 term.unset_mode(alacritty_terminal::ansi::Mode::AlternateScroll)
338 }
339
340 let term = Arc::new(FairMutex::new(term));
341
342 //Setup the pty...
343 let pty = match tty::new(
344 &pty_config,
345 TerminalSize::default().into(),
346 window.window_id().as_u64(),
347 ) {
348 Ok(pty) => pty,
349 Err(error) => {
350 bail!(TerminalError {
351 directory: working_directory,
352 shell,
353 source: error,
354 });
355 }
356 };
357
358 let fd = pty.file().as_raw_fd();
359 let shell_pid = pty.child().id();
360
361 //And connect them together
362 let event_loop = EventLoop::new(
363 term.clone(),
364 ZedListener(events_tx.clone()),
365 pty,
366 pty_config.hold,
367 false,
368 );
369
370 //Kick things off
371 let pty_tx = event_loop.channel();
372 let _io_thread = event_loop.spawn();
373
374 let terminal = Terminal {
375 pty_tx: Notifier(pty_tx),
376 term,
377 events: VecDeque::with_capacity(10), //Should never get this high.
378 last_content: Default::default(),
379 last_mouse: None,
380 matches: Vec::new(),
381 last_synced: Instant::now(),
382 sync_task: None,
383 selection_head: None,
384 shell_fd: fd as u32,
385 shell_pid,
386 foreground_process_info: None,
387 breadcrumb_text: String::new(),
388 scroll_px: px(0.),
389 last_mouse_position: None,
390 next_link_id: 0,
391 selection_phase: SelectionPhase::Ended,
392 cmd_pressed: false,
393 hovered_word: false,
394 color_for_index: Box::new(color_for_index),
395 };
396
397 Ok(TerminalBuilder {
398 terminal,
399 events_rx,
400 })
401 }
402
403 pub fn subscribe(mut self, cx: &mut ModelContext<Terminal>) -> Terminal {
404 //Event loop
405 cx.spawn_on_main(|this, mut cx| async move {
406 use futures::StreamExt;
407
408 while let Some(event) = self.events_rx.next().await {
409 this.update(&mut cx, |this, cx| {
410 //Process the first event immediately for lowered latency
411 this.process_event(&event, cx);
412 })?;
413
414 'outer: loop {
415 let mut events = vec![];
416 let mut timer = cx.executor().timer(Duration::from_millis(4)).fuse();
417 let mut wakeup = false;
418 loop {
419 futures::select_biased! {
420 _ = timer => break,
421 event = self.events_rx.next() => {
422 if let Some(event) = event {
423 if matches!(event, AlacTermEvent::Wakeup) {
424 wakeup = true;
425 } else {
426 events.push(event);
427 }
428
429 if events.len() > 100 {
430 break;
431 }
432 } else {
433 break;
434 }
435 },
436 }
437 }
438
439 if events.is_empty() && wakeup == false {
440 smol::future::yield_now().await;
441 break 'outer;
442 } else {
443 this.update(&mut cx, |this, cx| {
444 if wakeup {
445 this.process_event(&AlacTermEvent::Wakeup, cx);
446 }
447
448 for event in events {
449 this.process_event(&event, cx);
450 }
451 })?;
452 smol::future::yield_now().await;
453 }
454 }
455 }
456
457 anyhow::Ok(())
458 })
459 .detach();
460
461 self.terminal
462 }
463}
464
465#[derive(Debug, Clone, Deserialize, Serialize)]
466pub struct IndexedCell {
467 pub point: AlacPoint,
468 pub cell: Cell,
469}
470
471impl Deref for IndexedCell {
472 type Target = Cell;
473
474 #[inline]
475 fn deref(&self) -> &Cell {
476 &self.cell
477 }
478}
479
480// TODO: Un-pub
481#[derive(Clone)]
482pub struct TerminalContent {
483 pub cells: Vec<IndexedCell>,
484 pub mode: TermMode,
485 pub display_offset: usize,
486 pub selection_text: Option<String>,
487 pub selection: Option<SelectionRange>,
488 pub cursor: RenderableCursor,
489 pub cursor_char: char,
490 pub size: TerminalSize,
491 pub last_hovered_word: Option<HoveredWord>,
492}
493
494#[derive(Clone)]
495pub struct HoveredWord {
496 pub word: String,
497 pub word_match: RangeInclusive<AlacPoint>,
498 pub id: usize,
499}
500
501impl Default for TerminalContent {
502 fn default() -> Self {
503 TerminalContent {
504 cells: Default::default(),
505 mode: Default::default(),
506 display_offset: Default::default(),
507 selection_text: Default::default(),
508 selection: Default::default(),
509 cursor: RenderableCursor {
510 shape: alacritty_terminal::ansi::CursorShape::Block,
511 point: AlacPoint::new(Line(0), Column(0)),
512 },
513 cursor_char: Default::default(),
514 size: Default::default(),
515 last_hovered_word: None,
516 }
517 }
518}
519
520#[derive(PartialEq, Eq)]
521pub enum SelectionPhase {
522 Selecting,
523 Ended,
524}
525
526pub struct Terminal {
527 pty_tx: Notifier,
528 term: Arc<FairMutex<Term<ZedListener>>>,
529 events: VecDeque<InternalEvent>,
530 /// This is only used for mouse mode cell change detection
531 last_mouse: Option<(AlacPoint, AlacDirection)>,
532 /// This is only used for terminal hovered word checking
533 last_mouse_position: Option<Point<Pixels>>,
534 pub matches: Vec<RangeInclusive<AlacPoint>>,
535 pub last_content: TerminalContent,
536 last_synced: Instant,
537 sync_task: Option<Task<()>>,
538 pub selection_head: Option<AlacPoint>,
539 pub breadcrumb_text: String,
540 shell_pid: u32,
541 shell_fd: u32,
542 pub foreground_process_info: Option<LocalProcessInfo>,
543 scroll_px: Pixels,
544 next_link_id: usize,
545 selection_phase: SelectionPhase,
546 cmd_pressed: bool,
547 hovered_word: bool,
548 // An implementation of the 8 bit ANSI color palette
549 color_for_index: Box<dyn Fn(usize, &mut AppContext) -> Hsla + Send + Sync + 'static>,
550}
551
552impl Terminal {
553 fn process_event(&mut self, event: &AlacTermEvent, cx: &mut MainThread<ModelContext<Self>>) {
554 match event {
555 AlacTermEvent::Title(title) => {
556 self.breadcrumb_text = title.to_string();
557 cx.emit(Event::BreadcrumbsChanged);
558 }
559 AlacTermEvent::ResetTitle => {
560 self.breadcrumb_text = String::new();
561 cx.emit(Event::BreadcrumbsChanged);
562 }
563 AlacTermEvent::ClipboardStore(_, data) => {
564 cx.write_to_clipboard(ClipboardItem::new(data.to_string()))
565 }
566 AlacTermEvent::ClipboardLoad(_, format) => self.write_to_pty(format(
567 &cx.read_from_clipboard()
568 .map(|ci| ci.text().to_string())
569 .unwrap_or_else(|| "".to_string()),
570 )),
571 AlacTermEvent::PtyWrite(out) => self.write_to_pty(out.clone()),
572 AlacTermEvent::TextAreaSizeRequest(format) => {
573 self.write_to_pty(format(self.last_content.size.into()))
574 }
575 AlacTermEvent::CursorBlinkingChange => {
576 cx.emit(Event::BlinkChanged);
577 }
578 AlacTermEvent::Bell => {
579 cx.emit(Event::Bell);
580 }
581 AlacTermEvent::Exit => cx.emit(Event::CloseTerminal),
582 AlacTermEvent::MouseCursorDirty => {
583 //NOOP, Handled in render
584 }
585 AlacTermEvent::Wakeup => {
586 cx.emit(Event::Wakeup);
587
588 if self.update_process_info() {
589 cx.emit(Event::TitleChanged);
590 }
591 }
592 AlacTermEvent::ColorRequest(idx, fun_ptr) => {
593 self.events
594 .push_back(InternalEvent::ColorRequest(*idx, fun_ptr.clone()));
595 }
596 }
597 }
598
599 /// Update the cached process info, returns whether the Zed-relevant info has changed
600 fn update_process_info(&mut self) -> bool {
601 let mut pid = unsafe { libc::tcgetpgrp(self.shell_fd as i32) };
602 if pid < 0 {
603 pid = self.shell_pid as i32;
604 }
605
606 if let Some(process_info) = LocalProcessInfo::with_root_pid(pid as u32) {
607 let res = self
608 .foreground_process_info
609 .as_ref()
610 .map(|old_info| {
611 process_info.cwd != old_info.cwd || process_info.name != old_info.name
612 })
613 .unwrap_or(true);
614
615 self.foreground_process_info = Some(process_info.clone());
616
617 res
618 } else {
619 false
620 }
621 }
622
623 ///Takes events from Alacritty and translates them to behavior on this view
624 fn process_terminal_event(
625 &mut self,
626 event: &InternalEvent,
627 term: &mut Term<ZedListener>,
628 cx: &mut ModelContext<Self>,
629 ) {
630 match event {
631 InternalEvent::ColorRequest(index, format) => {
632 let color = term.colors()[*index]
633 .unwrap_or_else(|| to_alac_rgb((self.color_for_index)(*index, cx)));
634 self.write_to_pty(format(color))
635 }
636 InternalEvent::Resize(mut new_size) => {
637 new_size.size.height = cmp::max(new_size.line_height, new_size.height());
638 new_size.size.width = cmp::max(new_size.cell_width, new_size.width());
639
640 self.last_content.size = new_size.clone();
641
642 self.pty_tx.0.send(Msg::Resize(new_size.into())).ok();
643
644 term.resize(new_size);
645 }
646 InternalEvent::Clear => {
647 // Clear back buffer
648 term.clear_screen(ClearMode::Saved);
649
650 let cursor = term.grid().cursor.point;
651
652 // Clear the lines above
653 term.grid_mut().reset_region(..cursor.line);
654
655 // Copy the current line up
656 let line = term.grid()[cursor.line][..Column(term.grid().columns())]
657 .iter()
658 .cloned()
659 .enumerate()
660 .collect::<Vec<(usize, Cell)>>();
661
662 for (i, cell) in line {
663 term.grid_mut()[Line(0)][Column(i)] = cell;
664 }
665
666 // Reset the cursor
667 term.grid_mut().cursor.point =
668 AlacPoint::new(Line(0), term.grid_mut().cursor.point.column);
669 let new_cursor = term.grid().cursor.point;
670
671 // Clear the lines below the new cursor
672 if (new_cursor.line.0 as usize) < term.screen_lines() - 1 {
673 term.grid_mut().reset_region((new_cursor.line + 1)..);
674 }
675
676 cx.emit(Event::Wakeup);
677 }
678 InternalEvent::Scroll(scroll) => {
679 term.scroll_display(*scroll);
680 self.refresh_hovered_word();
681 }
682 InternalEvent::SetSelection(selection) => {
683 term.selection = selection.as_ref().map(|(sel, _)| sel.clone());
684
685 if let Some((_, head)) = selection {
686 self.selection_head = Some(*head);
687 }
688 cx.emit(Event::SelectionsChanged)
689 }
690 InternalEvent::UpdateSelection(position) => {
691 if let Some(mut selection) = term.selection.take() {
692 let point = grid_point(
693 *position,
694 self.last_content.size,
695 term.grid().display_offset(),
696 );
697
698 let side = mouse_side(*position, self.last_content.size);
699
700 selection.update(point, side);
701 term.selection = Some(selection);
702
703 self.selection_head = Some(point);
704 cx.emit(Event::SelectionsChanged)
705 }
706 }
707
708 InternalEvent::Copy => {
709 if let Some(txt) = term.selection_to_string() {
710 cx.run_on_main(|cx| cx.write_to_clipboard(ClipboardItem::new(txt)))
711 .detach();
712 }
713 }
714 InternalEvent::ScrollToAlacPoint(point) => {
715 term.scroll_to_point(*point);
716 self.refresh_hovered_word();
717 }
718 InternalEvent::FindHyperlink(position, open) => {
719 let prev_hovered_word = self.last_content.last_hovered_word.take();
720
721 let point = grid_point(
722 *position,
723 self.last_content.size,
724 term.grid().display_offset(),
725 )
726 .grid_clamp(term, Boundary::Grid);
727
728 let link = term.grid().index(point).hyperlink();
729 let found_word = if link.is_some() {
730 let mut min_index = point;
731 loop {
732 let new_min_index = min_index.sub(term, Boundary::Cursor, 1);
733 if new_min_index == min_index {
734 break;
735 } else if term.grid().index(new_min_index).hyperlink() != link {
736 break;
737 } else {
738 min_index = new_min_index
739 }
740 }
741
742 let mut max_index = point;
743 loop {
744 let new_max_index = max_index.add(term, Boundary::Cursor, 1);
745 if new_max_index == max_index {
746 break;
747 } else if term.grid().index(new_max_index).hyperlink() != link {
748 break;
749 } else {
750 max_index = new_max_index
751 }
752 }
753
754 let url = link.unwrap().uri().to_owned();
755 let url_match = min_index..=max_index;
756
757 Some((url, true, url_match))
758 } else if let Some(word_match) = regex_match_at(term, point, &WORD_REGEX) {
759 let maybe_url_or_path =
760 term.bounds_to_string(*word_match.start(), *word_match.end());
761 let original_match = word_match.clone();
762 let (sanitized_match, sanitized_word) =
763 if maybe_url_or_path.starts_with('[') && maybe_url_or_path.ends_with(']') {
764 (
765 Match::new(
766 word_match.start().add(term, Boundary::Cursor, 1),
767 word_match.end().sub(term, Boundary::Cursor, 1),
768 ),
769 maybe_url_or_path[1..maybe_url_or_path.len() - 1].to_owned(),
770 )
771 } else {
772 (word_match, maybe_url_or_path)
773 };
774
775 let is_url = match regex_match_at(term, point, &URL_REGEX) {
776 Some(url_match) => {
777 // `]` is a valid symbol in the `file://` URL, so the regex match will include it
778 // consider that when ensuring that the URL match is the same as the original word
779 if sanitized_match != original_match {
780 url_match.start() == sanitized_match.start()
781 && url_match.end() == original_match.end()
782 } else {
783 url_match == sanitized_match
784 }
785 }
786 None => false,
787 };
788 Some((sanitized_word, is_url, sanitized_match))
789 } else {
790 None
791 };
792
793 match found_word {
794 Some((maybe_url_or_path, is_url, url_match)) => {
795 if *open {
796 let target = if is_url {
797 MaybeNavigationTarget::Url(maybe_url_or_path)
798 } else {
799 MaybeNavigationTarget::PathLike(maybe_url_or_path)
800 };
801 cx.emit(Event::Open(target));
802 } else {
803 self.update_selected_word(
804 prev_hovered_word,
805 url_match,
806 maybe_url_or_path,
807 is_url,
808 cx,
809 );
810 }
811 self.hovered_word = true;
812 }
813 None => {
814 if self.hovered_word {
815 cx.emit(Event::NewNavigationTarget(None));
816 }
817 self.hovered_word = false;
818 }
819 }
820 }
821 }
822 }
823
824 fn update_selected_word(
825 &mut self,
826 prev_word: Option<HoveredWord>,
827 word_match: RangeInclusive<AlacPoint>,
828 word: String,
829 is_url: bool,
830 cx: &mut ModelContext<Self>,
831 ) {
832 if let Some(prev_word) = prev_word {
833 if prev_word.word == word && prev_word.word_match == word_match {
834 self.last_content.last_hovered_word = Some(HoveredWord {
835 word,
836 word_match,
837 id: prev_word.id,
838 });
839 return;
840 }
841 }
842
843 self.last_content.last_hovered_word = Some(HoveredWord {
844 word: word.clone(),
845 word_match,
846 id: self.next_link_id(),
847 });
848 let navigation_target = if is_url {
849 MaybeNavigationTarget::Url(word)
850 } else {
851 MaybeNavigationTarget::PathLike(word)
852 };
853 cx.emit(Event::NewNavigationTarget(Some(navigation_target)));
854 }
855
856 fn next_link_id(&mut self) -> usize {
857 let res = self.next_link_id;
858 self.next_link_id = self.next_link_id.wrapping_add(1);
859 res
860 }
861
862 pub fn last_content(&self) -> &TerminalContent {
863 &self.last_content
864 }
865
866 //To test:
867 //- Activate match on terminal (scrolling and selection)
868 //- Editor search snapping behavior
869
870 pub fn activate_match(&mut self, index: usize) {
871 if let Some(search_match) = self.matches.get(index).cloned() {
872 self.set_selection(Some((make_selection(&search_match), *search_match.end())));
873
874 self.events
875 .push_back(InternalEvent::ScrollToAlacPoint(*search_match.start()));
876 }
877 }
878
879 pub fn select_matches(&mut self, matches: Vec<RangeInclusive<AlacPoint>>) {
880 let matches_to_select = self
881 .matches
882 .iter()
883 .filter(|self_match| matches.contains(self_match))
884 .cloned()
885 .collect::<Vec<_>>();
886 for match_to_select in matches_to_select {
887 self.set_selection(Some((
888 make_selection(&match_to_select),
889 *match_to_select.end(),
890 )));
891 }
892 }
893
894 pub fn select_all(&mut self) {
895 let term = self.term.lock();
896 let start = AlacPoint::new(term.topmost_line(), Column(0));
897 let end = AlacPoint::new(term.bottommost_line(), term.last_column());
898 drop(term);
899 self.set_selection(Some((make_selection(&(start..=end)), end)));
900 }
901
902 fn set_selection(&mut self, selection: Option<(Selection, AlacPoint)>) {
903 self.events
904 .push_back(InternalEvent::SetSelection(selection));
905 }
906
907 pub fn copy(&mut self) {
908 self.events.push_back(InternalEvent::Copy);
909 }
910
911 pub fn clear(&mut self) {
912 self.events.push_back(InternalEvent::Clear)
913 }
914
915 ///Resize the terminal and the PTY.
916 pub fn set_size(&mut self, new_size: TerminalSize) {
917 self.events.push_back(InternalEvent::Resize(new_size))
918 }
919
920 ///Write the Input payload to the tty.
921 fn write_to_pty(&self, input: String) {
922 self.pty_tx.notify(input.into_bytes());
923 }
924
925 fn write_bytes_to_pty(&self, input: Vec<u8>) {
926 self.pty_tx.notify(input);
927 }
928
929 pub fn input(&mut self, input: String) {
930 self.events
931 .push_back(InternalEvent::Scroll(AlacScroll::Bottom));
932 self.events.push_back(InternalEvent::SetSelection(None));
933
934 self.write_to_pty(input);
935 }
936
937 pub fn input_bytes(&mut self, input: Vec<u8>) {
938 self.events
939 .push_back(InternalEvent::Scroll(AlacScroll::Bottom));
940 self.events.push_back(InternalEvent::SetSelection(None));
941
942 self.write_bytes_to_pty(input);
943 }
944
945 pub fn try_keystroke(&mut self, keystroke: &Keystroke, alt_is_meta: bool) -> bool {
946 let esc = to_esc_str(keystroke, &self.last_content.mode, alt_is_meta);
947 if let Some(esc) = esc {
948 self.input(esc);
949 true
950 } else {
951 false
952 }
953 }
954
955 pub fn try_modifiers_change(&mut self, modifiers: &Modifiers) -> bool {
956 let changed = self.cmd_pressed != modifiers.command;
957 if !self.cmd_pressed && modifiers.command {
958 self.refresh_hovered_word();
959 }
960 self.cmd_pressed = modifiers.command;
961 changed
962 }
963
964 ///Paste text into the terminal
965 pub fn paste(&mut self, text: &str) {
966 let paste_text = if self.last_content.mode.contains(TermMode::BRACKETED_PASTE) {
967 format!("{}{}{}", "\x1b[200~", text.replace('\x1b', ""), "\x1b[201~")
968 } else {
969 text.replace("\r\n", "\r").replace('\n', "\r")
970 };
971
972 self.input(paste_text);
973 }
974
975 pub fn try_sync(&mut self, cx: &mut ModelContext<Self>) {
976 let term = self.term.clone();
977
978 let mut terminal = if let Some(term) = term.try_lock_unfair() {
979 term
980 } else if self.last_synced.elapsed().as_secs_f32() > 0.25 {
981 term.lock_unfair() //It's been too long, force block
982 } else if let None = self.sync_task {
983 //Skip this frame
984 let delay = cx.executor().timer(Duration::from_millis(16));
985 self.sync_task = Some(cx.spawn(|weak_handle, mut cx| async move {
986 delay.await;
987 if let Some(handle) = weak_handle.upgrade() {
988 handle
989 .update(&mut cx, |terminal, cx| {
990 terminal.sync_task.take();
991 cx.notify();
992 })
993 .ok();
994 }
995 }));
996 return;
997 } else {
998 //No lock and delayed rendering already scheduled, nothing to do
999 return;
1000 };
1001
1002 //Note that the ordering of events matters for event processing
1003 while let Some(e) = self.events.pop_front() {
1004 self.process_terminal_event(&e, &mut terminal, cx)
1005 }
1006
1007 self.last_content = Self::make_content(&terminal, &self.last_content);
1008 self.last_synced = Instant::now();
1009 }
1010
1011 fn make_content(term: &Term<ZedListener>, last_content: &TerminalContent) -> TerminalContent {
1012 let content = term.renderable_content();
1013 TerminalContent {
1014 cells: content
1015 .display_iter
1016 //TODO: Add this once there's a way to retain empty lines
1017 // .filter(|ic| {
1018 // !ic.flags.contains(Flags::HIDDEN)
1019 // && !(ic.bg == Named(NamedColor::Background)
1020 // && ic.c == ' '
1021 // && !ic.flags.contains(Flags::INVERSE))
1022 // })
1023 .map(|ic| IndexedCell {
1024 point: ic.point,
1025 cell: ic.cell.clone(),
1026 })
1027 .collect::<Vec<IndexedCell>>(),
1028 mode: content.mode,
1029 display_offset: content.display_offset,
1030 selection_text: term.selection_to_string(),
1031 selection: content.selection,
1032 cursor: content.cursor,
1033 cursor_char: term.grid()[content.cursor.point].c,
1034 size: last_content.size,
1035 last_hovered_word: last_content.last_hovered_word.clone(),
1036 }
1037 }
1038
1039 pub fn focus_in(&self) {
1040 if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) {
1041 self.write_to_pty("\x1b[I".to_string());
1042 }
1043 }
1044
1045 pub fn focus_out(&mut self) {
1046 self.last_mouse_position = None;
1047 if self.last_content.mode.contains(TermMode::FOCUS_IN_OUT) {
1048 self.write_to_pty("\x1b[O".to_string());
1049 }
1050 }
1051
1052 pub fn mouse_changed(&mut self, point: AlacPoint, side: AlacDirection) -> bool {
1053 match self.last_mouse {
1054 Some((old_point, old_side)) => {
1055 if old_point == point && old_side == side {
1056 false
1057 } else {
1058 self.last_mouse = Some((point, side));
1059 true
1060 }
1061 }
1062 None => {
1063 self.last_mouse = Some((point, side));
1064 true
1065 }
1066 }
1067 }
1068
1069 pub fn mouse_mode(&self, shift: bool) -> bool {
1070 self.last_content.mode.intersects(TermMode::MOUSE_MODE) && !shift
1071 }
1072
1073 pub fn mouse_move(&mut self, e: &MouseMoveEvent, origin: Point<Pixels>) {
1074 let position = e.position - origin;
1075 self.last_mouse_position = Some(position);
1076 if self.mouse_mode(e.modifiers.shift) {
1077 let point = grid_point(
1078 position,
1079 self.last_content.size,
1080 self.last_content.display_offset,
1081 );
1082 let side = mouse_side(position, self.last_content.size);
1083
1084 if self.mouse_changed(point, side) {
1085 if let Some(bytes) = mouse_moved_report(point, e, self.last_content.mode) {
1086 self.pty_tx.notify(bytes);
1087 }
1088 }
1089 } else if self.cmd_pressed {
1090 self.word_from_position(Some(position));
1091 }
1092 }
1093
1094 fn word_from_position(&mut self, position: Option<Point<Pixels>>) {
1095 if self.selection_phase == SelectionPhase::Selecting {
1096 self.last_content.last_hovered_word = None;
1097 } else if let Some(position) = position {
1098 self.events
1099 .push_back(InternalEvent::FindHyperlink(position, false));
1100 }
1101 }
1102
1103 pub fn mouse_drag(&mut self, e: MouseMoveEvent, origin: Point<Pixels>, region: Bounds<Pixels>) {
1104 let position = e.position - origin;
1105 self.last_mouse_position = Some(position);
1106
1107 if !self.mouse_mode(e.modifiers.shift) {
1108 self.selection_phase = SelectionPhase::Selecting;
1109 // Alacritty has the same ordering, of first updating the selection
1110 // then scrolling 15ms later
1111 self.events
1112 .push_back(InternalEvent::UpdateSelection(position));
1113
1114 // Doesn't make sense to scroll the alt screen
1115 if !self.last_content.mode.contains(TermMode::ALT_SCREEN) {
1116 let scroll_delta = match self.drag_line_delta(e, region) {
1117 Some(value) => value,
1118 None => return,
1119 };
1120
1121 let scroll_lines =
1122 (scroll_delta / self.last_content.size.line_height).as_isize() as i32;
1123
1124 self.events
1125 .push_back(InternalEvent::Scroll(AlacScroll::Delta(scroll_lines)));
1126 }
1127 }
1128 }
1129
1130 fn drag_line_delta(&mut self, e: MouseMoveEvent, region: Bounds<Pixels>) -> Option<Pixels> {
1131 //TODO: Why do these need to be doubled? Probably the same problem that the IME has
1132 let top = region.origin.y + (self.last_content.size.line_height * 2.);
1133 let bottom = region.lower_left().y - (self.last_content.size.line_height * 2.);
1134 let scroll_delta = if e.position.y < top {
1135 (top - e.position.y).pow(1.1)
1136 } else if e.position.y > bottom {
1137 -((e.position.y - bottom).pow(1.1))
1138 } else {
1139 return None; //Nothing to do
1140 };
1141 Some(scroll_delta)
1142 }
1143
1144 pub fn mouse_down(&mut self, e: &MouseDownEvent, origin: Point<Pixels>) {
1145 let position = e.position - origin;
1146 let point = grid_point(
1147 position,
1148 self.last_content.size,
1149 self.last_content.display_offset,
1150 );
1151
1152 if self.mouse_mode(e.modifiers.shift) {
1153 if let Some(bytes) =
1154 mouse_button_report(point, e.button, e.modifiers, true, self.last_content.mode)
1155 {
1156 self.pty_tx.notify(bytes);
1157 }
1158 } else if e.button == MouseButton::Left {
1159 let position = e.position - origin;
1160 let point = grid_point(
1161 position,
1162 self.last_content.size,
1163 self.last_content.display_offset,
1164 );
1165
1166 // Use .opposite so that selection is inclusive of the cell clicked.
1167 let side = mouse_side(position, self.last_content.size);
1168
1169 let selection_type = match e.click_count {
1170 0 => return, //This is a release
1171 1 => Some(SelectionType::Simple),
1172 2 => Some(SelectionType::Semantic),
1173 3 => Some(SelectionType::Lines),
1174 _ => None,
1175 };
1176
1177 let selection =
1178 selection_type.map(|selection_type| Selection::new(selection_type, point, side));
1179
1180 if let Some(sel) = selection {
1181 self.events
1182 .push_back(InternalEvent::SetSelection(Some((sel, point))));
1183 }
1184 }
1185 }
1186
1187 pub fn mouse_up(
1188 &mut self,
1189 e: &MouseUpEvent,
1190 origin: Point<Pixels>,
1191 cx: &mut MainThread<ModelContext<Self>>,
1192 ) {
1193 let setting = settings2::get::<TerminalSettings>(cx);
1194
1195 let position = e.position - origin;
1196 if self.mouse_mode(e.modifiers.shift) {
1197 let point = grid_point(
1198 position,
1199 self.last_content.size,
1200 self.last_content.display_offset,
1201 );
1202
1203 if let Some(bytes) =
1204 mouse_button_report(point, e.button, e.modifiers, false, self.last_content.mode)
1205 {
1206 self.pty_tx.notify(bytes);
1207 }
1208 } else {
1209 if e.button == MouseButton::Left && setting.copy_on_select {
1210 self.copy();
1211 }
1212
1213 //Hyperlinks
1214 if self.selection_phase == SelectionPhase::Ended {
1215 let mouse_cell_index = content_index_for_mouse(position, &self.last_content.size);
1216 if let Some(link) = self.last_content.cells[mouse_cell_index].hyperlink() {
1217 cx.open_url(link.uri());
1218 } else if self.cmd_pressed {
1219 self.events
1220 .push_back(InternalEvent::FindHyperlink(position, true));
1221 }
1222 }
1223 }
1224
1225 self.selection_phase = SelectionPhase::Ended;
1226 self.last_mouse = None;
1227 }
1228
1229 ///Scroll the terminal
1230 pub fn scroll_wheel(&mut self, e: ScrollWheelEvent, origin: Point<Pixels>) {
1231 let mouse_mode = self.mouse_mode(e.shift);
1232
1233 if let Some(scroll_lines) = self.determine_scroll_lines(&e, mouse_mode) {
1234 if mouse_mode {
1235 let point = grid_point(
1236 e.position - origin,
1237 self.last_content.size,
1238 self.last_content.display_offset,
1239 );
1240
1241 if let Some(scrolls) =
1242 scroll_report(point, scroll_lines as i32, &e, self.last_content.mode)
1243 {
1244 for scroll in scrolls {
1245 self.pty_tx.notify(scroll);
1246 }
1247 };
1248 } else if self
1249 .last_content
1250 .mode
1251 .contains(TermMode::ALT_SCREEN | TermMode::ALTERNATE_SCROLL)
1252 && !e.shift
1253 {
1254 self.pty_tx.notify(alt_scroll(scroll_lines))
1255 } else {
1256 if scroll_lines != 0 {
1257 let scroll = AlacScroll::Delta(scroll_lines);
1258
1259 self.events.push_back(InternalEvent::Scroll(scroll));
1260 }
1261 }
1262 }
1263 }
1264
1265 fn refresh_hovered_word(&mut self) {
1266 self.word_from_position(self.last_mouse_position);
1267 }
1268
1269 fn determine_scroll_lines(&mut self, e: &ScrollWheelEvent, mouse_mode: bool) -> Option<i32> {
1270 let scroll_multiplier = if mouse_mode { 1. } else { SCROLL_MULTIPLIER };
1271 let line_height = self.last_content.size.line_height;
1272 match e.touch_phase {
1273 /* Reset scroll state on started */
1274 TouchPhase::Started => {
1275 self.scroll_px = px(0.);
1276 None
1277 }
1278 /* Calculate the appropriate scroll lines */
1279 TouchPhase::Moved => {
1280 let old_offset = (self.scroll_px / line_height).as_isize() as i32;
1281
1282 self.scroll_px += e.delta.pixel_delta(line_height).y * scroll_multiplier;
1283
1284 let new_offset = (self.scroll_px / line_height).as_isize() as i32;
1285
1286 // Whenever we hit the edges, reset our stored scroll to 0
1287 // so we can respond to changes in direction quickly
1288 self.scroll_px %= self.last_content.size.height();
1289
1290 Some(new_offset - old_offset)
1291 }
1292 TouchPhase::Ended => None,
1293 }
1294 }
1295
1296 pub fn find_matches(
1297 &mut self,
1298 searcher: RegexSearch,
1299 cx: &mut ModelContext<Self>,
1300 ) -> Task<Vec<RangeInclusive<AlacPoint>>> {
1301 let term = self.term.clone();
1302 cx.executor().spawn(async move {
1303 let term = term.lock();
1304
1305 all_search_matches(&term, &searcher).collect()
1306 })
1307 }
1308
1309 pub fn title(&self) -> String {
1310 self.foreground_process_info
1311 .as_ref()
1312 .map(|fpi| {
1313 format!(
1314 "{} — {}",
1315 truncate_and_trailoff(
1316 &fpi.cwd
1317 .file_name()
1318 .map(|name| name.to_string_lossy().to_string())
1319 .unwrap_or_default(),
1320 25
1321 ),
1322 truncate_and_trailoff(
1323 &{
1324 format!(
1325 "{}{}",
1326 fpi.name,
1327 if fpi.argv.len() >= 1 {
1328 format!(" {}", (&fpi.argv[1..]).join(" "))
1329 } else {
1330 "".to_string()
1331 }
1332 )
1333 },
1334 25
1335 )
1336 )
1337 })
1338 .unwrap_or_else(|| "Terminal".to_string())
1339 }
1340
1341 pub fn can_navigate_to_selected_word(&self) -> bool {
1342 self.cmd_pressed && self.hovered_word
1343 }
1344}
1345
1346impl Drop for Terminal {
1347 fn drop(&mut self) {
1348 self.pty_tx.0.send(Msg::Shutdown).ok();
1349 }
1350}
1351
1352impl EventEmitter for Terminal {
1353 type Event = Event;
1354}
1355
1356/// Based on alacritty/src/display/hint.rs > regex_match_at
1357/// Retrieve the match, if the specified point is inside the content matching the regex.
1358fn regex_match_at<T>(term: &Term<T>, point: AlacPoint, regex: &RegexSearch) -> Option<Match> {
1359 visible_regex_match_iter(term, regex).find(|rm| rm.contains(&point))
1360}
1361
1362/// Copied from alacritty/src/display/hint.rs:
1363/// Iterate over all visible regex matches.
1364pub fn visible_regex_match_iter<'a, T>(
1365 term: &'a Term<T>,
1366 regex: &'a RegexSearch,
1367) -> impl Iterator<Item = Match> + 'a {
1368 let viewport_start = Line(-(term.grid().display_offset() as i32));
1369 let viewport_end = viewport_start + term.bottommost_line();
1370 let mut start = term.line_search_left(AlacPoint::new(viewport_start, Column(0)));
1371 let mut end = term.line_search_right(AlacPoint::new(viewport_end, Column(0)));
1372 start.line = start.line.max(viewport_start - MAX_SEARCH_LINES);
1373 end.line = end.line.min(viewport_end + MAX_SEARCH_LINES);
1374
1375 RegexIter::new(start, end, AlacDirection::Right, term, regex)
1376 .skip_while(move |rm| rm.end().line < viewport_start)
1377 .take_while(move |rm| rm.start().line <= viewport_end)
1378}
1379
1380fn make_selection(range: &RangeInclusive<AlacPoint>) -> Selection {
1381 let mut selection = Selection::new(SelectionType::Simple, *range.start(), AlacDirection::Left);
1382 selection.update(*range.end(), AlacDirection::Right);
1383 selection
1384}
1385
1386fn all_search_matches<'a, T>(
1387 term: &'a Term<T>,
1388 regex: &'a RegexSearch,
1389) -> impl Iterator<Item = Match> + 'a {
1390 let start = AlacPoint::new(term.grid().topmost_line(), Column(0));
1391 let end = AlacPoint::new(term.grid().bottommost_line(), term.grid().last_column());
1392 RegexIter::new(start, end, AlacDirection::Right, term, regex)
1393}
1394
1395fn content_index_for_mouse(pos: Point<Pixels>, size: &TerminalSize) -> usize {
1396 let col = (pos.x / size.cell_width()).round().as_usize();
1397 let clamped_col = min(col, size.columns() - 1);
1398 let row = (pos.y / size.line_height()).round().as_usize();
1399 let clamped_row = min(row, size.screen_lines() - 1);
1400 clamped_row * size.columns() + clamped_col
1401}
1402
1403#[cfg(test)]
1404mod tests {
1405 use alacritty_terminal::{
1406 index::{Column, Line, Point as AlacPoint},
1407 term::cell::Cell,
1408 };
1409 use gpui2::{point, size, Pixels};
1410 use rand::{distributions::Alphanumeric, rngs::ThreadRng, thread_rng, Rng};
1411
1412 use crate::{content_index_for_mouse, IndexedCell, TerminalContent, TerminalSize};
1413
1414 #[test]
1415 fn test_mouse_to_cell_test() {
1416 let mut rng = thread_rng();
1417 const ITERATIONS: usize = 10;
1418 const PRECISION: usize = 1000;
1419
1420 for _ in 0..ITERATIONS {
1421 let viewport_cells = rng.gen_range(15..20);
1422 let cell_size = rng.gen_range(5 * PRECISION..20 * PRECISION) as f32 / PRECISION as f32;
1423
1424 let size = crate::TerminalSize {
1425 cell_width: Pixels::from(cell_size),
1426 line_height: Pixels::from(cell_size),
1427 size: size(
1428 Pixels::from(cell_size * (viewport_cells as f32)),
1429 Pixels::from(cell_size * (viewport_cells as f32)),
1430 ),
1431 };
1432
1433 let cells = get_cells(size, &mut rng);
1434 let content = convert_cells_to_content(size, &cells);
1435
1436 for row in 0..(viewport_cells - 1) {
1437 let row = row as usize;
1438 for col in 0..(viewport_cells - 1) {
1439 let col = col as usize;
1440
1441 let row_offset = rng.gen_range(0..PRECISION) as f32 / PRECISION as f32;
1442 let col_offset = rng.gen_range(0..PRECISION) as f32 / PRECISION as f32;
1443
1444 let mouse_pos = point(
1445 Pixels::from(col as f32 * cell_size + col_offset),
1446 Pixels::from(row as f32 * cell_size + row_offset),
1447 );
1448
1449 let content_index = content_index_for_mouse(mouse_pos, &content.size);
1450 let mouse_cell = content.cells[content_index].c;
1451 let real_cell = cells[row][col];
1452
1453 assert_eq!(mouse_cell, real_cell);
1454 }
1455 }
1456 }
1457 }
1458
1459 #[test]
1460 fn test_mouse_to_cell_clamp() {
1461 let mut rng = thread_rng();
1462
1463 let size = crate::TerminalSize {
1464 cell_width: Pixels::from(10.),
1465 line_height: Pixels::from(10.),
1466 size: size(Pixels::from(100.), Pixels::from(100.)),
1467 };
1468
1469 let cells = get_cells(size, &mut rng);
1470 let content = convert_cells_to_content(size, &cells);
1471
1472 assert_eq!(
1473 content.cells[content_index_for_mouse(
1474 point(Pixels::from(-10.), Pixels::from(-10.)),
1475 &content.size
1476 )]
1477 .c,
1478 cells[0][0]
1479 );
1480 assert_eq!(
1481 content.cells[content_index_for_mouse(
1482 point(Pixels::from(1000.), Pixels::from(1000.)),
1483 &content.size
1484 )]
1485 .c,
1486 cells[9][9]
1487 );
1488 }
1489
1490 fn get_cells(size: TerminalSize, rng: &mut ThreadRng) -> Vec<Vec<char>> {
1491 let mut cells = Vec::new();
1492
1493 for _ in 0..(f32::from(size.height() / size.line_height()) as usize) {
1494 let mut row_vec = Vec::new();
1495 for _ in 0..(f32::from(size.width() / size.cell_width()) as usize) {
1496 let cell_char = rng.sample(Alphanumeric) as char;
1497 row_vec.push(cell_char)
1498 }
1499 cells.push(row_vec)
1500 }
1501
1502 cells
1503 }
1504
1505 fn convert_cells_to_content(size: TerminalSize, cells: &Vec<Vec<char>>) -> TerminalContent {
1506 let mut ic = Vec::new();
1507
1508 for row in 0..cells.len() {
1509 for col in 0..cells[row].len() {
1510 let cell_char = cells[row][col];
1511 ic.push(IndexedCell {
1512 point: AlacPoint::new(Line(row as i32), Column(col)),
1513 cell: Cell {
1514 c: cell_char,
1515 ..Default::default()
1516 },
1517 });
1518 }
1519 }
1520
1521 TerminalContent {
1522 cells: ic,
1523 size,
1524 ..Default::default()
1525 }
1526 }
1527}