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