Skip to main content

sbom_tools/tui/views/
matrix.rs

1//! Matrix comparison view.
2//!
3//! Displays N×N SBOM comparison with similarity heatmap.
4
5use 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
16/// Render the matrix comparison view
17pub 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), // Header
22            Constraint::Min(15),   // Main content
23            Constraint::Length(8), // Clustering info
24            Constraint::Length(3), // Status bar
25        ])
26        .split(area);
27
28    // Header with filter/sort info
29    render_header(f, chunks[0], result, state);
30
31    // Main content - matrix and details
32    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    // Clustering info
41    render_clustering(f, chunks[2], result, state);
42
43    // Status bar
44    render_status_bar(f, chunks[3], result, state);
45
46    // Render overlays
47    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    // Create header row with SBOM names (truncated)
117    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        // Highlight column header if in highlight mode
122        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    // Create matrix rows with filtering based on threshold and focus mode
139    let rows: Vec<Row> = result
140        .sboms
141        .iter()
142        .enumerate()
143        .filter(|(i, _)| {
144            // In focus mode, only show focused row
145            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            // Highlight row name if in highlight mode
156            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                // In focus mode with column focus, only show focused column
174                if state.focus_mode {
175                    if let Some(focus_col) = state.focus_col {
176                        if j != focus_col && i != selected_row {
177                            // Skip this column in focus mode
178                        }
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                // Check threshold filter
188                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                    // Diagonal
197                    Style::default().fg(scheme.muted)
198                } else if !passes_threshold && i != j {
199                    // Dim cells that don't pass threshold
200                    Style::default().fg(scheme.muted)
201                } else if is_in_selected_row_or_col {
202                    // Highlight row/column
203                    let color = similarity_to_color(similarity);
204                    Style::default()
205                        .fg(color)
206                        .add_modifier(Modifier::UNDERLINED)
207                } else {
208                    // Color based on similarity
209                    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    // Calculate column widths
229    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        // Show diff details if available
318        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        // Show clusters with selection highlighting
375        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        // Show outliers
411        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    // Calculate average similarity
452    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
486/// Render pair diff modal
487fn 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        // Show sample components
576        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
627/// Render export modal
628fn 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
676/// Render clustering detail modal
677fn 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
794/// Render search overlay
795fn 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
821/// Convert similarity score to color
822fn 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/// Panels in the matrix view
837#[derive(Debug, Clone, Copy, PartialEq, Eq)]
838pub enum MatrixPanel {
839    Matrix,
840    Details,
841}