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 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}