Skip to main content

cgraph/tui/
mod.rs

1#![doc = include_str!("README.md")]
2
3use std::{
4    io::{self, Stdout},
5    sync::mpsc::{self, Sender},
6    time::{Duration, Instant},
7};
8
9use anyhow::Result;
10use crossterm::{
11    clipboard::CopyToClipboard,
12    event::{
13        self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
14        KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
15    },
16    execute,
17    terminal::{
18        EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
19        size as terminal_size,
20    },
21};
22use ratatui::{
23    Frame, Terminal,
24    backend::CrosstermBackend,
25    layout::{Alignment, Constraint, Layout, Rect},
26    style::{Color, Modifier, Style},
27    text::{Line, Span},
28    widgets::{Block, Paragraph},
29};
30use tokio::sync::mpsc::UnboundedReceiver;
31use tokio::task::JoinHandle;
32
33use crate::{
34    app::{
35        AnalysisBackend, AnalysisPhase, AnalysisStatus, App, HierarchyLoadRequest, SearchKind,
36        SearchRequest,
37    },
38    fetch::{HierarchyClient, WorkspaceSymbolClient, lsp::LspStatusUpdate},
39    ipc::{
40        IpcCommand, IpcEventSender,
41        protocol::{IpcRequest, IpcResponse},
42    },
43    state::{HierarchyDirection, NodeId, SourceLocation},
44};
45
46mod canvas;
47mod config_editor;
48mod help;
49mod messages;
50mod save;
51mod search;
52
53use canvas::{
54    CanvasConnections, CanvasNodePlacement, CanvasNodeWidget, canvas_layout, world_canvas_layout,
55};
56#[cfg(test)]
57use canvas::{EdgeVisualKind, placement_bounds, world_rects_overlap};
58
59pub type Tui = Terminal<CrosstermBackend<Stdout>>;
60
61enum InteractionRequest {
62    Search(SearchRequest),
63    Hierarchy(Vec<HierarchyLoadRequest>),
64    OpenLocation(SourceLocation),
65    EditConfig,
66    OpenMessages,
67    CopyMessages(String),
68}
69
70struct HierarchyQueryEvent {
71    request: HierarchyLoadRequest,
72    result: Result<crate::fetch::HierarchyResponse, String>,
73}
74
75#[derive(Default)]
76struct CanvasDragState {
77    previous: Option<(u16, u16)>,
78    pressed_node: Option<NodeId>,
79    dragged: bool,
80    last_click: Option<CanvasClick>,
81}
82
83struct CanvasClick {
84    node_id: NodeId,
85    at: Instant,
86}
87
88const DOUBLE_CLICK_TIMEOUT: Duration = Duration::from_millis(500);
89
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91enum NavigationDirection {
92    Left,
93    Right,
94    Up,
95    Down,
96}
97
98pub fn init() -> Result<Tui> {
99    enable_raw_mode()?;
100    let mut stdout = io::stdout();
101    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
102    Ok(Terminal::new(CrosstermBackend::new(stdout))?)
103}
104
105pub fn restore(terminal: &mut Tui) -> Result<()> {
106    disable_raw_mode()?;
107    execute!(
108        terminal.backend_mut(),
109        DisableMouseCapture,
110        LeaveAlternateScreen
111    )?;
112    terminal.show_cursor()?;
113    Ok(())
114}
115
116fn resume(terminal: &mut Tui) -> Result<()> {
117    enable_raw_mode()?;
118    execute!(
119        terminal.backend_mut(),
120        EnterAlternateScreen,
121        EnableMouseCapture
122    )?;
123    terminal.clear()?;
124    terminal.hide_cursor()?;
125    Ok(())
126}
127
128fn set_mouse_capture(terminal: &mut Tui, enabled: bool) -> Result<()> {
129    if enabled {
130        execute!(terminal.backend_mut(), EnableMouseCapture)?;
131    } else {
132        execute!(terminal.backend_mut(), DisableMouseCapture)?;
133    }
134    Ok(())
135}
136
137pub fn run(
138    terminal: &mut Tui,
139    app: &mut App,
140    mut symbol_client: Option<WorkspaceSymbolClient>,
141    mut hierarchy_client: Option<HierarchyClient>,
142    mut lsp_status_receiver: Option<UnboundedReceiver<LspStatusUpdate>>,
143    ipc_event_sender: Option<IpcEventSender>,
144    mut ipc_command_receiver: Option<tokio::sync::mpsc::Receiver<IpcCommand>>,
145) -> Result<()> {
146    // Crossterm is currently polled synchronously, while LSP work runs on the
147    // Tokio runtime. A small channel keeps async completion out of App and lets
148    // the loop continue rendering loading state and receiving input.
149    let (query_sender, query_receiver) = mpsc::channel::<search::QueryEvent>();
150    let (hierarchy_sender, hierarchy_receiver) = mpsc::channel::<HierarchyQueryEvent>();
151    let mut search_task: Option<JoinHandle<()>> = None;
152    let mut hierarchy_tasks = Vec::<JoinHandle<()>>::new();
153    let mut canvas_drag = CanvasDragState::default();
154    let mut message_view: Option<messages::MessageViewState> = None;
155
156    while !app.should_quit {
157        if let Some(receiver) = ipc_command_receiver.as_mut() {
158            while let Ok(command) = receiver.try_recv() {
159                apply_ipc_command(app, command);
160            }
161        }
162        if let Some(receiver) = lsp_status_receiver.as_mut() {
163            while let Ok(status) = receiver.try_recv() {
164                apply_lsp_status(app, status);
165            }
166        }
167        while let Ok(query_event) = query_receiver.try_recv() {
168            match query_event {
169                search::QueryEvent::Started(request_id) => app.start_search(request_id),
170                search::QueryEvent::Finished { request_id, result } => {
171                    app.finish_search(request_id, result);
172                }
173            }
174        }
175        while let Ok(event) = hierarchy_receiver.try_recv() {
176            with_stable_node_position(app, event.request.node_id, |app| {
177                app.finish_hierarchy(&event.request, event.result);
178            });
179        }
180        hierarchy_tasks.retain(|task| !task.is_finished());
181        if let Some(view) = message_view.as_mut() {
182            view.sync(&app.message_history);
183        }
184        terminal.draw(|frame| render_with_messages(frame, app, message_view.as_mut()))?;
185
186        if event::poll(Duration::from_millis(50))? {
187            let (width, height) = terminal_size()?;
188            let screen = Rect::new(0, 0, width, height);
189            let message_was_open = message_view.is_some();
190            let request = handle_event_with_messages(
191                app,
192                event::read()?,
193                hierarchy_client.is_some(),
194                screen,
195                &mut canvas_drag,
196                &mut message_view,
197            );
198            if app.search.is_none()
199                && let Some(task) = search_task.take()
200            {
201                task.abort();
202            }
203            match request {
204                Some(InteractionRequest::Search(request)) => {
205                    if let Some(client) = symbol_client.clone() {
206                        let next_task = search::schedule(client, request, query_sender.clone());
207                        if let Some(previous_task) = search_task.replace(next_task) {
208                            previous_task.abort();
209                        }
210                    }
211                }
212                Some(InteractionRequest::Hierarchy(requests)) => {
213                    if let Some(client) = hierarchy_client.clone() {
214                        for request in requests {
215                            hierarchy_tasks.push(schedule_hierarchy(
216                                client.clone(),
217                                request,
218                                hierarchy_sender.clone(),
219                            ));
220                        }
221                    }
222                }
223                Some(InteractionRequest::OpenLocation(location)) => {
224                    send_open_location(app, ipc_event_sender.as_ref(), &location);
225                }
226                Some(InteractionRequest::EditConfig) => {
227                    let reload = config_editor::edit_project_config(
228                        terminal,
229                        app,
230                        hierarchy_client.is_some(),
231                    )?;
232                    if let Some(reload) = reload {
233                        if let Some(client) = symbol_client.as_mut() {
234                            client.set_workspace_only(reload.workspace_only);
235                        }
236                        if let Some(client) = hierarchy_client.as_mut() {
237                            client.set_workspace_only(reload.workspace_only);
238                        }
239                        if let Some(client) = hierarchy_client.clone() {
240                            for request in reload.requests {
241                                hierarchy_tasks.push(schedule_hierarchy(
242                                    client.clone(),
243                                    request,
244                                    hierarchy_sender.clone(),
245                                ));
246                            }
247                        }
248                    }
249                }
250                Some(InteractionRequest::OpenMessages) => {
251                    message_view = Some(messages::MessageViewState::from_messages(
252                        &app.message_history,
253                    ));
254                }
255                Some(InteractionRequest::CopyMessages(text)) => {
256                    let character_count = text.chars().count();
257                    let notice = match copy_message_to_clipboard(terminal, &text) {
258                        Ok(()) => format!("yanked {character_count} chars via OSC 52"),
259                        Err(error) => format!("copy failed: {error:#}"),
260                    };
261                    if let Some(view) = message_view.as_mut() {
262                        view.set_copy_notice(notice);
263                    }
264                }
265                None => {}
266            }
267            if message_was_open != message_view.is_some() {
268                set_mouse_capture(terminal, message_view.is_none())?;
269            }
270        }
271    }
272
273    if let Some(task) = search_task {
274        task.abort();
275    }
276    for task in hierarchy_tasks {
277        task.abort();
278    }
279
280    Ok(())
281}
282
283fn copy_message_to_clipboard(terminal: &mut Tui, text: &str) -> Result<()> {
284    execute!(
285        terminal.backend_mut(),
286        CopyToClipboard::to_clipboard_from(text)
287    )?;
288    Ok(())
289}
290
291fn apply_ipc_command(app: &mut App, command: IpcCommand) {
292    let request_id = command.request_id();
293    let (request, responder) = command.into_parts();
294    let response = match request {
295        IpcRequest::FocusSymbol {
296            hierarchy,
297            symbol,
298            location,
299        } => match app.focus_symbol(crate::state::SymbolIdentity {
300            symbol: symbol.clone(),
301            kind: hierarchy,
302            location,
303        }) {
304            Ok(_) => {
305                app.set_canvas_notice(format!(
306                    "IPC request {request_id} focused {} symbol {symbol:?}",
307                    match hierarchy {
308                        crate::state::HierarchyKind::Call => "call",
309                        crate::state::HierarchyKind::Type => "type",
310                    }
311                ));
312                IpcResponse::Accepted
313            }
314            Err(message) => {
315                app.set_canvas_error(format!("IPC request {request_id} rejected: {message}"));
316                IpcResponse::Error { message }
317            }
318        },
319    };
320    if let Err(error) = responder.respond(response) {
321        app.set_canvas_error(format!("IPC response {request_id} failed: {error:#}"));
322    }
323}
324
325fn send_open_location(
326    app: &mut App,
327    event_sender: Option<&IpcEventSender>,
328    location: &SourceLocation,
329) {
330    let Some(event_sender) = event_sender else {
331        app.set_canvas_notice("IPC is not enabled; start cgraph with --ipc-socket <PATH>");
332        return;
333    };
334    match event_sender.send_open_location(location) {
335        Ok(1) => app.set_canvas_notice("Sent source location to 1 IPC client"),
336        Ok(client_count) => {
337            app.set_canvas_notice(format!(
338                "Sent source location to {client_count} IPC clients"
339            ));
340        }
341        Err(error) => app.set_canvas_error(format!("IPC open-location failed: {error:#}")),
342    }
343}
344
345fn schedule_hierarchy(
346    client: HierarchyClient,
347    request: HierarchyLoadRequest,
348    sender: Sender<HierarchyQueryEvent>,
349) -> JoinHandle<()> {
350    tokio::spawn(async move {
351        let result = client
352            .query(request.query.clone())
353            .await
354            .map_err(|error| format!("{error:#}"));
355        let _ = sender.send(HierarchyQueryEvent { request, result });
356    })
357}
358
359fn apply_lsp_status(app: &mut App, update: LspStatusUpdate) {
360    let server = match &app.analysis_status.backend {
361        AnalysisBackend::Lsp(server) => server.clone(),
362        _ => "LSP".to_owned(),
363    };
364    let status = match update {
365        LspStatusUpdate::Ready { message } => AnalysisStatus {
366            backend: AnalysisBackend::Lsp(server),
367            phase: AnalysisPhase::Ready,
368            message,
369            percentage: None,
370        },
371        LspStatusUpdate::Progress {
372            title,
373            message,
374            percentage,
375        } => AnalysisStatus {
376            backend: AnalysisBackend::Lsp(server),
377            phase: AnalysisPhase::Working,
378            message: Some(match message {
379                Some(message) => format!("{title}: {message}"),
380                None => title,
381            }),
382            percentage: percentage.map(|percentage| percentage.min(100)),
383        },
384        LspStatusUpdate::Warning(message) => AnalysisStatus {
385            backend: AnalysisBackend::Lsp(server),
386            phase: AnalysisPhase::Warning,
387            message: Some(message),
388            percentage: None,
389        },
390        LspStatusUpdate::Error(message) => AnalysisStatus {
391            backend: AnalysisBackend::Lsp(server),
392            phase: AnalysisPhase::Error,
393            message: Some(message),
394            percentage: None,
395        },
396        LspStatusUpdate::Disconnected(message) => AnalysisStatus {
397            backend: AnalysisBackend::Lsp(server),
398            phase: AnalysisPhase::Disconnected,
399            message: Some(message),
400            percentage: None,
401        },
402    };
403    app.set_analysis_status(status);
404}
405
406#[cfg(test)]
407fn handle_event(
408    app: &mut App,
409    event: Event,
410    analysis_available: bool,
411    screen: Rect,
412    canvas_drag: &mut CanvasDragState,
413) -> Option<InteractionRequest> {
414    let mut message_view = None;
415    handle_event_with_messages(
416        app,
417        event,
418        analysis_available,
419        screen,
420        canvas_drag,
421        &mut message_view,
422    )
423}
424
425fn handle_event_with_messages(
426    app: &mut App,
427    event: Event,
428    analysis_available: bool,
429    screen: Rect,
430    canvas_drag: &mut CanvasDragState,
431    message_view: &mut Option<messages::MessageViewState>,
432) -> Option<InteractionRequest> {
433    match event {
434        Event::Key(key) if key.kind == KeyEventKind::Press => {
435            let request = if let Some(view) = message_view.as_mut() {
436                match messages::handle_key(view, key) {
437                    messages::MessageAction::Close => {
438                        *message_view = None;
439                        None
440                    }
441                    messages::MessageAction::Copy(text) => {
442                        Some(InteractionRequest::CopyMessages(text))
443                    }
444                    messages::MessageAction::Handled => None,
445                }
446            } else if app.help.is_some() {
447                help::handle_key(app, key);
448                None
449            } else if app.search.is_some() {
450                search::handle_key(app, key).map(InteractionRequest::Search)
451            } else if app.save.is_some() {
452                save::handle_key(app, key);
453                None
454            } else {
455                handle_canvas_key(app, key, analysis_available, screen)
456            };
457            if app.search.is_some() || app.save.is_some() || app.help.is_some() {
458                *canvas_drag = CanvasDragState::default();
459            }
460            request
461        }
462        Event::Mouse(mouse) => {
463            if message_view.is_some() {
464                *canvas_drag = CanvasDragState::default();
465                None
466            } else if app.help.is_some() {
467                *canvas_drag = CanvasDragState::default();
468                help::handle_mouse(app, mouse);
469                None
470            } else if app.search.is_some() {
471                *canvas_drag = CanvasDragState::default();
472                search::handle_mouse(app, mouse, screen);
473                None
474            } else if app.save.is_some() {
475                *canvas_drag = CanvasDragState::default();
476                None
477            } else {
478                handle_canvas_mouse(app, mouse, analysis_available, screen, canvas_drag)
479            }
480        }
481        _ => None,
482    }
483}
484
485fn handle_canvas_key(
486    app: &mut App,
487    key: KeyEvent,
488    analysis_available: bool,
489    screen: Rect,
490) -> Option<InteractionRequest> {
491    if let Some(prefix) = app.pending_key.take() {
492        return match prefix {
493            'a' => match key.code {
494                KeyCode::Char('c') if key.modifiers == KeyModifiers::NONE => app
495                    .open_search(SearchKind::Call, analysis_available)
496                    .map(InteractionRequest::Search),
497                KeyCode::Char('t') if key.modifiers == KeyModifiers::NONE => app
498                    .open_search(SearchKind::Type, analysis_available)
499                    .map(InteractionRequest::Search),
500                _ => None,
501            },
502            'd' => {
503                match key.code {
504                    KeyCode::Char('d') if key.modifiers == KeyModifiers::NONE => {
505                        app.delete_selected_anchor();
506                    }
507                    KeyCode::Char('p') if key.modifiers == KeyModifiers::NONE => {
508                        app.delete_selected_branch(HierarchyDirection::Incoming);
509                    }
510                    KeyCode::Char('n') if key.modifiers == KeyModifiers::NONE => {
511                        app.delete_selected_branch(HierarchyDirection::Outgoing);
512                    }
513                    _ => {}
514                }
515                None
516            }
517            'e' => {
518                if key.code == KeyCode::Char('c') && key.modifiers == KeyModifiers::NONE {
519                    return Some(InteractionRequest::EditConfig);
520                }
521                None
522            }
523            't' => {
524                match key.code {
525                    KeyCode::Char('l') if key.modifiers == KeyModifiers::NONE => {
526                        return toggle_selected_branch_stably(
527                            app,
528                            HierarchyDirection::Incoming,
529                            analysis_available,
530                        )
531                        .map(|request| InteractionRequest::Hierarchy(vec![request]));
532                    }
533                    KeyCode::Char('r') if key.modifiers == KeyModifiers::NONE => {
534                        return toggle_selected_branch_stably(
535                            app,
536                            HierarchyDirection::Outgoing,
537                            analysis_available,
538                        )
539                        .map(|request| InteractionRequest::Hierarchy(vec![request]));
540                    }
541                    _ => {}
542                }
543                None
544            }
545            'g' => {
546                if key.code == KeyCode::Char('<') && key.modifiers == KeyModifiers::NONE {
547                    return Some(InteractionRequest::OpenMessages);
548                }
549                None
550            }
551            _ => None,
552        };
553    }
554
555    match key.code {
556        KeyCode::Char('a') if key.modifiers == KeyModifiers::NONE => {
557            app.pending_key = Some('a');
558        }
559        KeyCode::Char('d') if key.modifiers == KeyModifiers::NONE => {
560            app.pending_key = Some('d');
561        }
562        KeyCode::Char('e') if key.modifiers == KeyModifiers::NONE => {
563            app.pending_key = Some('e');
564        }
565        KeyCode::Char('t') if key.modifiers == KeyModifiers::NONE => {
566            app.pending_key = Some('t');
567        }
568        KeyCode::Char('g') if key.modifiers == KeyModifiers::NONE => {
569            app.pending_key = Some('g');
570        }
571        KeyCode::Left | KeyCode::Char('h') if key.modifiers == KeyModifiers::NONE => {
572            move_canvas_selection(app, NavigationDirection::Left, screen);
573        }
574        KeyCode::Right | KeyCode::Char('l') if key.modifiers == KeyModifiers::NONE => {
575            move_canvas_selection(app, NavigationDirection::Right, screen);
576        }
577        KeyCode::Up | KeyCode::Char('k') if key.modifiers == KeyModifiers::NONE => {
578            move_canvas_selection(app, NavigationDirection::Up, screen);
579        }
580        KeyCode::Down | KeyCode::Char('j') if key.modifiers == KeyModifiers::NONE => {
581            move_canvas_selection(app, NavigationDirection::Down, screen);
582        }
583        KeyCode::Char('r') if key.modifiers == KeyModifiers::NONE => {
584            let requests = app.refresh_selected_branches(analysis_available);
585            if !requests.is_empty() {
586                return Some(InteractionRequest::Hierarchy(requests));
587            }
588        }
589        KeyCode::Char('w') if key.modifiers == KeyModifiers::NONE => app.open_save(),
590        KeyCode::Char('?') if matches!(key.modifiers, KeyModifiers::NONE | KeyModifiers::SHIFT) => {
591            app.open_help();
592        }
593        KeyCode::Char('q') | KeyCode::Esc => app.quit(),
594        _ => {}
595    }
596
597    None
598}
599
600fn handle_canvas_mouse(
601    app: &mut App,
602    mouse: MouseEvent,
603    hierarchy_available: bool,
604    screen: Rect,
605    drag: &mut CanvasDragState,
606) -> Option<InteractionRequest> {
607    match mouse.kind {
608        MouseEventKind::Drag(MouseButton::Left) => {
609            let (previous_column, previous_row) = drag.previous?;
610            drag.dragged = true;
611            drag.last_click = None;
612            app.pan_viewport(
613                i32::from(mouse.column) - i32::from(previous_column),
614                i32::from(mouse.row) - i32::from(previous_row),
615            );
616            drag.previous = Some((mouse.column, mouse.row));
617            return None;
618        }
619        MouseEventKind::Up(MouseButton::Left) => {
620            let request = finish_canvas_click(app, mouse, screen, drag);
621            drag.previous = None;
622            drag.pressed_node = None;
623            drag.dragged = false;
624            return request;
625        }
626        MouseEventKind::Down(MouseButton::Left) => {}
627        _ => return None,
628    }
629    let point = (mouse.column, mouse.row).into();
630    let canvas = canvas_inner_area(screen);
631    if !canvas.contains(point) {
632        *drag = CanvasDragState::default();
633        return None;
634    }
635    let layout = canvas_layout(canvas, &app.graph, app.selected, app.viewport);
636
637    if let Some(node_id) = layout
638        .nodes
639        .iter()
640        .find(|placement| placement.incoming_button.contains(point))
641        .map(|placement| placement.node_id)
642    {
643        *drag = CanvasDragState::default();
644        return with_stable_node_position(app, node_id, |app| {
645            app.toggle_node_branch(node_id, HierarchyDirection::Incoming, hierarchy_available)
646        })
647        .map(|request| InteractionRequest::Hierarchy(vec![request]));
648    }
649    if let Some(node_id) = layout
650        .nodes
651        .iter()
652        .find(|placement| placement.outgoing_button.contains(point))
653        .map(|placement| placement.node_id)
654    {
655        *drag = CanvasDragState::default();
656        return with_stable_node_position(app, node_id, |app| {
657            app.toggle_node_branch(node_id, HierarchyDirection::Outgoing, hierarchy_available)
658        })
659        .map(|request| InteractionRequest::Hierarchy(vec![request]));
660    }
661    if let Some(node_id) = layout
662        .nodes
663        .iter()
664        .find(|placement| placement.area.contains(point))
665        .map(|placement| placement.node_id)
666    {
667        with_stable_node_position(app, node_id, |app| app.select_node(node_id));
668        drag.pressed_node = Some(node_id);
669    } else {
670        drag.pressed_node = None;
671        drag.last_click = None;
672    }
673    drag.dragged = false;
674    drag.previous = Some((mouse.column, mouse.row));
675    None
676}
677
678fn finish_canvas_click(
679    app: &mut App,
680    mouse: MouseEvent,
681    screen: Rect,
682    drag: &mut CanvasDragState,
683) -> Option<InteractionRequest> {
684    if drag.dragged {
685        return None;
686    }
687    let node_id = drag.pressed_node?;
688    let point = (mouse.column, mouse.row).into();
689    let canvas = canvas_inner_area(screen);
690    if !canvas.contains(point) {
691        drag.last_click = None;
692        return None;
693    }
694    let released_on_same_node = canvas_layout(canvas, &app.graph, app.selected, app.viewport)
695        .nodes
696        .iter()
697        .any(|placement| placement.node_id == node_id && placement.area.contains(point));
698    if !released_on_same_node {
699        drag.last_click = None;
700        return None;
701    }
702
703    let now = Instant::now();
704    let is_double_click = drag.last_click.as_ref().is_some_and(|click| {
705        click.node_id == node_id && now.duration_since(click.at) <= DOUBLE_CLICK_TIMEOUT
706    });
707    if !is_double_click {
708        drag.last_click = Some(CanvasClick { node_id, at: now });
709        return None;
710    }
711    drag.last_click = None;
712
713    let Some(location) = app
714        .graph
715        .node(node_id)
716        .and_then(|node| node.location.clone())
717        .filter(|location| {
718            !location.uri.is_empty() && location.line.is_some() && location.character.is_some()
719        })
720    else {
721        app.set_canvas_notice("Selected node has no exact source location");
722        return None;
723    };
724    Some(InteractionRequest::OpenLocation(location))
725}
726
727fn move_canvas_selection(app: &mut App, direction: NavigationDirection, screen: Rect) -> bool {
728    let layout = canvas_layout(
729        canvas_inner_area(screen),
730        &app.graph,
731        app.selected,
732        app.viewport,
733    );
734    let Some(current) = current_placement(&layout.nodes, app.selected) else {
735        return false;
736    };
737    let current_center = rect_center(current.visible_slot);
738    let next = layout
739        .nodes
740        .iter()
741        .filter(|candidate| candidate.node_id != current.node_id)
742        .filter_map(|candidate| {
743            let candidate_center = rect_center(candidate.visible_slot);
744            navigation_score(current_center, candidate_center, direction)
745                .map(|score| (score, candidate.node_id))
746        })
747        .min_by_key(|(score, node_id)| (*score, node_id.0));
748    let Some((_, node_id)) = next else {
749        return false;
750    };
751    with_stable_node_position(app, node_id, |app| app.select_node(node_id))
752}
753
754fn toggle_selected_branch_stably(
755    app: &mut App,
756    direction: HierarchyDirection,
757    hierarchy_available: bool,
758) -> Option<HierarchyLoadRequest> {
759    let selected = app.selected?;
760    with_stable_node_position(app, selected, |app| {
761        app.toggle_selected_branch(direction, hierarchy_available)
762    })
763}
764
765fn with_stable_node_position<T>(
766    app: &mut App,
767    node_id: NodeId,
768    mutation: impl FnOnce(&mut App) -> T,
769) -> T {
770    let before = node_world_anchor(app, node_id);
771    let result = mutation(app);
772    let after = node_world_anchor(app, node_id);
773    if let (Some((before_x, before_y)), Some((after_x, after_y))) = (before, after) {
774        app.pan_viewport(
775            before_x.saturating_sub(after_x),
776            before_y.saturating_sub(after_y),
777        );
778    }
779    result
780}
781
782fn node_world_anchor(app: &App, node_id: NodeId) -> Option<(i32, i32)> {
783    let node_id = app.graph.resolve_id(node_id)?;
784    world_canvas_layout(&app.graph, app.selected)
785        .nodes
786        .into_iter()
787        .find(|placement| placement.node_id == node_id)
788        .map(|placement| {
789            (
790                placement
791                    .slot
792                    .x
793                    .saturating_add(i32::from(placement.slot.width) / 2),
794                placement
795                    .slot
796                    .y
797                    .saturating_add(i32::from(placement.slot.height) / 2),
798            )
799        })
800}
801
802fn current_placement(
803    layout: &[CanvasNodePlacement],
804    selected: Option<NodeId>,
805) -> Option<&CanvasNodePlacement> {
806    selected
807        .and_then(|selected| {
808            layout
809                .iter()
810                .find(|placement| placement.node_id == selected)
811        })
812        .or_else(|| layout.first())
813}
814
815fn navigation_score(
816    current: (i32, i32),
817    candidate: (i32, i32),
818    direction: NavigationDirection,
819) -> Option<(i64, i32, i32)> {
820    let delta_x = candidate.0 - current.0;
821    let delta_y = candidate.1 - current.1;
822    let (primary, perpendicular) = match direction {
823        NavigationDirection::Left if delta_x < 0 => (-delta_x, delta_y.abs()),
824        NavigationDirection::Right if delta_x > 0 => (delta_x, delta_y.abs()),
825        NavigationDirection::Up if delta_y < 0 => (-delta_y, delta_x.abs()),
826        NavigationDirection::Down if delta_y > 0 => (delta_y, delta_x.abs()),
827        _ => return None,
828    };
829    let distance = i64::from(primary).pow(2) + i64::from(perpendicular).pow(2);
830    Some((distance, perpendicular, primary))
831}
832
833fn rect_center(area: Rect) -> (i32, i32) {
834    (
835        i32::from(area.x) + i32::from(area.width) / 2,
836        i32::from(area.y) + i32::from(area.height) / 2,
837    )
838}
839
840#[cfg(test)]
841fn render(frame: &mut Frame, app: &App) {
842    render_with_messages(frame, app, None);
843}
844
845fn render_with_messages(
846    frame: &mut Frame,
847    app: &App,
848    mut message_view: Option<&mut messages::MessageViewState>,
849) {
850    let [canvas, message_area, footer] = canvas_message_and_footer(frame.area());
851
852    let canvas_inner = canvas;
853
854    if app.graph.anchors().is_empty() {
855        frame.render_widget(
856            Paragraph::new(Line::from(Span::styled(
857                "Empty canvas",
858                Style::default().fg(Color::DarkGray),
859            )))
860            .alignment(Alignment::Center),
861            canvas_inner,
862        );
863    } else {
864        let layout = canvas_layout(canvas_inner, &app.graph, app.selected, app.viewport);
865        frame.render_widget(
866            CanvasConnections {
867                edges: &layout.edges,
868            },
869            canvas_inner,
870        );
871        for placement in layout.nodes {
872            let node = app
873                .graph
874                .node(placement.node_id)
875                .expect("canvas layout only contains existing nodes");
876            frame.render_widget(
877                CanvasNodeWidget {
878                    node,
879                    placement,
880                    selected: app.selected == Some(placement.node_id),
881                },
882                canvas_inner,
883            );
884        }
885    }
886
887    frame.render_widget(
888        Block::default().title(canvas_heading(app)).title_style(
889            Style::default()
890                .fg(Color::Cyan)
891                .add_modifier(Modifier::BOLD),
892        ),
893        canvas,
894    );
895
896    let hierarchy_failure = app.selected.and_then(|selected| {
897        let node = app.graph.node(selected)?;
898        node.incoming
899            .failure()
900            .or_else(|| node.outgoing.failure())
901            .map(|failure| format!("Hierarchy error: {failure} (tl/tr retries)"))
902    });
903    let footer_text = match app.pending_key {
904        Some('a') => "a_: c call search / t type search".to_owned(),
905        Some('d') => "d_: d unpin anchor / p clear left / n clear right".to_owned(),
906        Some('e') => "e_: c edit project config".to_owned(),
907        Some('t') => "t_: l toggle left / r toggle right".to_owned(),
908        Some('g') => "g_: <: show messages".to_owned(),
909        _ => "?: help  ac/at: add  tl/tr: expand  hjkl: move  q: quit".to_owned(),
910    };
911    render_message_summary(
912        frame,
913        message_area,
914        app.canvas_notice
915            .as_deref()
916            .map(|message| (message, app.canvas_notice_is_error()))
917            .or_else(|| hierarchy_failure.as_deref().map(|message| (message, true))),
918    );
919    render_footer(frame, footer, footer_text, &app.analysis_status);
920
921    if let Some(help_state) = &app.help {
922        help::render(frame, help_state);
923    } else if let Some(search) = &app.search {
924        search::render(frame, search);
925    } else if let Some(save_state) = &app.save {
926        save::render(frame, save_state);
927    }
928    if let Some(view) = message_view.as_mut() {
929        messages::render(frame, view, frame.area());
930    }
931}
932
933fn canvas_message_and_footer(screen: Rect) -> [Rect; 3] {
934    Layout::vertical([
935        Constraint::Min(0),
936        Constraint::Length(1),
937        Constraint::Length(1),
938    ])
939    .areas(screen)
940}
941
942fn canvas_inner_area(screen: Rect) -> Rect {
943    let [canvas, _, _] = canvas_message_and_footer(screen);
944    canvas
945}
946
947fn render_message_summary(frame: &mut Frame, area: Rect, message: Option<(&str, bool)>) {
948    let Some((message, is_error)) = message else {
949        return;
950    };
951    let (prefix, color) = if is_error {
952        ("ERROR: ", Color::Red)
953    } else {
954        ("", Color::Yellow)
955    };
956    frame.render_widget(
957        Paragraph::new(Line::from(vec![
958            Span::styled(prefix, Style::default().fg(color)),
959            Span::styled(message, Style::default().fg(color)),
960        ])),
961        area,
962    );
963}
964
965fn render_footer(frame: &mut Frame, area: Rect, shortcuts: String, status: &AnalysisStatus) {
966    let mut content = vec![Span::styled(
967        shortcuts,
968        Style::default().fg(Color::DarkGray),
969    )];
970    content.push(Span::styled("  │  ", Style::default().fg(Color::DarkGray)));
971    content.extend(analysis_status_line(status).spans);
972    frame.render_widget(Paragraph::new(Line::from(content)), area);
973}
974
975fn canvas_heading(app: &App) -> String {
976    app.selected
977        .and_then(|selected| app.graph.node(selected))
978        .and_then(|node| node.location.as_ref())
979        .map(|location| location.uri.trim())
980        .filter(|uri| !uri.is_empty())
981        .map(str::to_owned)
982        .unwrap_or_else(|| "CALL GRAPH".to_owned())
983}
984
985fn analysis_status_line(status: &AnalysisStatus) -> Line<'static> {
986    let (backend, backend_style) = match &status.backend {
987        AnalysisBackend::Lsp(server) => {
988            (format!("LSP: {server}"), Style::default().fg(Color::Cyan))
989        }
990        AnalysisBackend::TreeSitter(language) => (
991            format!("Tree-sitter: {language}"),
992            Style::default().fg(Color::Magenta),
993        ),
994        AnalysisBackend::None => (
995            "Backend: none".to_owned(),
996            Style::default().fg(Color::DarkGray),
997        ),
998    };
999    let (phase, phase_style) = match status.phase {
1000        AnalysisPhase::Inactive => ("Inactive", Style::default().fg(Color::DarkGray)),
1001        AnalysisPhase::Ready => ("Ready", Style::default().fg(Color::Green)),
1002        AnalysisPhase::Working => ("Working", Style::default().fg(Color::Yellow)),
1003        AnalysisPhase::Warning => ("Warning", Style::default().fg(Color::Yellow)),
1004        AnalysisPhase::Error => ("Error", Style::default().fg(Color::Red)),
1005        AnalysisPhase::Disconnected => ("Disconnected", Style::default().fg(Color::Red)),
1006    };
1007    let percentage = status
1008        .percentage
1009        .map(|percentage| format!(" {percentage}%"))
1010        .unwrap_or_default();
1011    let mut content = vec![
1012        Span::styled(backend, backend_style),
1013        Span::styled(" · ", Style::default().fg(Color::DarkGray)),
1014        Span::styled(phase, phase_style),
1015        Span::raw(percentage),
1016    ];
1017    if let Some(message) = status
1018        .message
1019        .as_ref()
1020        .filter(|message| !message.is_empty())
1021    {
1022        content.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
1023        content.push(Span::styled(
1024            message.clone(),
1025            Style::default().fg(Color::DarkGray),
1026        ));
1027    }
1028    Line::from(content)
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use clap::Parser;
1034    use crossterm::event::{
1035        KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
1036    };
1037    use ratatui::{Terminal, backend::TestBackend, layout::Rect};
1038    use tower_lsp::lsp_types::{SymbolKind, Url};
1039
1040    use super::messages;
1041    use super::{
1042        CanvasDragState, EdgeVisualKind, InteractionRequest, NavigationDirection,
1043        apply_ipc_command, apply_lsp_status, canvas_heading, canvas_inner_area, canvas_layout,
1044        handle_canvas_key, handle_canvas_mouse, move_canvas_selection, placement_bounds,
1045        rect_center, render, render_with_messages,
1046        search::{search_item, symbol_matches_search},
1047        send_open_location, with_stable_node_position, world_canvas_layout, world_rects_overlap,
1048    };
1049    use crate::{
1050        app::{AnalysisBackend, AnalysisPhase, App, SearchKind},
1051        cli::Cli,
1052        fetch::{
1053            CachePolicy, FetchSource, HierarchyResponse, WorkspaceSymbolMatch, lsp::LspStatusUpdate,
1054        },
1055        ipc::{
1056            IpcCommand,
1057            protocol::{Envelope, IpcRequest, IpcResponse},
1058        },
1059        state::{HierarchyDirection, HierarchyKind, LoadState, SourceLocation, SymbolIdentity},
1060    };
1061
1062    #[test]
1063    fn filters_workspace_symbols_by_search_kind() {
1064        assert!(symbol_matches_search(
1065            SearchKind::Call,
1066            SymbolKind::FUNCTION
1067        ));
1068        assert!(symbol_matches_search(SearchKind::Type, SymbolKind::STRUCT));
1069        assert!(!symbol_matches_search(SearchKind::Call, SymbolKind::STRUCT));
1070        assert!(!symbol_matches_search(
1071            SearchKind::Type,
1072            SymbolKind::FUNCTION
1073        ));
1074        let method = search_item(WorkspaceSymbolMatch {
1075            name: "App::run".to_owned(),
1076            kind: SymbolKind::METHOD,
1077            container_name: Some("App".to_owned()),
1078            uri: Url::parse("file:///workspace/src/main.rs").unwrap(),
1079            range: None,
1080        });
1081        assert_eq!(method.name, "App::run");
1082    }
1083
1084    #[test]
1085    fn ipc_focus_commands_mutate_app_and_return_matching_responses() {
1086        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1087        let request = IpcRequest::FocusSymbol {
1088            hierarchy: HierarchyKind::Call,
1089            symbol: "main".to_owned(),
1090            location: Some(SourceLocation {
1091                uri: "file:///workspace/src/main.rs".to_owned(),
1092                line: Some(4),
1093                character: Some(2),
1094            }),
1095        };
1096        let (command, mut responses) = IpcCommand::test_command(17, request);
1097
1098        apply_ipc_command(&mut app, command);
1099
1100        let selected = app.selected.unwrap();
1101        assert_eq!(app.graph.node(selected).unwrap().symbol, "main");
1102        assert!(app.graph.is_anchor(selected));
1103        let response: Envelope<IpcResponse> =
1104            serde_json::from_slice(&responses.try_recv().unwrap()).unwrap();
1105        assert_eq!(response.request_id, Some(17));
1106        assert_eq!(response.payload, IpcResponse::Accepted);
1107
1108        let (command, mut responses) = IpcCommand::test_command(
1109            18,
1110            IpcRequest::FocusSymbol {
1111                hierarchy: HierarchyKind::Type,
1112                symbol: String::new(),
1113                location: None,
1114            },
1115        );
1116        apply_ipc_command(&mut app, command);
1117        let response: Envelope<IpcResponse> =
1118            serde_json::from_slice(&responses.try_recv().unwrap()).unwrap();
1119        assert_eq!(response.request_id, Some(18));
1120        let IpcResponse::Error { message } = response.payload else {
1121            panic!("invalid focus request must return an error");
1122        };
1123        assert!(message.contains("symbol must not be empty"));
1124    }
1125
1126    #[test]
1127    fn maps_lsp_progress_without_losing_server_identity() {
1128        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1129        app.set_analysis_status(crate::app::AnalysisStatus::lsp(
1130            "rust-analyzer",
1131            AnalysisPhase::Ready,
1132        ));
1133
1134        apply_lsp_status(
1135            &mut app,
1136            LspStatusUpdate::Progress {
1137                title: "Roots Scanned".to_owned(),
1138                message: Some("68/251".to_owned()),
1139                percentage: Some(127),
1140            },
1141        );
1142
1143        assert_eq!(
1144            app.analysis_status.backend,
1145            AnalysisBackend::Lsp("rust-analyzer".to_owned())
1146        );
1147        assert_eq!(app.analysis_status.phase, AnalysisPhase::Working);
1148        assert_eq!(
1149            app.analysis_status.message.as_deref(),
1150            Some("Roots Scanned: 68/251")
1151        );
1152        assert_eq!(app.analysis_status.percentage, Some(100));
1153    }
1154
1155    #[test]
1156    fn footer_places_shortcuts_and_analysis_status_on_the_same_bottom_row() {
1157        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1158        app.set_analysis_status(crate::app::AnalysisStatus {
1159            backend: AnalysisBackend::Lsp("rust-analyzer".to_owned()),
1160            phase: AnalysisPhase::Working,
1161            message: Some("Indexing".to_owned()),
1162            percentage: Some(68),
1163        });
1164        let width = 120;
1165        let height = 20;
1166        let backend = TestBackend::new(width, height);
1167        let mut terminal = Terminal::new(backend).unwrap();
1168        terminal.draw(|frame| render(frame, &app)).unwrap();
1169        let bottom_row = (0..width).fold(String::new(), |mut row, x| {
1170            row.push_str(
1171                terminal
1172                    .backend()
1173                    .buffer()
1174                    .cell((x, height - 1))
1175                    .unwrap()
1176                    .symbol(),
1177            );
1178            row
1179        });
1180
1181        let shortcuts = bottom_row.find("hjkl: move").unwrap();
1182        let status = bottom_row.find("LSP: rust-analyzer").unwrap();
1183        assert!(shortcuts < status);
1184        assert!(bottom_row.contains("?: help"));
1185        assert!(!bottom_row.contains("w: save"));
1186        assert!(!bottom_row.contains("dd/dp/dn"));
1187        assert!(bottom_row.contains("Working 68%"));
1188        assert_eq!(
1189            bottom_row
1190                .chars()
1191                .filter(|character| *character == '│')
1192                .count(),
1193            1
1194        );
1195        for y in 0..height - 1 {
1196            let row = (0..width).fold(String::new(), |mut row, x| {
1197                row.push_str(terminal.backend().buffer().cell((x, y)).unwrap().symbol());
1198                row
1199            });
1200            assert!(!row.contains("LSP: rust-analyzer"));
1201        }
1202    }
1203
1204    #[test]
1205    fn messages_use_the_penultimate_row_without_replacing_footer_shortcuts() {
1206        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1207        app.set_analysis_status(crate::app::AnalysisStatus {
1208            backend: AnalysisBackend::Lsp("rust-analyzer".to_owned()),
1209            phase: AnalysisPhase::Error,
1210            message: Some("server crashed".to_owned()),
1211            percentage: None,
1212        });
1213        let backend = TestBackend::new(100, 12);
1214        let mut terminal = Terminal::new(backend).unwrap();
1215        terminal.draw(|frame| render(frame, &app)).unwrap();
1216        let message_row = (0..100).fold(String::new(), |mut row, x| {
1217            row.push_str(terminal.backend().buffer().cell((x, 10)).unwrap().symbol());
1218            row
1219        });
1220        let bottom_row = (0..100).fold(String::new(), |mut row, x| {
1221            row.push_str(terminal.backend().buffer().cell((x, 11)).unwrap().symbol());
1222            row
1223        });
1224        assert!(message_row.contains("ERROR: server crashed"));
1225        assert!(bottom_row.contains("hjkl: move"));
1226        assert!(bottom_row.contains("LSP: rust-analyzer"));
1227        assert!(!bottom_row.contains("server crashed"));
1228
1229        let mut hierarchy_app =
1230            App::from_cli(Cli::try_parse_from(["cgraph", "call", "main"]).unwrap());
1231        let request = hierarchy_app
1232            .toggle_selected_branch(HierarchyDirection::Outgoing, true)
1233            .unwrap();
1234        assert!(hierarchy_app.finish_hierarchy(&request, Err("content modified".to_owned())));
1235        terminal
1236            .draw(|frame| render(frame, &hierarchy_app))
1237            .unwrap();
1238        let message_row = (0..100).fold(String::new(), |mut row, x| {
1239            row.push_str(terminal.backend().buffer().cell((x, 10)).unwrap().symbol());
1240            row
1241        });
1242        let bottom_row = (0..100).fold(String::new(), |mut row, x| {
1243            row.push_str(terminal.backend().buffer().cell((x, 11)).unwrap().symbol());
1244            row
1245        });
1246        assert!(message_row.contains("ERROR: Hierarchy query failed"));
1247        assert!(message_row.contains("content modified"));
1248        assert!(bottom_row.contains("hjkl: move"));
1249    }
1250
1251    #[test]
1252    fn canvas_heading_uses_the_default_label_or_selected_node_uri() {
1253        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1254        assert_eq!(canvas_heading(&app), "CALL GRAPH");
1255
1256        let node = app.graph.pin_symbol(SymbolIdentity {
1257            symbol: "main".to_owned(),
1258            kind: HierarchyKind::Call,
1259            location: Some(SourceLocation {
1260                uri: "file:///workspace/src/main.rs".to_owned(),
1261                line: Some(4),
1262                character: Some(0),
1263            }),
1264        });
1265        app.selected = Some(node);
1266        assert_eq!(canvas_heading(&app), "file:///workspace/src/main.rs");
1267    }
1268
1269    #[test]
1270    fn delete_prefix_requires_a_complete_valid_command() {
1271        let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1272
1273        handle_canvas_key(
1274            &mut app,
1275            KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
1276            false,
1277            Rect::new(0, 0, 100, 24),
1278        );
1279        assert_eq!(app.pending_key, Some('d'));
1280        handle_canvas_key(
1281            &mut app,
1282            KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
1283            false,
1284            Rect::new(0, 0, 100, 24),
1285        );
1286        assert_eq!(app.graph.anchors().len(), 1);
1287
1288        handle_canvas_key(
1289            &mut app,
1290            KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
1291            false,
1292            Rect::new(0, 0, 100, 24),
1293        );
1294        handle_canvas_key(
1295            &mut app,
1296            KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE),
1297            false,
1298            Rect::new(0, 0, 100, 24),
1299        );
1300        assert!(app.graph.anchors().is_empty());
1301        assert_eq!(app.selected, None);
1302    }
1303
1304    #[test]
1305    fn ec_requires_the_complete_prefix_command() {
1306        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1307        let screen = Rect::new(0, 0, 100, 24);
1308
1309        assert!(
1310            handle_canvas_key(
1311                &mut app,
1312                KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
1313                true,
1314                screen,
1315            )
1316            .is_none()
1317        );
1318        assert_eq!(app.pending_key, Some('e'));
1319        assert!(
1320            handle_canvas_key(
1321                &mut app,
1322                KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
1323                true,
1324                screen,
1325            )
1326            .is_none()
1327        );
1328        handle_canvas_key(
1329            &mut app,
1330            KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE),
1331            true,
1332            screen,
1333        );
1334        assert!(matches!(
1335            handle_canvas_key(
1336                &mut app,
1337                KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE),
1338                true,
1339                screen,
1340            ),
1341            Some(InteractionRequest::EditConfig)
1342        ));
1343        assert_eq!(app.pending_key, None);
1344    }
1345
1346    #[test]
1347    fn g_less_than_opens_the_message_view_command() {
1348        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1349        let screen = Rect::new(0, 0, 100, 24);
1350
1351        assert!(
1352            handle_canvas_key(
1353                &mut app,
1354                KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
1355                false,
1356                screen,
1357            )
1358            .is_none()
1359        );
1360        assert_eq!(app.pending_key, Some('g'));
1361        assert!(matches!(
1362            handle_canvas_key(
1363                &mut app,
1364                KeyEvent::new(KeyCode::Char('<'), KeyModifiers::NONE),
1365                false,
1366                screen,
1367            ),
1368            Some(InteractionRequest::OpenMessages)
1369        ));
1370        assert_eq!(app.pending_key, None);
1371
1372        app.set_canvas_notice("first message");
1373        app.set_canvas_notice("second message");
1374        let mut view = messages::MessageViewState::from_messages(&app.message_history);
1375        let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap();
1376        terminal
1377            .draw(|frame| render_with_messages(frame, &app, Some(&mut view)))
1378            .unwrap();
1379        let content =
1380            terminal
1381                .backend()
1382                .buffer()
1383                .content()
1384                .iter()
1385                .fold(String::new(), |mut output, cell| {
1386                    output.push_str(cell.symbol());
1387                    output
1388                });
1389        let bottom_row = (0..100).fold(String::new(), |mut row, column| {
1390            row.push_str(
1391                terminal
1392                    .backend()
1393                    .buffer()
1394                    .cell((column, 23))
1395                    .unwrap()
1396                    .symbol(),
1397            );
1398            row
1399        });
1400        assert!(content.contains("Messages"));
1401        assert!(content.contains("first message"));
1402        assert!(content.contains("second message"));
1403        assert!(bottom_row.contains("?: help"));
1404        assert!(bottom_row.contains("Backend: none"));
1405    }
1406
1407    #[test]
1408    fn w_opens_an_empty_save_modal_without_quitting() {
1409        let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1410
1411        let request = handle_canvas_key(
1412            &mut app,
1413            KeyEvent::new(KeyCode::Char('w'), KeyModifiers::NONE),
1414            false,
1415            Rect::new(0, 0, 100, 24),
1416        );
1417
1418        assert!(request.is_none());
1419        assert_eq!(app.save.as_ref().unwrap().input, "");
1420        assert!(!app.should_quit);
1421    }
1422
1423    #[test]
1424    fn lays_out_multiple_anchors_with_the_selection_at_the_center() {
1425        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1426        pin(&mut app, "first", HierarchyKind::Call);
1427        let selected = pin(&mut app, "selected", HierarchyKind::Type);
1428        pin(&mut app, "third", HierarchyKind::Call);
1429        app.selected = Some(selected);
1430        let layout = world_canvas_layout(&app.graph, app.selected);
1431        let selected = layout
1432            .nodes
1433            .iter()
1434            .find(|placement| placement.node_id == selected)
1435            .unwrap();
1436
1437        assert_eq!(layout.nodes.len(), 3);
1438        assert_eq!(selected.slot.x + i32::from(selected.slot.width) / 2, 0);
1439        assert_eq!(selected.slot.y + i32::from(selected.slot.height) / 2, 0);
1440        assert!(
1441            layout
1442                .nodes
1443                .iter()
1444                .all(|placement| placement.slot.width > 0 && placement.slot.height > 0)
1445        );
1446    }
1447
1448    #[test]
1449    fn selection_changes_only_translate_the_stable_world_layout() {
1450        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1451        let first = pin(&mut app, "first", HierarchyKind::Call);
1452        let second = pin(&mut app, "second", HierarchyKind::Call);
1453        let third = pin(&mut app, "third", HierarchyKind::Call);
1454
1455        let first_selected = world_canvas_layout(&app.graph, Some(first));
1456        let second_selected = world_canvas_layout(&app.graph, Some(second));
1457        for (left, right) in [(first, second), (first, third), (second, third)] {
1458            let first_delta = world_delta(&first_selected, left, right);
1459            let second_delta = world_delta(&second_selected, left, right);
1460            assert_eq!(first_delta, second_delta);
1461        }
1462    }
1463
1464    #[test]
1465    fn tl_and_tr_toggle_only_the_requested_side() {
1466        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1467        let root = pin(&mut app, "root", HierarchyKind::Call);
1468        connect(&mut app, root, HierarchyDirection::Incoming, &["caller"]);
1469        connect(&mut app, root, HierarchyDirection::Outgoing, &["callee"]);
1470        app.selected = Some(root);
1471
1472        press(&mut app, 't');
1473        assert_eq!(app.pending_key, Some('t'));
1474        press(&mut app, 'l');
1475        assert!(app.graph.node(root).unwrap().incoming.expanded);
1476        assert!(!app.graph.node(root).unwrap().outgoing.expanded);
1477
1478        press(&mut app, 't');
1479        press(&mut app, 'r');
1480        assert!(app.graph.node(root).unwrap().incoming.expanded);
1481        assert!(app.graph.node(root).unwrap().outgoing.expanded);
1482
1483        press(&mut app, 't');
1484        press(&mut app, 'l');
1485        assert!(!app.graph.node(root).unwrap().incoming.expanded);
1486        assert!(app.graph.node(root).unwrap().outgoing.expanded);
1487    }
1488
1489    #[test]
1490    fn first_tl_schedules_only_the_left_branch_query() {
1491        let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1492        let screen = Rect::new(0, 0, 100, 24);
1493        handle_canvas_key(
1494            &mut app,
1495            KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE),
1496            true,
1497            screen,
1498        );
1499        let request = handle_canvas_key(
1500            &mut app,
1501            KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE),
1502            true,
1503            screen,
1504        );
1505
1506        let Some(InteractionRequest::Hierarchy(requests)) = request else {
1507            panic!("first tl must schedule a hierarchy request");
1508        };
1509        let [request] = requests.as_slice() else {
1510            panic!("first tl must schedule exactly one hierarchy request");
1511        };
1512        let root = app.selected.unwrap();
1513        assert_eq!(request.query.direction, HierarchyDirection::Incoming);
1514        assert_eq!(
1515            app.graph.node(root).unwrap().incoming.load_state,
1516            LoadState::Loading
1517        );
1518        assert_eq!(
1519            app.graph.node(root).unwrap().outgoing.load_state,
1520            LoadState::NotLoaded
1521        );
1522    }
1523
1524    #[test]
1525    fn r_schedules_refreshes_for_both_branches() {
1526        let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1527        let request = handle_canvas_key(
1528            &mut app,
1529            KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE),
1530            true,
1531            Rect::new(0, 0, 100, 24),
1532        );
1533
1534        let Some(InteractionRequest::Hierarchy(requests)) = request else {
1535            panic!("r must schedule hierarchy refresh requests");
1536        };
1537        assert_eq!(requests.len(), 2);
1538        assert!(
1539            requests
1540                .iter()
1541                .all(|request| request.cache_policy == CachePolicy::Refresh)
1542        );
1543        assert_eq!(
1544            requests
1545                .iter()
1546                .map(|request| request.query.direction)
1547                .collect::<Vec<_>>(),
1548            [HierarchyDirection::Incoming, HierarchyDirection::Outgoing]
1549        );
1550        let root = app.selected.unwrap();
1551        assert_eq!(
1552            app.graph.node(root).unwrap().incoming.load_state,
1553            LoadState::Loading
1554        );
1555        assert_eq!(
1556            app.graph.node(root).unwrap().outgoing.load_state,
1557            LoadState::Loading
1558        );
1559    }
1560
1561    #[test]
1562    fn canvas_layout_only_contains_children_of_expanded_branches() {
1563        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1564        let root_id = pin(&mut app, "root", HierarchyKind::Call);
1565        let child_id = connect(&mut app, root_id, HierarchyDirection::Incoming, &["caller"])[0];
1566        let area = Rect::new(0, 0, 100, 20);
1567
1568        let collapsed = canvas_layout(area, &app.graph, Some(root_id), Default::default());
1569        assert_eq!(collapsed.nodes.len(), 1);
1570
1571        app.graph.node_mut(root_id).unwrap().incoming.expanded = true;
1572        let expanded = canvas_layout(area, &app.graph, Some(root_id), Default::default());
1573        assert_eq!(expanded.nodes.len(), 2);
1574        assert!(
1575            expanded
1576                .nodes
1577                .iter()
1578                .any(|placement| placement.node_id == child_id)
1579        );
1580    }
1581
1582    #[test]
1583    fn expanded_node_rectangles_never_overlap() {
1584        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1585        let root = pin(&mut app, "root", HierarchyKind::Call);
1586        let mut callers = Vec::new();
1587        let mut callees = Vec::new();
1588        for index in 0..8 {
1589            callers.push(if index % 2 == 0 {
1590                format!("VeryLongCallerClass{index}::call_root")
1591            } else {
1592                format!("caller-{index}")
1593            });
1594            callees.push(format!("callee-{index}"));
1595        }
1596        connect_owned(&mut app, root, HierarchyDirection::Incoming, &callers);
1597        connect_owned(&mut app, root, HierarchyDirection::Outgoing, &callees);
1598        app.graph.node_mut(root).unwrap().incoming.expanded = true;
1599        app.graph.node_mut(root).unwrap().outgoing.expanded = true;
1600        let layout = world_canvas_layout(&app.graph, None);
1601
1602        assert!(
1603            layout.nodes.len() > 3,
1604            "test needs several visible hierarchy nodes"
1605        );
1606        for (index, placement) in layout.nodes.iter().enumerate() {
1607            for other in layout.nodes.iter().skip(index + 1) {
1608                assert!(
1609                    !world_rects_overlap(placement.slot, other.slot),
1610                    "placements overlap: {placement:?} and {other:?}"
1611                );
1612            }
1613        }
1614    }
1615
1616    #[test]
1617    fn visible_parent_child_relationship_renders_a_connector() {
1618        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1619        let root_id = pin(&mut app, "root", HierarchyKind::Call);
1620        let child_id = connect(&mut app, root_id, HierarchyDirection::Outgoing, &["child"])[0];
1621        app.graph.node_mut(root_id).unwrap().outgoing.expanded = true;
1622        app.selected = Some(root_id);
1623        let screen = Rect::new(0, 0, 100, 20);
1624        let layout = canvas_layout(
1625            canvas_inner_area(screen),
1626            &app.graph,
1627            app.selected,
1628            app.viewport,
1629        );
1630
1631        assert_eq!(layout.edges.len(), 1);
1632        assert_eq!(layout.edges[0].source_id, root_id);
1633        assert_eq!(layout.edges[0].target_id, child_id);
1634        assert!(
1635            layout.edges[0].cells.iter().any(|cell| cell.symbol == '▶'),
1636            "forward connections must show their direction before the target"
1637        );
1638        let connector = layout.edges[0]
1639            .cells
1640            .iter()
1641            .find(|cell| {
1642                layout.nodes.iter().all(|placement| {
1643                    !placement_bounds(*placement).contains((cell.x, cell.y).into())
1644                })
1645            })
1646            .expect("connection must occupy at least one cell between node boxes");
1647
1648        let backend = TestBackend::new(screen.width, screen.height);
1649        let mut terminal = Terminal::new(backend).unwrap();
1650        terminal.draw(|frame| render(frame, &app)).unwrap();
1651        assert_ne!(
1652            terminal
1653                .backend()
1654                .buffer()
1655                .cell((connector.x, connector.y))
1656                .unwrap()
1657                .symbol(),
1658            " "
1659        );
1660    }
1661
1662    #[test]
1663    fn node_box_does_not_render_call_or_type_corner_labels() {
1664        let app = App::from_cli(
1665            Cli::try_parse_from(["cgraph", "call", "VeryLongClassName::very_long_method_name"])
1666                .unwrap(),
1667        );
1668        let screen = Rect::new(0, 0, 80, 16);
1669        let layout = canvas_layout(
1670            canvas_inner_area(screen),
1671            &app.graph,
1672            app.selected,
1673            app.viewport,
1674        );
1675        let placement = layout.nodes[0];
1676        let node = app.graph.node(placement.node_id).unwrap();
1677        assert!(usize::from(placement.area.width.saturating_sub(2)) >= node.symbol.chars().count());
1678        let backend = TestBackend::new(screen.width, screen.height);
1679        let mut terminal = Terminal::new(backend).unwrap();
1680        terminal.draw(|frame| render(frame, &app)).unwrap();
1681        let top_border = (placement.area.x..placement.area.right())
1682            .map(|x| {
1683                terminal
1684                    .backend()
1685                    .buffer()
1686                    .cell((x, placement.area.y))
1687                    .unwrap()
1688                    .symbol()
1689            })
1690            .collect::<String>();
1691
1692        assert!(!top_border.contains("call"));
1693        assert!(!top_border.contains("type"));
1694    }
1695
1696    #[test]
1697    fn partially_visible_node_renders_a_true_slice_until_fully_offscreen() {
1698        let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "root"]).unwrap());
1699        let screen = Rect::new(0, 0, 40, 12);
1700        let canvas = canvas_inner_area(screen);
1701        let initial = canvas_layout(canvas, &app.graph, app.selected, app.viewport);
1702        let initial_slot = initial.nodes[0].slot;
1703        let desired_x = i64::from(canvas.x).saturating_sub(5);
1704        app.viewport.offset_x = i32::try_from(desired_x.saturating_sub(initial_slot.x)).unwrap();
1705
1706        let clipped = canvas_layout(canvas, &app.graph, app.selected, app.viewport);
1707        let placement = clipped.nodes[0];
1708        assert!(placement.visible_slot.width < placement.slot.width);
1709        assert_eq!(placement.visible_slot.x, canvas.x);
1710
1711        let backend = TestBackend::new(screen.width, screen.height);
1712        let mut terminal = Terminal::new(backend).unwrap();
1713        terminal.draw(|frame| render(frame, &app)).unwrap();
1714        assert_eq!(
1715            terminal
1716                .backend()
1717                .buffer()
1718                .cell((canvas.x, placement.area.y))
1719                .unwrap()
1720                .symbol(),
1721            "─",
1722            "the viewport boundary must not be rendered as a synthetic left border"
1723        );
1724
1725        app.viewport.offset_x = app.viewport.offset_x.saturating_sub(1000);
1726        assert!(
1727            canvas_layout(canvas, &app.graph, app.selected, app.viewport)
1728                .nodes
1729                .is_empty(),
1730            "a node should disappear only after it no longer intersects the viewport"
1731        );
1732    }
1733
1734    #[test]
1735    fn connection_remains_visible_when_its_target_box_is_offscreen() {
1736        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1737        let root_id = pin(&mut app, "root", HierarchyKind::Call);
1738        let child_id = connect(&mut app, root_id, HierarchyDirection::Outgoing, &["child"])[0];
1739        app.graph.node_mut(root_id).unwrap().outgoing.expanded = true;
1740        app.selected = Some(root_id);
1741        let screen = Rect::new(0, 0, 36, 12);
1742        let canvas = canvas_inner_area(screen);
1743        let layout = canvas_layout(canvas, &app.graph, app.selected, app.viewport);
1744
1745        assert!(
1746            layout
1747                .nodes
1748                .iter()
1749                .any(|placement| placement.node_id == root_id)
1750        );
1751        assert!(
1752            !layout
1753                .nodes
1754                .iter()
1755                .any(|placement| placement.node_id == child_id),
1756            "the fixture requires the target box to be completely offscreen"
1757        );
1758        let edge = layout
1759            .edges
1760            .iter()
1761            .find(|edge| edge.source_id == root_id && edge.target_id == child_id)
1762            .expect("an edge crossing the viewport must survive endpoint clipping");
1763        let continuation = edge
1764            .cells
1765            .iter()
1766            .find(|cell| cell.symbol == '▶')
1767            .expect("the offscreen target direction remains visible at the boundary");
1768        assert_eq!(continuation.x, canvas.right() - 1);
1769
1770        let backend = TestBackend::new(screen.width, screen.height);
1771        let mut terminal = Terminal::new(backend).unwrap();
1772        terminal.draw(|frame| render(frame, &app)).unwrap();
1773        assert_eq!(
1774            terminal
1775                .backend()
1776                .buffer()
1777                .cell((continuation.x, continuation.y))
1778                .unwrap()
1779                .symbol(),
1780            "▶"
1781        );
1782    }
1783
1784    #[test]
1785    fn canvas_mouse_selects_nodes_and_toggles_side_buttons_independently() {
1786        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1787        let first_id = pin(&mut app, "first", HierarchyKind::Call);
1788        let second_id = pin(&mut app, "second", HierarchyKind::Call);
1789        connect(
1790            &mut app,
1791            second_id,
1792            HierarchyDirection::Incoming,
1793            &["caller"],
1794        );
1795        connect(
1796            &mut app,
1797            second_id,
1798            HierarchyDirection::Outgoing,
1799            &["callee"],
1800        );
1801        app.selected = Some(first_id);
1802        let screen = Rect::new(0, 0, 100, 24);
1803        let layout = canvas_layout(
1804            canvas_inner_area(screen),
1805            &app.graph,
1806            app.selected,
1807            app.viewport,
1808        );
1809        let second_placement = *layout
1810            .nodes
1811            .iter()
1812            .find(|placement| placement.node_id == second_id)
1813            .unwrap();
1814
1815        click(
1816            &mut app,
1817            screen,
1818            second_placement.area.x + 1,
1819            second_placement.area.y + 1,
1820        );
1821        assert_eq!(app.selected, Some(second_id));
1822
1823        let after_selection = canvas_layout(
1824            canvas_inner_area(screen),
1825            &app.graph,
1826            app.selected,
1827            app.viewport,
1828        );
1829        let second_after_selection = *after_selection
1830            .nodes
1831            .iter()
1832            .find(|placement| placement.node_id == second_id)
1833            .unwrap();
1834        assert_eq!(second_after_selection.slot, second_placement.slot);
1835
1836        let first_placement = *after_selection
1837            .nodes
1838            .iter()
1839            .find(|placement| placement.node_id == first_id)
1840            .unwrap();
1841        click(
1842            &mut app,
1843            screen,
1844            first_placement.area.x + 1,
1845            first_placement.area.y + 1,
1846        );
1847        assert_eq!(app.selected, Some(first_id));
1848        let before_incoming_toggle = canvas_layout(
1849            canvas_inner_area(screen),
1850            &app.graph,
1851            app.selected,
1852            app.viewport,
1853        );
1854        let second_placement = *before_incoming_toggle
1855            .nodes
1856            .iter()
1857            .find(|placement| placement.node_id == second_id)
1858            .unwrap();
1859        click(
1860            &mut app,
1861            screen,
1862            second_placement.incoming_button.x + 1,
1863            second_placement.incoming_button.y,
1864        );
1865        assert_eq!(app.selected, Some(second_id));
1866        assert!(app.graph.node(second_id).unwrap().incoming.expanded);
1867        assert!(!app.graph.node(second_id).unwrap().outgoing.expanded);
1868        let after_incoming_toggle = canvas_layout(
1869            canvas_inner_area(screen),
1870            &app.graph,
1871            app.selected,
1872            app.viewport,
1873        );
1874        let second_after_incoming_toggle = *after_incoming_toggle
1875            .nodes
1876            .iter()
1877            .find(|placement| placement.node_id == second_id)
1878            .unwrap();
1879        assert_eq!(second_after_incoming_toggle.slot, second_placement.slot);
1880
1881        click(
1882            &mut app,
1883            screen,
1884            second_after_incoming_toggle.outgoing_button.x + 1,
1885            second_after_incoming_toggle.outgoing_button.y,
1886        );
1887        assert!(app.graph.node(second_id).unwrap().incoming.expanded);
1888        assert!(app.graph.node(second_id).unwrap().outgoing.expanded);
1889        let after_outgoing_toggle = canvas_layout(
1890            canvas_inner_area(screen),
1891            &app.graph,
1892            app.selected,
1893            app.viewport,
1894        );
1895        assert_eq!(
1896            after_outgoing_toggle
1897                .nodes
1898                .iter()
1899                .find(|placement| placement.node_id == second_id)
1900                .unwrap()
1901                .slot,
1902            second_after_incoming_toggle.slot
1903        );
1904    }
1905
1906    #[test]
1907    fn first_mouse_side_button_schedules_its_branch_query() {
1908        let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "type", "Root"]).unwrap());
1909        let screen = Rect::new(0, 0, 100, 24);
1910        let placement = canvas_layout(
1911            canvas_inner_area(screen),
1912            &app.graph,
1913            app.selected,
1914            app.viewport,
1915        )
1916        .nodes[0];
1917        let mut drag = CanvasDragState::default();
1918        let request = handle_canvas_mouse(
1919            &mut app,
1920            MouseEvent {
1921                kind: MouseEventKind::Down(MouseButton::Left),
1922                column: placement.outgoing_button.x + 1,
1923                row: placement.outgoing_button.y,
1924                modifiers: KeyModifiers::NONE,
1925            },
1926            true,
1927            screen,
1928            &mut drag,
1929        );
1930
1931        let Some(InteractionRequest::Hierarchy(requests)) = request else {
1932            panic!("first side-button click must schedule hierarchy loading");
1933        };
1934        let [request] = requests.as_slice() else {
1935            panic!("first side-button click must schedule exactly one hierarchy request");
1936        };
1937        let root = app.selected.unwrap();
1938        assert_eq!(request.query.direction, HierarchyDirection::Outgoing);
1939        assert_eq!(
1940            app.graph.node(root).unwrap().outgoing.load_state,
1941            LoadState::Loading
1942        );
1943        assert_eq!(
1944            app.graph.node(root).unwrap().incoming.load_state,
1945            LoadState::NotLoaded
1946        );
1947        assert_eq!(drag.previous, None);
1948    }
1949
1950    #[test]
1951    fn double_clicking_a_node_opens_only_an_exact_source_location() {
1952        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
1953        let node_id = pin(&mut app, "main", HierarchyKind::Call);
1954        app.selected = Some(node_id);
1955        let expected = app.graph.node(node_id).unwrap().location.clone().unwrap();
1956        let screen = Rect::new(0, 0, 100, 24);
1957        let placement = canvas_layout(
1958            canvas_inner_area(screen),
1959            &app.graph,
1960            app.selected,
1961            app.viewport,
1962        )
1963        .nodes[0];
1964        let column = placement.area.x + 1;
1965        let row = placement.area.y + 1;
1966        let mut pointer = CanvasDragState::default();
1967
1968        assert!(complete_click(&mut app, screen, &mut pointer, column, row).is_none());
1969        let second = complete_click(&mut app, screen, &mut pointer, column, row);
1970        let Some(InteractionRequest::OpenLocation(location)) = second else {
1971            panic!("double-click must emit the node's exact source location");
1972        };
1973        assert_eq!(location, expected);
1974        send_open_location(&mut app, None, &expected);
1975        assert_eq!(
1976            app.canvas_notice.as_deref(),
1977            Some("IPC is not enabled; start cgraph with --ipc-socket <PATH>")
1978        );
1979
1980        let mut provisional =
1981            App::from_cli(Cli::try_parse_from(["cgraph", "call", "unresolved"]).unwrap());
1982        let placement = canvas_layout(
1983            canvas_inner_area(screen),
1984            &provisional.graph,
1985            provisional.selected,
1986            provisional.viewport,
1987        )
1988        .nodes[0];
1989        let mut pointer = CanvasDragState::default();
1990        let column = placement.area.x + 1;
1991        let row = placement.area.y + 1;
1992        assert!(complete_click(&mut provisional, screen, &mut pointer, column, row).is_none());
1993        assert!(complete_click(&mut provisional, screen, &mut pointer, column, row).is_none());
1994        assert_eq!(
1995            provisional.canvas_notice.as_deref(),
1996            Some("Selected node has no exact source location")
1997        );
1998    }
1999
2000    #[test]
2001    fn hierarchy_completion_keeps_the_queried_node_center_stable() {
2002        let mut app = App::from_cli(Cli::try_parse_from(["cgraph", "call", "temporary"]).unwrap());
2003        let screen = Rect::new(0, 0, 100, 24);
2004        let request = app
2005            .toggle_selected_branch(HierarchyDirection::Outgoing, true)
2006            .unwrap();
2007        let before = canvas_layout(
2008            canvas_inner_area(screen),
2009            &app.graph,
2010            app.selected,
2011            app.viewport,
2012        )
2013        .nodes[0]
2014            .slot;
2015        let mut resolved_query = request.query.clone();
2016        resolved_query.symbol = identity(
2017            "VeryLongResolvedType::very_long_resolved_method",
2018            HierarchyKind::Call,
2019        );
2020
2021        assert!(with_stable_node_position(
2022            &mut app,
2023            request.node_id,
2024            |app| app.finish_hierarchy(
2025                &request,
2026                Ok(HierarchyResponse {
2027                    query: resolved_query,
2028                    children: Vec::new(),
2029                    source: FetchSource::Lsp,
2030                }),
2031            )
2032        ));
2033
2034        let after = canvas_layout(
2035            canvas_inner_area(screen),
2036            &app.graph,
2037            app.selected,
2038            app.viewport,
2039        )
2040        .nodes[0]
2041            .slot;
2042        assert_eq!(projected_center(after), projected_center(before));
2043        assert!(after.width > before.width);
2044    }
2045
2046    #[test]
2047    fn dragging_canvas_or_node_pans_viewport_and_reveals_offscreen_nodes() {
2048        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
2049        let root_id = pin(&mut app, "root", HierarchyKind::Call);
2050        let child_id = connect(&mut app, root_id, HierarchyDirection::Outgoing, &["child"])[0];
2051        app.graph.node_mut(root_id).unwrap().outgoing.expanded = true;
2052        app.selected = Some(root_id);
2053        let screen = Rect::new(0, 0, 60, 16);
2054        let canvas = canvas_inner_area(screen);
2055        let world_before = world_canvas_layout(&app.graph, app.selected);
2056        let initial = canvas_layout(canvas, &app.graph, app.selected, app.viewport);
2057        assert!(
2058            initial
2059                .nodes
2060                .iter()
2061                .any(|placement| placement.node_id == root_id)
2062        );
2063        let initial_child = initial
2064            .nodes
2065            .iter()
2066            .find(|placement| placement.node_id == child_id)
2067            .expect("the intersecting part of the child must remain visible");
2068        assert!(initial_child.visible_slot.width < initial_child.slot.width);
2069
2070        let root_placement = initial
2071            .nodes
2072            .iter()
2073            .find(|placement| placement.node_id == root_id)
2074            .copied()
2075            .unwrap();
2076        let mut node_drag = CanvasDragState::default();
2077        let node_start = root_placement.area.right() - 2;
2078        handle_canvas_mouse(
2079            &mut app,
2080            mouse_event(
2081                MouseEventKind::Down(MouseButton::Left),
2082                node_start,
2083                root_placement.area.y + 1,
2084            ),
2085            false,
2086            screen,
2087            &mut node_drag,
2088        );
2089        handle_canvas_mouse(
2090            &mut app,
2091            mouse_event(
2092                MouseEventKind::Drag(MouseButton::Left),
2093                node_start - 20,
2094                root_placement.area.y + 1,
2095            ),
2096            false,
2097            screen,
2098            &mut node_drag,
2099        );
2100
2101        assert_eq!(app.viewport.offset_x, -20);
2102        assert_eq!(world_canvas_layout(&app.graph, app.selected), world_before);
2103        let after_node_drag = canvas_layout(canvas, &app.graph, app.selected, app.viewport);
2104        let child_after_node_drag = after_node_drag
2105            .nodes
2106            .iter()
2107            .find(|placement| placement.node_id == child_id)
2108            .unwrap();
2109        assert_eq!(
2110            child_after_node_drag.visible_slot.width,
2111            child_after_node_drag.slot.width
2112        );
2113
2114        app.viewport = Default::default();
2115        let mut background_drag = CanvasDragState::default();
2116        let background_start = canvas.right() - 2;
2117        handle_canvas_mouse(
2118            &mut app,
2119            mouse_event(
2120                MouseEventKind::Down(MouseButton::Left),
2121                background_start,
2122                canvas.y,
2123            ),
2124            false,
2125            screen,
2126            &mut background_drag,
2127        );
2128        handle_canvas_mouse(
2129            &mut app,
2130            mouse_event(
2131                MouseEventKind::Drag(MouseButton::Left),
2132                background_start - 20,
2133                canvas.y,
2134            ),
2135            false,
2136            screen,
2137            &mut background_drag,
2138        );
2139
2140        assert_eq!(app.viewport.offset_x, -20);
2141        assert_eq!(world_canvas_layout(&app.graph, app.selected), world_before);
2142        let after_background_drag = canvas_layout(canvas, &app.graph, app.selected, app.viewport);
2143        let child_after_background_drag = after_background_drag
2144            .nodes
2145            .iter()
2146            .find(|placement| placement.node_id == child_id)
2147            .unwrap();
2148        assert_eq!(
2149            child_after_background_drag.visible_slot.width,
2150            child_after_background_drag.slot.width
2151        );
2152        assert_eq!(
2153            app.graph.node(root_id).unwrap().outgoing.neighbors[0],
2154            child_id
2155        );
2156    }
2157
2158    #[test]
2159    fn keyboard_navigation_uses_visible_node_geometry() {
2160        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
2161        let root_id = pin(&mut app, "root", HierarchyKind::Call);
2162        let incoming_id = connect(&mut app, root_id, HierarchyDirection::Incoming, &["caller"])[0];
2163        let outgoing_id = connect(&mut app, root_id, HierarchyDirection::Outgoing, &["callee"])[0];
2164        app.graph.node_mut(root_id).unwrap().incoming.expanded = true;
2165        app.graph.node_mut(root_id).unwrap().outgoing.expanded = true;
2166        app.selected = Some(root_id);
2167        let screen = Rect::new(0, 0, 100, 24);
2168
2169        let before_navigation = canvas_layout(
2170            canvas_inner_area(screen),
2171            &app.graph,
2172            app.selected,
2173            app.viewport,
2174        );
2175        let outgoing_before_selection = before_navigation
2176            .nodes
2177            .iter()
2178            .find(|placement| placement.node_id == outgoing_id)
2179            .unwrap()
2180            .slot;
2181        navigate(&mut app, screen, KeyCode::Right);
2182        assert_eq!(app.selected, Some(outgoing_id));
2183        let outgoing_after_selection = canvas_layout(
2184            canvas_inner_area(screen),
2185            &app.graph,
2186            app.selected,
2187            app.viewport,
2188        )
2189        .nodes
2190        .into_iter()
2191        .find(|placement| placement.node_id == outgoing_id)
2192        .unwrap()
2193        .slot;
2194        assert_eq!(outgoing_after_selection, outgoing_before_selection);
2195        navigate(&mut app, screen, KeyCode::Char('h'));
2196        assert_eq!(app.selected, Some(root_id));
2197        navigate(&mut app, screen, KeyCode::Left);
2198        assert_eq!(app.selected, Some(incoming_id));
2199        navigate(&mut app, screen, KeyCode::Char('l'));
2200        assert_eq!(app.selected, Some(root_id));
2201
2202        pin(&mut app, "other-before", HierarchyKind::Call);
2203        for index in 0..3 {
2204            pin(&mut app, &format!("other-{index}"), HierarchyKind::Call);
2205        }
2206        let before = canvas_layout(
2207            canvas_inner_area(screen),
2208            &app.graph,
2209            app.selected,
2210            app.viewport,
2211        );
2212        let before_center = rect_center(
2213            before
2214                .nodes
2215                .iter()
2216                .find(|placement| placement.node_id == root_id)
2217                .unwrap()
2218                .area,
2219        );
2220        assert!(move_canvas_selection(
2221            &mut app,
2222            NavigationDirection::Up,
2223            screen
2224        ));
2225        let selected = app.selected.unwrap();
2226        let after = canvas_layout(
2227            canvas_inner_area(screen),
2228            &app.graph,
2229            app.selected,
2230            app.viewport,
2231        );
2232        let selected_center = rect_center(
2233            after
2234                .nodes
2235                .iter()
2236                .find(|placement| placement.node_id == selected)
2237                .unwrap()
2238                .area,
2239        );
2240        assert!(selected_center.1 <= before_center.1);
2241        assert!(move_canvas_selection(
2242            &mut app,
2243            NavigationDirection::Down,
2244            screen
2245        ));
2246    }
2247
2248    #[test]
2249    fn diamond_layout_uses_one_shared_node_and_keeps_all_edges() {
2250        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
2251        let root = pin(&mut app, "root", HierarchyKind::Call);
2252        let branches = connect(
2253            &mut app,
2254            root,
2255            HierarchyDirection::Outgoing,
2256            &["left", "right"],
2257        );
2258        app.graph.node_mut(root).unwrap().outgoing.expanded = true;
2259        let shared_from_left = connect(
2260            &mut app,
2261            branches[0],
2262            HierarchyDirection::Outgoing,
2263            &["shared"],
2264        )[0];
2265        let shared_from_right = connect(
2266            &mut app,
2267            branches[1],
2268            HierarchyDirection::Outgoing,
2269            &["shared"],
2270        )[0];
2271        app.graph.node_mut(branches[0]).unwrap().outgoing.expanded = true;
2272        app.graph.node_mut(branches[1]).unwrap().outgoing.expanded = true;
2273
2274        let layout = world_canvas_layout(&app.graph, Some(root));
2275        assert_eq!(shared_from_left, shared_from_right);
2276        assert_eq!(layout.nodes.len(), 4);
2277        assert_eq!(layout.edges.len(), 4);
2278        assert_eq!(
2279            layout
2280                .nodes
2281                .iter()
2282                .filter(|placement| placement.node_id == shared_from_left)
2283                .count(),
2284            1
2285        );
2286    }
2287
2288    #[test]
2289    fn cycle_edges_use_the_special_double_line_style() {
2290        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
2291        let first = pin(&mut app, "first", HierarchyKind::Call);
2292        let second = connect(&mut app, first, HierarchyDirection::Outgoing, &["second"])[0];
2293        let third = connect(&mut app, second, HierarchyDirection::Outgoing, &["third"])[0];
2294        connect(&mut app, third, HierarchyDirection::Outgoing, &["first"]);
2295        for node_id in [first, second, third] {
2296            app.graph.node_mut(node_id).unwrap().outgoing.expanded = true;
2297        }
2298
2299        let world = world_canvas_layout(&app.graph, Some(first));
2300        assert_eq!(world.nodes.len(), 3);
2301        assert!(
2302            world
2303                .edges
2304                .iter()
2305                .all(|edge| edge.visual_kind == EdgeVisualKind::BackOrCycle)
2306        );
2307        let canvas = canvas_layout(
2308            Rect::new(0, 0, 120, 30),
2309            &app.graph,
2310            Some(first),
2311            Default::default(),
2312        );
2313        assert!(
2314            canvas
2315                .edges
2316                .iter()
2317                .flat_map(|edge| &edge.cells)
2318                .any(|cell| { matches!(cell.symbol, '═' | '║' | '╬' | '◀') })
2319        );
2320    }
2321
2322    #[test]
2323    fn self_loop_is_rendered_as_a_special_loop() {
2324        let mut app = App::from_cli(Cli::try_parse_from(["cgraph"]).unwrap());
2325        let recursive = pin(&mut app, "recursive", HierarchyKind::Call);
2326        connect(
2327            &mut app,
2328            recursive,
2329            HierarchyDirection::Outgoing,
2330            &["recursive"],
2331        );
2332        app.graph.node_mut(recursive).unwrap().outgoing.expanded = true;
2333
2334        let layout = canvas_layout(
2335            Rect::new(0, 0, 100, 24),
2336            &app.graph,
2337            Some(recursive),
2338            Default::default(),
2339        );
2340        assert_eq!(layout.edges.len(), 1);
2341        assert_eq!(layout.edges[0].visual_kind, EdgeVisualKind::SelfLoop);
2342        assert!(layout.edges[0].cells.iter().any(|cell| cell.symbol == '↺'));
2343    }
2344
2345    fn press(app: &mut App, character: char) {
2346        handle_canvas_key(
2347            app,
2348            KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE),
2349            false,
2350            Rect::new(0, 0, 100, 24),
2351        );
2352    }
2353
2354    fn navigate(app: &mut App, screen: Rect, key_code: KeyCode) {
2355        handle_canvas_key(
2356            app,
2357            KeyEvent::new(key_code, KeyModifiers::NONE),
2358            false,
2359            screen,
2360        );
2361    }
2362
2363    fn click(app: &mut App, screen: Rect, column: u16, row: u16) {
2364        let mut drag = CanvasDragState::default();
2365        handle_canvas_mouse(
2366            app,
2367            MouseEvent {
2368                kind: MouseEventKind::Down(MouseButton::Left),
2369                column,
2370                row,
2371                modifiers: KeyModifiers::NONE,
2372            },
2373            false,
2374            screen,
2375            &mut drag,
2376        );
2377    }
2378
2379    fn complete_click(
2380        app: &mut App,
2381        screen: Rect,
2382        pointer: &mut CanvasDragState,
2383        column: u16,
2384        row: u16,
2385    ) -> Option<InteractionRequest> {
2386        handle_canvas_mouse(
2387            app,
2388            mouse_event(MouseEventKind::Down(MouseButton::Left), column, row),
2389            false,
2390            screen,
2391            pointer,
2392        );
2393        handle_canvas_mouse(
2394            app,
2395            mouse_event(MouseEventKind::Up(MouseButton::Left), column, row),
2396            false,
2397            screen,
2398            pointer,
2399        )
2400    }
2401
2402    fn mouse_event(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
2403        MouseEvent {
2404            kind,
2405            column,
2406            row,
2407            modifiers: KeyModifiers::NONE,
2408        }
2409    }
2410
2411    fn pin(app: &mut App, symbol: &str, kind: HierarchyKind) -> crate::state::NodeId {
2412        app.graph.pin_symbol(identity(symbol, kind))
2413    }
2414
2415    fn connect(
2416        app: &mut App,
2417        node_id: crate::state::NodeId,
2418        direction: HierarchyDirection,
2419        children: &[&str],
2420    ) -> Vec<crate::state::NodeId> {
2421        let kind = app.graph.node(node_id).unwrap().kind;
2422        app.graph
2423            .replace_branch_neighbors(
2424                node_id,
2425                direction,
2426                children
2427                    .iter()
2428                    .map(|symbol| identity(symbol, kind))
2429                    .collect(),
2430            )
2431            .unwrap()
2432    }
2433
2434    fn connect_owned(
2435        app: &mut App,
2436        node_id: crate::state::NodeId,
2437        direction: HierarchyDirection,
2438        children: &[String],
2439    ) -> Vec<crate::state::NodeId> {
2440        let names = children.iter().map(String::as_str).collect::<Vec<_>>();
2441        connect(app, node_id, direction, &names)
2442    }
2443
2444    fn identity(symbol: &str, kind: HierarchyKind) -> SymbolIdentity {
2445        SymbolIdentity {
2446            symbol: symbol.to_owned(),
2447            kind,
2448            location: Some(SourceLocation {
2449                uri: "file:///workspace/src/main.rs".to_owned(),
2450                line: Some(symbol.bytes().map(u32::from).sum()),
2451                character: Some(0),
2452            }),
2453        }
2454    }
2455
2456    fn world_delta(
2457        layout: &crate::tui::canvas::WorldLayoutSnapshot,
2458        from: crate::state::NodeId,
2459        to: crate::state::NodeId,
2460    ) -> (i32, i32) {
2461        let from = layout
2462            .nodes
2463            .iter()
2464            .find(|placement| placement.node_id == from)
2465            .unwrap()
2466            .slot;
2467        let to = layout
2468            .nodes
2469            .iter()
2470            .find(|placement| placement.node_id == to)
2471            .unwrap()
2472            .slot;
2473        (to.x - from.x, to.y - from.y)
2474    }
2475
2476    fn projected_center(slot: crate::tui::canvas::ProjectedRect) -> (i64, i64) {
2477        (
2478            slot.x + i64::from(slot.width) / 2,
2479            slot.y + i64::from(slot.height) / 2,
2480        )
2481    }
2482}