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