terminal.rs

  1mod color_translation;
  2pub mod connection;
  3mod modal;
  4pub mod terminal_element;
  5
  6use alacritty_terminal::{
  7    event::{Event as AlacTermEvent, EventListener},
  8    grid::Scroll,
  9    term::SizeInfo,
 10};
 11
 12use connection::{Event, TerminalConnection};
 13use dirs::home_dir;
 14use editor::Input;
 15use futures::channel::mpsc::UnboundedSender;
 16use gpui::{
 17    actions, elements::*, impl_internal_actions, AppContext, ClipboardItem, Entity, ModelHandle,
 18    MutableAppContext, View, ViewContext,
 19};
 20use modal::deploy_modal;
 21
 22use project::{Project, ProjectPath};
 23use settings::Settings;
 24use smallvec::SmallVec;
 25use std::path::PathBuf;
 26use workspace::{Item, Workspace};
 27
 28use crate::terminal_element::TerminalEl;
 29
 30//ASCII Control characters on a keyboard
 31const ETX_CHAR: char = 3_u8 as char; //'End of text', the control code for 'ctrl-c'
 32const TAB_CHAR: char = 9_u8 as char;
 33const CARRIAGE_RETURN_CHAR: char = 13_u8 as char;
 34const ESC_CHAR: char = 27_u8 as char; // == \x1b
 35const DEL_CHAR: char = 127_u8 as char;
 36const LEFT_SEQ: &str = "\x1b[D";
 37const RIGHT_SEQ: &str = "\x1b[C";
 38const UP_SEQ: &str = "\x1b[A";
 39const DOWN_SEQ: &str = "\x1b[B";
 40const DEBUG_TERMINAL_WIDTH: f32 = 1000.; //This needs to be wide enough that the prompt can fill the whole space.
 41const DEBUG_TERMINAL_HEIGHT: f32 = 200.;
 42const DEBUG_CELL_WIDTH: f32 = 5.;
 43const DEBUG_LINE_HEIGHT: f32 = 5.;
 44
 45//For bel, use a yellow dot. (equivalent to dirty file with conflict)
 46//For title, introduce max title length and
 47
 48///Event to transmit the scroll from the element to the view
 49#[derive(Clone, Debug, PartialEq)]
 50pub struct ScrollTerminal(pub i32);
 51
 52actions!(
 53    terminal,
 54    [
 55        Sigint,
 56        Escape,
 57        Del,
 58        Return,
 59        Left,
 60        Right,
 61        Up,
 62        Down,
 63        Tab,
 64        Clear,
 65        Copy,
 66        Paste,
 67        Deploy,
 68        Quit,
 69        DeployModal,
 70    ]
 71);
 72impl_internal_actions!(terminal, [ScrollTerminal]);
 73
 74///Initialize and register all of our action handlers
 75pub fn init(cx: &mut MutableAppContext) {
 76    cx.add_action(Terminal::deploy);
 77    cx.add_action(Terminal::send_sigint);
 78    cx.add_action(Terminal::escape);
 79    cx.add_action(Terminal::quit);
 80    cx.add_action(Terminal::del);
 81    cx.add_action(Terminal::carriage_return);
 82    cx.add_action(Terminal::left);
 83    cx.add_action(Terminal::right);
 84    cx.add_action(Terminal::up);
 85    cx.add_action(Terminal::down);
 86    cx.add_action(Terminal::tab);
 87    cx.add_action(Terminal::copy);
 88    cx.add_action(Terminal::paste);
 89    cx.add_action(Terminal::scroll_terminal);
 90    cx.add_action(Terminal::input);
 91    cx.add_action(Terminal::clear);
 92    cx.add_action(deploy_modal);
 93}
 94
 95///A translation struct for Alacritty to communicate with us from their event loop
 96#[derive(Clone)]
 97pub struct ZedListener(UnboundedSender<AlacTermEvent>);
 98
 99impl EventListener for ZedListener {
100    fn send_event(&self, event: AlacTermEvent) {
101        self.0.unbounded_send(event).ok();
102    }
103}
104
105///A terminal view, maintains the PTY's file handles and communicates with the terminal
106pub struct Terminal {
107    connection: ModelHandle<TerminalConnection>,
108    has_new_content: bool,
109    //Currently using iTerm bell, show bell emoji in tab until input is received
110    has_bell: bool,
111    // Only for styling purposes. Doesn't effect behavior
112    modal: bool,
113}
114
115impl Entity for Terminal {
116    type Event = Event;
117}
118
119impl Terminal {
120    ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices
121    ///To get the right working directory from a workspace, use: `get_wd_for_workspace()`
122    fn new(working_directory: Option<PathBuf>, modal: bool, cx: &mut ViewContext<Self>) -> Self {
123        //The details here don't matter, the terminal will be resized on the first layout
124        let size_info = SizeInfo::new(
125            DEBUG_TERMINAL_WIDTH,
126            DEBUG_TERMINAL_HEIGHT,
127            DEBUG_CELL_WIDTH,
128            DEBUG_LINE_HEIGHT,
129            0.,
130            0.,
131            false,
132        );
133
134        let connection =
135            cx.add_model(|cx| TerminalConnection::new(working_directory, size_info, cx));
136
137        Terminal::from_connection(connection, modal, cx)
138    }
139
140    fn from_connection(
141        connection: ModelHandle<TerminalConnection>,
142        modal: bool,
143        cx: &mut ViewContext<Self>,
144    ) -> Terminal {
145        cx.observe(&connection, |_, _, cx| cx.notify()).detach();
146        cx.subscribe(&connection, |this, _, event, cx| match event {
147            Event::Wakeup => {
148                if cx.is_self_focused() {
149                    cx.notify()
150                } else {
151                    this.has_new_content = true;
152                    cx.emit(Event::TitleChanged);
153                }
154            }
155            Event::Bell => {
156                this.has_bell = true;
157                cx.emit(Event::TitleChanged);
158            }
159            _ => cx.emit(*event),
160        })
161        .detach();
162
163        Terminal {
164            connection,
165            has_new_content: true,
166            has_bell: false,
167            modal,
168        }
169    }
170
171    ///Scroll the terminal. This locks the terminal
172    fn scroll_terminal(&mut self, scroll: &ScrollTerminal, cx: &mut ViewContext<Self>) {
173        self.connection
174            .read(cx)
175            .term
176            .lock()
177            .scroll_display(Scroll::Delta(scroll.0));
178    }
179
180    fn input(&mut self, Input(text): &Input, cx: &mut ViewContext<Self>) {
181        self.connection.update(cx, |connection, _| {
182            //TODO: This is probably not encoding UTF8 correctly (see alacritty/src/input.rs:L825-837)
183            connection.write_to_pty(text.clone());
184        });
185
186        if self.has_bell {
187            self.has_bell = false;
188            cx.emit(Event::TitleChanged);
189        }
190    }
191
192    fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
193        self.connection
194            .update(cx, |connection, _| connection.clear());
195    }
196
197    ///Create a new Terminal in the current working directory or the user's home directory
198    fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
199        let wd = get_wd_for_workspace(workspace, cx);
200        workspace.add_item(Box::new(cx.add_view(|cx| Terminal::new(wd, false, cx))), cx);
201    }
202
203    ///Tell Zed to close us
204    fn quit(&mut self, _: &Quit, cx: &mut ViewContext<Self>) {
205        cx.emit(Event::CloseTerminal);
206    }
207
208    ///Attempt to paste the clipboard into the terminal
209    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
210        let term = self.connection.read(cx).term.lock();
211        let copy_text = term.selection_to_string();
212        match copy_text {
213            Some(s) => cx.write_to_clipboard(ClipboardItem::new(s)),
214            None => (),
215        }
216    }
217
218    ///Attempt to paste the clipboard into the terminal
219    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
220        if let Some(item) = cx.read_from_clipboard() {
221            self.connection.update(cx, |connection, _| {
222                connection.write_to_pty(item.text().to_owned());
223            })
224        }
225    }
226
227    ///Send the `up` key
228    fn up(&mut self, _: &Up, cx: &mut ViewContext<Self>) {
229        self.connection.update(cx, |connection, _| {
230            connection.write_to_pty(UP_SEQ.to_string());
231        });
232    }
233
234    ///Send the `down` key
235    fn down(&mut self, _: &Down, cx: &mut ViewContext<Self>) {
236        self.connection.update(cx, |connection, _| {
237            connection.write_to_pty(DOWN_SEQ.to_string());
238        });
239    }
240
241    ///Send the `tab` key
242    fn tab(&mut self, _: &Tab, cx: &mut ViewContext<Self>) {
243        self.connection.update(cx, |connection, _| {
244            connection.write_to_pty(TAB_CHAR.to_string());
245        });
246    }
247
248    ///Send `SIGINT` (`ctrl-c`)
249    fn send_sigint(&mut self, _: &Sigint, cx: &mut ViewContext<Self>) {
250        self.connection.update(cx, |connection, _| {
251            connection.write_to_pty(ETX_CHAR.to_string());
252        });
253    }
254
255    ///Send the `escape` key
256    fn escape(&mut self, _: &Escape, cx: &mut ViewContext<Self>) {
257        self.connection.update(cx, |connection, _| {
258            connection.write_to_pty(ESC_CHAR.to_string());
259        });
260    }
261
262    ///Send the `delete` key. TODO: Difference between this and backspace?
263    fn del(&mut self, _: &Del, cx: &mut ViewContext<Self>) {
264        self.connection.update(cx, |connection, _| {
265            connection.write_to_pty(DEL_CHAR.to_string());
266        });
267    }
268
269    ///Send a carriage return. TODO: May need to check the terminal mode.
270    fn carriage_return(&mut self, _: &Return, cx: &mut ViewContext<Self>) {
271        self.connection.update(cx, |connection, _| {
272            connection.write_to_pty(CARRIAGE_RETURN_CHAR.to_string());
273        });
274    }
275
276    //Send the `left` key
277    fn left(&mut self, _: &Left, cx: &mut ViewContext<Self>) {
278        self.connection.update(cx, |connection, _| {
279            connection.write_to_pty(LEFT_SEQ.to_string());
280        });
281    }
282
283    //Send the `right` key
284    fn right(&mut self, _: &Right, cx: &mut ViewContext<Self>) {
285        self.connection.update(cx, |connection, _| {
286            connection.write_to_pty(RIGHT_SEQ.to_string());
287        });
288    }
289}
290
291impl View for Terminal {
292    fn ui_name() -> &'static str {
293        "Terminal"
294    }
295
296    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
297        let element = {
298            let connection_handle = self.connection.clone().downgrade();
299            let view_id = cx.view_id();
300            TerminalEl::new(view_id, connection_handle, self.modal).contained()
301        };
302
303        if self.modal {
304            let settings = cx.global::<Settings>();
305            let container_style = settings.theme.terminal.modal_container;
306            element.with_style(container_style).boxed()
307        } else {
308            element.boxed()
309        }
310    }
311
312    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
313        cx.emit(Event::Activate);
314        self.has_new_content = false;
315    }
316
317    fn keymap_context(&self, _: &gpui::AppContext) -> gpui::keymap::Context {
318        let mut context = Self::default_keymap_context();
319        if self.modal {
320            context.set.insert("ModalTerminal".into());
321        }
322        context
323    }
324}
325
326impl Item for Terminal {
327    fn tab_content(&self, tab_theme: &theme::Tab, cx: &gpui::AppContext) -> ElementBox {
328        let settings = cx.global::<Settings>();
329        let search_theme = &settings.theme.search; //TODO properly integrate themes
330
331        let mut flex = Flex::row();
332
333        if self.has_bell {
334            flex.add_child(
335                Svg::new("icons/zap.svg") //TODO: Swap out for a better icon, or at least resize this
336                    .with_color(tab_theme.label.text.color)
337                    .constrained()
338                    .with_width(search_theme.tab_icon_width)
339                    .aligned()
340                    .boxed(),
341            );
342        };
343
344        flex.with_child(
345            Label::new(
346                self.connection.read(cx).title.clone(),
347                tab_theme.label.clone(),
348            )
349            .aligned()
350            .contained()
351            .with_margin_left(if self.has_bell {
352                search_theme.tab_icon_spacing
353            } else {
354                0.
355            })
356            .boxed(),
357        )
358        .boxed()
359    }
360
361    fn clone_on_split(&self, cx: &mut ViewContext<Self>) -> Option<Self> {
362        //From what I can tell, there's no  way to tell the current working
363        //Directory of the terminal from outside the terminal. There might be
364        //solutions to this, but they are non-trivial and require more IPC
365        Some(Terminal::new(
366            self.connection.read(cx).associated_directory.clone(),
367            false,
368            cx,
369        ))
370    }
371
372    fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
373        None
374    }
375
376    fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
377        SmallVec::new()
378    }
379
380    fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
381        false
382    }
383
384    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
385
386    fn can_save(&self, _cx: &gpui::AppContext) -> bool {
387        false
388    }
389
390    fn save(
391        &mut self,
392        _project: gpui::ModelHandle<Project>,
393        _cx: &mut ViewContext<Self>,
394    ) -> gpui::Task<gpui::anyhow::Result<()>> {
395        unreachable!("save should not have been called");
396    }
397
398    fn save_as(
399        &mut self,
400        _project: gpui::ModelHandle<Project>,
401        _abs_path: std::path::PathBuf,
402        _cx: &mut ViewContext<Self>,
403    ) -> gpui::Task<gpui::anyhow::Result<()>> {
404        unreachable!("save_as should not have been called");
405    }
406
407    fn reload(
408        &mut self,
409        _project: gpui::ModelHandle<Project>,
410        _cx: &mut ViewContext<Self>,
411    ) -> gpui::Task<gpui::anyhow::Result<()>> {
412        gpui::Task::ready(Ok(()))
413    }
414
415    fn is_dirty(&self, _: &gpui::AppContext) -> bool {
416        self.has_new_content
417    }
418
419    fn should_update_tab_on_event(event: &Self::Event) -> bool {
420        matches!(event, &Event::TitleChanged)
421    }
422
423    fn should_close_item_on_event(event: &Self::Event) -> bool {
424        matches!(event, &Event::CloseTerminal)
425    }
426
427    fn should_activate_item_on_event(event: &Self::Event) -> bool {
428        matches!(event, &Event::Activate)
429    }
430}
431
432///Gets the intuitively correct working directory from the given workspace
433///If there is an active entry for this project, returns that entry's worktree root.
434///If there's no active entry but there is a worktree, returns that worktrees root.
435///If either of these roots are files, or if there are any other query failures,
436///  returns the user's home directory
437fn get_wd_for_workspace(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
438    let project = workspace.project().read(cx);
439
440    project
441        .active_entry()
442        .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
443        .or_else(|| workspace.worktrees(cx).next())
444        .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
445        .and_then(|wt| {
446            wt.root_entry()
447                .filter(|re| re.is_dir())
448                .map(|_| wt.abs_path().to_path_buf())
449        })
450        .or_else(|| home_dir())
451}
452
453#[cfg(test)]
454mod tests {
455
456    use super::*;
457    use alacritty_terminal::{
458        grid::GridIterator,
459        index::{Column, Line, Point, Side},
460        selection::{Selection, SelectionType},
461        term::cell::Cell,
462    };
463    use gpui::TestAppContext;
464    use itertools::Itertools;
465
466    use std::{path::Path, time::Duration};
467    use workspace::AppState;
468
469    ///Basic integration test, can we get the terminal to show up, execute a command,
470    //and produce noticable output?
471    #[gpui::test]
472    async fn test_terminal(cx: &mut TestAppContext) {
473        let terminal = cx.add_view(Default::default(), |cx| Terminal::new(None, false, cx));
474
475        terminal.update(cx, |terminal, cx| {
476            terminal.connection.update(cx, |connection, _| {
477                connection.write_to_pty("expr 3 + 4".to_string());
478            });
479            terminal.carriage_return(&Return, cx);
480        });
481
482        cx.set_condition_duration(Some(Duration::from_secs(2)));
483        terminal
484            .condition(cx, |terminal, cx| {
485                let term = terminal.connection.read(cx).term.clone();
486                let content = grid_as_str(term.lock().renderable_content().display_iter);
487                content.contains("7")
488            })
489            .await;
490        cx.set_condition_duration(None);
491    }
492
493    /// Integration test for selections, clipboard, and terminal execution
494    #[gpui::test]
495    async fn test_copy(cx: &mut TestAppContext) {
496        let mut result_line: i32 = 0;
497        let terminal = cx.add_view(Default::default(), |cx| Terminal::new(None, false, cx));
498        cx.set_condition_duration(Some(Duration::from_secs(2)));
499
500        terminal.update(cx, |terminal, cx| {
501            terminal.connection.update(cx, |connection, _| {
502                connection.write_to_pty("expr 3 + 4".to_string());
503            });
504            terminal.carriage_return(&Return, cx);
505        });
506
507        terminal
508            .condition(cx, |terminal, cx| {
509                let term = terminal.connection.read(cx).term.clone();
510                let content = grid_as_str(term.lock().renderable_content().display_iter);
511
512                if content.contains("7") {
513                    let idx = content.chars().position(|c| c == '7').unwrap();
514                    result_line = content.chars().take(idx).filter(|c| *c == '\n').count() as i32;
515                    true
516                } else {
517                    false
518                }
519            })
520            .await;
521
522        terminal.update(cx, |terminal, cx| {
523            let mut term = terminal.connection.read(cx).term.lock();
524            term.selection = Some(Selection::new(
525                SelectionType::Semantic,
526                Point::new(Line(2), Column(0)),
527                Side::Right,
528            ));
529            drop(term);
530            terminal.copy(&Copy, cx)
531        });
532
533        cx.assert_clipboard_content(Some(&"7"));
534        cx.set_condition_duration(None);
535    }
536
537    ///Working directory calculation tests
538
539    ///No Worktrees in project -> home_dir()
540    #[gpui::test]
541    async fn no_worktree(cx: &mut TestAppContext) {
542        //Setup variables
543        let params = cx.update(AppState::test);
544        let project = Project::test(params.fs.clone(), [], cx).await;
545        let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
546
547        //Test
548        cx.read(|cx| {
549            let workspace = workspace.read(cx);
550            let active_entry = project.read(cx).active_entry();
551
552            //Make sure enviroment is as expeted
553            assert!(active_entry.is_none());
554            assert!(workspace.worktrees(cx).next().is_none());
555
556            let res = get_wd_for_workspace(workspace, cx);
557            assert_eq!(res, home_dir())
558        });
559    }
560
561    ///No active entry, but a worktree, worktree is a file -> home_dir()
562    #[gpui::test]
563    async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
564        //Setup variables
565        let params = cx.update(AppState::test);
566        let project = Project::test(params.fs.clone(), [], cx).await;
567        let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
568        let (wt, _) = project
569            .update(cx, |project, cx| {
570                project.find_or_create_local_worktree("/root.txt", true, cx)
571            })
572            .await
573            .unwrap();
574
575        cx.update(|cx| {
576            wt.update(cx, |wt, cx| {
577                wt.as_local()
578                    .unwrap()
579                    .create_entry(Path::new(""), false, cx)
580            })
581        })
582        .await
583        .unwrap();
584
585        //Test
586        cx.read(|cx| {
587            let workspace = workspace.read(cx);
588            let active_entry = project.read(cx).active_entry();
589
590            //Make sure enviroment is as expeted
591            assert!(active_entry.is_none());
592            assert!(workspace.worktrees(cx).next().is_some());
593
594            let res = get_wd_for_workspace(workspace, cx);
595            assert_eq!(res, home_dir())
596        });
597    }
598
599    //No active entry, but a worktree, worktree is a folder -> worktree_folder
600    #[gpui::test]
601    async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
602        //Setup variables
603        let params = cx.update(AppState::test);
604        let project = Project::test(params.fs.clone(), [], cx).await;
605        let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
606        let (wt, _) = project
607            .update(cx, |project, cx| {
608                project.find_or_create_local_worktree("/root/", true, cx)
609            })
610            .await
611            .unwrap();
612
613        //Setup root folder
614        cx.update(|cx| {
615            wt.update(cx, |wt, cx| {
616                wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
617            })
618        })
619        .await
620        .unwrap();
621
622        //Test
623        cx.update(|cx| {
624            let workspace = workspace.read(cx);
625            let active_entry = project.read(cx).active_entry();
626
627            assert!(active_entry.is_none());
628            assert!(workspace.worktrees(cx).next().is_some());
629
630            let res = get_wd_for_workspace(workspace, cx);
631            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
632        });
633    }
634
635    //Active entry with a work tree, worktree is a file -> home_dir()
636    #[gpui::test]
637    async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
638        //Setup variables
639        let params = cx.update(AppState::test);
640        let project = Project::test(params.fs.clone(), [], cx).await;
641        let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
642        let (wt, _) = project
643            .update(cx, |project, cx| {
644                project.find_or_create_local_worktree("/root.txt", true, cx)
645            })
646            .await
647            .unwrap();
648
649        //Setup root
650        let entry = cx
651            .update(|cx| {
652                wt.update(cx, |wt, cx| {
653                    wt.as_local()
654                        .unwrap()
655                        .create_entry(Path::new(""), false, cx)
656                })
657            })
658            .await
659            .unwrap();
660
661        cx.update(|cx| {
662            let p = ProjectPath {
663                worktree_id: wt.read(cx).id(),
664                path: entry.path,
665            };
666            project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
667        });
668
669        //Test
670        cx.update(|cx| {
671            let workspace = workspace.read(cx);
672            let active_entry = project.read(cx).active_entry();
673
674            assert!(active_entry.is_some());
675
676            let res = get_wd_for_workspace(workspace, cx);
677            assert_eq!(res, home_dir());
678        });
679    }
680
681    //Active entry, with a worktree, worktree is a folder -> worktree_folder
682    #[gpui::test]
683    async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
684        //Setup variables
685        let params = cx.update(AppState::test);
686        let project = Project::test(params.fs.clone(), [], cx).await;
687        let (_, workspace) = cx.add_window(|cx| Workspace::new(project.clone(), cx));
688        let (wt, _) = project
689            .update(cx, |project, cx| {
690                project.find_or_create_local_worktree("/root/", true, cx)
691            })
692            .await
693            .unwrap();
694
695        //Setup root
696        let entry = cx
697            .update(|cx| {
698                wt.update(cx, |wt, cx| {
699                    wt.as_local().unwrap().create_entry(Path::new(""), true, cx)
700                })
701            })
702            .await
703            .unwrap();
704
705        cx.update(|cx| {
706            let p = ProjectPath {
707                worktree_id: wt.read(cx).id(),
708                path: entry.path,
709            };
710            project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
711        });
712
713        //Test
714        cx.update(|cx| {
715            let workspace = workspace.read(cx);
716            let active_entry = project.read(cx).active_entry();
717
718            assert!(active_entry.is_some());
719
720            let res = get_wd_for_workspace(workspace, cx);
721            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
722        });
723    }
724
725    pub(crate) fn grid_as_str(grid_iterator: GridIterator<Cell>) -> String {
726        let lines = grid_iterator.group_by(|i| i.point.line.0);
727        lines
728            .into_iter()
729            .map(|(_, line)| line.map(|i| i.c).collect::<String>())
730            .collect::<Vec<String>>()
731            .join("\n")
732    }
733}