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