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