1use crate::diff::MatrixResult;
6use crate::tui::app::MatrixState;
7use crate::tui::theme::colors;
8use ratatui::{
9 layout::{Constraint, Direction, Layout, Rect},
10 style::{Color, Modifier, Style},
11 text::{Line, Span},
12 widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap},
13 Frame,
14};
15
16pub fn render_matrix(f: &mut Frame, area: Rect, result: &MatrixResult, state: &MatrixState) {
18 let chunks = Layout::default()
19 .direction(Direction::Vertical)
20 .constraints([
21 Constraint::Length(3), Constraint::Min(15), Constraint::Length(8), Constraint::Length(3), ])
26 .split(area);
27
28 render_header(f, chunks[0], result, state);
30
31 let main_chunks = Layout::default()
33 .direction(Direction::Horizontal)
34 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
35 .split(chunks[1]);
36
37 render_similarity_matrix(f, main_chunks[0], result, state);
38 render_pair_details(f, main_chunks[1], result, state);
39
40 render_clustering(f, chunks[2], result, state);
42
43 render_status_bar(f, chunks[3], result, state);
45
46 if state.show_pair_diff {
48 render_pair_diff_modal(f, area, result, state);
49 }
50
51 if state.show_export_options {
52 render_export_modal(f, area);
53 }
54
55 if state.show_clustering_details {
56 render_clustering_detail_modal(f, area, result, state);
57 }
58
59 if state.search.active {
60 render_search_overlay(f, area, state);
61 }
62}
63
64fn render_header(f: &mut Frame, area: Rect, result: &MatrixResult, state: &MatrixState) {
65 let scheme = colors();
66 let title = format!(
67 " Matrix: {}×{} SBOMs ({} pairs) ",
68 result.sboms.len(),
69 result.sboms.len(),
70 result.num_pairs()
71 );
72
73 let text = vec![Line::from(vec![
74 Span::styled(
75 title,
76 Style::default()
77 .fg(scheme.primary)
78 .add_modifier(Modifier::BOLD),
79 ),
80 Span::raw(" │ "),
81 Span::styled("Sort: ", Style::default().fg(scheme.text_muted)),
82 Span::styled(
83 format!(
84 "{} {}",
85 state.sort_by.label(),
86 state.sort_direction.indicator()
87 ),
88 Style::default().fg(scheme.accent),
89 ),
90 Span::raw(" │ "),
91 Span::styled("Threshold: ", Style::default().fg(scheme.text_muted)),
92 Span::styled(state.threshold.label(), Style::default().fg(scheme.accent)),
93 if state.focus_mode {
94 Span::styled(" │ Focus Mode", Style::default().fg(scheme.warning))
95 } else {
96 Span::raw("")
97 },
98 if state.highlight_row_col {
99 Span::styled(" │ Highlight", Style::default().fg(scheme.info))
100 } else {
101 Span::raw("")
102 },
103 ])];
104
105 let header = Paragraph::new(text).block(Block::default().borders(Borders::ALL));
106
107 f.render_widget(header, area);
108}
109
110fn render_similarity_matrix(f: &mut Frame, area: Rect, result: &MatrixResult, state: &MatrixState) {
111 let scheme = colors();
112 let is_active = matches!(state.active_panel, MatrixPanel::Matrix);
113 let selected_row = state.selected_row;
114 let selected_col = state.selected_col;
115
116 let mut header_cells = vec![Cell::from("").style(Style::default().fg(scheme.primary))];
118 for (j, sbom) in result.sboms.iter().enumerate() {
119 let name: String = sbom.name.chars().take(8).collect();
120
121 let header_style = if state.highlight_row_col && j == selected_col {
123 Style::default()
124 .fg(scheme.accent)
125 .add_modifier(Modifier::BOLD)
126 } else if state.search.matches.contains(&j) {
127 Style::default()
128 .fg(scheme.warning)
129 .add_modifier(Modifier::BOLD)
130 } else {
131 Style::default().fg(scheme.primary)
132 };
133
134 header_cells.push(Cell::from(name).style(header_style));
135 }
136 let header = Row::new(header_cells).bottom_margin(1);
137
138 let rows: Vec<Row> = result
140 .sboms
141 .iter()
142 .enumerate()
143 .filter(|(i, _)| {
144 if state.focus_mode {
146 if let Some(focus_row) = state.focus_row {
147 return *i == focus_row;
148 }
149 }
150 true
151 })
152 .map(|(i, row_sbom)| {
153 let row_name: String = row_sbom.name.chars().take(8).collect();
154
155 let row_name_style = if state.highlight_row_col && i == selected_row {
157 Style::default()
158 .fg(scheme.accent)
159 .add_modifier(Modifier::BOLD)
160 } else if state.search.matches.contains(&i) {
161 Style::default()
162 .fg(scheme.warning)
163 .add_modifier(Modifier::BOLD)
164 } else {
165 Style::default()
166 .fg(scheme.text)
167 .add_modifier(Modifier::BOLD)
168 };
169
170 let mut cells = vec![Cell::from(row_name).style(row_name_style)];
171
172 for j in 0..result.sboms.len() {
173 if state.focus_mode {
175 if let Some(focus_col) = state.focus_col {
176 if j != focus_col && i != selected_row {
177 }
179 }
180 }
181
182 let similarity = result.get_similarity(i, j);
183 let is_selected = i == selected_row && j == selected_col;
184 let is_in_selected_row_or_col =
185 state.highlight_row_col && (i == selected_row || j == selected_col);
186
187 let passes_threshold = state.passes_threshold(similarity);
189
190 let cell_style = if is_selected {
191 Style::default()
192 .bg(scheme.accent)
193 .fg(scheme.badge_fg_dark)
194 .add_modifier(Modifier::BOLD)
195 } else if i == j {
196 Style::default().fg(scheme.muted)
198 } else if !passes_threshold && i != j {
199 Style::default().fg(scheme.muted)
201 } else if is_in_selected_row_or_col {
202 let color = similarity_to_color(similarity);
204 Style::default()
205 .fg(color)
206 .add_modifier(Modifier::UNDERLINED)
207 } else {
208 let color = similarity_to_color(similarity);
210 Style::default().fg(color)
211 };
212
213 let cell_text = if i == j {
214 " - ".to_string()
215 } else if !passes_threshold && !is_selected && !is_in_selected_row_or_col {
216 " · ".to_string()
217 } else {
218 format!("{:.0}%", similarity * 100.0)
219 };
220
221 cells.push(Cell::from(cell_text).style(cell_style));
222 }
223
224 Row::new(cells)
225 })
226 .collect();
227
228 let n = result.sboms.len();
230 let name_width = 9;
231 let cell_width = 6;
232 let mut constraints = vec![Constraint::Length(name_width as u16)];
233 for _ in 0..n {
234 constraints.push(Constraint::Length(cell_width as u16));
235 }
236
237 let border_color = if is_active {
238 scheme.accent
239 } else {
240 scheme.text
241 };
242 let title = " Similarity Matrix [z: zoom, r: row, c: col, Enter: diff] ".to_string();
243
244 let table = Table::new(rows, constraints).header(header).block(
245 Block::default()
246 .title(title)
247 .borders(Borders::ALL)
248 .border_style(Style::default().fg(border_color)),
249 );
250
251 f.render_widget(table, area);
252}
253
254fn render_pair_details(f: &mut Frame, area: Rect, result: &MatrixResult, state: &MatrixState) {
255 let scheme = colors();
256 let row = state.selected_row;
257 let col = state.selected_col;
258
259 let (sbom_a, sbom_b) = if row < result.sboms.len() && col < result.sboms.len() {
260 (&result.sboms[row], &result.sboms[col])
261 } else {
262 return;
263 };
264
265 let similarity = result.get_similarity(row, col);
266
267 let mut text = vec![
268 Line::from(vec![Span::styled(
269 "Comparing: ",
270 Style::default().fg(scheme.text_muted),
271 )]),
272 Line::from(vec![
273 Span::styled(&sbom_a.name, Style::default().fg(scheme.primary)),
274 Span::raw(" ↔ "),
275 Span::styled(&sbom_b.name, Style::default().fg(scheme.primary)),
276 ]),
277 Line::from(""),
278 ];
279
280 if row == col {
281 text.push(Line::from(vec![Span::styled(
282 "(Same SBOM)",
283 Style::default().fg(scheme.text_muted),
284 )]));
285 } else {
286 text.extend(vec![
287 Line::from(vec![
288 Span::styled("Similarity: ", Style::default().fg(scheme.text_muted)),
289 Span::styled(
290 format!("{:.1}%", similarity * 100.0),
291 Style::default()
292 .fg(similarity_to_color(similarity))
293 .add_modifier(Modifier::BOLD),
294 ),
295 ]),
296 Line::from(""),
297 Line::from(vec![
298 Span::styled(&sbom_a.name, Style::default().fg(scheme.text)),
299 Span::raw(": "),
300 Span::styled(
301 sbom_a.component_count.to_string(),
302 Style::default().fg(scheme.primary),
303 ),
304 Span::raw(" components"),
305 ]),
306 Line::from(vec![
307 Span::styled(&sbom_b.name, Style::default().fg(scheme.text)),
308 Span::raw(": "),
309 Span::styled(
310 sbom_b.component_count.to_string(),
311 Style::default().fg(scheme.primary),
312 ),
313 Span::raw(" components"),
314 ]),
315 ]);
316
317 if let Some(diff) = result.get_diff(row, col) {
319 text.extend(vec![
320 Line::from(""),
321 Line::from(vec![Span::styled(
322 "Changes:",
323 Style::default().fg(scheme.text_muted),
324 )]),
325 Line::from(vec![
326 Span::styled(" + Added: ", Style::default().fg(scheme.added)),
327 Span::raw(diff.summary.components_added.to_string()),
328 ]),
329 Line::from(vec![
330 Span::styled(" - Removed: ", Style::default().fg(scheme.removed)),
331 Span::raw(diff.summary.components_removed.to_string()),
332 ]),
333 Line::from(vec![
334 Span::styled(" ~ Modified: ", Style::default().fg(scheme.accent)),
335 Span::raw(diff.summary.components_modified.to_string()),
336 ]),
337 ]);
338 }
339
340 text.push(Line::from(""));
341 text.push(Line::from(vec![
342 Span::styled("Press ", Style::default().fg(scheme.text_muted)),
343 Span::styled("Enter", Style::default().fg(scheme.primary)),
344 Span::styled(" for detailed diff", Style::default().fg(scheme.text_muted)),
345 ]));
346 }
347
348 let block = Block::default()
349 .title(" Pair Details ")
350 .borders(Borders::ALL)
351 .border_style(Style::default().fg(scheme.info));
352
353 let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
354 f.render_widget(paragraph, area);
355}
356
357fn render_clustering(f: &mut Frame, area: Rect, result: &MatrixResult, state: &MatrixState) {
358 let scheme = colors();
359 let text = if let Some(ref clustering) = result.clustering {
360 let mut lines = vec![
361 Line::from(vec![
362 Span::styled("Algorithm: ", Style::default().fg(scheme.text_muted)),
363 Span::styled(&clustering.algorithm, Style::default().fg(scheme.text)),
364 Span::raw(" "),
365 Span::styled("Threshold: ", Style::default().fg(scheme.text_muted)),
366 Span::styled(
367 format!("{:.0}%", clustering.threshold * 100.0),
368 Style::default().fg(scheme.primary),
369 ),
370 ]),
371 Line::from(""),
372 ];
373
374 for (i, cluster) in clustering.clusters.iter().enumerate() {
376 let members: Vec<String> = cluster
377 .members
378 .iter()
379 .filter_map(|&idx| result.sboms.get(idx))
380 .map(|s| s.name.clone())
381 .collect();
382
383 let cluster_label = cluster
384 .label
385 .clone()
386 .unwrap_or(format!("Cluster {}", i + 1));
387 let is_selected = i == state.selected_cluster;
388
389 let label_style = if is_selected {
390 Style::default()
391 .fg(scheme.accent)
392 .add_modifier(Modifier::BOLD)
393 } else {
394 Style::default()
395 .fg(scheme.critical)
396 .add_modifier(Modifier::BOLD)
397 };
398
399 lines.push(Line::from(vec![
400 Span::styled(format!("{}: ", cluster_label), label_style),
401 Span::styled(members.join(", "), Style::default().fg(scheme.text)),
402 Span::raw(" "),
403 Span::styled(
404 format!("({:.0}% similarity)", cluster.internal_similarity * 100.0),
405 Style::default().fg(scheme.text_muted),
406 ),
407 ]));
408 }
409
410 if !clustering.outliers.is_empty() {
412 let outliers: Vec<String> = clustering
413 .outliers
414 .iter()
415 .filter_map(|&idx| result.sboms.get(idx))
416 .map(|s| s.name.clone())
417 .collect();
418
419 lines.push(Line::from(vec![
420 Span::styled("Outliers: ", Style::default().fg(scheme.removed)),
421 Span::styled(outliers.join(", "), Style::default().fg(scheme.text)),
422 ]));
423 }
424
425 lines
426 } else {
427 vec![Line::from(vec![Span::styled(
428 "No clustering computed",
429 Style::default().fg(scheme.text_muted),
430 )])]
431 };
432
433 let block = Block::default()
434 .title(format!(
435 " Clustering ({} clusters) [C: details] ",
436 result
437 .clustering
438 .as_ref()
439 .map(|c| c.clusters.len())
440 .unwrap_or(0)
441 ))
442 .borders(Borders::ALL)
443 .border_style(Style::default().fg(scheme.critical));
444
445 let paragraph = Paragraph::new(text).block(block).wrap(Wrap { trim: true });
446 f.render_widget(paragraph, area);
447}
448
449fn render_status_bar(f: &mut Frame, area: Rect, result: &MatrixResult, _state: &MatrixState) {
450 let scheme = colors();
451 let total_pairs = result.num_pairs();
453 let avg_similarity: f64 = if total_pairs > 0 {
454 result.similarity_scores.iter().sum::<f64>() / total_pairs as f64
455 } else {
456 0.0
457 };
458
459 let status = Line::from(vec![
460 Span::styled("Pairs: ", Style::default().fg(scheme.text_muted)),
461 Span::styled(total_pairs.to_string(), Style::default().fg(scheme.primary)),
462 Span::raw(" "),
463 Span::styled("Avg: ", Style::default().fg(scheme.text_muted)),
464 Span::styled(
465 format!("{:.0}%", avg_similarity * 100.0),
466 Style::default().fg(similarity_to_color(avg_similarity)),
467 ),
468 Span::raw(" │ "),
469 Span::styled("/", Style::default().fg(scheme.primary)),
470 Span::raw(": search "),
471 Span::styled("t", Style::default().fg(scheme.primary)),
472 Span::raw(": threshold "),
473 Span::styled("z", Style::default().fg(scheme.primary)),
474 Span::raw(": focus "),
475 Span::styled("H", Style::default().fg(scheme.primary)),
476 Span::raw(": highlight "),
477 Span::styled("x", Style::default().fg(scheme.primary)),
478 Span::raw(": export"),
479 ]);
480
481 let block = Block::default().borders(Borders::ALL);
482 let paragraph = Paragraph::new(status).block(block);
483 f.render_widget(paragraph, area);
484}
485
486fn render_pair_diff_modal(f: &mut Frame, area: Rect, result: &MatrixResult, state: &MatrixState) {
488 let scheme = colors();
489
490 let modal_width = area.width * 80 / 100;
491 let modal_height = area.height * 70 / 100;
492 let modal_x = (area.width - modal_width) / 2;
493 let modal_y = (area.height - modal_height) / 2;
494 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
495
496 f.render_widget(Clear, modal_area);
497
498 let row = state.selected_row;
499 let col = state.selected_col;
500
501 let (sbom_a, sbom_b) = match (result.sboms.get(row), result.sboms.get(col)) {
502 (Some(a), Some(b)) => (a, b),
503 _ => return,
504 };
505
506 let similarity = result.get_similarity(row, col);
507
508 let mut lines = vec![
509 Line::from(vec![
510 Span::styled("Comparing: ", Style::default().fg(scheme.text_muted)),
511 Span::styled(
512 &sbom_a.name,
513 Style::default()
514 .fg(scheme.primary)
515 .add_modifier(Modifier::BOLD),
516 ),
517 Span::raw(" ↔ "),
518 Span::styled(
519 &sbom_b.name,
520 Style::default()
521 .fg(scheme.warning)
522 .add_modifier(Modifier::BOLD),
523 ),
524 ]),
525 Line::from(""),
526 Line::from(vec![
527 Span::styled("Similarity: ", Style::default().fg(scheme.text_muted)),
528 Span::styled(
529 format!("{:.1}%", similarity * 100.0),
530 Style::default()
531 .fg(similarity_to_color(similarity))
532 .add_modifier(Modifier::BOLD),
533 ),
534 ]),
535 Line::from(""),
536 Line::from(vec![
537 Span::styled(&sbom_a.name, Style::default().fg(scheme.text)),
538 Span::raw(": "),
539 Span::styled(
540 sbom_a.component_count.to_string(),
541 Style::default().fg(scheme.primary),
542 ),
543 Span::raw(" components"),
544 ]),
545 Line::from(vec![
546 Span::styled(&sbom_b.name, Style::default().fg(scheme.text)),
547 Span::raw(": "),
548 Span::styled(
549 sbom_b.component_count.to_string(),
550 Style::default().fg(scheme.primary),
551 ),
552 Span::raw(" components"),
553 ]),
554 ];
555
556 if let Some(diff) = result.get_diff(row, col) {
557 lines.push(Line::from(""));
558 lines.push(Line::from(vec![Span::styled(
559 "Detailed Changes:",
560 Style::default().fg(scheme.text_muted),
561 )]));
562 lines.push(Line::from(vec![
563 Span::styled(" + Added: ", Style::default().fg(scheme.added)),
564 Span::raw(diff.summary.components_added.to_string()),
565 ]));
566 lines.push(Line::from(vec![
567 Span::styled(" - Removed: ", Style::default().fg(scheme.removed)),
568 Span::raw(diff.summary.components_removed.to_string()),
569 ]));
570 lines.push(Line::from(vec![
571 Span::styled(" ~ Modified: ", Style::default().fg(scheme.modified)),
572 Span::raw(diff.summary.components_modified.to_string()),
573 ]));
574
575 lines.push(Line::from(""));
577 lines.push(Line::from(vec![Span::styled(
578 "Added Components:",
579 Style::default().fg(scheme.added),
580 )]));
581 for comp in diff.components.added.iter().take(5) {
582 lines.push(Line::from(vec![
583 Span::raw(" + "),
584 Span::styled(&comp.name, Style::default().fg(scheme.text)),
585 Span::raw(" "),
586 Span::styled(
587 comp.new_version.as_deref().unwrap_or(""),
588 Style::default().fg(scheme.text_muted),
589 ),
590 ]));
591 }
592
593 lines.push(Line::from(""));
594 lines.push(Line::from(vec![Span::styled(
595 "Removed Components:",
596 Style::default().fg(scheme.removed),
597 )]));
598 for comp in diff.components.removed.iter().take(5) {
599 lines.push(Line::from(vec![
600 Span::raw(" - "),
601 Span::styled(&comp.name, Style::default().fg(scheme.text)),
602 Span::raw(" "),
603 Span::styled(
604 comp.old_version.as_deref().unwrap_or(""),
605 Style::default().fg(scheme.text_muted),
606 ),
607 ]));
608 }
609 }
610
611 lines.push(Line::from(""));
612 lines.push(Line::from(vec![
613 Span::styled("Esc", Style::default().fg(scheme.primary)),
614 Span::raw(": close"),
615 ]));
616
617 let block = Block::default()
618 .title(format!(" Diff: {} ↔ {} ", sbom_a.name, sbom_b.name))
619 .borders(Borders::ALL)
620 .border_style(Style::default().fg(scheme.accent))
621 .style(Style::default().bg(scheme.muted));
622
623 let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
624 f.render_widget(paragraph, modal_area);
625}
626
627fn render_export_modal(f: &mut Frame, area: Rect) {
629 let scheme = colors();
630
631 let modal_width = 40;
632 let modal_height = 12;
633 let modal_x = (area.width - modal_width) / 2;
634 let modal_y = (area.height - modal_height) / 2;
635 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
636
637 f.render_widget(Clear, modal_area);
638
639 let lines = vec![
640 Line::from(vec![Span::styled(
641 "Export Matrix As:",
642 Style::default()
643 .fg(scheme.primary)
644 .add_modifier(Modifier::BOLD),
645 )]),
646 Line::from(""),
647 Line::from(vec![
648 Span::styled("c", Style::default().fg(scheme.accent)),
649 Span::raw(" - CSV (comma-separated)"),
650 ]),
651 Line::from(vec![
652 Span::styled("j", Style::default().fg(scheme.accent)),
653 Span::raw(" - JSON"),
654 ]),
655 Line::from(vec![
656 Span::styled("h", Style::default().fg(scheme.accent)),
657 Span::raw(" - HTML (visual heatmap)"),
658 ]),
659 Line::from(""),
660 Line::from(vec![
661 Span::styled("Esc", Style::default().fg(scheme.primary)),
662 Span::raw(": cancel"),
663 ]),
664 ];
665
666 let block = Block::default()
667 .title(" Export Matrix ")
668 .borders(Borders::ALL)
669 .border_style(Style::default().fg(scheme.warning))
670 .style(Style::default().bg(scheme.muted));
671
672 let paragraph = Paragraph::new(lines).block(block);
673 f.render_widget(paragraph, modal_area);
674}
675
676fn render_clustering_detail_modal(
678 f: &mut Frame,
679 area: Rect,
680 result: &MatrixResult,
681 state: &MatrixState,
682) {
683 let scheme = colors();
684
685 let modal_width = area.width * 70 / 100;
686 let modal_height = area.height * 60 / 100;
687 let modal_x = (area.width - modal_width) / 2;
688 let modal_y = (area.height - modal_height) / 2;
689 let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
690
691 f.render_widget(Clear, modal_area);
692
693 let mut lines = vec![
694 Line::from(vec![Span::styled(
695 "Clustering Details",
696 Style::default()
697 .fg(scheme.primary)
698 .add_modifier(Modifier::BOLD),
699 )]),
700 Line::from(""),
701 ];
702
703 if let Some(ref clustering) = result.clustering {
704 lines.push(Line::from(vec![
705 Span::styled("Algorithm: ", Style::default().fg(scheme.text_muted)),
706 Span::styled(&clustering.algorithm, Style::default().fg(scheme.text)),
707 ]));
708 lines.push(Line::from(vec![
709 Span::styled("Threshold: ", Style::default().fg(scheme.text_muted)),
710 Span::styled(
711 format!("{:.0}%", clustering.threshold * 100.0),
712 Style::default().fg(scheme.primary),
713 ),
714 ]));
715 lines.push(Line::from(""));
716
717 for (i, cluster) in clustering.clusters.iter().enumerate() {
718 let is_selected = i == state.selected_cluster;
719 let style = if is_selected {
720 Style::default()
721 .fg(scheme.accent)
722 .add_modifier(Modifier::BOLD)
723 } else {
724 Style::default().fg(scheme.text)
725 };
726
727 let label = cluster
728 .label
729 .clone()
730 .unwrap_or(format!("Cluster {}", i + 1));
731 lines.push(Line::from(vec![Span::styled(format!("{}:", label), style)]));
732 lines.push(Line::from(vec![
733 Span::styled(" Similarity: ", Style::default().fg(scheme.text_muted)),
734 Span::styled(
735 format!("{:.1}%", cluster.internal_similarity * 100.0),
736 Style::default().fg(similarity_to_color(cluster.internal_similarity)),
737 ),
738 ]));
739 lines.push(Line::from(vec![Span::styled(
740 " Members: ",
741 Style::default().fg(scheme.text_muted),
742 )]));
743
744 for &member_idx in &cluster.members {
745 if let Some(sbom) = result.sboms.get(member_idx) {
746 lines.push(Line::from(vec![
747 Span::raw(" • "),
748 Span::styled(&sbom.name, Style::default().fg(scheme.text)),
749 ]));
750 }
751 }
752 lines.push(Line::from(""));
753 }
754
755 if !clustering.outliers.is_empty() {
756 lines.push(Line::from(vec![Span::styled(
757 "Outliers:",
758 Style::default().fg(scheme.removed),
759 )]));
760 for &outlier_idx in &clustering.outliers {
761 if let Some(sbom) = result.sboms.get(outlier_idx) {
762 lines.push(Line::from(vec![
763 Span::raw(" • "),
764 Span::styled(&sbom.name, Style::default().fg(scheme.text)),
765 ]));
766 }
767 }
768 }
769 } else {
770 lines.push(Line::from(vec![Span::styled(
771 "No clustering data available.",
772 Style::default().fg(scheme.text_muted),
773 )]));
774 }
775
776 lines.push(Line::from(""));
777 lines.push(Line::from(vec![
778 Span::styled("j/k", Style::default().fg(scheme.primary)),
779 Span::raw(": navigate clusters "),
780 Span::styled("Esc", Style::default().fg(scheme.primary)),
781 Span::raw(": close"),
782 ]));
783
784 let block = Block::default()
785 .title(" Clustering Details ")
786 .borders(Borders::ALL)
787 .border_style(Style::default().fg(scheme.critical))
788 .style(Style::default().bg(scheme.muted));
789
790 let paragraph = Paragraph::new(lines).block(block).wrap(Wrap { trim: true });
791 f.render_widget(paragraph, modal_area);
792}
793
794fn render_search_overlay(f: &mut Frame, area: Rect, state: &MatrixState) {
796 let scheme = colors();
797
798 let search_area = Rect::new(area.x, area.height - 3, area.width, 3);
799 f.render_widget(Clear, search_area);
800
801 let search_text = Line::from(vec![
802 Span::styled("Search SBOM: ", Style::default().fg(scheme.text_muted)),
803 Span::styled(&state.search.query, Style::default().fg(scheme.text)),
804 Span::styled("│", Style::default().fg(scheme.accent)),
805 Span::raw(" "),
806 Span::styled(
807 state.search.match_position(),
808 Style::default().fg(scheme.text_muted),
809 ),
810 ]);
811
812 let block = Block::default()
813 .borders(Borders::ALL)
814 .border_style(Style::default().fg(scheme.accent))
815 .style(Style::default().bg(scheme.muted));
816
817 let paragraph = Paragraph::new(search_text).block(block);
818 f.render_widget(paragraph, search_area);
819}
820
821fn similarity_to_color(similarity: f64) -> Color {
823 if similarity >= 0.9 {
824 colors().added
825 } else if similarity >= 0.7 {
826 colors().success
827 } else if similarity >= 0.5 {
828 colors().accent
829 } else if similarity >= 0.3 {
830 colors().warning
831 } else {
832 colors().removed
833 }
834}
835
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
838pub enum MatrixPanel {
839 Matrix,
840 Details,
841}